Files
browser/platform_web/components/PluginLifecycleWorkbench.tsx
T
2026-07-22 15:27:26 +08:00

123 lines
7.8 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 { 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="console-record plugin-lifecycle-state">
<div className="console-record-head"><strong>{installation.currentState} {installation.desiredState}</strong><span className="status-pill status-active">{installation.compatibility || "pending"}</span></div>
<div className="console-record-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="console-empty-note">该服务器尚无插件生命周期记录。</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];
}