Files
browser/platform_web/pages/ServersPage.tsx
T

467 lines
19 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 { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
defaultServerCreateForm,
endpointLabel,
pendingJobsForServer,
pluginLabel,
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 { 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 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) => ({
...current,
pluginId: pluginResponse.items.some((plugin) => plugin.id === current.pluginId) ? current.pluginId : pluginResponse.items[0]?.id || "",
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", "创建服务器");
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: 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 });
try {
let message = "运行操作已提交";
if (action === "generate-run") {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
message = `run 包已生成,artifact ${distribution.artifactId}`;
} else if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
message = `run 下载引用已创建,artifact ${reference.artifactId}`;
} else 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));
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`;
} else if (action === "generate-client-manager") {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({
serverInstanceId: instance.id,
profileKey: defaults.clientProfileKey,
targetOs: defaults.clientOs,
targetArch: "amd64",
repositoryUrl: defaults.repositoryUrl,
sourceRevision: "main"
})
);
message = `客户端管理器已生成,artifact ${distribution.artifactId}`;
} else if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
message = `依赖检查任务已排队,job ${job.id}`;
} else if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
message = `依赖安装任务已排队,job ${job.id}`;
} else if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
message = "已打开服务器详情,可切换到日志页查看实时日志";
} else if (action === "historical-logs") {
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
message = `历史日志回填任务已排队,job ${job.id}`;
}
operations.succeed(operationId, message);
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId);
}
}
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>
</div>
<button type="submit" className="primary-command" disabled={createPending} 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>
)}
</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);
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>
<details className="runtime-action-menu">
<summary className="icon-command">运行操作</summary>
<div className="action-list">
{serverQuickActions.map((action) => (
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
<Candy size={13} />
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</details>
</div>
</article>
);
}
const serverQuickActions: ServerQuickRuntimeAction[] = [
"generate-run",
"download-run",
"push-run-update",
"generate-client-manager",
"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 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";
}