Files
browser/platform_web/components/ClientManagerLifecyclePanel.tsx
T
2026-07-20 16:42:33 +08:00

222 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 workspacePlatform 保留 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); }