1010 lines
44 KiB
TypeScript
1010 lines
44 KiB
TypeScript
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
|
||
import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
|
||
import { platformApiClient } from "../api/client";
|
||
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
|
||
import {
|
||
RuntimeTaskProgressDialog,
|
||
type RuntimeTaskDialogAction,
|
||
runtimeBuildStages,
|
||
runtimeDependencyStages,
|
||
runtimeDownloadStages,
|
||
runtimeKeyResetStages,
|
||
runtimeLogStages,
|
||
runtimeRunBuildStages,
|
||
runtimeUpdateStages,
|
||
useRuntimeTaskController
|
||
} from "../components/RuntimeTaskProgress";
|
||
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
|
||
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
|
||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import {
|
||
canDeleteServer,
|
||
defaultServerCreateForm,
|
||
endpointLabel,
|
||
pluginCreateInputDefaults,
|
||
type ServerCreateFormState
|
||
} from "../contracts/serverManagement";
|
||
import { summarizeServerOperations } from "../contracts/operationsConsole";
|
||
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
|
||
import {
|
||
clientManagerBuildRequest,
|
||
dependencyJobRequest,
|
||
logBackfillRequest,
|
||
runDistributionGenerateRequest,
|
||
runUpdateRequest,
|
||
serverCreateRequestFromForm,
|
||
serverDeleteConfirmation
|
||
} from "../schemas/serverManagement";
|
||
import { isPlatformAdmin } from "../contracts/workspace";
|
||
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
|
||
import { cx } from "../utils/classes";
|
||
|
||
type ListState = "loading" | "ready" | "error";
|
||
|
||
interface RunTargetSelectionState {
|
||
instance: ServerInstanceResponse;
|
||
targetOs: string;
|
||
targetArch: string;
|
||
}
|
||
|
||
const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
|
||
{ id: "all", label: "全部" },
|
||
{ id: "online", label: "在线" },
|
||
{ id: "offline", label: "离线" },
|
||
{ id: "attention", label: "需关注" }
|
||
];
|
||
|
||
export function ServersPage({ session, operations, onNavigate }: PageComponentProps) {
|
||
const [listState, setListState] = useState<ListState>("loading");
|
||
const [listError, setListError] = useState<string>("");
|
||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
|
||
const [instances, setInstances] = useState<ServerInstanceResponse[]>([]);
|
||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||
const [metrics, setMetrics] = useState<Map<string, ServerMetricsResponse>>(new Map());
|
||
const [metricsPending, setMetricsPending] = useState(true);
|
||
const [metricsError, setMetricsError] = useState("");
|
||
const [keyword, setKeyword] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
|
||
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const [editDeployment, setEditDeployment] = useState<{ instance: ServerInstanceResponse; deployment: import("../api/types").ServerDeploymentResponse } | null>(null);
|
||
const runtimeTask = useRuntimeTaskController();
|
||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
|
||
const [deletePassword, setDeletePassword] = useState("");
|
||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
|
||
|
||
const refreshList = useCallback(async () => {
|
||
setListState("loading");
|
||
try {
|
||
const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([
|
||
platformApiClient.listGamePlugins(),
|
||
platformApiClient.listRunEndpoints(),
|
||
platformApiClient.listServerInstances(),
|
||
platformApiClient.listJobs()
|
||
]);
|
||
setPlugins(pluginResponse.items);
|
||
setEndpoints(endpointResponse.items);
|
||
setInstances(instanceResponse.items);
|
||
setJobs(jobResponse.items);
|
||
setForm((current) => {
|
||
const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0];
|
||
const profileKey = plugin?.runtimeProfiles?.lifecycleProfiles?.some((profile) => profile.key === current.profileKey)
|
||
? current.profileKey
|
||
: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "";
|
||
return {
|
||
...current,
|
||
pluginId: plugin?.id ?? "",
|
||
profileKey,
|
||
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
|
||
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
|
||
? current.runEndpointId
|
||
: endpointResponse.items[0]?.id || ""
|
||
};
|
||
});
|
||
setListState("ready");
|
||
setListError("");
|
||
} catch (error) {
|
||
setListState("error");
|
||
setListError(error instanceof Error ? error.message : "加载失败");
|
||
}
|
||
}, []);
|
||
|
||
const refreshMetrics = useCallback(async () => {
|
||
setMetricsPending(true);
|
||
try {
|
||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||
setMetrics(new Map(metricsResponse.items.map((item) => [item.serverInstanceId, item])));
|
||
setMetricsError("");
|
||
} catch (error) {
|
||
setMetrics(new Map());
|
||
setMetricsError(error instanceof Error ? error.message : "服务器指标加载失败");
|
||
} finally {
|
||
setMetricsPending(false);
|
||
}
|
||
}, []);
|
||
|
||
const refresh = useCallback(async () => {
|
||
await Promise.all([refreshList(), refreshMetrics()]);
|
||
}, [refreshList, refreshMetrics]);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
const cards = useMemo<ServerCardView[]>(
|
||
() =>
|
||
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
|
||
instance: summary.instance,
|
||
metrics: summary.metrics,
|
||
pendingJobs: summary.activeJobs,
|
||
activeJobs: summary.activeJobs,
|
||
failedJobs: summary.failedJobs,
|
||
latestJob: summary.latestJob
|
||
})),
|
||
[instances, jobs, metrics]
|
||
);
|
||
|
||
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
|
||
const createPending = operations.isPending("platform", "创建服务器");
|
||
const canManageServers = session.capabilities.includes("servers.manage");
|
||
async function handleCreate(nextForm: ServerCreateFormState) {
|
||
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
|
||
try {
|
||
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(nextForm));
|
||
operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};可在 Run 注册后绑定并部署。`, result.job.id ? result.job : undefined);
|
||
setForm(defaultServerCreateForm(plugins, endpoints));
|
||
setShowCreate(false);
|
||
await refresh();
|
||
if (result.job.id) onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" });
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
|
||
}
|
||
}
|
||
|
||
async function openEditDeployment(instance: ServerInstanceResponse) {
|
||
if (instance.state === "running" || instance.state === "installing") return;
|
||
try {
|
||
const deployment = await platformApiClient.getServerDeployment(instance.id);
|
||
const plugin = plugins.find((item) => item.id === instance.pluginId);
|
||
setForm({ ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment.mode ?? "guided-install", shell: deployment.shell ?? "" });
|
||
setEditDeployment({ instance, deployment });
|
||
} catch (error) {
|
||
const operationId = operations.begin({ intent: "读取部署设置", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
operations.fail(operationId, error instanceof Error ? error.message : "部署设置加载失败");
|
||
}
|
||
}
|
||
|
||
async function handleUpdateDeployment(nextForm: ServerCreateFormState) {
|
||
if (!editDeployment) return;
|
||
const { instance } = editDeployment;
|
||
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
try {
|
||
await platformApiClient.updateServerDeployment(instance.id, { runEndpointId: nextForm.runEndpointId || undefined, mode: nextForm.deploymentMode, profileKey: nextForm.profileKey || undefined, createInputs: nextForm.createInputs, serverRoot: nextForm.serverRoot.trim() || undefined, workingDirectory: nextForm.workingDirectory.trim() || undefined, installCommand: nextForm.installCommand.trim() || undefined, startCommand: nextForm.startCommand.trim() || undefined, stopCommand: nextForm.stopCommand.trim() || undefined, statusCommand: nextForm.statusCommand.trim() || undefined, shell: nextForm.shell || undefined });
|
||
operations.succeed(operationId, "部署设置已保存;路径和命令保持受保护状态。");
|
||
setEditDeployment(null);
|
||
await refresh();
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "部署设置保存失败");
|
||
}
|
||
}
|
||
|
||
async function handleDeleteServer() {
|
||
if (!deleteConfirmation) {
|
||
return;
|
||
}
|
||
setDeleteBusy(true);
|
||
const operationId = operations.begin({ intent: "删除服务器", targetKind: "server", targetId: deleteConfirmation.serverInstanceId, requester: session.displayName });
|
||
try {
|
||
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, { password: deletePassword });
|
||
operations.succeed(operationId, `服务器已删除:${deleteConfirmation.serverInstanceId}`);
|
||
setDeleteConfirmation(null);
|
||
setDeletePassword("");
|
||
await refresh();
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "服务器删除失败", operationId);
|
||
} finally {
|
||
setDeleteBusy(false);
|
||
setDeletePassword("");
|
||
}
|
||
}
|
||
|
||
|
||
function openRunTargetSelection(instance: ServerInstanceResponse) {
|
||
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
|
||
setRuntimeTaskActions([]);
|
||
setRunTargetSelection({ instance, targetOs: defaults.runOs, targetArch: "amd64" });
|
||
}
|
||
|
||
async function handleRunTargetSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!runTargetSelection) {
|
||
return;
|
||
}
|
||
const { instance, targetOs, targetArch } = runTargetSelection;
|
||
setRunTargetSelection(null);
|
||
await generateRunForTarget(instance, targetOs, targetArch);
|
||
}
|
||
|
||
async function generateRunForTarget(instance: ServerInstanceResponse, targetOs: string, targetArch: string) {
|
||
const intent = quickRuntimeActionLabel("generate-run");
|
||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:generate-run`, requester: session.displayName });
|
||
setRuntimeTaskActions([]);
|
||
try {
|
||
const distribution = await runtimeTask.runTrackedTask({
|
||
title: intent,
|
||
description: `${instance.name}(${instance.id})选择生成平台 ${targetOs}/${targetArch},正在拉取 run 更新并构建可下载产物。`,
|
||
stages: runtimeRunBuildStages,
|
||
start: async () => {
|
||
await requireQuickRuntimeActionAvailable(instance.id, "generate-run");
|
||
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
|
||
return { value: distribution, jobId: distribution.buildJobId };
|
||
},
|
||
poll: (jobId) => platformApiClient.getJob(jobId)
|
||
});
|
||
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
|
||
const message = `run ${distribution.targetOs}/${distribution.targetArch} 已构建完成,artifact ${distribution.artifactId}`;
|
||
operations.succeed(operationId, message);
|
||
runtimeTask.succeedTask(message);
|
||
setRuntimeTaskActions([
|
||
{
|
||
label: "下载 run",
|
||
kind: "primary",
|
||
onClick: () => void downloadGeneratedRun(instance, artifact)
|
||
},
|
||
{
|
||
label: "更新 run",
|
||
disabled: !serverIsOnline(instance.state),
|
||
title: serverIsOnline(instance.state) ? "更新 run" : "run 未运行,无法在线更新",
|
||
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
|
||
}
|
||
]);
|
||
await refresh();
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "运行操作失败";
|
||
operations.fail(operationId, message, operationId);
|
||
runtimeTask.failTask(message);
|
||
}
|
||
}
|
||
|
||
async function handleQuickRuntimeAction(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction) {
|
||
if (action === "generate-run") {
|
||
openRunTargetSelection(instance);
|
||
return;
|
||
}
|
||
if (action === "reset-run-key") {
|
||
const confirmed = typeof window === "undefined" || window.confirm("重置 run 密钥会立刻断开已部署 run,并要求重新生成新的 run 可执行文件。继续?");
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
}
|
||
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
|
||
const intent = quickRuntimeActionLabel(action);
|
||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
|
||
setRuntimeTaskActions([]);
|
||
let generatedClientProfile: string | null = null;
|
||
try {
|
||
await requireQuickRuntimeActionAvailable(instance.id, action);
|
||
let message: string;
|
||
if (action === "generate-client-manager") {
|
||
const distribution = await runtimeTask.runTrackedTask({
|
||
title: intent,
|
||
description: quickRuntimeTaskDescription(instance, action),
|
||
stages: runtimeBuildStages,
|
||
start: async () => {
|
||
const distribution = await platformApiClient.generateClientManager(
|
||
instance.id,
|
||
clientManagerBuildRequest({
|
||
serverInstanceId: instance.id,
|
||
profileKey: defaults.clientProfileKey,
|
||
targetOs: defaults.clientOs,
|
||
targetArch: "amd64",
|
||
repositoryUrl: defaults.repositoryUrl,
|
||
sourceRevision: "main"
|
||
})
|
||
);
|
||
return { value: distribution, jobId: distribution.buildJobId };
|
||
},
|
||
poll: (jobId) => platformApiClient.getJob(jobId)
|
||
});
|
||
generatedClientProfile = defaults.clientProfileKey;
|
||
message = `客户端二进制已构建并上传,artifact ${distribution.artifactId}`;
|
||
} else {
|
||
message = await runtimeTask.runTask({
|
||
title: intent,
|
||
description: quickRuntimeTaskDescription(instance, action),
|
||
stages: quickRuntimeStages(action),
|
||
executeStageIndex: quickRuntimeExecuteStageIndex(action),
|
||
execute: async () => {
|
||
if (action === "download-run") {
|
||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||
return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`;
|
||
}
|
||
if (action === "push-run-update") {
|
||
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
|
||
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
|
||
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
|
||
}
|
||
if (action === "reset-run-key") {
|
||
const key = await platformApiClient.resetRunKey(instance.id);
|
||
return `run 密钥已重置到第 ${key.generation} 代,旧 run 会话已失效,请重新生成并部署 run`;
|
||
}
|
||
if (action === "dependencies-check") {
|
||
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
|
||
return `依赖检查任务已排队,job ${job.id}`;
|
||
}
|
||
if (action === "dependencies-install") {
|
||
const catalog = await platformApiClient.getDependencyCatalog(instance.id);
|
||
const plan = catalog.plans.find((candidate) => candidate.key === defaults.installPlanKey);
|
||
const probe = catalog.probes.find((candidate) => candidate.key === defaults.probeKey);
|
||
if (!plan || probe?.installPlanKey !== plan.key) throw new Error("Platform 未返回与当前 probe 匹配的审核安装计划");
|
||
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
|
||
return `依赖安装任务已排队,job ${job.id}`;
|
||
}
|
||
if (action === "live-logs") {
|
||
onNavigate("serverDetail", { serverId: instance.id });
|
||
return "已打开服务器详情,可切换到日志页查看实时日志";
|
||
}
|
||
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
|
||
return `历史日志回填任务已排队,job ${job.id}`;
|
||
}
|
||
});
|
||
}
|
||
operations.succeed(operationId, message);
|
||
runtimeTask.succeedTask(message);
|
||
if (generatedClientProfile) {
|
||
const clientProfile = generatedClientProfile;
|
||
setRuntimeTaskActions([
|
||
{
|
||
label: "下载客户端",
|
||
kind: "primary",
|
||
onClick: () => void downloadGeneratedClient(instance, clientProfile)
|
||
}
|
||
]);
|
||
}
|
||
await refresh();
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "运行操作失败";
|
||
operations.fail(operationId, message, operationId);
|
||
runtimeTask.failTask(message);
|
||
}
|
||
}
|
||
|
||
async function downloadGeneratedRun(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
|
||
setRuntimeTaskActions([]);
|
||
try {
|
||
const message = await runtimeTask.runTask({
|
||
title: "下载 run",
|
||
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}。`,
|
||
stages: runtimeDownloadStages,
|
||
executeStageIndex: 1,
|
||
execute: async () => {
|
||
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
|
||
}
|
||
});
|
||
runtimeTask.succeedTask(message);
|
||
} catch (error) {
|
||
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
|
||
}
|
||
}
|
||
|
||
async function pushGeneratedRunUpdate(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
|
||
setRuntimeTaskActions([]);
|
||
const operationId = operations.begin({ intent: "更新 run", targetKind: "server", targetId: `${instance.id}:push-run-update`, requester: session.displayName });
|
||
try {
|
||
const message = await runtimeTask.runTask({
|
||
title: "更新 run",
|
||
description: `${instance.name} 将使用刚生成的 artifact ${artifact.artifactId} 派发 run 自更新任务。`,
|
||
stages: runtimeUpdateStages,
|
||
executeStageIndex: 2,
|
||
execute: async () => {
|
||
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
|
||
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
|
||
}
|
||
});
|
||
operations.succeed(operationId, message);
|
||
runtimeTask.succeedTask(message);
|
||
await refresh();
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "推送 run 更新失败";
|
||
operations.fail(operationId, message, operationId);
|
||
runtimeTask.failTask(message);
|
||
}
|
||
}
|
||
|
||
async function downloadGeneratedClient(instance: ServerInstanceResponse, profileKey: string) {
|
||
setRuntimeTaskActions([]);
|
||
try {
|
||
const message = await runtimeTask.runTask({
|
||
title: "下载客户端",
|
||
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
|
||
stages: runtimeDownloadStages,
|
||
executeStageIndex: 1,
|
||
execute: async () => {
|
||
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
|
||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
|
||
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
|
||
}
|
||
});
|
||
runtimeTask.succeedTask(message);
|
||
} catch (error) {
|
||
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
|
||
}
|
||
}
|
||
|
||
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
|
||
const runTargetPlugin = runTargetSelection ? plugins.find((plugin) => plugin.id === runTargetSelection.instance.pluginId) : undefined;
|
||
const runTargetOsOptions = runPlatformOptions(runTargetPlugin, runTargetSelection?.targetOs ?? "linux");
|
||
const runTargetArchOptions = ["amd64", "arm64"];
|
||
|
||
return (
|
||
<section className="servers-page" aria-labelledby="server-page-title">
|
||
<header className="page-header">
|
||
<div>
|
||
<p className="page-kicker">{isPlatformAdmin(session) ? "平台管理员" : "我的服务器"}</p>
|
||
<h1 id="server-page-title" className="page-title">
|
||
服务器管理
|
||
</h1>
|
||
</div>
|
||
<div className="action-strip">
|
||
<button type="button" className="icon-command" title="刷新服务器状态" onClick={() => void refresh()}>
|
||
<Sparkles size={16} />
|
||
<span>刷新</span>
|
||
</button>
|
||
<button type="button" className="icon-command" title={canManageServers ? "创建服务器" : "当前账号没有创建权限"} disabled={!canManageServers} onClick={() => setShowCreate((current) => !current)}>
|
||
<Candy size={16} />
|
||
<span>创建服务器</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{metricsError && (
|
||
<div className="operations-inline-warning" role="status">
|
||
<AlertTriangle size={14} />
|
||
<span>服务器指标不可用:{metricsError}</span>
|
||
<button type="button" className="inline-link-command" onClick={() => void refreshMetrics()}>
|
||
重试指标
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{latestCreate && (
|
||
<div className="inline-result-strip" aria-live="polite">
|
||
<ResultBadge
|
||
status={latestCreate.status}
|
||
label={
|
||
latestCreate.status === "pending"
|
||
? "创建中…"
|
||
: latestCreate.status === "succeeded"
|
||
? (latestCreate.message ?? "创建成功")
|
||
: `创建失败:${latestCreate.errorReason ?? "未知原因"}(诊断 ${latestCreate.diagnosticId ?? latestCreate.id})`
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<ServerDeploymentWorkflow open={showCreate && canManageServers} kind="create" plugins={plugins} endpoints={endpoints} initialForm={form} busy={createPending} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />
|
||
<ServerDeploymentWorkflow open={editDeployment !== null} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={form} deployment={editDeployment?.deployment} busy={editDeployment ? operations.isPending(editDeployment.instance.id, "更新部署定义") : false} onClose={() => setEditDeployment(null)} onSubmit={handleUpdateDeployment} />
|
||
|
||
<ManagementDialog
|
||
open={runTargetSelection !== null}
|
||
title="选择生成平台"
|
||
description="选择 run 目标平台和架构后,平台会展示拉取 run 更新、检测构建环境、构建中、构建完成的进度。"
|
||
onClose={() => setRunTargetSelection(null)}
|
||
>
|
||
<form className="provider-form dialog-form" onSubmit={(event) => void handleRunTargetSubmit(event)} aria-label="选择生成平台">
|
||
<div className="form-grid">
|
||
<label>
|
||
生成平台
|
||
<select
|
||
value={runTargetSelection?.targetOs ?? ""}
|
||
onChange={(event) => setRunTargetSelection((current) => (current ? { ...current, targetOs: event.target.value } : current))}
|
||
required
|
||
>
|
||
{runTargetOsOptions.map((option) => (
|
||
<option key={option} value={option}>
|
||
{runPlatformLabel(option)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
架构
|
||
<select
|
||
value={runTargetSelection?.targetArch ?? "amd64"}
|
||
onChange={(event) => setRunTargetSelection((current) => (current ? { ...current, targetArch: event.target.value } : current))}
|
||
required
|
||
>
|
||
{runTargetArchOptions.map((option) => (
|
||
<option key={option} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="confirm-actions">
|
||
<button type="button" onClick={() => setRunTargetSelection(null)}>取消</button>
|
||
<button type="submit" className="confirm-primary">
|
||
<Sparkles size={16} />
|
||
<span>开始生成</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</ManagementDialog>
|
||
|
||
<div className="server-toolbar" role="search">
|
||
<Search size={16} aria-hidden="true" />
|
||
<input
|
||
type="search"
|
||
value={keyword}
|
||
placeholder="搜索服务器名称、ID 或插件"
|
||
aria-label="搜索服务器"
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
/>
|
||
{statusFilters.map((filter) => (
|
||
<button
|
||
key={filter.id}
|
||
type="button"
|
||
className={cx("segmented-button", statusFilter === filter.id && "segmented-button-active")}
|
||
onClick={() => setStatusFilter(filter.id)}
|
||
>
|
||
{filter.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{listState === "loading" && <LoadingState label="正在加载服务器列表…" />}
|
||
{listState === "error" && <ErrorState title="服务器列表加载失败" reason={listError} diagnosticId="server-list" onRetry={() => void refreshList()} />}
|
||
{listState === "ready" && cards.length === 0 && (
|
||
<EmptyState
|
||
icon={<CakeSlice size={26} />}
|
||
title="暂无可管理的服务器"
|
||
description={
|
||
isPlatformAdmin(session)
|
||
? "平台还没有服务器实例。点击上方“创建服务器”开始,或检查运行节点状态。"
|
||
: "当前账号名下没有可管理的服务器。如果这不符合预期,请联系平台管理员为你分配服务器,或点击刷新重试。"
|
||
}
|
||
actionLabel="刷新"
|
||
onAction={() => void refresh()}
|
||
/>
|
||
)}
|
||
{listState === "ready" && cards.length > 0 && visibleCards.length === 0 && (
|
||
<EmptyState title="没有匹配的服务器" description="调整搜索关键字或状态筛选后再试。" actionLabel="清除筛选" onAction={() => {
|
||
setKeyword("");
|
||
setStatusFilter("all");
|
||
}} />
|
||
)}
|
||
|
||
{visibleCards.length > 0 && (
|
||
<div className="server-card-grid" aria-label="server list">
|
||
{visibleCards.map((card) => (
|
||
<ServerCard
|
||
key={card.instance.id}
|
||
card={card}
|
||
metricsPending={metricsPending}
|
||
metricsUnavailable={Boolean(metricsError)}
|
||
canManage={canManageServers}
|
||
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
|
||
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
|
||
onEdit={() => void openEditDeployment(card.instance)}
|
||
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
||
onDelete={() => {
|
||
setDeletePassword("");
|
||
setDeleteConfirmation(serverDeleteConfirmation(card.instance));
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<ConfirmDialog
|
||
open={deleteConfirmation !== null}
|
||
title="删除服务器"
|
||
description={`确认删除 ${deleteConfirmation?.name ?? ""}(${deleteConfirmation?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
|
||
confirmLabel="确认删除"
|
||
danger
|
||
busy={deleteBusy}
|
||
confirmDisabled={deletePassword.trim() === ""}
|
||
onCancel={() => {
|
||
setDeleteConfirmation(null);
|
||
setDeletePassword("");
|
||
}}
|
||
onConfirm={() => void handleDeleteServer()}
|
||
>
|
||
<label>
|
||
请输入当前登录密码
|
||
<input
|
||
type="password"
|
||
autoComplete="current-password"
|
||
value={deletePassword}
|
||
onChange={(event) => setDeletePassword(event.target.value)}
|
||
/>
|
||
</label>
|
||
</ConfirmDialog>
|
||
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
|
||
</section>
|
||
);
|
||
}
|
||
|
||
type ServerQuickRuntimeAction =
|
||
| "generate-run"
|
||
| "download-run"
|
||
| "push-run-update"
|
||
| "reset-run-key"
|
||
| "generate-client-manager"
|
||
| "dependencies-check"
|
||
| "dependencies-install"
|
||
| "live-logs"
|
||
| "historical-logs";
|
||
|
||
async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> {
|
||
const runtimeActions = await platformApiClient.getServerRuntimeActions(serverInstanceId);
|
||
const runtimeAction = runtimeActions.actions.find((candidate) => candidate.key === action);
|
||
if (!runtimeAction) {
|
||
throw new Error("平台未返回该运行操作");
|
||
}
|
||
if (!runtimeAction.available) {
|
||
throw new Error(`该操作不可用:${runtimeAction.reason || "平台暂未开放该操作"}`);
|
||
}
|
||
}
|
||
|
||
function serverDeleteDisabledReason(session: PageComponentProps["session"], instance: ServerInstanceResponse): string {
|
||
if (!isPlatformAdmin(session) && instance.ownerUserId !== session.id) {
|
||
return "仅创建人或平台管理员可删除";
|
||
}
|
||
if (!canDeleteServer(instance.state)) {
|
||
return "运行中、安装中或已删除的服务器不能直接删除";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
interface ServerCardProps {
|
||
card: ServerCardView;
|
||
metricsPending: boolean;
|
||
metricsUnavailable: boolean;
|
||
canManage: boolean;
|
||
deleteDisabledReason: string;
|
||
onOpen: () => void;
|
||
onEdit: () => void;
|
||
onQuickAction: (action: ServerQuickRuntimeAction) => void;
|
||
onDelete: () => void;
|
||
}
|
||
|
||
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onQuickAction, onDelete }: ServerCardProps) {
|
||
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
|
||
const online = serverIsOnline(instance.state);
|
||
const canDelete = deleteDisabledReason === "";
|
||
const canOpenActions = canManage || canDelete;
|
||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||
const menuPanelRef = useRef<HTMLDivElement>(null);
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||
|
||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||
|
||
const openMenu = useCallback(() => {
|
||
const trigger = menuButtonRef.current;
|
||
if (!trigger) {
|
||
setMenuOpen(true);
|
||
return;
|
||
}
|
||
|
||
const rect = trigger.getBoundingClientRect();
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
const menuWidth = Math.min(320, Math.max(220, viewportWidth - 24));
|
||
const estimatedMenuHeight = 288;
|
||
const left = Math.min(Math.max(12, rect.right - menuWidth), Math.max(12, viewportWidth - menuWidth - 12));
|
||
const belowTop = rect.bottom + 8;
|
||
const top = belowTop + estimatedMenuHeight <= viewportHeight - 12 ? belowTop : Math.max(12, rect.top - estimatedMenuHeight - 8);
|
||
|
||
setMenuStyle({ left, top, width: menuWidth });
|
||
setMenuOpen(true);
|
||
}, []);
|
||
|
||
const toggleMenu = useCallback(() => {
|
||
if (menuOpen) {
|
||
closeMenu();
|
||
return;
|
||
}
|
||
openMenu();
|
||
}, [closeMenu, menuOpen, openMenu]);
|
||
|
||
useEffect(() => {
|
||
if (!menuOpen) {
|
||
return undefined;
|
||
}
|
||
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
const target = event.target;
|
||
if (!(target instanceof Node)) {
|
||
return;
|
||
}
|
||
if (menuButtonRef.current?.contains(target) || menuPanelRef.current?.contains(target)) {
|
||
return;
|
||
}
|
||
closeMenu();
|
||
};
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
closeMenu();
|
||
menuButtonRef.current?.focus();
|
||
}
|
||
};
|
||
|
||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
window.addEventListener("resize", closeMenu);
|
||
window.addEventListener("scroll", closeMenu, true);
|
||
return () => {
|
||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||
document.removeEventListener("keydown", handleKeyDown);
|
||
window.removeEventListener("resize", closeMenu);
|
||
window.removeEventListener("scroll", closeMenu, true);
|
||
};
|
||
}, [closeMenu, menuOpen]);
|
||
|
||
const chooseQuickAction = (action: ServerQuickRuntimeAction) => {
|
||
if (!canManage) {
|
||
return;
|
||
}
|
||
closeMenu();
|
||
onQuickAction(action);
|
||
};
|
||
|
||
return (
|
||
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
|
||
<div className="server-card-head">
|
||
<span>
|
||
<strong>{instance.name}</strong>
|
||
<span className="provider-id">{instance.id}</span>
|
||
</span>
|
||
<span className={cx("status-pill", statusClass(instance.state))}>{stateLabel(instance.state)}</span>
|
||
</div>
|
||
<div className="server-card-stats">
|
||
<span className="server-card-stat">
|
||
<span>玩家</span>
|
||
<strong>{formatStat(metrics?.playerCount, metricsPending, (value) => (metrics?.maxPlayers ? `${value}/${metrics.maxPlayers}` : `${value}`))}</strong>
|
||
</span>
|
||
<span className="server-card-stat">
|
||
<span>TPS</span>
|
||
<strong>{formatStat(metrics?.tps, metricsPending, (value) => value.toFixed(1))}</strong>
|
||
</span>
|
||
<span className="server-card-stat">
|
||
<span>延迟</span>
|
||
<strong>{formatStat(metrics?.latencyMs, metricsPending, (value) => `${Math.round(value)}ms`)}</strong>
|
||
</span>
|
||
<span className="server-card-stat">
|
||
<span>任务</span>
|
||
<strong>{failedJobs > 0 ? `${failedJobs} 失败` : pendingJobs > 0 ? `${pendingJobs} 进行中` : online ? "空闲" : "--"}</strong>
|
||
</span>
|
||
</div>
|
||
<div className="server-card-meters">
|
||
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
|
||
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
|
||
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
|
||
</div>
|
||
{metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} />指标不可用</span>}
|
||
{failedJobs > 0 && <span className="server-card-warning"><AlertTriangle size={13} />存在失败任务,打开详情恢复</span>}
|
||
<div className="action-strip" style={{ justifyContent: "space-between" }}>
|
||
<button type="button" className="icon-command" onClick={onOpen}>
|
||
<Sparkles size={14} />
|
||
<span>详情</span>
|
||
</button>
|
||
<button type="button" className="icon-command" disabled={!canManage || instance.state === "running" || instance.state === "installing"} title={instance.state === "running" || instance.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={onEdit}><span>编辑部署</span></button>
|
||
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
|
||
<span>运行操作</span>
|
||
</button>
|
||
</div>
|
||
{menuOpen &&
|
||
typeof document !== "undefined" &&
|
||
createPortal(
|
||
<div ref={menuPanelRef} className="runtime-action-popover" style={menuStyle} role="menu" aria-label={`${instance.name} 运行操作`}>
|
||
{serverQuickActionGroups.map((group) => (
|
||
<section key={group.label} className="runtime-action-group" aria-label={group.label}>
|
||
<span className="runtime-action-group-label">{group.label}</span>
|
||
<div className="runtime-action-grid">
|
||
{group.actions.map((action) => (
|
||
<button key={action} type="button" className="runtime-action-item" role="menuitem" disabled={!canManage} title={canManage ? quickRuntimeActionLabel(action) : "当前账号没有运行操作权限"} onClick={() => chooseQuickAction(action)}>
|
||
<span>{quickRuntimeActionLabel(action)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
<section className="runtime-action-group" aria-label="危险操作">
|
||
<span className="runtime-action-group-label">危险操作</span>
|
||
<div className="runtime-action-grid">
|
||
<button
|
||
type="button"
|
||
className="runtime-action-item danger-command"
|
||
role="menuitem"
|
||
disabled={!canManage}
|
||
title={canManage ? "重置 run 密钥" : "当前账号没有运行操作权限"}
|
||
onClick={() => chooseQuickAction("reset-run-key")}
|
||
>
|
||
<AlertTriangle size={13} />
|
||
<span>重置 run 密钥</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="runtime-action-item danger-command"
|
||
role="menuitem"
|
||
disabled={deleteDisabledReason !== ""}
|
||
title={deleteDisabledReason || "删除服务器"}
|
||
onClick={() => {
|
||
closeMenu();
|
||
onDelete();
|
||
}}
|
||
>
|
||
<Trash2 size={13} />
|
||
<span>删除服务器</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntimeAction[] }> = [
|
||
{
|
||
label: "运行分发",
|
||
actions: ["generate-run", "download-run", "push-run-update", "generate-client-manager"]
|
||
},
|
||
{
|
||
label: "诊断维护",
|
||
actions: ["dependencies-check", "dependencies-install", "live-logs", "historical-logs"]
|
||
}
|
||
];
|
||
|
||
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||
switch (action) {
|
||
case "generate-run":
|
||
return "生成 run";
|
||
case "download-run":
|
||
return "下载 run";
|
||
case "push-run-update":
|
||
return "更新 run";
|
||
case "reset-run-key":
|
||
return "重置 run 密钥";
|
||
case "generate-client-manager":
|
||
return "生成客户端";
|
||
case "dependencies-check":
|
||
return "依赖检查";
|
||
case "dependencies-install":
|
||
return "依赖安装";
|
||
case "live-logs":
|
||
return "实时日志";
|
||
case "historical-logs":
|
||
return "历史日志";
|
||
}
|
||
}
|
||
|
||
function quickRuntimeStages(action: ServerQuickRuntimeAction) {
|
||
if (action === "generate-run") {
|
||
return runtimeRunBuildStages;
|
||
}
|
||
if (action === "generate-client-manager") {
|
||
return runtimeBuildStages;
|
||
}
|
||
if (action === "download-run") {
|
||
return runtimeDownloadStages;
|
||
}
|
||
if (action === "push-run-update") {
|
||
return runtimeUpdateStages;
|
||
}
|
||
if (action === "reset-run-key") {
|
||
return runtimeKeyResetStages;
|
||
}
|
||
if (action === "dependencies-check" || action === "dependencies-install") {
|
||
return runtimeDependencyStages;
|
||
}
|
||
return runtimeLogStages;
|
||
}
|
||
|
||
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
|
||
if (action === "generate-run" || action === "generate-client-manager") {
|
||
return 3;
|
||
}
|
||
if (action === "push-run-update") {
|
||
return 2;
|
||
}
|
||
if (action === "reset-run-key") {
|
||
return 2;
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
function quickRuntimeTaskDescription(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction): string {
|
||
const label = quickRuntimeActionLabel(action);
|
||
return `${instance.name}(${instance.id})${label},通过平台 API 派发并保留可追踪进度。`;
|
||
}
|
||
|
||
function quickRuntimeDefaultsForPlugin(pluginId: string) {
|
||
const isScum = pluginId.toLowerCase().includes("scum");
|
||
return {
|
||
runOs: isScum ? "windows" : "linux",
|
||
clientOs: "windows",
|
||
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
|
||
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
||
probeKey: isScum ? "steamcmd" : "java-21",
|
||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||
logSourceKey: isScum ? "server-log" : "latest-log"
|
||
};
|
||
}
|
||
|
||
function runPlatformOptions(plugin: GamePluginResponse | undefined, fallback: string): string[] {
|
||
const options = new Set<string>();
|
||
const add = (value: string | undefined) => {
|
||
const normalized = value?.trim().toLowerCase();
|
||
if (normalized) {
|
||
options.add(normalized);
|
||
}
|
||
};
|
||
add(fallback);
|
||
plugin?.supportedOs?.forEach(add);
|
||
plugin?.runtimeProfiles?.lifecycleProfiles?.forEach((profile) => profile.platforms?.forEach(add));
|
||
["linux", "windows", "darwin"].forEach(add);
|
||
return [...options];
|
||
}
|
||
|
||
function runPlatformLabel(platform: string): string {
|
||
switch (platform) {
|
||
case "linux":
|
||
return "Linux";
|
||
case "windows":
|
||
return "Windows";
|
||
case "darwin":
|
||
return "macOS";
|
||
default:
|
||
return platform;
|
||
}
|
||
}
|
||
|
||
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
|
||
if (typeof value === "number" && Number.isFinite(value)) {
|
||
return format(value);
|
||
}
|
||
return pending ? "…" : "--";
|
||
}
|
||
|
||
export function stateLabel(state: ServerInstanceResponse["state"]): string {
|
||
switch (state) {
|
||
case "installing":
|
||
return "安装中";
|
||
case "ready":
|
||
return "就绪";
|
||
case "running":
|
||
return "运行中";
|
||
case "stopped":
|
||
return "已停止";
|
||
case "failed":
|
||
return "异常";
|
||
case "draft":
|
||
return "草稿";
|
||
case "deleted":
|
||
return "已删除";
|
||
}
|
||
}
|
||
|
||
export function statusClass(state: ServerInstanceResponse["state"]): string {
|
||
if (state === "running" || state === "ready") {
|
||
return "status-active";
|
||
}
|
||
if (state === "failed" || state === "deleted") {
|
||
return "status-error";
|
||
}
|
||
return "status-disabled";
|
||
}
|