Files
browser/platform_web/pages/ServersPage.tsx
T
2026-07-11 14:56:10 +08:00

331 lines
13 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 { 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);
}
}
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 })} />
))}
</div>
)}
</section>
);
}
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;
onOpen: () => void;
}
function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
const online = serverIsOnline(instance.state);
return (
<button type="button" className="server-card" onClick={onOpen} 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>
</button>
);
}
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";
}