功能修改
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { FileCheck2, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { AIConfigDiffPreviewResponse } from "../api/types";
|
||||
import { ConfirmDialog } from "./OperationControls";
|
||||
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
export function AIConfigDiffReviewPanel() {
|
||||
const [items, setItems] = useState<AIConfigDiffPreviewResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<AIConfigDiffPreviewResponse | null>(null);
|
||||
const [busyId, setBusyId] = useState("");
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await platformApiClient.listAIConfigDiffs();
|
||||
setItems(response.items);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "AI 配置审查队列加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function approve() {
|
||||
if (!selected || busyId) return;
|
||||
setBusyId(selected.id);
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await platformApiClient.approveAIConfigDiff(selected.id, `web:ai.config.approve:${selected.id}`);
|
||||
setResult({ status: "succeeded", label: `已审批 ${response.preview.id} · 写入任务 ${response.dispatch.job.id}` });
|
||||
setSelected(null);
|
||||
await refresh();
|
||||
} catch (caught) {
|
||||
setResult({ status: "failed", label: caught instanceof Error ? caught.message : "AI 配置审批失败" });
|
||||
setSelected(null);
|
||||
} finally {
|
||||
setBusyId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="console-panel ai-diff-review-panel" aria-label="AI config diff review">
|
||||
<div className="panel-header">
|
||||
<h2><FileCheck2 size={16} /> AI 配置审查</h2>
|
||||
<button type="button" className="icon-command" disabled={loading || Boolean(busyId)} onClick={() => void refresh()}><RotateCw size={14} /><span>刷新</span></button>
|
||||
</div>
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
{loading && <LoadingState label="正在同步 AI 配置差异…" compact />}
|
||||
{!loading && error && <ErrorState title="AI 配置审查不可用" reason={error} diagnosticId="ai-config-diffs" onRetry={() => void refresh()} compact />}
|
||||
{!loading && !error && (
|
||||
<div className="operation-list">
|
||||
{items.length === 0 && <p className="operations-module-empty">当前没有 AI 配置差异。</p>}
|
||||
{items.slice(0, 12).map((item) => (
|
||||
<div key={item.id} className="operation-item">
|
||||
<div className="operation-item-head"><strong>{item.serverInstanceId} · {item.key}</strong><span className={`status-pill status-${item.state === "approved" ? "succeeded" : item.state === "pending" ? "warning" : "disabled"}`}>{item.state}</span></div>
|
||||
<div className="operation-meta"><span>请求 {item.requestId}</span><span>版本 {item.configVersion}</span><span>{item.model || "Platform model"}</span><span>到期 {new Date(item.expiresAt).toLocaleString()}</span>{item.jobId && <span>任务 {item.jobId}</span>}</div>
|
||||
<p>{item.diffSummary}</p>
|
||||
{item.proposedConfig && <pre className="log-view ai-config-proposal">{item.proposedConfig}</pre>}
|
||||
{item.state === "pending" && <div className="row-actions"><button type="button" disabled={Boolean(busyId)} onClick={() => setSelected(item)}><FileCheck2 size={14} /><span>审查并批准</span></button></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog open={selected !== null} title="批准 AI 配置差异" description={selected ? `服务器 ${selected.serverInstanceId},配置版本 ${selected.configVersion},差异 ${selected.id}。` : "确认 AI 配置差异。"} confirmLabel="批准并派发" busy={Boolean(busyId)} onCancel={() => { if (!busyId) setSelected(null); }} onConfirm={() => void approve()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PageRoute } from "../contracts/page";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import appShellSource from "./AppShell.tsx?raw";
|
||||
import { AppShell } from "./AppShell";
|
||||
|
||||
const routes: PageRoute[] = [
|
||||
{ id: "home", label: "首页", path: "/home", hash: "#/home", description: "概览", requiredCapability: "platform.overview.read", showInNav: true },
|
||||
{ id: "servers", label: "服务器管理", path: "/servers", hash: "#/servers", description: "服务器", requiredCapability: "servers.read", showInNav: true }
|
||||
];
|
||||
|
||||
const session: CurrentUserView = {
|
||||
id: "operator",
|
||||
displayName: "Operator",
|
||||
status: "active",
|
||||
roles: ["platformAdmin"],
|
||||
capabilities: ["platform.overview.read", "servers.read"],
|
||||
profile: {},
|
||||
source: "api"
|
||||
};
|
||||
|
||||
describe("AppShell mobile navigation", () => {
|
||||
it("renders a labelled edge control and vertical menu labels", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AppShell routes={routes} currentPage="home" session={session} operations={[]} onNavigate={() => undefined}>
|
||||
<p>页面内容</p>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
expect(html).toContain('class="mobile-sidebar-handle"');
|
||||
expect(html).toContain('aria-controls="primary-sidebar"');
|
||||
expect(html).toContain('id="primary-sidebar"');
|
||||
expect(html).toContain("平台概览");
|
||||
expect(html).toContain("服务器管理");
|
||||
expect(html).toContain("关闭导航菜单");
|
||||
});
|
||||
|
||||
it("uses bounded edge-swipe thresholds and accessible dismissal", () => {
|
||||
expect(appShellSource).toContain("start.x <= 28 && deltaX >= 56");
|
||||
expect(appShellSource).toContain("deltaX <= -56");
|
||||
expect(appShellSource).toContain('event.key === "Escape"');
|
||||
expect(appShellSource).toContain("setIsMobileSidebarOpen(false)");
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
ShieldCheck,
|
||||
UserRoundPen,
|
||||
WandSparkles,
|
||||
Wrench
|
||||
Wrench,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
|
||||
import { type ComponentType, type ReactNode, type TouchEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { PageId, PageParams, PageRoute } from "../contracts/page";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import type { CurrentUserView, OperationRecord } from "../contracts/workspace";
|
||||
import { MagicalParticleLayer } from "./MagicalParticleLayer";
|
||||
import { OperationsTray } from "./OperationsTray";
|
||||
import {
|
||||
applyBackgroundImage,
|
||||
applyThemeBackgroundPreset,
|
||||
@@ -33,6 +35,7 @@ interface AppShellProps {
|
||||
routes: PageRoute[];
|
||||
currentPage: PageId;
|
||||
session: CurrentUserView;
|
||||
operations: OperationRecord[];
|
||||
onNavigate: (pageId: PageId, params?: PageParams) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -59,9 +62,11 @@ const roleLabels: Record<CurrentUserView["roles"][number], string> = {
|
||||
serverAdmin: "服务器管理员"
|
||||
};
|
||||
|
||||
export function AppShell({ routes, currentPage, session, onNavigate, children }: AppShellProps) {
|
||||
export function AppShell({ routes, currentPage, session, operations, onNavigate, children }: AppShellProps) {
|
||||
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleThemePaletteChange(event: Event) {
|
||||
@@ -81,6 +86,29 @@ export function AppShell({ routes, currentPage, session, onNavigate, children }:
|
||||
return () => window.removeEventListener(themePaletteChangeEvent, handleThemePaletteChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobileSidebarOpen) {
|
||||
return;
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setIsMobileSidebarOpen(false);
|
||||
}
|
||||
}
|
||||
const isNarrow = window.matchMedia("(max-width: 760px)").matches;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
if (isNarrow) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
if (isNarrow) {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
}
|
||||
};
|
||||
}, [isMobileSidebarOpen]);
|
||||
|
||||
const activePalette = themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0];
|
||||
const routesById = new Map(routes.map((route) => [route.id, route]));
|
||||
const visibleGroups = menuGroups
|
||||
@@ -91,15 +119,66 @@ export function AppShell({ routes, currentPage, session, onNavigate, children }:
|
||||
.filter((group) => group.routes.length > 0);
|
||||
|
||||
function activateGroup(group: (typeof visibleGroups)[number]) {
|
||||
setIsMobileSidebarOpen(false);
|
||||
if (group.routes[0].id !== currentPage) {
|
||||
onNavigate(group.routes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent<HTMLDivElement>) {
|
||||
const touch = event.touches[0];
|
||||
touchStart.current = touch ? { x: touch.clientX, y: touch.clientY } : null;
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent<HTMLDivElement>) {
|
||||
const start = touchStart.current;
|
||||
const touch = event.changedTouches[0];
|
||||
touchStart.current = null;
|
||||
if (!start || !touch || !window.matchMedia("(max-width: 760px)").matches) {
|
||||
return;
|
||||
}
|
||||
const deltaX = touch.clientX - start.x;
|
||||
const deltaY = touch.clientY - start.y;
|
||||
if (Math.abs(deltaY) >= Math.abs(deltaX)) {
|
||||
return;
|
||||
}
|
||||
if (!isMobileSidebarOpen && start.x <= 28 && deltaX >= 56) {
|
||||
setIsMobileSidebarOpen(true);
|
||||
} else if (isMobileSidebarOpen && deltaX <= -56) {
|
||||
setIsMobileSidebarOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed")}>
|
||||
<div
|
||||
className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed", isMobileSidebarOpen && "app-shell-mobile-sidebar-open")}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<MagicalParticleLayer />
|
||||
<aside className="app-sidebar">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sidebar-handle"
|
||||
aria-label="打开导航菜单"
|
||||
aria-controls="primary-sidebar"
|
||||
aria-expanded={isMobileSidebarOpen}
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<WandSparkles size={17} aria-hidden="true" />
|
||||
<span>菜单</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sidebar-backdrop"
|
||||
aria-label="关闭导航菜单"
|
||||
aria-hidden={!isMobileSidebarOpen}
|
||||
tabIndex={isMobileSidebarOpen ? 0 : -1}
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
<aside id="primary-sidebar" className={cx("app-sidebar", isMobileSidebarOpen && "app-sidebar-mobile-open")}>
|
||||
<button type="button" className="mobile-sidebar-close" aria-label="关闭导航菜单" onClick={() => setIsMobileSidebarOpen(false)}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="app-brand" aria-label={themeTokens.appName}>
|
||||
<span className="app-brand-mark">
|
||||
<WandSparkles size={17} />
|
||||
@@ -140,8 +219,17 @@ export function AppShell({ routes, currentPage, session, onNavigate, children }:
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<OperationsTray operations={operations} />
|
||||
<div className="app-session">
|
||||
<button type="button" className="app-account-button" aria-current={currentPage === "profileSettings" ? "page" : undefined} onClick={() => onNavigate("profileSettings")}>
|
||||
<button
|
||||
type="button"
|
||||
className="app-account-button"
|
||||
aria-current={currentPage === "profileSettings" ? "page" : undefined}
|
||||
onClick={() => {
|
||||
setIsMobileSidebarOpen(false);
|
||||
onNavigate("profileSettings");
|
||||
}}
|
||||
>
|
||||
<span className="account-avatar" aria-hidden="true">
|
||||
{session.profile.avatarUrl ? <img src={session.profile.avatarUrl} alt="" /> : <Heart size={17} />}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Activity, Ban, KeyRound, PackageCheck, Play, RefreshCw, RotateCcw, ShieldAlert, Square, Trash2, UploadCloud } from "lucide-react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { ClientManagerInstallationResponse, ClientManagerLifecycleOperation } from "../api/types";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import type { OperationTracker } from "../stores/operations";
|
||||
import { cx } from "../utils/classes";
|
||||
import { ConfirmDialog } from "./OperationControls";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: ClientManagerInstallationResponse[] };
|
||||
|
||||
interface ClientManagerLifecyclePanelProps {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
session: CurrentUserView;
|
||||
operations: OperationTracker;
|
||||
}
|
||||
|
||||
interface PendingConfirmation {
|
||||
title: string;
|
||||
description: string;
|
||||
danger?: boolean;
|
||||
execute: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ClientManagerLifecyclePanel({ serverId, serverName, session, operations }: ClientManagerLifecyclePanelProps) {
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
const [confirmation, setConfirmation] = useState<PendingConfirmation | null>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setState({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listClientManagerLifecycles(serverId);
|
||||
setState({ status: "ready", items: response.items });
|
||||
} catch (error) {
|
||||
setState({ status: "error", reason: safeError(error, "Client Manager 状态加载失败") });
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => { void refresh(true); }, [refresh]);
|
||||
|
||||
const hasActiveJob = state.status === "ready" && state.items.some((item) => item.job && ["queued", "accepted", "running", "retrying"].includes(item.job.state));
|
||||
useEffect(() => {
|
||||
if (!hasActiveJob) return undefined;
|
||||
const timer = window.setInterval(() => void refresh(), 2500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasActiveJob, refresh]);
|
||||
|
||||
async function runCommand(item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) {
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${serverId}:client-manager:${item.profileKey}`, requester: session.displayName });
|
||||
setResult({ status: "pending", label: `${intent} 已提交,等待 Platform/Run 返回真实状态` });
|
||||
try {
|
||||
const next = await execute();
|
||||
setState((current) => current.status === "ready" ? { status: "ready", items: current.items.map((entry) => entry.id === next.id ? next : entry) } : current);
|
||||
const label = next.job ? `${intent} 已排队,job ${next.job.id}` : `${intent} 已完成状态更新`;
|
||||
operations.succeed(operationId, label);
|
||||
setResult({ status: "succeeded", label });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const reason = safeError(error, `${intent} 失败`);
|
||||
operations.fail(operationId, reason, operationId);
|
||||
setResult({ status: "failed", label: reason });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmCommand(config: PendingConfirmation) {
|
||||
setConfirmation(config);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel client-manager-lifecycle-panel" aria-label="Client Manager 生命周期">
|
||||
<div className="panel-header">
|
||||
<h2><PackageCheck size={17} /> Client Manager 生命周期</h2>
|
||||
<div className="action-strip">
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
<button type="button" className="icon-command" title="刷新 Client Manager 状态" onClick={() => void refresh()}>
|
||||
<RefreshCw size={15} /><span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && <LoadingState label="正在读取 Client Manager 部署与组件健康状态…" />}
|
||||
{state.status === "error" && <ErrorState title="Client Manager 状态不可用" reason={state.reason} diagnosticId={`client-manager:${serverId}`} onRetry={() => void refresh(true)} />}
|
||||
{state.status === "ready" && state.items.length === 0 && <EmptyState title="尚无 Client Manager 生命周期记录" description="先在运行分发区按插件声明构建 Client Manager;可用 artifact 会在这里进入部署闭环。" />}
|
||||
{state.status === "ready" && state.items.length > 0 && (
|
||||
<div className="client-manager-lifecycle-list">
|
||||
{state.items.map((item) => (
|
||||
<ClientManagerLifecycleRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
serverName={serverName}
|
||||
runCommand={runCommand}
|
||||
confirmCommand={confirmCommand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmation !== null}
|
||||
title={confirmation?.title ?? ""}
|
||||
description={confirmation?.description ?? ""}
|
||||
confirmLabel="确认执行"
|
||||
danger={confirmation?.danger}
|
||||
busy={confirmBusy}
|
||||
onCancel={() => setConfirmation(null)}
|
||||
onConfirm={() => {
|
||||
if (!confirmation) return;
|
||||
setConfirmBusy(true);
|
||||
void confirmation.execute().finally(() => {
|
||||
setConfirmBusy(false);
|
||||
setConfirmation(null);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientManagerLifecycleRowProps {
|
||||
item: ClientManagerInstallationResponse;
|
||||
serverName: string;
|
||||
runCommand: (item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) => Promise<void>;
|
||||
confirmCommand: (config: PendingConfirmation) => void;
|
||||
}
|
||||
|
||||
function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmCommand }: ClientManagerLifecycleRowProps) {
|
||||
const actionMap = useMemo(() => new Map(item.actions.map((action) => [action.operation, action])), [item.actions]);
|
||||
const available = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.available ?? false;
|
||||
const reason = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.reason ?? "Platform 当前状态不允许此操作";
|
||||
const distributionId = item.distribution?.id ?? "";
|
||||
const idempotency = (operation: string) => `client-manager.${operation}:${item.serverInstanceId}:${item.profileKey}:${Date.now()}`;
|
||||
const control = (operation: "start" | "stop" | "restart" | "status" | "rollback") =>
|
||||
platformApiClient.controlClientManager(item.serverInstanceId, { profileKey: item.profileKey, operation, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency(operation) });
|
||||
|
||||
const deploy = () => runCommand(item, item.requiresRedeploy ? "重新部署 Client Manager" : "部署 Client Manager", () => platformApiClient.deployClientManager(item.serverInstanceId, {
|
||||
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("deploy")
|
||||
}));
|
||||
const update = () => runCommand(item, "更新 Client Manager", () => platformApiClient.updateClientManager(item.serverInstanceId, {
|
||||
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, approved: true, idempotencyKey: idempotency("update")
|
||||
}));
|
||||
|
||||
return (
|
||||
<section className="client-manager-lifecycle-row" aria-label={`${item.profileKey} lifecycle`}>
|
||||
<div className="client-manager-lifecycle-head">
|
||||
<div>
|
||||
<strong>{item.profileKey}</strong>
|
||||
<span className="provider-id">{item.targetOs}/{item.targetArch} · deployment generation {item.deploymentGeneration} · key generation {item.keyGeneration}</span>
|
||||
</div>
|
||||
<div className="tag-list">
|
||||
<span className={cx("status-pill", lifecycleTone(item.status))}>{lifecycleLabel(item.status)}</span>
|
||||
<span className={cx("status-pill", healthTone(item.health))}><Activity size={12} /> {healthLabel(item.health)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="client-manager-version-grid">
|
||||
<VersionCell label="目标版本" version={item.desiredVersion} revision={item.desiredRevision} artifact={item.desiredArtifactId} />
|
||||
<VersionCell label="当前版本" version={item.activeVersion} revision={item.activeRevision} artifact={item.activeArtifactId} />
|
||||
<VersionCell label="回滚版本" version={item.previousVersion} revision={item.previousRevision} artifact={item.previousArtifactId} />
|
||||
<div className="client-manager-version-cell"><span>组件身份</span><strong>{item.lastSeenAt ? `最后心跳 ${formatTime(item.lastSeenAt)}` : "等待独立注册"}</strong><small>{item.healthReason || "未收到安全健康原因"}</small></div>
|
||||
</div>
|
||||
|
||||
<div className="client-manager-phase-line">
|
||||
<span><ShieldAlert size={14} /> {item.phase || "等待生命周期事件"}</span>
|
||||
{item.lastOperation && <span>最近操作 {item.lastOperation}</span>}
|
||||
{item.lastSuccessfulJobId && <span>最近成功 job {item.lastSuccessfulJobId}</span>}
|
||||
</div>
|
||||
|
||||
{item.job && (
|
||||
<div className="client-manager-job-progress" aria-label="Client Manager job progress">
|
||||
<div><span>job {item.job.id} · attempt {item.job.attempt} · {item.job.state}</span><strong>{item.job.progress.percent}%</strong></div>
|
||||
<progress max={100} value={item.job.progress.percent} />
|
||||
<small>{item.job.progress.message || "等待 Run 回报真实阶段"}</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item.retryable || item.requiresRedeploy || item.status === "failed") && (
|
||||
<div className="client-manager-recovery">
|
||||
<ShieldAlert size={16} />
|
||||
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 Platform 审计与 job 失败原因后选择重新部署、回滚或卸载。"}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="client-manager-command-grid">
|
||||
<LifecycleButton icon={<UploadCloud size={14} />} label={item.activeArtifactId ? "重新部署" : "部署"} disabled={!available("deploy") || !distributionId} reason={!distributionId ? "没有可用 distribution" : reason("deploy")} onClick={deploy} />
|
||||
<LifecycleButton icon={<Play size={14} />} label="启动" disabled={!available("start")} reason={reason("start")} onClick={() => void runCommand(item, "启动 Client Manager", () => control("start"))} />
|
||||
<LifecycleButton icon={<Square size={14} />} label="停止" disabled={!available("stop")} reason={reason("stop")} onClick={() => void runCommand(item, "停止 Client Manager", () => control("stop"))} />
|
||||
<LifecycleButton icon={<RefreshCw size={14} />} label="重启" disabled={!available("restart")} reason={reason("restart")} onClick={() => void runCommand(item, "重启 Client Manager", () => control("restart"))} />
|
||||
<LifecycleButton icon={<Activity size={14} />} label="检查状态" disabled={!available("status")} reason={reason("status")} onClick={() => void runCommand(item, "检查 Client Manager 状态", () => control("status"))} />
|
||||
<LifecycleButton icon={<UploadCloud size={14} />} label="更新" disabled={!available("update") || !distributionId} reason={!distributionId ? "没有兼容的可用 distribution" : reason("update")} onClick={() => confirmCommand({ title: "批准 Client Manager 更新", description: `将 ${serverName} 的 ${item.profileKey} 从 ${item.activeVersion || "未安装"} 更新到 ${item.desiredVersion || "目标版本"}。Run 将 staged activate、健康确认,并在失败时恢复 previous slot。`, execute: update })} />
|
||||
<LifecycleButton icon={<RotateCcw size={14} />} label="回滚" disabled={!available("rollback")} reason={reason("rollback")} onClick={() => confirmCommand({ title: "回滚 Client Manager", description: `确认将 ${item.profileKey} 回滚到 ${item.previousVersion || "previous slot"}?当前组件 session 将被撤销并需要重新注册。`, danger: true, execute: () => runCommand(item, "回滚 Client Manager", () => control("rollback")) })} />
|
||||
<LifecycleButton icon={<RefreshCw size={14} />} label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} />
|
||||
<LifecycleButton icon={<Ban size={14} />} label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} />
|
||||
<LifecycleButton icon={<KeyRound size={14} />} label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} />
|
||||
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理受控 Client Manager workspace,Platform 保留 build、artifact 与审计历史。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionCell({ label, version, revision, artifact }: { label: string; version?: string; revision?: string; artifact?: string }) {
|
||||
return <div className="client-manager-version-cell"><span>{label}</span><strong>{version || "--"}</strong><small>{revision ? `revision ${shortRef(revision)}` : "revision --"}{artifact ? ` · artifact ${shortRef(artifact)}` : ""}</small></div>;
|
||||
}
|
||||
|
||||
function LifecycleButton({ icon, label, disabled, reason, danger, onClick }: { icon: ReactNode; label: string; disabled: boolean; reason: string; danger?: boolean; onClick: () => void }) {
|
||||
return <button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : label} onClick={onClick}>{icon}<span>{label}</span></button>;
|
||||
}
|
||||
|
||||
function lifecycleLabel(status: ClientManagerInstallationResponse["status"]): string {
|
||||
return ({ requested: "已请求", building: "构建中", available: "可部署", deploying: "部署中", installed: "已安装", registering: "等待注册", online: "在线", degraded: "降级", offline: "离线", updating: "更新中", rolling_back: "回滚中", stopping: "停止中", uninstalled: "已卸载", failed: "失败" })[status];
|
||||
}
|
||||
function lifecycleTone(status: ClientManagerInstallationResponse["status"]): string { return ["online", "installed"].includes(status) ? "status-active" : ["failed", "offline", "uninstalled"].includes(status) ? "status-disabled" : "status-pending"; }
|
||||
function healthLabel(health: ClientManagerInstallationResponse["health"]): string { return ({ unknown: "健康未知", healthy: "健康", degraded: "健康降级", unhealthy: "不健康", offline: "心跳离线" })[health]; }
|
||||
function healthTone(health: ClientManagerInstallationResponse["health"]): string { return health === "healthy" ? "status-active" : health === "unknown" || health === "degraded" ? "status-pending" : "status-disabled"; }
|
||||
function shortRef(value: string): string { return value.length > 18 ? `${value.slice(0, 18)}…` : value; }
|
||||
function formatTime(value: string): string { const time = new Date(value); return Number.isNaN(time.getTime()) ? "未知" : time.toLocaleString(); }
|
||||
function safeError(error: unknown, fallback: string): string { const message = error instanceof Error ? error.message : fallback; return message.replace(/Bearer\s+\S+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]").slice(0, 240); }
|
||||
@@ -0,0 +1,40 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import operationControlsSource from "./OperationControls.tsx?raw";
|
||||
import { ConfirmDialog, ManagementDialog } from "./OperationControls";
|
||||
|
||||
describe("shared operation dialogs", () => {
|
||||
it("renders labelled, busy-aware confirmation semantics", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="确认停用"
|
||||
description="会保留审计记录"
|
||||
confirmLabel="停用"
|
||||
busy
|
||||
onConfirm={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain('role="dialog"');
|
||||
expect(html).toContain('aria-labelledby=');
|
||||
expect(html).toContain('aria-describedby=');
|
||||
expect(html).toContain('aria-busy="true"');
|
||||
expect(operationControlsSource).toContain('event.key === "Escape"');
|
||||
expect(operationControlsSource).toContain("previousFocus?.focus()");
|
||||
});
|
||||
|
||||
it("keeps management forms in a dialog surface", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ManagementDialog open title="编辑资源" onClose={() => undefined}>
|
||||
<p>内容</p>
|
||||
</ManagementDialog>
|
||||
);
|
||||
|
||||
expect(html).toContain('role="dialog"');
|
||||
expect(html).toContain("编辑资源");
|
||||
expect(html).toContain('aria-modal="true"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useId, useRef } from "react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
@@ -14,14 +14,26 @@ interface ConfirmDialogProps {
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, description, confirmLabel, danger, busy, onConfirm, onCancel, children }: ConfirmDialogProps) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const panelRef = useDialogLifecycle(open, onCancel, !busy);
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="confirm-backdrop" role="presentation" onClick={onCancel}>
|
||||
<div className="confirm-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
<div className="confirm-backdrop" role="presentation" onClick={() => !busy && onCancel()}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="confirm-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
aria-busy={busy}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<p id={descriptionId}>{description}</p>
|
||||
{children}
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={onCancel} disabled={busy}>
|
||||
@@ -46,26 +58,66 @@ interface ManagementDialogProps {
|
||||
}
|
||||
|
||||
export function ManagementDialog({ open, title, description, wide, onClose, children }: ManagementDialogProps) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const panelRef = useDialogLifecycle(open, onClose, true);
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="confirm-backdrop management-dialog-backdrop" role="presentation" onClick={onClose}>
|
||||
<div className={`drawer-panel management-dialog-panel${wide ? " management-dialog-wide" : ""}`} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`drawer-panel management-dialog-panel${wide ? " management-dialog-wide" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="panel-header">
|
||||
<h2>{title}</h2>
|
||||
<button type="button" className="theme-upload drawer-close" onClick={onClose}>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}>
|
||||
<X size={14} />
|
||||
<span>关闭</span>
|
||||
</button>
|
||||
</div>
|
||||
{description && <p className="dialog-description">{description}</p>}
|
||||
{description && <p id={descriptionId} className="dialog-description">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useDialogLifecycle(open: boolean, onClose: () => void, canClose: boolean) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const panel = panelRef.current;
|
||||
const focusTarget = panel?.querySelector<HTMLElement>("button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])");
|
||||
focusTarget?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && canClose) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previousFocus?.focus();
|
||||
};
|
||||
}, [canClose, onClose, open]);
|
||||
|
||||
return panelRef;
|
||||
}
|
||||
|
||||
interface UsageMeterProps {
|
||||
label: string;
|
||||
percent?: number;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, Clock3, ListChecks } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { OperationRecord } from "../contracts/workspace";
|
||||
import { projectOperationForTray } from "../contracts/operationsConsole";
|
||||
import { cx } from "../utils/classes";
|
||||
|
||||
interface OperationsTrayProps {
|
||||
operations: OperationRecord[];
|
||||
}
|
||||
|
||||
export function OperationsTray({ operations }: OperationsTrayProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const items = useMemo(() => operations.slice(0, 6).map(projectOperationForTray), [operations]);
|
||||
const pendingCount = operations.filter((operation) => operation.status === "pending").length;
|
||||
const failedCount = operations.filter((operation) => operation.status === "failed").length;
|
||||
|
||||
return (
|
||||
<section className={cx("operations-tray", open && "operations-tray-open")} aria-label="当前会话操作">
|
||||
<button
|
||||
type="button"
|
||||
className="operations-tray-trigger"
|
||||
aria-expanded={open}
|
||||
aria-controls="session-operations-panel"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span className="operations-tray-glyph" aria-hidden="true">
|
||||
<ListChecks size={17} />
|
||||
</span>
|
||||
<span className="operations-tray-copy">
|
||||
<strong>会话操作</strong>
|
||||
<span>{pendingCount > 0 ? `${pendingCount} 项处理中` : failedCount > 0 ? `${failedCount} 项失败` : `${operations.length} 条记录`}</span>
|
||||
</span>
|
||||
{open ? <ChevronDown size={15} aria-hidden="true" /> : <ChevronUp size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div id="session-operations-panel" className="operations-tray-panel" role="region" aria-live="polite">
|
||||
<div className="operations-tray-heading">
|
||||
<strong>当前浏览器会话</strong>
|
||||
<span>持久任务与审计记录以 Platform 页面为准</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="operations-tray-empty">本会话尚未提交资源变更。</p>
|
||||
) : (
|
||||
<ol className="operations-tray-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className={cx("operations-tray-item", `operations-tray-item-${item.status}`)}>
|
||||
<span className="operations-tray-status" aria-hidden="true">
|
||||
{item.status === "pending" ? <Clock3 size={14} /> : item.status === "succeeded" ? <CheckCircle2 size={14} /> : <AlertTriangle size={14} />}
|
||||
</span>
|
||||
<span className="operations-tray-item-copy">
|
||||
<strong>{item.intent}</strong>
|
||||
<span>{item.targetLabel}</span>
|
||||
<span>{item.status === "failed" ? item.errorReason : item.message ?? operationStatusLabel(item.status)}</span>
|
||||
{item.diagnosticId && <code>{item.diagnosticId}</code>}
|
||||
</span>
|
||||
<time dateTime={item.updatedAt}>{formatOperationTime(item.updatedAt)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function operationStatusLabel(status: OperationRecord["status"]): string {
|
||||
if (status === "pending") {
|
||||
return "等待 Platform 响应";
|
||||
}
|
||||
return status === "succeeded" ? "Platform 已确认" : "Platform 请求失败";
|
||||
}
|
||||
|
||||
function formatOperationTime(value: string): string {
|
||||
const time = new Date(value);
|
||||
return Number.isNaN(time.getTime()) ? "时间未知" : time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { PackageCheck, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { PluginLifecycleInstallationResponse, PluginLifecycleOperation, ServerInstanceResponse } from "../api/types";
|
||||
import { ConfirmDialog } from "./OperationControls";
|
||||
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
interface PluginLifecycleWorkbenchProps {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
operations?: PluginLifecycleOperation[];
|
||||
serverId?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = lifecycleOperations, serverId, disabled = false }: PluginLifecycleWorkbenchProps) {
|
||||
const [servers, setServers] = useState<ServerInstanceResponse[]>([]);
|
||||
const [installations, setInstallations] = useState<PluginLifecycleInstallationResponse[]>([]);
|
||||
const [selectedServerId, setSelectedServerId] = useState(serverId ?? "");
|
||||
const [operation, setOperation] = useState<PluginLifecycleOperation>(operations[0] ?? "install");
|
||||
const [targetVersion, setTargetVersion] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [serverResponse, lifecycleResponse] = await Promise.all([
|
||||
platformApiClient.listServerInstances(),
|
||||
platformApiClient.listPluginLifecycles({ pluginId, ...(serverId ? { serverInstanceId: serverId } : {}) })
|
||||
]);
|
||||
const compatibleServers = serverResponse.items.filter((server) => server.pluginId === pluginId && (!serverId || server.id === serverId));
|
||||
setServers(compatibleServers);
|
||||
setInstallations(lifecycleResponse.items);
|
||||
setSelectedServerId((current) => current || compatibleServers[0]?.id || "");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "插件生命周期加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pluginId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const installation = useMemo(() => installations.find((item) => item.serverInstanceId === selectedServerId), [installations, selectedServerId]);
|
||||
|
||||
async function submit() {
|
||||
if (!selectedServerId || busy) return;
|
||||
setBusy(true);
|
||||
setResult({ status: "pending", label: `${lifecycleOperationLabel(operation)}提交中…` });
|
||||
try {
|
||||
const response = await platformApiClient.runPluginLifecycle(pluginId, {
|
||||
serverInstanceId: selectedServerId,
|
||||
operation,
|
||||
targetVersion: targetVersion.trim() || undefined,
|
||||
idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`,
|
||||
confirmed: disruptiveOperations.includes(operation)
|
||||
});
|
||||
const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.auditEventId && `审计 ${response.installation.auditEventId}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · ");
|
||||
setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}:${response.status}${evidence ? ` · ${evidence}` : ""}` });
|
||||
setConfirming(false);
|
||||
await refresh();
|
||||
} catch (caught) {
|
||||
setResult({ status: "failed", label: caught instanceof Error ? caught.message : "插件生命周期操作失败" });
|
||||
setConfirming(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plugin-lifecycle-workbench" aria-label={`${pluginName} production lifecycle`}>
|
||||
<div className="panel-header">
|
||||
<h3><PackageCheck size={15} /> 生产生命周期</h3>
|
||||
<button type="button" className="icon-command" disabled={loading || busy} onClick={() => void refresh()} title="刷新插件生命周期"><RotateCw size={14} /><span>刷新</span></button>
|
||||
</div>
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
{loading && <LoadingState label="正在同步插件生命周期…" compact />}
|
||||
{!loading && error && <ErrorState title="插件生命周期不可用" reason={error} diagnosticId={`plugin-lifecycle:${pluginId}`} onRetry={() => void refresh()} compact />}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="server-toolbar plugin-lifecycle-controls">
|
||||
<select aria-label="生命周期服务器" value={selectedServerId} disabled={Boolean(serverId) || busy} onChange={(event) => setSelectedServerId(event.target.value)}>
|
||||
{servers.length === 0 && <option value="">暂无匹配服务器</option>}
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name} · {server.id}</option>)}
|
||||
</select>
|
||||
<select aria-label="生命周期操作" value={operation} disabled={busy} onChange={(event) => setOperation(event.target.value as PluginLifecycleOperation)}>
|
||||
{operations.map((item) => <option key={item} value={item}>{lifecycleOperationLabel(item)}</option>)}
|
||||
</select>
|
||||
{(operation === "install" || operation === "upgrade") && <input aria-label="目标版本" placeholder="目标版本" value={targetVersion} disabled={busy} onChange={(event) => setTargetVersion(event.target.value)} />}
|
||||
<button type="button" className="primary-command" disabled={disabled || busy || !selectedServerId} onClick={() => setConfirming(true)}>{busy ? "提交中…" : "执行"}</button>
|
||||
</div>
|
||||
{installation ? (
|
||||
<div className="operation-item plugin-lifecycle-state">
|
||||
<div className="operation-item-head"><strong>{installation.currentState} → {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
|
||||
<div className="operation-meta">
|
||||
<span>当前 {installation.currentVersion || "--"}</span><span>目标 {installation.targetVersion || "--"}</span><span>依赖 {installation.dependencyState || "unknown"}</span>
|
||||
{installation.jobId && <span>任务 {installation.jobId}</span>}{installation.auditEventId && <span>审计 {installation.auditEventId}</span>}{installation.alertId && <span>告警 {installation.alertId}</span>}
|
||||
</div>
|
||||
{installation.failureReason && <p className="operation-error">{installation.failureReason}</p>}
|
||||
</div>
|
||||
) : <p className="operations-module-empty">该服务器尚无插件生命周期记录。</p>}
|
||||
</>
|
||||
)}
|
||||
<ConfirmDialog open={confirming} title={`确认${lifecycleOperationLabel(operation)}`} description={`插件 ${pluginName},服务器 ${selectedServerId || "--"}${targetVersion ? `,目标版本 ${targetVersion}` : ""}。`} confirmLabel={lifecycleOperationLabel(operation)} danger={disruptiveOperations.includes(operation)} busy={busy} onCancel={() => { if (!busy) setConfirming(false); }} onConfirm={() => void submit()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lifecycleOperations: PluginLifecycleOperation[] = ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"];
|
||||
const disruptiveOperations: PluginLifecycleOperation[] = ["disable", "rollback", "retire"];
|
||||
|
||||
function lifecycleOperationLabel(operation: PluginLifecycleOperation) {
|
||||
return ({ install: "安装", enable: "启用", disable: "停用", upgrade: "升级", rollback: "回滚", retire: "退役", "dependency-check": "依赖检查" } as Record<PluginLifecycleOperation, string>)[operation];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Activity, AlertTriangle, Check, CheckCheck, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { AlertResponse, ProductionCapacitySummaryResponse } from "../api/types";
|
||||
import { cx } from "../utils/classes";
|
||||
import { ConfirmDialog } from "./OperationControls";
|
||||
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type AlertAction = "acknowledge" | "resolve" | "retry";
|
||||
|
||||
interface ProductionGovernancePanelProps {
|
||||
compact?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ProductionGovernancePanel({ compact = false, title = "容量与告警" }: ProductionGovernancePanelProps) {
|
||||
const [capacity, setCapacity] = useState<ProductionCapacitySummaryResponse | null>(null);
|
||||
const [alerts, setAlerts] = useState<AlertResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [intent, setIntent] = useState<{ alert: AlertResponse; action: AlertAction } | null>(null);
|
||||
const [busyKey, setBusyKey] = useState("");
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [capacityResponse, alertResponse] = await Promise.all([platformApiClient.getProductionCapacity(), platformApiClient.listAlerts()]);
|
||||
setCapacity(capacityResponse);
|
||||
setAlerts(alertResponse.items);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "生产治理状态加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function submitIntent() {
|
||||
if (!intent || busyKey) return;
|
||||
const key = `${intent.alert.id}:${intent.action}`;
|
||||
setBusyKey(key);
|
||||
setResult(null);
|
||||
try {
|
||||
if (intent.action === "acknowledge") {
|
||||
await platformApiClient.acknowledgeAlert(intent.alert.id, "operator acknowledged from production console");
|
||||
} else if (intent.action === "resolve") {
|
||||
await platformApiClient.resolveAlert(intent.alert.id, "operator resolved after production review");
|
||||
} else {
|
||||
await platformApiClient.retryAlert(intent.alert.id, `web:alert.retry:${intent.alert.id}:${Date.now()}`);
|
||||
}
|
||||
setResult({ status: "succeeded", label: `${alertActionLabel(intent.action)}已由 Platform 持久化` });
|
||||
setIntent(null);
|
||||
await refresh();
|
||||
} catch (caught) {
|
||||
setResult({ status: "failed", label: caught instanceof Error ? caught.message : `${alertActionLabel(intent.action)}失败` });
|
||||
setIntent(null);
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
const visibleAlerts = compact ? alerts.filter((alert) => alert.state !== "resolved").slice(0, 3) : alerts.slice(0, 12);
|
||||
const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? [];
|
||||
|
||||
return (
|
||||
<section className="console-panel production-governance-panel" aria-label="production capacity and alerts">
|
||||
<div className="panel-header">
|
||||
<h2><AlertTriangle size={16} /> {title}</h2>
|
||||
<button type="button" className="icon-command" disabled={loading || Boolean(busyKey)} onClick={() => void refresh()} title="刷新容量与告警">
|
||||
<RotateCw size={14} />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||||
{loading && <LoadingState label="正在同步容量与告警…" compact />}
|
||||
{!loading && error && <ErrorState title="生产治理状态不可用" reason={error} diagnosticId="production-governance" onRetry={() => void refresh()} compact />}
|
||||
{!loading && !error && capacity && (
|
||||
<>
|
||||
<dl className="operations-pulse-strip production-capacity-strip">
|
||||
<div><dt>运行槽位</dt><dd>{capacity.totalRunningJobs}/{capacity.totalMaxJobs}</dd></div>
|
||||
<div><dt>排队</dt><dd>{capacity.totalQueuedJobs}</dd></div>
|
||||
<div><dt>未关闭告警</dt><dd>{capacity.activeAlerts}</dd></div>
|
||||
</dl>
|
||||
<div className="operations-endpoint-list" aria-label="capacity endpoints">
|
||||
{visibleEndpoints.map((endpoint) => (
|
||||
<div key={endpoint.runEndpointId} className="operations-endpoint-row">
|
||||
<span><strong>{endpoint.displayName}</strong><small>{endpoint.pressureCodes?.join(", ") || "capacity.available"}</small></span>
|
||||
<span className={cx("status-pill", endpoint.pressureCodes?.length ? "status-warning" : `status-${endpoint.status}`)}>{endpoint.pressureCodes?.length ? "压力" : endpoint.status}</span>
|
||||
<span>{endpoint.runningJobs}/{endpoint.maxJobs} · 队列 {endpoint.queuedJobs}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="operation-list production-alert-list" aria-label="durable alerts">
|
||||
{visibleAlerts.length === 0 && <p className="operations-module-empty">当前没有持久告警。</p>}
|
||||
{visibleAlerts.map((alert) => {
|
||||
const pending = busyKey.startsWith(`${alert.id}:`);
|
||||
return (
|
||||
<div key={alert.id} className="operation-item">
|
||||
<div className="operation-item-head">
|
||||
<strong>{alert.title}</strong>
|
||||
<span className={cx("status-pill", alert.severity === "critical" ? "status-failed" : alert.state === "resolved" ? "status-succeeded" : "status-warning")}>{alert.state}</span>
|
||||
</div>
|
||||
<p>{alert.message}</p>
|
||||
<div className="operation-meta">
|
||||
<span>{alert.sourceKind} · {alert.sourceId}</span>
|
||||
<span>发生 {alert.occurrenceCount} 次</span>
|
||||
{alert.lastJobId && <span>任务 {alert.lastJobId}</span>}
|
||||
{alert.lastAuditEventId && <span>审计 {alert.lastAuditEventId}</span>}
|
||||
</div>
|
||||
{alert.state !== "resolved" && (
|
||||
<div className="row-actions production-alert-actions">
|
||||
{alert.state === "active" && <button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "acknowledge" })}><Check size={14} /><span>确认</span></button>}
|
||||
<button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "resolve" })}><CheckCheck size={14} /><span>解决</span></button>
|
||||
{alert.retryable && <button type="button" disabled={pending} onClick={() => setIntent({ alert, action: "retry" })}><Activity size={14} /><span>重试源</span></button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={intent !== null}
|
||||
title={intent ? `${alertActionLabel(intent.action)}告警` : "告警操作"}
|
||||
description={intent ? `目标 ${intent.alert.id},仅处理来源 ${intent.alert.sourceKind}/${intent.alert.sourceId}。` : "确认告警操作。"}
|
||||
confirmLabel={intent ? alertActionLabel(intent.action) : "确认"}
|
||||
danger={intent?.action === "resolve"}
|
||||
busy={Boolean(busyKey)}
|
||||
onCancel={() => { if (!busyKey) setIntent(null); }}
|
||||
onConfirm={() => void submitIntent()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function alertActionLabel(action: AlertAction) {
|
||||
if (action === "acknowledge") return "确认";
|
||||
if (action === "resolve") return "解决";
|
||||
return "重试来源";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
||||
import { ProductionGovernancePanel } from "./ProductionGovernancePanel";
|
||||
import governanceSource from "./ProductionGovernancePanel.tsx?raw";
|
||||
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
||||
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
|
||||
|
||||
describe("production operations components", () => {
|
||||
it("renders persisted loading states without optimistic terminal success", () => {
|
||||
expect(renderToStaticMarkup(<ProductionGovernancePanel />)).toContain("正在同步容量与告警");
|
||||
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
|
||||
for (const source of [governanceSource, lifecycleSource, diffSource]) {
|
||||
expect(source).not.toContain("setTimeout");
|
||||
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||
expect(source).toContain("disabled=");
|
||||
}
|
||||
expect(governanceSource).toContain("if (!intent || busyKey) return");
|
||||
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
||||
expect(diffSource).toContain("if (!selected || busyId) return");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GameClientBridgeSnapshotResponse } from "../api/types";
|
||||
import type { ScumOperationsPageContract } from "../contracts/scumOperations";
|
||||
import { ScumOperationsPanel, type ScumOperationsPanelData } from "./ScumOperationsPanel";
|
||||
import scumOperationsPanelSource from "./ScumOperationsPanel.tsx?raw";
|
||||
|
||||
const now = "2026-07-20T08:00:00Z";
|
||||
|
||||
const contract: ScumOperationsPageContract = {
|
||||
pluginId: "game.scum",
|
||||
routeKey: "operations",
|
||||
serverInstanceId: "server-1",
|
||||
title: "SCUM 运维",
|
||||
permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "client-manager.request"],
|
||||
commands: [
|
||||
{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 },
|
||||
{ type: "companion.diagnostics", title: "Collect diagnostics", permission: "server.game-client.read", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostics.json", timeoutSeconds: 30, maxPayloadBytes: 2048 },
|
||||
{ type: "restart.prepare", title: "Prepare restart", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", payloadSchemaRef: "schemas/bridge/restart.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }
|
||||
],
|
||||
snapshots: ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"].map((type) => ({ type, schemaVersion: "1", schemaRef: `schemas/bridge/${type}.json`, keepForSeconds: 3600, maxRecords: 24 })),
|
||||
queryTemplates: [],
|
||||
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
|
||||
logEvents: [
|
||||
{ key: "scum-chat", title: "SCUM chat", sourceKey: "scum-chat-events", eventType: "scum.chat", permission: "server.logs.read", schemaRef: "schemas/log-events/chat.json", retentionDays: 30, severity: "info" },
|
||||
{ key: "scum-kill", title: "SCUM kill", sourceKey: "scum-chat-events", eventType: "scum.kill", permission: "server.logs.read", schemaRef: "schemas/log-events/kill.json", retentionDays: 30, severity: "warning" }
|
||||
],
|
||||
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }
|
||||
};
|
||||
|
||||
function snapshot(type: string, payload: GameClientBridgeSnapshotResponse["payload"]): GameClientBridgeSnapshotResponse {
|
||||
return { id: `snapshot-${type}`, serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client", type, schemaVersion: "1", streamKey: "current", sequence: 1, observedAt: now, payload, retention: { keepForSeconds: 3600, maxRecords: 24 }, createdAt: now, expiresAt: "2026-07-20T09:00:00Z" };
|
||||
}
|
||||
|
||||
const data: ScumOperationsPanelData = {
|
||||
status: { serverInstanceId: "server-1", pluginId: "game.scum", available: true, profiles: [{ pluginId: "game.scum", profileKey: "scum-client", available: true, commandTypes: ["announcement.send", "companion.diagnostics", "restart.prepare"], snapshotTypes: contract.snapshots.map((item) => item.type), queryTemplateKeys: [] }] },
|
||||
commands: [{ id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client", commandType: "announcement.send", priority: 50, state: "succeeded", approvalState: "approved", resultSummary: "announcement delivered", result: { status: "succeeded", summary: "announcement delivered", payload: { delivered: true }, completedAt: now }, expiresAt: now, createdAt: now, updatedAt: now, completedAt: now }],
|
||||
snapshots: [
|
||||
snapshot("companion.health", { status: "online", observedAt: now, version: "1.0.0", latencyMs: 20 }),
|
||||
snapshot("online.sessions", { observedAt: now, onlineCount: 1, sessions: [{ sessionId: "game-session-1", playerName: "Moonlight" }] }),
|
||||
snapshot("players", { observedAt: now, players: [{ playerId: "player-1", playerName: "Moonlight", status: "online" }] }),
|
||||
snapshot("squads", { observedAt: now, squads: [{ squadId: "squad-1", name: "Lunar", memberCount: 4 }] }),
|
||||
snapshot("vehicles", { observedAt: now, vehicles: [{ vehicleId: "vehicle-1", vehicleType: "truck", status: "parked" }] }),
|
||||
snapshot("flags", { observedAt: now, flags: [{ flagId: "flag-1", status: "active" }] })
|
||||
],
|
||||
logs: [{ streamKey: "scum.chat", eventType: "scum.chat", entry: { seq: 1, timestamp: now, level: "info", line: "token=raw-secret /Users/operator/scum.log", redacted: true } }],
|
||||
backups: [{ id: "backup-1", serverInstanceId: "server-1", artifactId: "artifact-1", checksum: "sha256:safe", sizeBytes: 2048, state: "available", recoveryStatus: "verified", retentionUntil: "2026-07-27T08:00:00Z", createdAt: now, updatedAt: now }],
|
||||
errors: []
|
||||
};
|
||||
|
||||
describe("ScumOperationsPanel", () => {
|
||||
it("renders the first safe SCUM operations surface", () => {
|
||||
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={data} />);
|
||||
for (const label of ["Companion", "命令队列与结果", "玩家与世界状态快照", "玩家 1", "会话 1", "小队 1", "载具 1", "旗帜 1", "语义日志", "scum.chat", "维护与备份策略", "backup-1", "已批准"]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
expect(html).toContain("不触发自动封禁或惩罚");
|
||||
expect(html).not.toContain("raw-secret");
|
||||
expect(html).not.toContain("/Users/");
|
||||
expect(html).not.toMatch(/sessionToken|componentKey|secretRef|hostPath|dsn|runSocket|credential/i);
|
||||
});
|
||||
|
||||
it("keeps commands disabled with a visible bridge availability reason", () => {
|
||||
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, status: { ...data.status!, available: false, reason: "compatible companion is offline", profiles: data.status!.profiles.map((profile) => ({ ...profile, available: false, reason: "component heartbeat is unavailable" })) } }} />);
|
||||
expect(html).toContain("compatible companion is offline");
|
||||
expect(html).toContain("disabled");
|
||||
|
||||
const profileReasonHtml = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, status: { ...data.status!, available: true, reason: undefined, profiles: data.status!.profiles.map((profile) => ({ ...profile, available: false, reason: "component heartbeat is unavailable" })) } }} />);
|
||||
expect(profileReasonHtml).toContain("component heartbeat is unavailable");
|
||||
expect(profileReasonHtml).toContain("disabled");
|
||||
});
|
||||
|
||||
it("renders every command approval state without leaking unsafe result details", () => {
|
||||
const approvalStates = ["not_required", "pending", "approved", "rejected"] as const;
|
||||
const unsafeSummary = "token=raw-command-secret /Users/operator/result.json unix:///var/run/scum.sock";
|
||||
const commands = approvalStates.map((approvalState, index) => ({
|
||||
...data.commands[0]!,
|
||||
id: `command-${index + 1}`,
|
||||
approvalState,
|
||||
resultSummary: approvalState === "rejected" ? unsafeSummary : `approval ${approvalState}`,
|
||||
result: approvalState === "rejected" ? { ...data.commands[0]!.result!, summary: unsafeSummary } : data.commands[0]!.result
|
||||
}));
|
||||
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, commands }} />);
|
||||
|
||||
for (const label of ["无需审批", "待审批", "已批准", "已拒绝"]) {
|
||||
expect(html).toContain(label);
|
||||
}
|
||||
expect(html).not.toContain("raw-command-secret");
|
||||
expect(html).not.toContain("/Users/operator");
|
||||
expect(html).not.toContain("unix:///var/run");
|
||||
});
|
||||
|
||||
it("uses shared console surfaces without page-local ambient decoration", () => {
|
||||
expect(scumOperationsPanelSource).toContain('className="console-panel"');
|
||||
expect(scumOperationsPanelSource).toContain('className="resource-table-wrap"');
|
||||
expect(scumOperationsPanelSource).not.toMatch(/position:\s*fixed|sparkle|snowflake|magic-circle|backdrop-layer/i);
|
||||
expect(scumOperationsPanelSource).not.toContain("InsecureSkipVerify");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import { Activity, BellRing, Database, RefreshCw, ShieldCheck, UsersRound, Wrench } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type {
|
||||
BackupResponse,
|
||||
GameClientBridgeCommandResponse,
|
||||
GameClientBridgeJsonObject,
|
||||
GameClientBridgeSnapshotResponse,
|
||||
GameClientBridgeStatusResponse,
|
||||
LogEntryBody,
|
||||
LogStreamResponse
|
||||
} from "../api/types";
|
||||
import type { ScumOperationsPageContract, ScumOperationsSnapshotView } from "../contracts/scumOperations";
|
||||
import { projectScumOperationsSnapshots } from "../schemas/scumOperations";
|
||||
import { cx } from "../utils/classes";
|
||||
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
|
||||
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||
|
||||
type SnapshotSection = "players" | "sessions" | "squads" | "vehicles" | "flags";
|
||||
|
||||
export interface ScumSemanticLogView {
|
||||
streamKey: string;
|
||||
eventType: string;
|
||||
entry: LogEntryBody;
|
||||
}
|
||||
|
||||
export interface ScumOperationsPanelData {
|
||||
status?: GameClientBridgeStatusResponse;
|
||||
commands: GameClientBridgeCommandResponse[];
|
||||
snapshots: GameClientBridgeSnapshotResponse[];
|
||||
logs: ScumSemanticLogView[];
|
||||
backups: BackupResponse[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ScumOperationsPanelState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; reason: string }
|
||||
| { status: "ready"; data: ScumOperationsPanelData };
|
||||
|
||||
interface ScumOperationsPanelProps {
|
||||
contract: ScumOperationsPageContract;
|
||||
initialData?: ScumOperationsPanelData;
|
||||
}
|
||||
|
||||
export function ScumOperationsPanel({ contract, initialData }: ScumOperationsPanelProps) {
|
||||
const [state, setState] = useState<ScumOperationsPanelState>(() => initialData ? { status: "ready", data: initialData } : { status: "loading" });
|
||||
const [snapshotSection, setSnapshotSection] = useState<SnapshotSection>("players");
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [pendingCommand, setPendingCommand] = useState<string | null>(null);
|
||||
const [actionResult, setActionResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setState({ status: "loading" });
|
||||
const results = await Promise.allSettled([
|
||||
platformApiClient.getGameClientBridgeStatus(contract.serverInstanceId),
|
||||
platformApiClient.listGameClientBridgeCommands(contract.serverInstanceId),
|
||||
platformApiClient.listGameClientBridgeSnapshots(contract.serverInstanceId, { limit: 200 }),
|
||||
loadSemanticLogs(contract),
|
||||
platformApiClient.listBackups(contract.serverInstanceId)
|
||||
]);
|
||||
const errors = results.flatMap((result, index) => result.status === "rejected" ? [loadErrorLabel(index, result.reason)] : []);
|
||||
const status = settledValue(results[0]);
|
||||
const commands = settledValue(results[1]);
|
||||
const snapshots = settledValue(results[2]);
|
||||
const logs = settledValue(results[3]);
|
||||
const backups = settledValue(results[4]);
|
||||
if (!status && !commands && !snapshots) {
|
||||
setState({ status: "error", reason: errors.join(";") || "Game Client Bridge 核心投影不可用。" });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
status: "ready",
|
||||
data: {
|
||||
status,
|
||||
commands: commands?.items ?? [],
|
||||
snapshots: snapshots?.items ?? [],
|
||||
logs: logs ?? [],
|
||||
backups: backups?.items ?? [],
|
||||
errors
|
||||
}
|
||||
});
|
||||
}, [contract]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
void refresh();
|
||||
}
|
||||
}, [initialData, refresh]);
|
||||
|
||||
const snapshots = useMemo(() => state.status === "ready" ? projectScumOperationsSnapshots(state.data.snapshots) : emptySnapshotView(), [state]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <LoadingState label="正在加载 SCUM Bridge 运维投影…" />;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <ErrorState title="SCUM 运维投影不可用" reason={state.reason} onRetry={() => void refresh()} />;
|
||||
}
|
||||
|
||||
const data = state.data;
|
||||
const bridgeAvailable = data.status?.available === true;
|
||||
const bridgeReason = data.status?.reason || data.status?.profiles.find((profile) => !profile.available)?.reason;
|
||||
const profile = data.status?.profiles.find((candidate) => candidate.available) ?? data.status?.profiles[0];
|
||||
const canQueue = bridgeAvailable && profile?.available === true;
|
||||
const diagnostics = contract.commands.find((command) => command.type === "companion.diagnostics");
|
||||
const announcementDeclaration = contract.commands.find((command) => command.type === "announcement.send");
|
||||
const announcementAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("announcement.send")) === true;
|
||||
const diagnosticsAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("companion.diagnostics")) === true;
|
||||
const latestSnapshotAt = data.snapshots.reduce((latest, snapshot) => snapshot.observedAt > latest ? snapshot.observedAt : latest, "");
|
||||
|
||||
async function queueCommand(commandType: string, payload: GameClientBridgeJsonObject) {
|
||||
const declaration = contract.commands.find((command) => command.type === commandType);
|
||||
const commandProfile = data.status?.profiles.find((candidate) => candidate.available && candidate.commandTypes.includes(commandType));
|
||||
if (!declaration || !commandProfile) {
|
||||
setActionResult({ status: "failed", label: "命令未在当前可用 Bridge profile 中声明。" });
|
||||
return;
|
||||
}
|
||||
setPendingCommand(commandType);
|
||||
setActionResult({ status: "pending", label: `正在提交 ${declaration.title}` });
|
||||
try {
|
||||
const expirySeconds = Math.max(300, Math.min(3600, declaration.timeoutSeconds * 2));
|
||||
const queued = await platformApiClient.queueGameClientBridgeCommand(contract.serverInstanceId, {
|
||||
profileKey: commandProfile.profileKey,
|
||||
commandType,
|
||||
payload,
|
||||
idempotencyKey: `web-scum-${commandType.replaceAll(".", "-")}-${Date.now()}`,
|
||||
priority: 50,
|
||||
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString()
|
||||
});
|
||||
setState((current) => current.status === "ready" ? {
|
||||
status: "ready",
|
||||
data: { ...current.data, commands: [queued, ...current.data.commands.filter((command) => command.id !== queued.id)] }
|
||||
} : current);
|
||||
setAnnouncement("");
|
||||
setActionResult({ status: "succeeded", label: `命令已进入队列,审批状态:${approvalLabel(queued.approvalState)}` });
|
||||
} catch (error) {
|
||||
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" });
|
||||
} finally {
|
||||
setPendingCommand(null);
|
||||
}
|
||||
}
|
||||
|
||||
function submitAnnouncement(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const message = announcement.trim();
|
||||
if (!message || message.length > 500) {
|
||||
setActionResult({ status: "failed", label: "公告内容必须为 1–500 个字符。" });
|
||||
return;
|
||||
}
|
||||
void queueCommand("announcement.send", { message });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="console-page" aria-label="SCUM operations">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<strong>Game Client Bridge 运维投影</strong>
|
||||
<p className="provider-id">只显示 Platform 安全 DTO 与 game.scum 声明,不接触组件会话、主机路径或 Run socket。</p>
|
||||
</div>
|
||||
<button type="button" className="icon-command" onClick={() => void refresh()}>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data.errors.length > 0 && <ErrorState title="部分运维投影不可用" reason={data.errors.join(";")} compact />}
|
||||
|
||||
<div className="console-grid" aria-label="SCUM operations summary">
|
||||
<Metric label="Bridge" value={bridgeAvailable ? "可用" : "不可用"} detail={bridgeReason || profile?.profileKey || "未声明 profile"} tone={bridgeAvailable ? "success" : "warning"} />
|
||||
<Metric label="Companion" value={healthLabel(snapshots.health?.status)} detail={snapshots.health?.observedAt ? `观测于 ${formatTime(snapshots.health.observedAt)}` : "暂无健康快照"} tone={snapshots.health?.status === "online" ? "success" : "warning"} />
|
||||
<Metric label="在线会话" value={String(snapshots.sessions.total)} detail={snapshots.sessions.observedAt ? `快照 ${formatTime(snapshots.sessions.observedAt)}` : "暂无会话快照"} tone="neutral" />
|
||||
<Metric label="最近快照" value={latestSnapshotAt ? formatTime(latestSnapshotAt) : "--"} detail={`${data.snapshots.length} 条安全投影`} tone="neutral" />
|
||||
</div>
|
||||
|
||||
<section className="console-panel" aria-label="SCUM command queue">
|
||||
<div className="panel-header">
|
||||
<h2><BellRing size={16} aria-hidden="true" /> 命令队列与结果</h2>
|
||||
<span className="page-status">{canQueue ? "Bridge 可提交" : bridgeReason || "Bridge 不可提交"}</span>
|
||||
</div>
|
||||
<div className="action-list">
|
||||
{announcementDeclaration && (
|
||||
<form className="provider-form" onSubmit={submitAnnouncement} aria-label="发送 SCUM 公告">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
公告内容
|
||||
<input value={announcement} maxLength={500} onChange={(event) => setAnnouncement(event.target.value)} placeholder="输入 1–500 字公告" disabled={!announcementAvailable || pendingCommand !== null} />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="icon-command" disabled={!announcementAvailable || pendingCommand !== null || !announcement.trim()}>
|
||||
<BellRing size={14} aria-hidden="true" />
|
||||
<span>{pendingCommand === "announcement.send" ? "提交中" : `提交公告 · ${approvalLevelLabel(announcementDeclaration.approvalLevel)}`}</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{diagnostics && (
|
||||
<div className="plugin-control-row">
|
||||
<span><strong>{diagnostics.title}</strong><p>仅请求声明的窗口状态摘要与最多 10 条诊断,不传递组件会话或路径。</p></span>
|
||||
<button type="button" className="icon-command" disabled={!diagnosticsAvailable || pendingCommand !== null} onClick={() => void queueCommand("companion.diagnostics", { includeWindowState: true, maxEntries: 10 })}>
|
||||
<Activity size={14} aria-hidden="true" />
|
||||
<span>{pendingCommand === "companion.diagnostics" ? "提交中" : "采集诊断"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{actionResult && <ResultBadge status={actionResult.status} label={safeDiagnosticText(actionResult.label) ?? actionResult.label} />}
|
||||
</div>
|
||||
<CommandTable commands={data.commands} />
|
||||
</section>
|
||||
|
||||
<section className="console-panel" aria-label="SCUM snapshot projections">
|
||||
<div className="panel-header">
|
||||
<h2><UsersRound size={16} aria-hidden="true" /> 玩家与世界状态快照</h2>
|
||||
<div className="action-strip" role="group" aria-label="SCUM snapshot sections">
|
||||
{snapshotTabs(snapshots).map((tab) => (
|
||||
<button key={tab.key} type="button" className={cx("icon-command")} aria-pressed={snapshotSection === tab.key} onClick={() => setSnapshotSection(tab.key)}>
|
||||
<span>{tab.label} {tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SnapshotTable section={snapshotSection} snapshots={snapshots} />
|
||||
</section>
|
||||
|
||||
<section className="console-panel" aria-label="SCUM semantic logs">
|
||||
<div className="panel-header">
|
||||
<h2><Database size={16} aria-hidden="true" /> 语义日志</h2>
|
||||
<span className="page-status">{contract.logEvents.length} 类已声明事件</span>
|
||||
</div>
|
||||
<div className="action-list">
|
||||
<span>{contract.logEvents.map((event) => event.eventType).join(" / ") || "插件未声明语义日志事件"}</span>
|
||||
<span>可疑行为仅作为证据与复核线索展示,不触发自动封禁或惩罚。</span>
|
||||
</div>
|
||||
<SemanticLogTable logs={data.logs} />
|
||||
</section>
|
||||
|
||||
<section className="console-panel" aria-label="SCUM maintenance policy">
|
||||
<div className="panel-header">
|
||||
<h2><Wrench size={16} aria-hidden="true" /> 维护与备份策略</h2>
|
||||
<span className="page-status">审批优先</span>
|
||||
</div>
|
||||
<div className="action-list">
|
||||
<span><strong>依赖策略</strong> {contract.productionLifecycle.dependencyPolicy === "required" ? "必须满足依赖后执行" : "依赖为可选项"}</span>
|
||||
<span><strong>生命周期审批</strong> {contract.productionLifecycle.approvalRequired.join(" / ") || "无额外声明"}</span>
|
||||
{contract.commands.filter((command) => command.permission === "server.game-client.maintenance").map((command) => (
|
||||
<span key={command.type}><ShieldCheck size={14} aria-hidden="true" /> {command.title} · {approvalLevelLabel(command.approvalLevel)}</span>
|
||||
))}
|
||||
<span>本页不自动执行重启、奖励、惩罚或备份;操作仍需插件声明、Platform 权限与审批。</span>
|
||||
</div>
|
||||
<BackupTable backups={data.backups} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSemanticLogs(contract: ScumOperationsPageContract): Promise<ScumSemanticLogView[]> {
|
||||
const streamsResponse = await platformApiClient.listLogStreams();
|
||||
const sourceByKey = new Map(contract.logSources.map((source) => [source.key, source]));
|
||||
const eventTypesByStream = new Map<string, string[]>();
|
||||
for (const event of contract.logEvents) {
|
||||
const streamKey = sourceByKey.get(event.sourceKey)?.streamKey;
|
||||
if (!streamKey) continue;
|
||||
eventTypesByStream.set(streamKey, [...(eventTypesByStream.get(streamKey) ?? []), event.eventType]);
|
||||
}
|
||||
const streams = streamsResponse.items.filter((stream) => stream.serverInstanceId === contract.serverInstanceId && eventTypesByStream.has(stream.streamKey)).slice(0, 12);
|
||||
const results = await Promise.allSettled(streams.map(async (stream) => ({
|
||||
stream,
|
||||
response: await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 50), limit: 50 })
|
||||
})));
|
||||
return results.flatMap((result) => {
|
||||
if (result.status === "rejected") return [];
|
||||
return result.value.response.entries.map((entry) => ({
|
||||
streamKey: result.value.stream.streamKey,
|
||||
eventType: declaredEventType(entry, result.value.stream, eventTypesByStream),
|
||||
entry
|
||||
}));
|
||||
}).sort((left, right) => right.entry.timestamp.localeCompare(left.entry.timestamp)).slice(0, 100);
|
||||
}
|
||||
|
||||
function declaredEventType(entry: LogEntryBody, stream: LogStreamResponse, eventTypesByStream: Map<string, string[]>): string {
|
||||
const declared = eventTypesByStream.get(stream.streamKey) ?? [];
|
||||
const projected = entry.fields?.eventType;
|
||||
return projected && declared.includes(projected) ? projected : declared.join(" / ") || stream.streamKey;
|
||||
}
|
||||
|
||||
function settledValue<T>(result: PromiseSettledResult<T>): T | undefined {
|
||||
return result.status === "fulfilled" ? result.value : undefined;
|
||||
}
|
||||
|
||||
function loadErrorLabel(index: number, reason: unknown): string {
|
||||
const labels = ["Bridge 状态", "命令队列", "快照", "语义日志", "备份记录"];
|
||||
const detail = safeDiagnosticText(reason instanceof Error ? reason.message : String(reason), "加载失败");
|
||||
return `${labels[index] ?? "运维数据"}:${detail}`;
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail, tone }: { label: string; value: string; detail: string; tone: "neutral" | "success" | "warning" }) {
|
||||
return (
|
||||
<article className={cx("metric-card", `metric-tone-${tone}`)}>
|
||||
<span className="metric-label">{label}</span>
|
||||
<strong className="metric-value">{value}</strong>
|
||||
<p className="metric-detail">{detail}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandTable({ commands }: { commands: GameClientBridgeCommandResponse[] }) {
|
||||
if (commands.length === 0) return <p className="provider-id">暂无命令记录。</p>;
|
||||
return (
|
||||
<div className="resource-table-wrap">
|
||||
<table className="resource-table">
|
||||
<thead><tr><th>命令</th><th>状态</th><th>审批</th><th>结果</th><th>更新时间</th></tr></thead>
|
||||
<tbody>
|
||||
{commands.slice(0, 50).map((command) => (
|
||||
<tr key={command.id}>
|
||||
<td><strong>{command.commandType}</strong><span className="provider-id">{command.id}</span></td>
|
||||
<td>{commandStateLabel(command.state)}</td>
|
||||
<td>{approvalLabel(command.approvalState)}</td>
|
||||
<td>{safeDiagnosticText(command.result?.summary || command.resultSummary, "--") || "--"}</td>
|
||||
<td>{formatTime(command.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotTabs(snapshots: ScumOperationsSnapshotView): Array<{ key: SnapshotSection; label: string; count: number }> {
|
||||
return [
|
||||
{ key: "players", label: "玩家", count: snapshots.players.total },
|
||||
{ key: "sessions", label: "会话", count: snapshots.sessions.total },
|
||||
{ key: "squads", label: "小队", count: snapshots.squads.total },
|
||||
{ key: "vehicles", label: "载具", count: snapshots.vehicles.total },
|
||||
{ key: "flags", label: "旗帜", count: snapshots.flags.total }
|
||||
];
|
||||
}
|
||||
|
||||
function SnapshotTable({ section, snapshots }: { section: SnapshotSection; snapshots: ScumOperationsSnapshotView }) {
|
||||
const configuration: Record<SnapshotSection, { headers: string[]; rows: ReactNode[][]; total: number }> = {
|
||||
players: { headers: ["玩家", "状态", "小队", "延迟", "最后出现"], total: snapshots.players.total, rows: snapshots.players.items.map((item) => [`${item.playerName} · ${item.playerId}`, item.status, item.squadId ?? "--", item.pingMs === undefined ? "--" : `${item.pingMs} ms`, formatTime(item.lastSeenAt)]) },
|
||||
sessions: { headers: ["会话", "玩家", "开始时间"], total: snapshots.sessions.total, rows: snapshots.sessions.items.map((item) => [item.sessionId, item.playerName, formatTime(item.startedAt)]) },
|
||||
squads: { headers: ["小队", "成员", "队长", "最后活跃"], total: snapshots.squads.total, rows: snapshots.squads.items.map((item) => [`${item.name} · ${item.squadId}`, item.memberCount, item.leaderPlayerId ?? "--", formatTime(item.lastActiveAt)]) },
|
||||
vehicles: { headers: ["载具", "状态", "所有者 / 小队", "燃油 / 耐久", "最后出现"], total: snapshots.vehicles.total, rows: snapshots.vehicles.items.map((item) => [`${item.vehicleType} · ${item.vehicleId}`, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, `${percent(item.fuelPercent)} / ${percent(item.healthPercent)}`, formatTime(item.lastSeenAt)]) },
|
||||
flags: { headers: ["旗帜", "状态", "所有者 / 小队", "半径", "更新时间"], total: snapshots.flags.total, rows: snapshots.flags.items.map((item) => [item.flagId, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, item.radiusMeters === undefined ? "--" : `${item.radiusMeters} m`, formatTime(item.lastUpdatedAt)]) }
|
||||
};
|
||||
const table = configuration[section];
|
||||
if (table.total === 0) return <p className="provider-id">该类型暂无安全快照。</p>;
|
||||
return (
|
||||
<div className="resource-table-wrap">
|
||||
<table className="resource-table">
|
||||
<thead><tr>{table.headers.map((header) => <th key={header}>{header}</th>)}</tr></thead>
|
||||
<tbody>{table.rows.map((row, rowIndex) => <tr key={`${section}-${rowIndex}`}>{row.map((cell, index) => <td key={`${index}-${String(cell)}`}>{cell}</td>)}</tr>)}</tbody>
|
||||
</table>
|
||||
{table.total > table.rows.length && <p className="provider-id">仅展示前 {table.rows.length} / {table.total} 项。</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SemanticLogTable({ logs }: { logs: ScumSemanticLogView[] }) {
|
||||
if (logs.length === 0) return <p className="provider-id">暂无可用语义日志投影。</p>;
|
||||
return (
|
||||
<div className="resource-table-wrap">
|
||||
<table className="resource-table">
|
||||
<thead><tr><th>事件类型</th><th>时间</th><th>级别</th><th>安全摘要</th></tr></thead>
|
||||
<tbody>{logs.slice(0, 100).map((log) => (
|
||||
<tr key={`${log.streamKey}-${log.entry.seq}`}>
|
||||
<td>{log.eventType}</td>
|
||||
<td>{formatTime(log.entry.timestamp)}</td>
|
||||
<td>{log.entry.level || "info"}{log.entry.redacted ? " · 已脱敏" : ""}</td>
|
||||
<td>{safeDiagnosticText(log.entry.line, "日志内容已隐藏")}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupTable({ backups }: { backups: BackupResponse[] }) {
|
||||
if (backups.length === 0) return <p className="provider-id">暂无 Platform 备份记录。</p>;
|
||||
return (
|
||||
<div className="resource-table-wrap">
|
||||
<table className="resource-table">
|
||||
<thead><tr><th>备份</th><th>状态</th><th>大小</th><th>恢复状态</th><th>保留至</th></tr></thead>
|
||||
<tbody>{backups.slice(0, 20).map((backup) => (
|
||||
<tr key={backup.id}><td>{backup.id}</td><td>{backup.state}</td><td>{formatBytes(backup.sizeBytes)}</td><td>{backup.recoveryStatus || "--"}</td><td>{formatTime(backup.retentionUntil)}</td></tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptySnapshotView(): ScumOperationsSnapshotView {
|
||||
const empty = { total: 0, items: [] };
|
||||
return { sessions: { ...empty }, players: { ...empty }, squads: { ...empty }, vehicles: { ...empty }, flags: { ...empty } };
|
||||
}
|
||||
|
||||
function approvalLevelLabel(level: string): string {
|
||||
if (level === "platform-admin") return "需平台管理员审批";
|
||||
if (level === "operator") return "需操作员审批";
|
||||
return "无需额外审批";
|
||||
}
|
||||
|
||||
function approvalLabel(state: string): string {
|
||||
if (state === "not_required") return "无需审批";
|
||||
if (state === "approved") return "已批准";
|
||||
if (state === "rejected") return "已拒绝";
|
||||
return "待审批";
|
||||
}
|
||||
|
||||
function commandStateLabel(state: string): string {
|
||||
const labels: Record<string, string> = { pending: "等待领取", claimed: "执行中", succeeded: "成功", failed: "失败", cancelled: "已取消", expired: "已过期" };
|
||||
return labels[state] ?? state;
|
||||
}
|
||||
|
||||
function healthLabel(status: string | undefined): string {
|
||||
if (status === "online") return "在线";
|
||||
if (status === "degraded") return "降级";
|
||||
if (status === "offline") return "离线";
|
||||
return "未知";
|
||||
}
|
||||
|
||||
function formatTime(value: string | undefined): string {
|
||||
if (!value) return "--";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? "--" : parsed.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function percent(value: number | undefined): string {
|
||||
return value === undefined ? "--" : `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { AlertTriangle, CheckCircle2, Copy, Loader2, MoonStar, Sparkles, XCircle
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cx } from "../utils/classes";
|
||||
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
@@ -58,7 +59,7 @@ export function ErrorState({ title, reason, diagnosticId, onRetry, compact }: Er
|
||||
<AlertTriangle size={compact ? 16 : 24} />
|
||||
</span>
|
||||
<strong>{title}</strong>
|
||||
{reason && <p>{reason}</p>}
|
||||
{reason && <p>{safeDiagnosticText(reason)}</p>}
|
||||
{diagnosticId && <DiagnosticSummary diagnosticId={diagnosticId} />}
|
||||
{onRetry && (
|
||||
<button type="button" className="state-action" onClick={onRetry}>
|
||||
@@ -81,7 +82,7 @@ export function ResultBadge({ status, label }: ResultBadgeProps) {
|
||||
return (
|
||||
<span className={cx("result-badge", `result-badge-${status}`)}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<span>{status === "failed" ? safeDiagnosticText(label) : label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user