Integrate SCUM real ops workflows
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user