Complete platform management workflows

This commit is contained in:
npc0-hue
2026-07-14 16:39:37 +08:00
parent 7e05d0a4e7
commit 4f33f761a3
106 changed files with 11313 additions and 460 deletions
+503 -18
View File
@@ -1,26 +1,29 @@
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
ConfigDiffLineResponse,
ArtifactDownloadReferenceResponse,
ArtifactResponse,
ClientManagerDistributionResponse,
GamePluginResponse,
JobResponse,
LogEntryBody,
LogStreamResponse,
RunDistributionResponse,
ServerConfigDiffPreviewResponse,
ServerConfigResponse,
ServerInstanceResponse,
ServerMemberResponse,
ServerMetricsResponse
ServerMetricsResponse,
ServerRuntimeActionsResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, pluginLabel } from "../contracts/serverManagement";
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
@@ -30,7 +33,16 @@ import {
type PluginControlGroupView,
type ServerDetailSection
} from "../contracts/workspace";
import { serverLifecycleCommandRequest } from "../schemas/serverManagement";
import {
clientManagerBuildRequest,
dependencyJobRequest,
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverArchiveConfirmation,
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { cx } from "../utils/classes";
@@ -38,7 +50,6 @@ import { stateLabel, statusClass } from "./ServersPage";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
const fallbackConfig = "# server.properties\nmax-players=20\nmotd=Welcome to the server\npvp=true\nview-distance=8\n";
const defaultConfigKey = "server.properties";
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
@@ -49,6 +60,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -59,14 +71,19 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse] = await Promise.all([
const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId)
platformApiClient.listJobs(serverId),
platformApiClient
.getServerRuntimeActions(serverId)
.then((data): LoadState<ServerRuntimeActionsResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerRuntimeActionsResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse);
const artifactLists = await Promise.all(
jobResponse.items.slice(0, 20).map((job) =>
platformApiClient
@@ -79,6 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
} catch (error) {
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setArtifacts([]);
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
}
try {
const metricsResponse = await platformApiClient.listServerMetrics();
@@ -224,6 +242,25 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
</nav>
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
{section === "overview" && (
<RuntimeDistributionSection
instance={instance.data}
runtimeActions={runtimeActions}
session={session}
operations={operations}
onOpenLogs={() => setSection("logs")}
onChanged={() => void refresh()}
/>
)}
{section === "overview" && (
<ServerMetadataSection
instance={instance.data}
session={session}
operations={operations}
onChanged={(next) => setInstance({ status: "ready", data: next })}
onArchived={() => onNavigate("servers")}
/>
)}
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
@@ -264,6 +301,99 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
return [...byID.values()];
}
interface ServerMetadataSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: (instance: ServerInstanceResponse) => void;
onArchived: () => void;
}
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
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 : "服务器信息更新失败" });
}
}
async function archiveServer() {
setConfirmBusy(true);
const operationId = operations.begin({ intent: "归档服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.archiveServerInstance(instance.id);
operations.succeed(operationId, `服务器已归档:${instance.id}`);
setResult({ status: "succeeded", label: `${instance.name} 已归档` });
setConfirmArchive(null);
onArchived();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器归档失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "归档失败,平台拒绝当前状态" });
} finally {
setConfirmBusy(false);
}
}
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>
<button type="button" className="icon-command danger-command" disabled={!canArchiveServer(instance.state)} onClick={() => setConfirmArchive(serverArchiveConfirmation(instance))}>
<Archive size={14} />
<span></span>
</button>
</div>
</form>
{!canArchiveServer(instance.state) && (
<p className="provider-id" style={{ marginTop: 10 }}>
</p>
)}
<ConfirmDialog
open={confirmArchive !== null}
title="归档服务器"
description={`确认归档 ${confirmArchive?.name ?? ""}${confirmArchive?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
confirmLabel="确认归档"
danger
busy={confirmBusy}
onCancel={() => setConfirmArchive(null)}
onConfirm={() => void archiveServer()}
/>
</article>
);
}
interface ServerAdministratorsSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
@@ -451,6 +581,362 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio
);
}
interface RuntimeDistributionSectionProps {
instance: ServerInstanceResponse;
runtimeActions: LoadState<ServerRuntimeActionsResponse>;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onOpenLogs: () => void;
onChanged: () => void;
}
function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) {
const defaults = runtimeDefaultsForPlugin(instance.pluginId);
const [targetOs, setTargetOs] = useState(defaults.runOs);
const [targetArch, setTargetArch] = useState("amd64");
const [profileKey, setProfileKey] = useState(defaults.clientProfileKey);
const [repositoryUrl, setRepositoryUrl] = useState(defaults.repositoryUrl);
const [sourceRevision, setSourceRevision] = useState(defaults.sourceRevision);
const [probeKey, setProbeKey] = useState(defaults.probeKey);
const [installPlanKey, setInstallPlanKey] = useState(defaults.installPlanKey);
const [logSourceKey, setLogSourceKey] = useState(defaults.logSourceKey);
const [checkpointRef, setCheckpointRef] = useState("");
const [lastRun, setLastRun] = useState<RunDistributionResponse | null>(null);
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const actionByKey = useMemo(() => {
if (runtimeActions.status !== "ready") {
return new Map<string, { available: boolean; reason?: string }>();
}
return new Map(runtimeActions.data.actions.map((action) => [action.key, { available: action.available, reason: action.reason }]));
}, [runtimeActions]);
function canUse(key: string): boolean {
return actionByKey.get(key)?.available ?? false;
}
function reasonFor(key: string): string {
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
}
async function runOperation<T>(intent: string, execute: () => Promise<T>, summarize: (value: T) => string) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
setResult({ status: "pending", label: `${intent} 执行中` });
try {
const value = await execute();
const label = summarize(value);
operations.succeed(operationId, label);
setResult({ status: "succeeded", label });
onChanged();
} catch (error) {
const reason = error instanceof Error ? error.message : `${intent} 失败`;
operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason });
}
}
function latestRunArtifact(): { artifactId: string; checksum?: string } | null {
if (lastRun) {
return { artifactId: lastRun.artifactId, checksum: lastRun.checksum };
}
if (lastDownload) {
return { artifactId: lastDownload.artifactId, checksum: lastDownload.checksum };
}
return null;
}
return (
<article className="console-panel" aria-label="run distribution controls">
<div className="panel-header">
<h2>
<PackageOpen size={16} style={{ verticalAlign: "-2px" }} />
</h2>
{runtimeActions.status === "ready" ? (
<span className={cx("status-pill", runtimeActions.data.runStatus === "online" ? "status-active" : "status-disabled")}>
run {runtimeActions.data.runStatus}
</span>
) : runtimeActions.status === "error" ? (
<ResultBadge status="failed" label={runtimeActions.reason} />
) : (
<span className="page-status"></span>
)}
</div>
{result && (
<div style={{ marginBottom: 10 }}>
<ResultBadge status={result.status} label={result.label} />
</div>
)}
<div className="provider-form" style={{ marginBottom: 12 }}>
<div className="form-grid">
<label>
run
<select value={targetOs} onChange={(event) => setTargetOs(event.target.value)}>
<option value="linux">linux</option>
<option value="windows">windows</option>
<option value="darwin">darwin</option>
</select>
</label>
<label>
<select value={targetArch} onChange={(event) => setTargetArch(event.target.value)}>
<option value="amd64">amd64</option>
<option value="arm64">arm64</option>
</select>
</label>
<label>
profile
<input value={profileKey} onChange={(event) => setProfileKey(event.target.value)} />
</label>
<label>
<input value={repositoryUrl} onChange={(event) => setRepositoryUrl(event.target.value)} />
</label>
<label>
revision
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} />
</label>
<label>
probe
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
</label>
<label>
plan
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
</label>
<label>
<input value={logSourceKey} onChange={(event) => setLogSourceKey(event.target.value)} />
</label>
</div>
</div>
<div className="plugin-group-body">
<RuntimeActionRow
title="run 包"
description={`生成 ${targetOs}/${targetArch} run。包内含当前密钥,界面只显示 generation、artifact 和 secret ref。`}
disabled={!canUse("generate-run")}
reason={reasonFor("generate-run")}
actionLabel="生成 run"
onAction={() =>
void runOperation(
"生成 run",
async () => {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
setLastRun(distribution);
return distribution;
},
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 已生成,artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`
)
}
/>
<RuntimeActionRow
title="run 下载与更新"
description={lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
disabled={!canUse("download-run")}
reason={reasonFor("download-run")}
actionLabel="下载 run"
onAction={() =>
void runOperation(
"下载 run",
async () => {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
setLastDownload(reference);
return reference;
},
(reference) => `下载引用已创建,artifact ${reference.artifactId},有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}`
)
}
secondaryLabel="推送更新"
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
secondaryReason={latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
onSecondary={() =>
void runOperation(
"推送 run 更新",
async () => {
const artifact = latestRunArtifact();
if (!artifact) {
throw new Error("请先生成或下载 run 包");
}
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
},
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`
)
}
/>
<RuntimeActionRow
title="run 密钥"
description="重置后旧 run 包会失效,必须重新生成并重新部署。"
disabled={!canUse("reset-run-key")}
reason={reasonFor("reset-run-key")}
actionLabel="重置 run 密钥"
danger
onAction={() =>
void runOperation(
"重置 run 密钥",
() => platformApiClient.resetRunKey(instance.id),
(key) => `run 密钥已重置,generation ${key.generation}fingerprint ${key.fingerprint}`
)
}
/>
<RuntimeActionRow
title="客户端管理器"
description={lastClient ? `最近构建 ${lastClient.artifactId}generation ${lastClient.keyGeneration}` : "按插件声明的 profile 构建客户端管理器,使用独立密钥。"}
disabled={!canUse("generate-client-manager")}
reason={reasonFor("generate-client-manager")}
actionLabel="生成客户端"
onAction={() =>
void runOperation(
"生成客户端管理器",
async () => {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({ serverInstanceId: instance.id, profileKey, targetOs, targetArch, repositoryUrl, sourceRevision })
);
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器已生成,artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`
)
}
secondaryLabel="下载客户端"
secondaryDisabled={!canUse("download-client-manager")}
secondaryReason={reasonFor("download-client-manager")}
onSecondary={() =>
void runOperation(
"下载客户端管理器",
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }),
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}`
)
}
/>
<RuntimeActionRow
title="客户端密钥"
description="客户端管理器和 run 使用不同密钥。重置后旧客户端必须重新生成。"
disabled={!canUse("reset-client-manager-key")}
reason={reasonFor("reset-client-manager-key")}
actionLabel="重置客户端密钥"
danger
onAction={() =>
void runOperation(
"重置客户端密钥",
() => platformApiClient.resetClientManagerKey(instance.id, { componentKind: "client-manager", componentKey: profileKey }),
(key) => `客户端密钥已重置,generation ${key.generation}fingerprint ${key.fingerprint}`
)
}
/>
<RuntimeActionRow
title="依赖"
description={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
disabled={!canUse("dependencies-check")}
reason={reasonFor("dependencies-check")}
actionLabel="依赖检查"
onAction={() =>
void runOperation(
"依赖检查",
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
(job) => `依赖检查任务已排队,job ${job.id}`
)
}
secondaryLabel="依赖安装"
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
onSecondary={() =>
void runOperation(
"依赖安装",
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
(job) => `依赖安装任务已排队,job ${job.id}`
)
}
/>
<RuntimeActionRow
title="日志"
description="实时日志来自平台日志 API,历史日志通过 backfill job 返回 cursor/ref。"
disabled={!canUse("live-logs")}
reason={reasonFor("live-logs")}
actionLabel="实时日志"
onAction={onOpenLogs}
secondaryLabel="历史回填"
secondaryDisabled={!canUse("historical-logs")}
secondaryReason={reasonFor("historical-logs")}
onSecondary={() =>
void runOperation(
"历史日志回填",
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
(job) => `历史日志回填任务已排队,job ${job.id}`
)
}
>
<label>
checkpoint ref
<input value={checkpointRef} placeholder="可选 artifact://logs/checkpoint/..." onChange={(event) => setCheckpointRef(event.target.value)} />
</label>
</RuntimeActionRow>
</div>
</article>
);
}
interface RuntimeActionRowProps {
title: string;
description: string;
disabled: boolean;
reason: string;
actionLabel: string;
danger?: boolean;
onAction: () => void;
secondaryLabel?: string;
secondaryDisabled?: boolean;
secondaryReason?: string;
onSecondary?: () => void;
children?: ReactNode;
}
function RuntimeActionRow({ title, description, disabled, reason, actionLabel, danger, onAction, secondaryLabel, secondaryDisabled, secondaryReason, onSecondary, children }: RuntimeActionRowProps) {
return (
<div className="plugin-control-row">
<span>
<strong>{title}</strong>
<p>{description}</p>
{disabled && <span className="provider-id">{reason}</span>}
{children}
</span>
<div className="action-strip">
<button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : actionLabel} onClick={onAction}>
<Sparkles size={14} />
<span>{actionLabel}</span>
</button>
{secondaryLabel && onSecondary && (
<button type="button" className="icon-command" disabled={secondaryDisabled} title={secondaryDisabled ? secondaryReason : secondaryLabel} onClick={onSecondary}>
<Download size={14} />
<span>{secondaryLabel}</span>
</button>
)}
</div>
</div>
);
}
function runtimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {
runOs: isScum ? "windows" : "linux",
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
sourceRevision: "main",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
logSourceKey: isScum ? "server-log" : "latest-log"
};
}
function safeRuntimeRef(ref: string): string {
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
return ref;
}
return "[redacted-ref]";
}
interface LogsSectionProps {
serverId: string;
}
@@ -471,8 +957,8 @@ function LogsSection({ serverId }: LogsSectionProps) {
const refresh = useCallback(async () => {
setStreams({ status: "loading" });
try {
const response = await platformApiClient.listLogStreams();
const serverStreams = response.items.filter((stream) => stream.serverInstanceId === serverId);
const response = await platformApiClient.listServerLiveLogs(serverId);
const serverStreams = response.items;
setStreams({ status: "ready", data: serverStreams });
const collected: Array<LogEntryBody & { source: string }> = [];
for (const stream of serverStreams) {
@@ -659,9 +1145,9 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
setDraft(response.content);
} catch {
setConfig({ status: "ready", data: { content: fallbackConfig, source: "local" } });
setDraft(fallbackConfig);
} catch (error) {
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
setDraft("");
}
}, [serverId]);
@@ -717,9 +1203,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
<article className="console-panel" aria-label="server configuration">
<div className="panel-header">
<h2></h2>
{config.status === "ready" && (
<span className="page-status">{config.data.source === "api" ? `配置版本 v${instance.configVersion}` : "本地示例配置(配置读取接口未提供)"}</span>
)}
{config.status === "ready" && <span className="page-status"> v{instance.configVersion}</span>}
</div>
{writeOperation && (
<div style={{ marginBottom: 10 }}>
@@ -736,6 +1220,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
</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)}>
@@ -1125,7 +1610,7 @@ interface LlmSectionProps {
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
const [prompt, setPrompt] = useState("");
const [currentConfig, setCurrentConfig] = useState<string>(fallbackConfig);
const [currentConfig, setCurrentConfig] = useState<string>("");
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
@@ -1140,7 +1625,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
}
})
.catch(() => {
// keep the local fallback config
setCurrentConfig("");
});
return () => {
cancelled = true;