Files
browser/platform_web/pages/ServerDetailPage.tsx
T

1339 lines
62 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 { Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
ConfigDiffLineResponse,
ArtifactResponse,
BackupResponse,
GamePluginResponse,
JobResponse,
LogStreamResponse,
ServerConfigDiffPreviewResponse,
ServerConfigResponse,
ServerInstanceResponse,
ServerMemberResponse,
ServerMetricsResponse,
ServerDeploymentResponse,
MetricSampleResponse,
RemoteAdapterDeclarationResponse,
RunEndpointResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import {
serverDetailSections,
serverIsOnline,
isPlatformAdmin,
type ConfigDiffView,
type LlmSuggestionView,
type ServerDetailSection
} from "../contracts/workspace";
import {
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { diffHasChanges } from "../utils/diff";
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage";
import { PluginPageHostPage } from "./PluginPageHostPage";
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
const defaultConfigKey = "server.properties";
const serverDetailRefreshMs = 5000;
const serverMetricFreshMs = 30000;
export function ServerDetailPage(props: PageComponentProps) {
const { session, params, operations, onNavigate } = props;
const serverId = params.serverId ?? "";
const [section, setSection] = useState<ServerDetailSection>("logs");
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
const [metricHistory, setMetricHistory] = useState<MetricSampleResponse[]>([]);
const [backups, setBackups] = useState<BackupResponse[]>([]);
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [terminalOpen, setTerminalOpen] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const refresh = useCallback(async () => {
if (!serverId) {
setInstance({ status: "error", reason: "缺少服务器 ID" });
return;
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId),
platformApiClient
.getServerDeployment(serverId)
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setJobs(jobResponse.items);
setDeployment(deploymentResponse);
setMetricHistory(metricHistoryResponse.items);
setBackups(backupResponse.items);
setRemoteAdapters(adapterResponse.items);
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
const artifactLists = await Promise.all(
jobResponse.items.slice(0, 20).map((job) =>
platformApiClient
.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })
.then((response) => response.items)
.catch(() => [] as ArtifactResponse[])
)
);
setArtifacts(uniqueArtifacts(artifactLists.flat()));
} catch (error) {
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setArtifacts([]);
setRunEndpoint(undefined);
setDeployment({ status: "error", reason: "部署定义加载失败" });
setMetricHistory([]);
setBackups([]);
setRemoteAdapters([]);
}
try {
const metricsResponse = await platformApiClient.listServerMetrics();
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
} catch {
setMetrics(null);
}
}, [serverId]);
useEffect(() => {
void refresh();
}, [refresh]);
const refreshOperationalState = useCallback(async () => {
if (!serverId) return;
try {
const [detail, jobResponse, metricsResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listJobs(serverId),
platformApiClient.listServerMetrics(),
platformApiClient.listRunEndpoints()
]);
setInstance({ status: "ready", data: detail });
setJobs(jobResponse.items);
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
} catch {
setMetrics(null);
}
}, [serverId]);
useEffect(() => {
const timer = window.setInterval(() => void refreshOperationalState(), serverDetailRefreshMs);
return () => window.clearInterval(timer);
}, [refreshOperationalState]);
const serverOperations = useMemo(
() => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)),
[operations.operations, serverId]
);
const canManageServers = session.capabilities.includes("servers.manage");
const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined;
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
const detailStateText = instance.status === "ready" && detailFreshness === "fresh" ? stateLabel(instance.data.state) : instance.status === "ready" ? `最后观测:${stateLabel(instance.data.state)}Run 未验证)` : "未验证";
const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]);
useEffect(() => {
if (!params.routeKey || !readyPlugin?.pages.some((page) => page.key === params.routeKey)) return;
setSection(`plugin:${params.routeKey}`);
}, [params.routeKey, readyPlugin]);
useEffect(() => {
if (detailSections.some((entry) => entry.id === section)) return;
setSection(detailSections[0]?.id ?? "logs");
}, [detailSections, section]);
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
setConfirm({
title: action === "start" ? "启动服务器" : "停止服务器",
description:
action === "start"
? `确认启动服务器 ${current.name}${current.id})?`
: `停止服务器 ${current.name}${current.id})会断开所有在线玩家,确认继续?`,
danger: action === "stop",
run: async () => {
const operationId = operations.begin({
intent: action === "start" ? "启动服务器" : "停止服务器",
targetKind: "server",
targetId: current.id,
requester: session.displayName
});
try {
const result =
action === "start"
? await platformApiClient.startServerInstance(current.id, serverLifecycleCommandRequest(current, "start"))
: await platformApiClient.stopServerInstance(current.id, serverLifecycleCommandRequest(current, "stop"));
operations.succeed(operationId, `任务 ${result.job.id}${result.job.capability})已派发`, result.job);
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "操作失败", operationId);
}
}
});
}
if (!serverId) {
return (
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
);
}
return (
<section className="server-detail-page" aria-labelledby="server-detail-title">
<div className="action-strip">
<button type="button" className="icon-command" onClick={() => onNavigate("servers")}>
<MoonStar size={16} />
<span>返回列表</span>
</button>
<button type="button" className="icon-command" onClick={() => void refresh()}>
<Sparkles size={16} />
<span>刷新</span>
</button>
</div>
{instance.status === "loading" && <LoadingState label="正在加载服务器详情…" />}
{instance.status === "error" && (
<ErrorState title="服务器详情加载失败" reason={instance.reason} diagnosticId={`server-detail:${serverId}`} onRetry={() => void refresh()} />
)}
{instance.status !== "ready" && (
<nav className="section-tabs" aria-label="server sections">
{detailSections.map((entry) => (
<button
key={entry.id}
type="button"
className={cx("section-tab", section === entry.id && "section-tab-active")}
aria-current={section === entry.id ? "page" : undefined}
onClick={() => setSection(entry.id)}
>
{entry.label}
</button>
))}
</nav>
)}
{instance.status === "ready" && (
<>
<header className="server-detail-header">
<div className="server-detail-title-row">
<div>
<h1 id="server-detail-title">{instance.data.name}</h1>
<span className="provider-id">
{instance.data.id} · 插件 {instance.data.pluginId}@{instance.data.pluginVersion} · 节点 {instance.data.runEndpointId}
</span>
</div>
<div className="action-strip">
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
<button
type="button"
className="icon-command"
disabled={!canStartServer(instance.data.state) || operations.isPending(instance.data.id, "启动服务器")}
onClick={() => requestLifecycle(instance.data, "start")}
>
<WandSparkles size={15} />
<span>启动</span>
</button>
<button
type="button"
className="icon-command danger-command"
disabled={!canStopServer(instance.data.state) || operations.isPending(instance.data.id, "停止服务器")}
onClick={() => requestLifecycle(instance.data, "stop")}
>
<Square size={15} />
<span>停止</span>
</button>
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有管理权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span>管理终端</span></button>
</div>
</div>
<div className="server-detail-stat-strip">
<HeaderStat label="状态" value={detailFreshness === "fresh" ? (serverIsOnline(instance.data.state) ? "在线" : "离线") : "未验证"} />
<HeaderStat label="玩家" value={metrics?.playerCount !== undefined ? `${metrics.playerCount}${metrics.maxPlayers ? `/${metrics.maxPlayers}` : ""}` : "--"} />
<HeaderStat label="TPS" value={metrics?.tps !== undefined ? metrics.tps.toFixed(1) : "--"} />
<HeaderStat label="延迟" value={metrics?.latencyMs !== undefined ? `${Math.round(metrics.latencyMs)}ms` : "--"} />
<HeaderStat label="指标" value={metricFreshnessLabel(metrics)} />
</div>
<div className="server-detail-meter-strip">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
</header>
<nav className="section-tabs" aria-label="server sections">
{detailSections.map((entry) => (
<button
key={entry.id}
type="button"
className={cx("section-tab", section === entry.id && "section-tab-active")}
aria-current={section === entry.id ? "page" : undefined}
onClick={() => setSection(entry.id)}
>
{entry.label}
</button>
))}
</nav>
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{pluginPageKeyFromSection(section) && readyPlugin && <PluginPageSection pageProps={props} serverId={serverId} plugin={readyPlugin} routeKey={pluginPageKeyFromSection(section) ?? ""} />}
{section === "config" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
{section === "config" && (
<ServerMetadataSection
instance={instance.data}
session={session}
operations={operations}
onChanged={(next) => setInstance({ status: "ready", data: next })}
/>
)}
{section === "config" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
</>
)}
<ConfirmDialog
open={confirm !== null}
title={confirm?.title ?? ""}
description={confirm?.description ?? ""}
confirmLabel="确认执行"
danger={confirm?.danger}
busy={confirmBusy}
onCancel={() => setConfirm(null)}
onConfirm={() => {
if (!confirm) {
return;
}
setConfirmBusy(true);
void confirm.run().finally(() => {
setConfirmBusy(false);
setConfirm(null);
});
}}
/>
</section>
);
}
function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
const byID = new Map<string, ArtifactResponse>();
for (const artifact of artifacts) {
byID.set(artifact.id, artifact);
}
return [...byID.values()];
}
function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> {
const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title }));
return [...pluginPages, ...serverDetailSections];
}
function pluginPageKeyFromSection(section: ServerDetailSection): string | null {
return section.startsWith("plugin:") ? section.slice("plugin:".length) : null;
}
interface PluginPageSectionProps {
pageProps: PageComponentProps;
serverId: string;
plugin: GamePluginResponse;
routeKey: string;
}
function PluginPageSection({ pageProps, serverId, plugin, routeKey }: PluginPageSectionProps) {
const params = useMemo(() => ({ ...pageProps.params, pluginId: plugin.id, routeKey, serverId }), [pageProps.params.pluginId, pageProps.params.routeKey, pageProps.params.serverId, plugin.id, routeKey, serverId]);
return <PluginPageHostPage {...pageProps} params={params} initialPlugin={plugin} embedded />;
}
interface ServerMetadataSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: (instance: ServerInstanceResponse) => void;
}
function ServerMetadataSection({ instance, session, operations, onChanged }: ServerMetadataSectionProps) {
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
useEffect(() => {
setDraft(serverMetadataFormFromInstance(instance));
}, [instance.id, instance.name]);
async function saveMetadata(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const operationId = operations.begin({ intent: "更新服务器信息", targetKind: "server", targetId: instance.id, requester: session.displayName });
setResult({ status: "pending", label: "正在保存服务器信息" });
try {
const updated = await platformApiClient.updateServerInstance(instance.id, serverMetadataUpdateRequestFromForm(draft));
onChanged(updated);
operations.succeed(operationId, `服务器信息已更新:${updated.id}`);
setResult({ status: "succeeded", label: `已更新 ${updated.name}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器信息更新失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "服务器信息更新失败" });
}
}
return (
<article className="console-panel" aria-label="server metadata">
<div className="panel-header">
<h2>
<Pencil size={16} style={{ verticalAlign: "-2px" }} /> 基本信息
</h2>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveMetadata(event)}>
<label>
显示名称
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<div className="action-strip">
<button type="submit" className="primary-command" disabled={draft.name.trim() === instance.name}>
<Pencil size={14} />
<span>保存名称</span>
</button>
</div>
</form>
</article>
);
}
interface ServerDeploymentSectionProps {
instance: ServerInstanceResponse;
deployment: LoadState<ServerDeploymentResponse>;
}
function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSectionProps) {
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data;
const projection = view.projection;
const isScumTemplate = (instance.pluginId === "game.scum" && (view.mode === "guided-install" || view.mode === "existing-server")) || projection?.templateKey?.startsWith("scum-");
return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 部署定义</h2><span className="page-status">{view.mode || "未配置"} · 修订 {view.revision}</span></div>
<p className="section-copy">服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。</p>
<div className="console-row-list"><div className="console-row"><span>服务器目录</span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span>高级执行目录</span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span>启动设置</span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span>最近 Run 调度</span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run 执行确认</span><strong>已按 r{view.latestDispatch.deploymentRevision} 确认执行</strong></div>}</div>
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM 受控模板</span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span>预检 / 扫描</span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span>配置映射 / 健康验证</span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span>失败原因</span><strong>{projection.failureCode}</strong></div>}</div>}
</article>;
}
function deploymentProjectionLabel(value?: string): string {
switch (value) {
case "queued": return "排队中";
case "running": return "执行中";
case "passed": return "已通过";
case "applied": return "已写入";
case "unchanged": return "未变化";
case "failed": return "失败";
case "skipped": return "已跳过";
default: return "待返回";
}
}
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
switch (progress.phase) {
case "queued": return "任务已排队,等待 Run 领取";
case "claimed": return "Run 已领取任务";
case "preflight": return "正在执行本机预检";
case "install": return "正在安装服务器";
case "configure": return "正在写入游戏配置";
case "start": return "正在启动服务器";
case "health": return "正在进行健康检查";
default: return "部署任务已提交";
}
}
interface ServerAdministratorsSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
onChanged: (instance: ServerInstanceResponse) => void;
}
function ServerAdministratorsSection({ instance, session, onChanged }: ServerAdministratorsSectionProps) {
const [candidates, setCandidates] = useState<LoadState<ServerMemberResponse[]>>({ status: "loading" });
const [selectedUserId, setSelectedUserId] = useState("");
const [busyUserId, setBusyUserId] = useState<string | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
const isOwner = instance.ownerUserId === session.id;
const refreshCandidates = useCallback(async () => {
if (!isOwner) {
setCandidates({ status: "ready", data: [] });
return;
}
setCandidates({ status: "loading" });
try {
const response = await platformApiClient.listServerAdministratorCandidates(instance.id);
setCandidates({ status: "ready", data: response.items });
setSelectedUserId((current) => (response.items.some((user) => user.id === current) ? current : response.items[0]?.id ?? ""));
} catch (error) {
setCandidates({ status: "error", reason: error instanceof Error ? error.message : "加载候选管理员失败" });
}
}, [instance.id, isOwner]);
useEffect(() => {
void refreshCandidates();
}, [refreshCandidates]);
async function addAdministrator(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedUserId) {
return;
}
setBusyUserId(selectedUserId);
setResult(null);
try {
const next = await platformApiClient.addServerAdministrator(instance.id, { userId: selectedUserId });
onChanged(next);
setResult({ status: "succeeded", label: "已添加服务器管理员" });
await refreshCandidates();
} catch (error) {
setResult({ status: "failed", label: error instanceof Error ? error.message : "添加管理员失败" });
} finally {
setBusyUserId(null);
}
}
async function removeAdministrator(userId: string) {
setBusyUserId(userId);
setResult(null);
try {
const next = await platformApiClient.removeServerAdministrator(instance.id, userId);
onChanged(next);
setResult({ status: "succeeded", label: "已移除服务器管理员" });
await refreshCandidates();
} catch (error) {
setResult({ status: "failed", label: error instanceof Error ? error.message : "移除管理员失败" });
} finally {
setBusyUserId(null);
}
}
return (
<article className="console-panel" aria-label="server administrators">
<div className="panel-header">
<h2>
<ShieldCheck size={16} style={{ verticalAlign: "-2px" }} /> 管理成员
</h2>
<span className="page-status">{isOwner ? "服主可邀请/移除管理员" : "仅服主可调整成员"}</span>
</div>
{result && (
<div style={{ marginBottom: 10 }}>
<ResultBadge status={result.status} label={result.label} />
</div>
)}
<div className="action-list">
<span>服主:{instance.ownerUserId || "未绑定"}</span>
<span>服务器管理员:{instance.adminUserIds.length > 0 ? instance.adminUserIds.join(" / ") : "暂无"}</span>
</div>
{isOwner && (
<>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void addAdministrator(event)} aria-label="邀请服务器管理员">
<div className="form-grid">
<label>
邀请管理员
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} disabled={candidates.status !== "ready" || candidates.data.length === 0}>
{candidates.status === "ready" && candidates.data.length === 0 && <option value="">暂无可邀请用户</option>}
{candidates.status === "ready" &&
candidates.data.map((user) => (
<option key={user.id} value={user.id}>
{user.displayName}{user.email ?? user.id}
</option>
))}
</select>
</label>
</div>
{candidates.status === "loading" && <LoadingState label="正在加载可邀请用户…" compact />}
{candidates.status === "error" && (
<ErrorState title="候选管理员加载失败" reason={candidates.reason} diagnosticId={`server-admin-candidates:${instance.id}`} onRetry={() => void refreshCandidates()} compact />
)}
<button type="submit" className="primary-command" disabled={!selectedUserId || busyUserId !== null}>
<UserRoundPlus size={14} />
<span>{busyUserId === selectedUserId ? "邀请中…" : "邀请为管理员"}</span>
</button>
</form>
{instance.adminUserIds.length > 0 && (
<div className="resource-list" style={{ marginTop: 12 }}>
{instance.adminUserIds.map((userId) => (
<article key={userId} className="resource-list-item">
<span>
<strong>{userId}</strong>
<span className="provider-id">服务器管理员</span>
</span>
<button type="button" className="theme-upload" disabled={busyUserId !== null} onClick={() => void removeAdministrator(userId)}>
<UserRoundMinus size={13} />
<span>{busyUserId === userId ? "移除中…" : "移除"}</span>
</button>
</article>
))}
</div>
)}
</>
)}
</article>
);
}
function HeaderStat({ label, value }: { label: string; value: string }) {
return (
<span className="server-card-stat">
<span>{label}</span>
<strong>{value}</strong>
</span>
);
}
function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
if (!metrics || metrics.source === "run-metrics-pending") return "等待上报";
const collectedAt = new Date(metrics.collectedAt).getTime();
if (Number.isFinite(collectedAt) && Date.now() - collectedAt > serverMetricFreshMs) return "指标过期";
return new Date(metrics.collectedAt).toLocaleTimeString();
}
interface LogsSectionProps {
serverId: string;
}
interface LogFilterState {
level: string;
keyword: string;
source: string;
sinceMinutes: string;
}
function LogsSection({ serverId }: LogsSectionProps) {
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
const [selected, setSelected] = useState<LiveLogEntry | null>(null);
const [eventSourceKey, setEventSourceKey] = useState(0);
const refresh = useCallback(() => setEventSourceKey((current) => current + 1), []);
useEffect(() => {
setStreams({ status: "loading" });
setEntries([]);
setSelected(null);
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: 200 });
events.addEventListener("stream", (event) => {
const stream = parseLogStreamEvent(event);
if (!stream) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
});
events.addEventListener("ready", () => {
ready = true;
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
});
events.addEventListener("log", (event) => {
const payload = parseServerLogEvent(event);
if (!payload) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], 1000));
});
events.onerror = () => {
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
};
return () => events.close();
}, [eventSourceKey, serverId]);
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
const visible = useMemo(() => {
const keyword = filter.keyword.trim().toLowerCase();
const sinceMs = filter.sinceMinutes === "all" ? null : Date.now() - Number(filter.sinceMinutes) * 60_000;
return entries.filter((entry) => {
if (filter.level !== "all" && (entry.level ?? "info").toLowerCase() !== filter.level) {
return false;
}
if (filter.source !== "all" && entry.source !== filter.source) {
return false;
}
if (keyword && !entry.line.toLowerCase().includes(keyword)) {
return false;
}
if (sinceMs !== null && new Date(entry.timestamp).getTime() < sinceMs) {
return false;
}
return true;
}).sort(compareLogEntriesDesc);
}, [entries, filter]);
return (
<article className="console-panel" aria-label="server logs">
<div className="panel-header">
<h2>日志</h2>
<button type="button" className="icon-command" onClick={refresh}>
<Sparkles size={14} />
<span>重连</span>
</button>
</div>
<div className="log-filter-bar">
<select value={filter.level} aria-label="按级别过滤" onChange={(event) => setFilter((current) => ({ ...current, level: event.target.value }))}>
<option value="all">全部级别</option>
<option value="error">error</option>
<option value="warn">warn</option>
<option value="info">info</option>
<option value="debug">debug</option>
</select>
<select value={filter.source} aria-label="按来源过滤" onChange={(event) => setFilter((current) => ({ ...current, source: event.target.value }))}>
<option value="all">全部来源</option>
{sources.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
<select
value={filter.sinceMinutes}
aria-label="按时间过滤"
onChange={(event) => setFilter((current) => ({ ...current, sinceMinutes: event.target.value }))}
>
<option value="all">全部时间</option>
<option value="15">最近 15 分钟</option>
<option value="60">最近 1 小时</option>
<option value="1440">最近 24 小时</option>
</select>
<input
type="search"
value={filter.keyword}
placeholder="关键字"
aria-label="按关键字过滤"
onChange={(event) => setFilter((current) => ({ ...current, keyword: event.target.value }))}
/>
</div>
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={refresh} compact />}
{streams.status === "ready" && entries.length === 0 && (
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="重连" onAction={refresh} />
)}
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
)}
{visible.length > 0 && (
<div className="log-list" role="list">
{visible.map((entry) => (
<button key={`${entry.streamId}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
<span>{entry.line}</span>
</button>
))}
</div>
)}
{selected && (
<div className="drawer-backdrop" role="presentation" onClick={() => setSelected(null)}>
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="日志详情" onClick={(event) => event.stopPropagation()}>
<div className="panel-header">
<h2>日志详情</h2>
<button type="button" className="drawer-close" onClick={() => setSelected(null)}>
关闭
</button>
</div>
<dl className="detail-list">
<div>
<dt>时间</dt>
<dd>{new Date(selected.timestamp).toLocaleString()}</dd>
</div>
<div>
<dt>级别</dt>
<dd>{(selected.level ?? "info").toUpperCase()}</dd>
</div>
<div>
<dt>来源</dt>
<dd>{selected.source}</dd>
</div>
<div>
<dt>序号</dt>
<dd>{selected.seq}</dd>
</div>
<div>
<dt>内容</dt>
<dd>{selected.line}</dd>
</div>
{selected.fields && Object.keys(selected.fields).length > 0 && (
<div>
<dt>字段</dt>
<dd>
{Object.entries(selected.fields)
.map(([key, value]) => `${key}=${value}`)
.join(" ")}
</dd>
</div>
)}
</dl>
<DiagnosticSummary diagnosticId={`log:${serverId}:${selected.source}:${selected.seq}`} detail={selected.line} />
</div>
</div>
)}
</article>
);
}
function levelClass(level?: string): string {
const normalized = (level ?? "info").toLowerCase();
if (normalized === "error" || normalized === "fatal") {
return "log-level-error";
}
if (normalized === "warn" || normalized === "warning") {
return "log-level-warn";
}
return "log-level-info";
}
function compareLogEntriesDesc(a: LiveLogEntry, b: LiveLogEntry): number {
const time = (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0);
if (time !== 0) return time;
return b.seq - a.seq;
}
interface ConfigSectionProps {
serverId: string;
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
}
function ConfigSection({ serverId, instance, session, operations }: ConfigSectionProps) {
const [config, setConfig] = useState<LoadState<{ content: string; source: "api" | "local" }>>({ status: "loading" });
const [draft, setDraft] = useState("");
const [diff, setDiff] = useState<ConfigDiffView | null>(null);
const [previewBusy, setPreviewBusy] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setConfig({ status: "loading" });
try {
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
setDraft(response.content);
} catch (error) {
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
setDraft("");
}
}, [serverId]);
useEffect(() => {
void refresh();
}, [refresh]);
async function prepareDiff(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (config.status !== "ready") {
return;
}
setPreviewBusy(true);
setPreviewError(null);
try {
const preview = await platformApiClient.previewServerConfigDiff(serverId, {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
key: defaultConfigKey,
proposedContent: draft
});
setDiff(configDiffViewFromPreview(preview));
} catch (error) {
setPreviewError(error instanceof Error ? error.message : "配置差异预览失败");
} finally {
setPreviewBusy(false);
}
}
async function submitDiff() {
if (!diff || config.status !== "ready") {
return;
}
const operationId = operations.begin({ intent: "写入配置", targetKind: "config", targetId: serverId, requester: session.displayName });
try {
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
expectedConfigVersion: diff.configVersion ?? instance.configVersion,
expectedChecksum: diff.checksum ?? instance.configChecksum,
key: diff.key ?? defaultConfigKey,
proposedContent: diff.nextContent,
proposedContentInputRef: diff.proposedContentInputRef,
idempotencyKey: `web:config.write:${serverId}:${Date.now()}`
});
const job = dispatch.job;
operations.succeed(operationId, `配置写入任务 ${job.id} 已派发`, job);
setDiff(null);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "配置写入任务派发失败", operationId);
}
}
const writeOperation = operations.operations.find((operation) => operation.intent === "写入配置" && operation.targetId === serverId);
return (
<article className="console-panel" aria-label="server configuration">
<div className="panel-header">
<h2>配置</h2>
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}{instance.configChecksum ? ` · ${instance.configChecksum.slice(0, 18)}` : ""}</span>}
</div>
{writeOperation && (
<div style={{ marginBottom: 10 }}>
<ResultBadge
status={writeOperation.status}
label={
writeOperation.status === "pending"
? "配置写入中…"
: writeOperation.status === "succeeded"
? (writeOperation.message ?? "写入任务已派发")
: `写入失败:${writeOperation.errorReason}(诊断 ${writeOperation.diagnosticId}`
}
/>
</div>
)}
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
{config.status === "ready" && (
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
<label>
配置内容
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} rows={10} aria-label="配置编辑器" />
</label>
<button type="submit" className="primary-command" disabled={previewBusy || draft === config.data.content}>
{previewBusy ? "预览中…" : "预览变更"}
</button>
</form>
)}
{diff && (
<div className="drawer-backdrop" role="presentation" onClick={() => setDiff(null)}>
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="配置变更确认" onClick={(event) => event.stopPropagation()}>
<div className="panel-header">
<h2>确认配置变更</h2>
<span className="page-status">{diff.summary}</span>
</div>
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>
目标服务器:<strong>{instance.name}</strong>{serverId})。请检查平台返回的差异,确认后才会派发写入任务。
</p>
<DiffView lines={diff.lines} />
<div className="confirm-actions">
<button type="button" onClick={() => setDiff(null)}>
取消
</button>
<button type="button" className="confirm-primary" disabled={!diffHasChanges(diff)} onClick={() => void submitDiff()}>
确认并派发写入任务
</button>
</div>
</div>
</div>
)}
</article>
);
}
interface LlmSectionProps {
serverId: string;
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
}
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
const [prompt, setPrompt] = useState("");
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [approvalBusy, setApprovalBusy] = useState(false);
const [suggestionError, setSuggestionError] = useState("");
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!prompt.trim()) {
return;
}
setBusy(true);
setSuggestion(null);
setSuggestionError("");
try {
const response = await platformApiClient.invokeAI({ requestId: `web:ai.config:${serverId}:${Date.now()}`, serverInstanceId: serverId, purpose: "config.suggest", prompt: prompt.trim() });
if (response.status !== "ok") {
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
}
const recommendation = response.configRecommendation;
const preview = recommendation?.suggestedConfig
? await platformApiClient.previewServerConfigDiff(serverId, {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
key: recommendation.key,
proposedContent: recommendation.suggestedConfig
})
: undefined;
setSuggestion({
serverInstanceId: serverId,
source: "api",
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
diffId: recommendation?.diffId,
expiresAt: recommendation?.expiresAt,
diff: preview ? configDiffViewFromPreview(preview) : undefined
});
} catch (caught) {
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
} finally {
setBusy(false);
}
}
async function applySuggestion() {
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) {
return;
}
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
setApprovalBusy(true);
try {
const approved = await platformApiClient.approveAIConfigDiff(suggestion.diffId, `web:ai.config.approve:${suggestion.diffId}`);
const job = approved.dispatch.job;
operations.succeed(operationId, `AI 建议已确认,写入任务 ${job.id} 已派发`, job);
setSuggestion(null);
setConfirming(false);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "写入任务派发失败", operationId);
setConfirming(false);
} finally {
setApprovalBusy(false);
}
}
const llmOperation = operations.operations.find((operation) => operation.intent === "应用 AI 配置建议" && operation.targetId === serverId);
return (
<article className="console-panel" aria-label="llm configuration assistance">
<div className="panel-header">
<h2>
<Sparkles size={16} style={{ verticalAlign: "-2px" }} /> AI 配置助手
</h2>
<span className="page-status">建议仅作用于 {serverId}</span>
</div>
<p style={{ margin: "0 0 12px", color: "var(--ink-soft)", fontSize: 13.5 }}>
AI 建议会先生成推荐说明和配置差异,<strong>不会自动写入</strong>。只有你确认差异后,平台才会派发写入任务。前端不会接触任何 AI 提供商密钥。
</p>
{llmOperation && (
<div style={{ marginBottom: 10 }}>
<ResultBadge
status={llmOperation.status}
label={
llmOperation.status === "pending"
? "写入中…"
: llmOperation.status === "succeeded"
? (llmOperation.message ?? "已派发")
: `失败:${llmOperation.errorReason}(诊断 ${llmOperation.diagnosticId}`
}
/>
</div>
)}
{suggestionError && <ErrorState title="AI 建议不可用" reason={suggestionError} diagnosticId={`ai-config:${serverId}`} onRetry={() => setSuggestionError("")} compact />}
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void requestSuggestion(event)}>
<label>
想让 AI 帮你调整什么?
<textarea
value={prompt}
rows={3}
placeholder="例如:把最大玩家数提高到 40,并关闭 PVP"
onChange={(event) => setPrompt(event.target.value)}
/>
</label>
<button type="submit" className="primary-command" disabled={busy || !prompt.trim()}>
{busy ? "生成建议中…" : "生成建议"}
</button>
</form>
{suggestion && (
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
<div className="panel-header" style={{ marginBottom: 0 }}>
<h3>AI 建议</h3>
<span className="page-status">平台 AI Provider</span>
</div>
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
{suggestion.diff ? (
<>
<DiffView lines={suggestion.diff.lines} />
<div className="confirm-actions">
<button type="button" onClick={() => setSuggestion(null)}>
放弃建议
</button>
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
确认差异并写入
</button>
</div>
</>
) : (
<span className="provider-id">该建议没有生成可应用的配置差异,仅供参考。</span>
)}
</div>
)}
<ConfirmDialog
open={confirming}
title="确认应用 AI 配置建议"
description={`即将向服务器 ${instance.name}${serverId})派发配置写入任务。写入内容以上方差异为准。`}
confirmLabel="确认写入"
busy={approvalBusy || llmOperation?.status === "pending"}
onCancel={() => setConfirming(false)}
onConfirm={() => void applySuggestion()}
/>
</article>
);
}
export function configDiffViewFromPreview(preview: ServerConfigDiffPreviewResponse): ConfigDiffView {
const lines = preview.diff.map(configDiffLineFromPreviewLine);
const added = lines.filter((line) => line.kind === "added").length;
const removed = lines.filter((line) => line.kind === "removed").length;
return {
serverInstanceId: preview.serverInstanceId,
configVersion: preview.configVersion,
checksum: preview.checksum,
key: preview.key,
source: preview.source,
summary: `+${added} / -${removed} 行变更`,
lines,
nextContent: preview.proposedContent ?? "",
proposedContentInputRef: preview.proposedContentInputRef
};
}
function configDiffLineFromPreviewLine(line: ConfigDiffLineResponse): ConfigDiffView["lines"][number] {
return {
kind: line.kind === "context" ? "same" : line.kind,
text: line.content
};
}
interface HistorySectionProps {
serverId: string;
serverOperations: PageComponentProps["operations"]["operations"];
jobs: JobResponse[];
artifacts: ArtifactResponse[];
metricHistory: MetricSampleResponse[];
backups: BackupResponse[];
remoteAdapters: RemoteAdapterDeclarationResponse[];
}
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
return (
<>
<ProductionGovernancePanel compact title={`服务器 ${serverId} 的容量与告警`} />
<div className="overview-two-col" aria-label="operation history">
<article className="console-panel">
<div className="panel-header">
<h2>本次会话操作</h2>
</div>
{serverOperations.length === 0 ? (
<EmptyState title="暂无操作记录" description="在本页发起启动、停止、插件页面操作或配置写入后,这里会显示完整的操作生命周期。" />
) : (
<div className="console-record-list">
{serverOperations.map((operation) => (
<div key={operation.id} className="console-record">
<div className="console-record-head">
<strong>{operation.intent}</strong>
<ResultBadge
status={operation.status}
label={operation.status === "pending" ? "进行中" : operation.status === "succeeded" ? "成功" : "失败"}
/>
</div>
<div className="console-record-meta">
<span>
操作 <code>{operation.id}</code>
</span>
{operation.jobId && (
<span>
任务 <code>{operation.jobId}</code>
{operation.jobState ? `${operation.jobState}` : ""}
</span>
)}
<span>目标 {operation.targetId}</span>
<span>发起人 {operation.requester}</span>
<span>{new Date(operation.updatedAt).toLocaleString()}</span>
</div>
{operation.message && <span className="provider-id">{operation.message}</span>}
{operation.status === "failed" && operation.errorReason && (
<span className="provider-id">
失败原因:{operation.errorReason}
{operation.diagnosticId ? `(诊断 ${operation.diagnosticId}` : ""}
</span>
)}
</div>
))}
</div>
)}
</article>
<article className="console-panel">
<div className="panel-header">
<h2>平台任务记录</h2>
</div>
{jobs.length === 0 ? (
<EmptyState title="暂无任务" description="该服务器还没有平台侧任务记录。" />
) : (
<div className="console-record-list">
{jobs.slice(0, 20).map((job) => (
<div key={job.id} className="console-record">
<div className="console-record-head">
<strong>{jobCapabilityLabel(job.capability)}</strong>
<span className={cx("status-pill", job.state === "succeeded" ? "status-active" : job.state === "failed" ? "status-error" : "status-disabled")}>
{job.state}
</span>
</div>
<div className="console-record-meta">
<span>
任务 <code>{job.id}</code>
</span>
<span>进度 {job.progress.percent}%</span>
{job.progress.phase && <span>阶段:{deploymentProgressLabel(job.progress)}</span>}
<span>
尝试 {job.attempt}/{job.retryPolicy.maxAttempts}
</span>
{job.nextAttemptAt && <span>下次尝试 {new Date(job.nextAttemptAt).toLocaleString()}</span>}
{job.lastReconciledAt && <span>最近协调 {new Date(job.lastReconciledAt).toLocaleString()}</span>}
<span>{new Date(job.updatedAt).toLocaleString()}</span>
</div>
{job.progress.message && <span className="provider-id">{job.progress.message}</span>}
{job.cancelReason && <span className="provider-id">取消原因:{job.cancelReason}</span>}
{job.reconcileOutcome && <span className="provider-id">协调结果:{job.reconcileOutcome}</span>}
{job.executionResult && (job.executionResult.processState || job.executionResult.checksum || job.executionResult.version !== undefined) && (
<span className="provider-id">
执行结果:{job.executionResult.processState ?? job.executionResult.kind ?? "已记录"}
{job.executionResult.version !== undefined ? ` · v${job.executionResult.version}` : ""}
{job.executionResult.checksum ? ` · ${job.executionResult.checksum.slice(0, 18)}` : ""}
{job.executionResult.sizeBytes !== undefined ? ` · ${job.executionResult.sizeBytes} B` : ""}
</span>
)}
</div>
))}
</div>
)}
</article>
<ArtifactDownloadPanel serverId={serverId} artifacts={artifacts} />
<article className="console-panel" aria-label="durable observability">
<div className="panel-header">
<h2>持久化观测</h2>
</div>
<div className="console-record-list">
<div className="console-record">
<div className="console-record-head"><strong>指标样本</strong><span className="status-pill status-active">{metricHistory.length} </span></div>
<div className="console-record-meta"><span>最新采集 {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}</span></div>
</div>
<div className="console-record">
<div className="console-record-head"><strong>备份记录</strong><span className="status-pill status-active">{backups.length} </span></div>
<div className="console-record-meta">{backups.slice(0, 4).map((backup) => <span key={backup.id}>{backup.id} · {backup.state} · {backup.checksum.slice(0, 18)}</span>)}</div>
</div>
<div className="console-record">
<div className="console-record-head"><strong>远端适配器声明</strong><span className="status-pill status-active">{remoteAdapters.length} </span></div>
<div className="console-record-meta">{remoteAdapters.slice(0, 4).map((adapter) => <span key={adapter.key}>{adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")}</span>)}</div>
</div>
</div>
</article>
</div>
</>
);
}
interface ArtifactDownloadPanelProps {
serverId: string;
artifacts: ArtifactResponse[];
}
function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [result, setResult] = useState<Record<string, { status: "pending" | "succeeded" | "failed"; label: string; progress?: number }>>({});
async function downloadArtifact(artifact: ArtifactResponse) {
setActiveId(artifact.id);
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
try {
const reference = await platformApiClient.openArtifactDownload(artifact.id);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
});
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
} catch (error) {
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
} finally {
setActiveId(null);
}
}
return (
<article className="console-panel">
<div className="panel-header">
<h2>
<PackageOpen size={16} />
浏览器制品传输
</h2>
</div>
{artifacts.length === 0 ? (
<EmptyState title="暂无可下载制品" description="该服务器当前没有已完成的可用制品。" />
) : (
<div className="console-record-list">
{artifacts.slice(0, 12).map((artifact) => {
const itemResult = result[artifact.id];
return (
<div key={artifact.id} className="console-record">
<div className="console-record-head">
<strong>{artifact.id}</strong>
<ResultBadge status={itemResult?.status ?? "pending"} label={itemResult?.label ?? artifact.state} />
</div>
<div className="console-record-meta">
<span>服务器 {serverId}</span>
<span>{formatBytes(artifact.sizeBytes)}</span>
<span>{artifact.checksum}</span>
</div>
{itemResult?.progress !== undefined && <UsageMeter label="传输" percent={itemResult.progress} />}
<div className="action-strip">
<button type="button" className="icon-command" disabled={activeId !== null} onClick={() => void downloadArtifact(artifact)}>
<Download size={14} />
<span>{activeId === artifact.id ? "传输中" : "打开"}</span>
</button>
</div>
</div>
);
})}
</div>
)}
</article>
);
}
function formatBytes(value: number): string {
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}