776 lines
32 KiB
TypeScript
776 lines
32 KiB
TypeScript
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
|
||
import { type CSSProperties, type ChangeEvent, 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,
|
||
runtimeLogStages,
|
||
runtimeUpdateStages,
|
||
useRuntimeTaskController
|
||
} from "../components/RuntimeTaskProgress";
|
||
import { UsageMeter } from "../components/OperationControls";
|
||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import {
|
||
defaultServerCreateForm,
|
||
endpointLabel,
|
||
pendingJobsForServer,
|
||
pluginLabel,
|
||
runtimeBindingFields,
|
||
type ServerCreateFormState
|
||
} from "../contracts/serverManagement";
|
||
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
|
||
import {
|
||
clientManagerBuildRequest,
|
||
dependencyJobRequest,
|
||
logBackfillRequest,
|
||
runDistributionGenerateRequest,
|
||
runUpdateRequest,
|
||
serverCreateRequestFromForm
|
||
} from "../schemas/serverManagement";
|
||
import { isPlatformAdmin } from "../contracts/workspace";
|
||
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
|
||
import { cx } from "../utils/classes";
|
||
|
||
type ListState = "loading" | "ready" | "error";
|
||
|
||
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 [keyword, setKeyword] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
|
||
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
const runtimeTask = useRuntimeTaskController();
|
||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||
|
||
const refresh = 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 : "加载失败");
|
||
}
|
||
setMetricsPending(true);
|
||
try {
|
||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||
setMetrics(new Map(metricsResponse.items.map((item) => [item.serverInstanceId, item])));
|
||
} catch {
|
||
setMetrics(new Map());
|
||
} finally {
|
||
setMetricsPending(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
const cards = useMemo<ServerCardView[]>(
|
||
() =>
|
||
instances.map((instance) => ({
|
||
instance,
|
||
metrics: metrics.get(instance.id),
|
||
pendingJobs: pendingJobsForServer(jobs, instance.id).length
|
||
})),
|
||
[instances, jobs, metrics]
|
||
);
|
||
|
||
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
|
||
const createPending = operations.isPending("platform", "创建服务器");
|
||
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
|
||
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
|
||
|
||
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
||
const { name, value } = event.target;
|
||
setForm((current) => {
|
||
if (name === "pluginId") {
|
||
const plugin = plugins.find((item) => item.id === value);
|
||
return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {} };
|
||
}
|
||
if (name === "profileKey") {
|
||
return { ...current, profileKey: value, bindings: {} };
|
||
}
|
||
return { ...current, [name]: value };
|
||
});
|
||
}
|
||
|
||
function updateBinding(key: string, value: string) {
|
||
setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } }));
|
||
}
|
||
|
||
async function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
|
||
try {
|
||
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(form));
|
||
operations.succeed(operationId, `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发`, result.job);
|
||
setForm(defaultServerCreateForm(plugins, endpoints));
|
||
setShowCreate(false);
|
||
await refresh();
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
|
||
}
|
||
}
|
||
|
||
|
||
async function handleQuickRuntimeAction(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction) {
|
||
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 generatedRunArtifact: { artifactId: string; checksum?: string } | null = null;
|
||
let generatedClientProfile: string | null = null;
|
||
try {
|
||
let message: string;
|
||
if (action === "generate-run") {
|
||
const distribution = await runtimeTask.runTrackedTask({
|
||
title: intent,
|
||
description: quickRuntimeTaskDescription(instance, action),
|
||
stages: runtimeBuildStages,
|
||
start: async () => {
|
||
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
|
||
return { value: distribution, jobId: distribution.buildJobId };
|
||
},
|
||
poll: (jobId) => platformApiClient.getJob(jobId)
|
||
});
|
||
generatedRunArtifact = { artifactId: distribution.artifactId };
|
||
message = `run 二进制已构建并上传,artifact ${distribution.artifactId}`;
|
||
} else 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 === "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 (generatedRunArtifact) {
|
||
const artifact = generatedRunArtifact;
|
||
setRuntimeTaskActions([
|
||
{
|
||
label: "下载 run",
|
||
kind: "primary",
|
||
onClick: () => void downloadGeneratedRun(instance, artifact)
|
||
},
|
||
{
|
||
label: "推送更新",
|
||
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
|
||
}
|
||
]);
|
||
} else 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 === "创建服务器");
|
||
|
||
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="创建服务器" onClick={() => setShowCreate((current) => !current)}>
|
||
<Candy size={16} />
|
||
<span>创建服务器</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{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>
|
||
)}
|
||
|
||
{showCreate && (
|
||
<form className="provider-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
|
||
<div className="form-header">
|
||
<h2>创建服务器</h2>
|
||
</div>
|
||
<div className="form-grid">
|
||
<label>
|
||
实例 ID
|
||
<input name="id" value={form.id} onChange={updateForm} placeholder="server-example-3" required />
|
||
</label>
|
||
<label>
|
||
名称
|
||
<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required />
|
||
</label>
|
||
<label>
|
||
插件
|
||
<select name="pluginId" value={form.pluginId} onChange={updateForm} required>
|
||
{plugins.map((plugin) => (
|
||
<option key={plugin.id} value={plugin.id}>
|
||
{pluginLabel(plugin, plugin.id)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
运行节点
|
||
<select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} required>
|
||
{endpoints.map((endpoint) => (
|
||
<option key={endpoint.id} value={endpoint.id}>
|
||
{endpointLabel(endpoint, endpoint.id)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
运行配置
|
||
<select name="profileKey" value={form.profileKey} onChange={updateForm} required>
|
||
{(selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? []).map((profile) => (
|
||
<option key={profile.key} value={profile.key}>
|
||
{profile.key} · {profile.mode}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{createBindingFields.map((field) => (
|
||
<label key={field.key}>
|
||
{field.key}{field.required ? "(必填)" : ""}
|
||
<input
|
||
type={field.sensitive ? "password" : "text"}
|
||
autoComplete="off"
|
||
value={form.bindings[field.key] ?? ""}
|
||
onChange={(event) => updateBinding(field.key, event.target.value)}
|
||
placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"}
|
||
required={field.required}
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<button type="submit" className="primary-command" disabled={createPending || !form.profileKey} title="创建服务器">
|
||
<Sparkles size={16} />
|
||
<span>{createPending ? "创建中…" : "创建并安装"}</span>
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
<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 refresh()} />}
|
||
{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}
|
||
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
|
||
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
|
||
</section>
|
||
);
|
||
}
|
||
|
||
type ServerQuickRuntimeAction =
|
||
| "generate-run"
|
||
| "download-run"
|
||
| "push-run-update"
|
||
| "generate-client-manager"
|
||
| "dependencies-check"
|
||
| "dependencies-install"
|
||
| "live-logs"
|
||
| "historical-logs";
|
||
|
||
interface ServerCardProps {
|
||
card: ServerCardView;
|
||
metricsPending: boolean;
|
||
onOpen: () => void;
|
||
onQuickAction: (action: ServerQuickRuntimeAction) => void;
|
||
}
|
||
|
||
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
|
||
const { instance, metrics, pendingJobs } = card;
|
||
const online = serverIsOnline(instance.state);
|
||
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 = 232;
|
||
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) => {
|
||
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>{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>
|
||
<div className="action-strip" style={{ justifyContent: "space-between" }}>
|
||
<button type="button" className="icon-command" onClick={onOpen}>
|
||
<Sparkles size={14} />
|
||
<span>详情</span>
|
||
</button>
|
||
<button ref={menuButtonRef} type="button" className="icon-command" 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" onClick={() => chooseQuickAction(action)}>
|
||
<span>{quickRuntimeActionLabel(action)}</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 "推送更新";
|
||
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" || action === "generate-client-manager") {
|
||
return runtimeBuildStages;
|
||
}
|
||
if (action === "download-run") {
|
||
return runtimeDownloadStages;
|
||
}
|
||
if (action === "push-run-update") {
|
||
return runtimeUpdateStages;
|
||
}
|
||
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;
|
||
}
|
||
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 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";
|
||
}
|