331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
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";
|
||
}
|