first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
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";
}