Files
browser/platform_web/pages/ServerDetailPage.tsx
T

750 lines
35 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 { 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,
GamePluginResponse,
JobResponse,
ServerInstanceResponse,
ServerMemberResponse,
ServerMetricsResponse,
ServerDeploymentResponse,
ServerConfigDiffPreviewResponse,
RunEndpointResponse
} from "../api/types";
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
type ConfigDiffView,
type LlmSuggestionView,
type ServerDetailSection
} from "../contracts/workspace";
import {
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage";
import { PluginPageHostPage } from "./PluginPageHostPage";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
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>("manage");
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 [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const refresh = useCallback(async () => {
if (!serverId) {
setInstance({ status: "error", reason: "缺少服务器 ID" });
return;
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse, deploymentResponse, 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.listRunEndpoints().catch(() => ({ items: [], count: 0 }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setJobs(jobResponse.items);
setDeployment(deploymentResponse);
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
} catch (error) {
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setRunEndpoint(undefined);
setDeployment({ status: "error", reason: "部署定义加载失败" });
}
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 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 ?? "manage");
}, [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} · Run 心跳 {runEndpoint ? "已自动附着" : "等待上报"}
</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={!canManageServers}
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
onClick={() => setTerminalOpen(true)}
>
<Terminal size={15} />
<span>打开终端</span>
</button>
<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>
</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>
{pluginPageKeyFromSection(section) && readyPlugin && <PluginPageSection pageProps={props} serverId={serverId} plugin={readyPlugin} routeKey={pluginPageKeyFromSection(section) ?? ""} />}
{section === "manage" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
{section === "manage" && (
<ServerMetadataSection
instance={instance.data}
session={session}
operations={operations}
onChanged={(next) => setInstance({ status: "ready", data: next })}
/>
)}
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
<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 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 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;
setSuggestion({
serverInstanceId: serverId,
source: "api",
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
diffId: recommendation?.diffId,
expiresAt: recommendation?.expiresAt,
diffSummary: recommendation?.diffSummary
});
} catch (caught) {
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
} finally {
setBusy(false);
}
}
async function applySuggestion() {
if (!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.diffId ? (
<>
<div className="console-record">
<div className="console-record-head"><strong>Reviewable AI diff</strong><span className="status-pill status-active">pending</span></div>
<div className="console-record-meta"><span>Diff {suggestion.diffId}</span>{suggestion.expiresAt && <span>到期 {new Date(suggestion.expiresAt).toLocaleString()}</span>}</div>
<span className="provider-id">{suggestion.diffSummary ?? "平台已保存可审查配置差异;批准后才会派发写入任务。"}</span>
</div>
<div className="confirm-actions">
<button type="button" onClick={() => setSuggestion(null)}>
放弃建议
</button>
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
审批 AI 差异
</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
};
}