Files
browser/platform_web/pages/ServersPage.tsx
T

1056 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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,
pluginCreateInputDefaults,
runtimeObservationFreshness,
type ServerCreateFormState
} from "../contracts/serverManagement";
import { summarizeServerOperations } from "../contracts/operationsConsole";
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
import {
clientManagerBuildRequest,
dependencyJobRequest,
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: "需关注" }
];
const serverListRefreshMs = 5000;
const serverMetricFreshMs = 30000;
const serverForceDeleteConfirmation = "FORCE DELETE";
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 [deleteForceConfirmation, setDeleteForceConfirmation] = useState("");
const [deleteBusy, setDeleteBusy] = useState(false);
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
const refreshList = useCallback(async (showLoading = true) => {
if (showLoading) 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);
if (showLoading) setForm((current) => {
const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0];
return {
...current,
pluginId: plugin?.id ?? "",
};
});
if (showLoading) {
setListState("ready");
setListError("");
}
} catch (error) {
if (showLoading) 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]);
useEffect(() => {
const timer = window.setInterval(() => {
void refreshList(false);
void refreshMetrics();
}, serverListRefreshMs);
return () => window.clearInterval(timer);
}, [refreshList, refreshMetrics]);
const cards = useMemo<ServerCardView[]>(
() =>
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
instance: summary.instance,
endpoint: endpoints.find((endpoint) => endpoint.id === summary.instance.runEndpointId),
metrics: summary.metrics,
pendingJobs: summary.activeJobs,
activeJobs: summary.activeJobs,
failedJobs: summary.failedJobs,
latestJob: summary.latestJob
})),
[endpoints, 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));
setShowCreate(false);
await refresh();
onNavigate("serverDetail", { serverId: result.instance.id });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
}
}
async function openEditDeployment(instance: ServerInstanceResponse) {
try {
const deployment = await platformApiClient.getServerDeployment(instance.id);
const plugin = plugins.find((item) => item.id === instance.pluginId);
setForm({ ...defaultServerCreateForm(plugins), name: instance.name, pluginId: instance.pluginId, 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, { mode: nextForm.deploymentMode, 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;
}
const forceDelete = deleteConfirmation.state === "running" || deleteConfirmation.state === "installing";
setDeleteBusy(true);
const operationId = operations.begin({ intent: "删除服务器", targetKind: "server", targetId: deleteConfirmation.serverInstanceId, requester: session.displayName });
try {
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, {
password: deletePassword,
force: forceDelete,
confirmation: forceDelete ? deleteForceConfirmation : undefined
});
operations.succeed(operationId, `服务器已删除:${deleteConfirmation.serverInstanceId}`);
setDeleteConfirmation(null);
setDeletePassword("");
setDeleteForceConfirmation("");
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}`;
}
throw new Error("该运行操作已下线");
}
});
}
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} initialForm={form} busy={createPending} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />
<ServerDeploymentWorkflow open={editDeployment !== null} kind="edit" plugins={plugins} initialForm={form} deployment={editDeployment?.deployment} busy={editDeployment ? operations.isPending(editDeployment.instance.id, "更新部署定义") : false} onReveal={() => platformApiClient.revealServerDeployment(editDeployment?.instance.id ?? "")} 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"
name="server-list-search"
autoComplete="off"
data-1p-ignore="true"
data-lpignore="true"
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)
? "平台还没有服务器实例。点击上方“创建服务器”开始,再生成并启动 Run。"
: "当前账号名下没有可管理的服务器。如果这不符合预期,请联系平台管理员为你分配服务器,或点击刷新重试。"
}
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("");
setDeleteForceConfirmation("");
setDeleteConfirmation(serverDeleteConfirmation(card.instance));
}}
/>
))}
</div>
)}
<ConfirmDialog
open={deleteConfirmation !== null}
title="删除服务器"
description={deleteDialogDescription(deleteConfirmation)}
confirmLabel="确认删除"
danger
busy={deleteBusy}
confirmDisabled={deletePassword.trim() === "" || (deleteRequiresForceConfirmation(deleteConfirmation) && deleteForceConfirmation.trim() !== serverForceDeleteConfirmation)}
onCancel={() => {
setDeleteConfirmation(null);
setDeletePassword("");
setDeleteForceConfirmation("");
}}
onConfirm={() => void handleDeleteServer()}
>
<label>
请输入当前登录密码
<input
type="password"
name="server-delete-password-confirmation"
autoComplete="new-password"
data-1p-ignore="true"
data-lpignore="true"
data-bwignore="true"
data-protonpass-ignore="true"
value={deletePassword}
onChange={(event) => setDeletePassword(event.target.value)}
/>
</label>
{deleteRequiresForceConfirmation(deleteConfirmation) && (
<label>
运行中/安装中的服务器将被强制软删除;这不会停止远端进程。请输入 {serverForceDeleteConfirmation}
<input
value={deleteForceConfirmation}
onChange={(event) => setDeleteForceConfirmation(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";
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 "";
}
function deleteRequiresForceConfirmation(confirmation: ReturnType<typeof serverDeleteConfirmation> | null): boolean {
return confirmation?.state === "running" || confirmation?.state === "installing";
}
function deleteDialogDescription(confirmation: ReturnType<typeof serverDeleteConfirmation> | null): string {
const name = confirmation?.name ?? "";
const id = confirmation?.serverInstanceId ?? "";
if (deleteRequiresForceConfirmation(confirmation)) {
return `确认强制删除 ${name}${id})?平台只会把实例标记为已删除并保留历史记录,不会停止可能仍在远端运行的进程。`;
}
return `确认删除 ${name}${id})?历史记录会保留。`;
}
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, endpoint, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const freshness = runtimeObservationFreshness(instance, endpoint);
const canDelete = deleteDisabledReason === "";
const canOpenActions = canManage || canDelete;
const metricsWaiting = metrics?.source === "run-metrics-pending";
const metricsStale = isMetricsStale(metrics);
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", freshness === "fresh" ? statusClass(instance.state) : "status-disabled")}>{freshness === "fresh" ? stateLabel(instance.state) : `最后观测:${stateLabel(instance.state)}Run 未验证)`}</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} pending={metricsPending || metricsWaiting} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} pending={metricsPending || metricsWaiting} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} pending={metricsPending || metricsWaiting} />
</div>
{metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} />指标不可用</span>}
{!metricsUnavailable && metricsWaiting && <span className="server-card-warning"><AlertTriangle size={13} />等待指标上报</span>}
{!metricsUnavailable && metricsStale && <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} title={canManage ? "编辑部署" : "当前账号没有运行操作权限"} 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"]
}
];
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 "依赖安装";
}
}
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 runtimeDependencyStages;
}
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"
};
}
export 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);
}
};
plugin?.supportedOs?.forEach(add);
if (options.size === 0) {
plugin?.runtimeProfiles?.lifecycleProfiles?.forEach((profile) => profile.platforms?.forEach(add));
}
if (options.size === 0) {
add(fallback);
}
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 ? "…" : "--";
}
function isMetricsStale(metrics?: ServerMetricsResponse): boolean {
if (!metrics || metrics.source === "run-metrics-pending") return false;
const collectedAt = new Date(metrics.collectedAt).getTime();
return Number.isFinite(collectedAt) && Date.now() - collectedAt > serverMetricFreshMs;
}
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";
}