first commit
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user