Integrate SCUM real ops workflows

This commit is contained in:
npc0-hue
2026-08-10 21:12:53 +08:00
parent 1063330710
commit a770bc6250
88 changed files with 6375 additions and 2719 deletions
+19 -621
View File
@@ -1,36 +1,25 @@
import { Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, 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,
ServerConfigDiffPreviewResponse,
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 { ConfirmDialog, UsageMeter } from "../components/OperationControls";
import { 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
@@ -39,34 +28,25 @@ 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 [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 [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);
@@ -77,7 +57,7 @@ export function ServerDetailPage(props: PageComponentProps) {
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse, endpointResponse] = await Promise.all([
const [detail, pluginResponse, jobResponse, deploymentResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId),
@@ -85,36 +65,17 @@ export function ServerDetailPage(props: PageComponentProps) {
.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();
@@ -151,10 +112,6 @@ export function ServerDetailPage(props: PageComponentProps) {
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";
@@ -168,7 +125,7 @@ export function ServerDetailPage(props: PageComponentProps) {
useEffect(() => {
if (detailSections.some((entry) => entry.id === section)) return;
setSection(detailSections[0]?.id ?? "logs");
setSection(detailSections[0]?.id ?? "manage");
}, [detailSections, section]);
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
@@ -270,7 +227,6 @@ export function ServerDetailPage(props: PageComponentProps) {
<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">
@@ -301,11 +257,9 @@ export function ServerDetailPage(props: PageComponentProps) {
))}
</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" && (
{section === "manage" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
{section === "manage" && (
<ServerMetadataSection
instance={instance.data}
session={session}
@@ -313,11 +267,8 @@ export function ServerDetailPage(props: PageComponentProps) {
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 === "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} />}
{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)} />
</>
)}
@@ -344,14 +295,6 @@ export function ServerDetailPage(props: PageComponentProps) {
);
}
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];
@@ -619,343 +562,6 @@ function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
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;
@@ -985,21 +591,13 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
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
diffSummary: recommendation?.diffSummary
});
} catch (caught) {
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
@@ -1009,7 +607,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
}
async function applySuggestion() {
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) {
if (!suggestion?.diffId || approvalBusy) {
return;
}
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
@@ -1078,15 +676,19 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
<span className="page-status"> AI Provider</span>
</div>
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
{suggestion.diff ? (
{suggestion.diffId ? (
<>
<DiffView lines={suggestion.diff.lines} />
<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>
</>
@@ -1132,207 +734,3 @@ function configDiffLineFromPreviewLine(line: ConfigDiffLineResponse): ConfigDiff
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`;
}