245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
import { AlertTriangle, ListChecks, RotateCcw, Sparkles, WandSparkles } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import { platformApiClient } from "../api/client";
|
|
import type { JobResponse, ServerInstanceResponse } from "../api/types";
|
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
|
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
|
import type { PageComponentProps } from "../contracts/page";
|
|
import { cx } from "../utils/classes";
|
|
|
|
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
|
|
|
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
|
|
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
|
|
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
|
|
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
|
|
|
const refreshJobs = useCallback(async () => {
|
|
setJobs({ status: "loading" });
|
|
try {
|
|
const response = await platformApiClient.listJobs();
|
|
setJobs({ status: "ready", data: response.items });
|
|
} catch (error) {
|
|
setJobs({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
|
}
|
|
}, []);
|
|
|
|
const refreshServers = useCallback(async () => {
|
|
setServers({ status: "loading" });
|
|
try {
|
|
const response = await platformApiClient.listServerInstances();
|
|
setServers({ status: "ready", data: response.items });
|
|
} catch (error) {
|
|
setServers({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
|
}
|
|
}, []);
|
|
|
|
const refreshAll = useCallback(() => {
|
|
void refreshJobs();
|
|
void refreshServers();
|
|
}, [refreshJobs, refreshServers]);
|
|
|
|
useEffect(() => {
|
|
refreshAll();
|
|
}, [refreshAll]);
|
|
|
|
const jobItems = jobs.status === "ready" ? jobs.data : [];
|
|
const serverItems = servers.status === "ready" ? servers.data : [];
|
|
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
|
|
const failedServers = useMemo(() => serverItems.filter((server) => server.state === "failed").slice(0, 8), [serverItems]);
|
|
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
|
|
|
|
async function retryJob(job: JobResponse) {
|
|
const retryStamp = Date.now();
|
|
const operationId = operations.begin({ intent: "重试任务", targetKind: "platform", targetId: job.id, requester: session.displayName });
|
|
setTriageResult({ status: "pending", label: `正在重试 ${job.id}` });
|
|
try {
|
|
const retried = await platformApiClient.createJob({
|
|
id: `${job.id}-retry-${retryStamp}`,
|
|
serverInstanceId: job.serverInstanceId,
|
|
runEndpointId: job.runEndpointId,
|
|
capability: job.capability,
|
|
targetKey: job.targetKey,
|
|
inputRef: job.inputRef,
|
|
idempotencyKey: `web-retry-${job.id}-${retryStamp}`,
|
|
progress: { percent: 0, message: `retry of ${job.id}` }
|
|
});
|
|
operations.succeed(operationId, `已创建重试任务:${retried.id}`, retried);
|
|
setTriageResult({ status: "succeeded", label: `已创建重试任务 ${retried.id}` });
|
|
void refreshJobs();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "任务重试失败";
|
|
operations.fail(operationId, message);
|
|
setTriageResult({ status: "failed", label: message });
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="maintenance-page">
|
|
<header className="page-header">
|
|
<div>
|
|
<p className="page-kicker">平台管理员</p>
|
|
<h1 className="page-title">
|
|
<WandSparkles size={22} style={{ verticalAlign: "-3px" }} /> 系统维护
|
|
</h1>
|
|
</div>
|
|
<button type="button" className="icon-command" onClick={refreshAll}>
|
|
<Sparkles size={16} />
|
|
<span>刷新</span>
|
|
</button>
|
|
</header>
|
|
|
|
<div className="form-guidance maintenance-triage-intro">
|
|
<strong>维护排障入口</strong>
|
|
<span>从异常服务器和最近失败任务进入重试、查看相关服务器、查看日志链路。</span>
|
|
</div>
|
|
|
|
<section className="maintenance-triage-grid" aria-label="维护排障入口">
|
|
<button type="button" className="triage-card" onClick={() => void refreshServers()}>
|
|
<AlertTriangle size={18} />
|
|
<span>异常服务器</span>
|
|
<strong>{servers.status === "ready" ? `${failedServers.length} 个需关注` : "加载中"}</strong>
|
|
<small>查看失败实例、更新时间和关联日志链路。</small>
|
|
</button>
|
|
<button type="button" className="triage-card" onClick={() => void refreshJobs()}>
|
|
<ListChecks size={18} />
|
|
<span>最近失败任务</span>
|
|
<strong>{jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"}</strong>
|
|
<small>从失败任务进入重试、查看相关服务器和查看日志链路。</small>
|
|
</button>
|
|
</section>
|
|
|
|
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
|
|
|
<section className="console-panel" aria-label="failed servers">
|
|
<div className="panel-header">
|
|
<h2>异常服务器</h2>
|
|
</div>
|
|
{servers.status === "loading" && <LoadingState label="正在加载服务器…" compact />}
|
|
{servers.status === "error" && <ErrorState title="服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
|
|
{servers.status === "ready" && failedServers.length === 0 && (
|
|
<EmptyState title="暂无异常服务器" description="当前没有处于失败状态的服务器实例。" actionLabel="刷新服务器" onAction={() => void refreshServers()} />
|
|
)}
|
|
{servers.status === "ready" && failedServers.length > 0 && (
|
|
<div className="console-record-list">
|
|
{failedServers.map((server) => (
|
|
<div key={server.id} className="console-record">
|
|
<div className="console-record-head">
|
|
<strong>{server.name}</strong>
|
|
<span className={cx("status-pill", "status-error")}>{serverStateLabel(server.state)}</span>
|
|
</div>
|
|
<div className="console-record-meta">
|
|
<span>服务器 <code>{server.id}</code></span>
|
|
<span>插件 {server.pluginId}@{server.pluginVersion}</span>
|
|
<span>{formatTimestamp(server.updatedAt)}</span>
|
|
</div>
|
|
<div className="console-row-actions">
|
|
<button type="button" className="theme-upload" onClick={() => onNavigate("serverDetail", { serverId: server.id })}>
|
|
查看相关服务器
|
|
</button>
|
|
<button type="button" className="theme-upload" onClick={() => onNavigate("serverDetail", { serverId: server.id })}>
|
|
查看日志链路
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="console-panel" aria-label="failed jobs">
|
|
<div className="panel-header">
|
|
<h2>最近失败任务</h2>
|
|
</div>
|
|
{jobs.status === "loading" && <LoadingState label="正在加载任务…" compact />}
|
|
{jobs.status === "error" && <ErrorState title="任务加载失败" reason={jobs.reason} diagnosticId="maintenance-jobs" onRetry={() => void refreshJobs()} compact />}
|
|
{jobs.status === "ready" && failedJobs.length === 0 && (
|
|
<EmptyState title="暂无失败任务" description="最近任务没有失败记录。" actionLabel="刷新任务" onAction={() => void refreshJobs()} />
|
|
)}
|
|
{jobs.status === "ready" && failedJobs.length > 0 && (
|
|
<div className="console-record-list">
|
|
{failedJobs.map((job) => {
|
|
const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined;
|
|
return (
|
|
<div key={job.id} className="console-record">
|
|
<div className="console-record-head">
|
|
<strong>{jobCapabilityLabel(job.capability)}</strong>
|
|
<span className="status-pill status-error">{jobStateLabel(job.state)}</span>
|
|
</div>
|
|
<div className="console-record-meta">
|
|
<span>任务 <code>{job.id}</code></span>
|
|
<span>服务器 {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
|
|
<span>{progressMessage(job)}</span>
|
|
<span>尝试 {job.attempt}/{job.retryPolicy.maxAttempts}</span>
|
|
<span>{formatTimestamp(job.updatedAt)}</span>
|
|
</div>
|
|
<div className="console-row-actions">
|
|
<button type="button" className="theme-upload" onClick={() => void retryJob(job)}>
|
|
<RotateCcw size={13} />
|
|
重试
|
|
</button>
|
|
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
|
|
查看相关服务器
|
|
</button>
|
|
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
|
|
查看日志链路
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function jobStateLabel(state: JobResponse["state"]): string {
|
|
switch (state) {
|
|
case "queued":
|
|
return "排队";
|
|
case "accepted":
|
|
return "已接收";
|
|
case "running":
|
|
return "运行中";
|
|
case "retrying":
|
|
return "等待重试";
|
|
case "succeeded":
|
|
return "成功";
|
|
case "cancelled":
|
|
return "已取消";
|
|
default:
|
|
return "失败";
|
|
}
|
|
}
|
|
|
|
function serverStateLabel(state: ServerInstanceResponse["state"]): string {
|
|
switch (state) {
|
|
case "running":
|
|
return "运行中";
|
|
case "stopped":
|
|
return "已停止";
|
|
case "installing":
|
|
return "安装中";
|
|
case "ready":
|
|
return "就绪";
|
|
case "deleted":
|
|
return "已删除";
|
|
case "draft":
|
|
return "草稿";
|
|
default:
|
|
return "失败";
|
|
}
|
|
}
|
|
|
|
function progressMessage(job: JobResponse): string {
|
|
return job.progress.message ? `原因 ${job.progress.message}` : `进度 ${job.progress.percent}%`;
|
|
}
|
|
|
|
function formatTimestamp(value: string): string {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
}
|