Files
browser/platform_web/pages/MaintenancePage.tsx
T

314 lines
14 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 { ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { JobResponse, RunEndpointResponse, 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 [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
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 refreshEndpoints = useCallback(async () => {
setEndpoints({ status: "loading" });
try {
const response = await platformApiClient.listRunEndpoints();
setEndpoints({ status: "ready", data: response.items });
} catch (error) {
setEndpoints({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
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 refreshEndpoints();
void refreshJobs();
void refreshServers();
}, [refreshEndpoints, refreshJobs, refreshServers]);
useEffect(() => {
refreshAll();
}, [refreshAll]);
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
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 serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
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>从节点详情和最近失败任务进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。</span>
</div>
<section className="maintenance-triage-grid" aria-label="维护排障入口">
<button type="button" className="triage-card" onClick={() => void refreshEndpoints()}>
<ServerCog size={18} />
<span>节点详情</span>
<strong>{endpoints.status === "ready" ? `${unhealthyEndpointCount} 个需关注` : "加载中"}</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="run endpoints">
<div className="panel-header">
<h2>运行节点</h2>
</div>
{endpoints.status === "loading" && <LoadingState label="正在加载运行节点…" compact />}
{endpoints.status === "error" && (
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} compact />
)}
{servers.status === "error" && <ErrorState title="相关服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
{endpoints.status === "ready" && endpoints.data.length === 0 && (
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
)}
{endpoints.status === "ready" && endpoints.data.length > 0 && (
<div className="resource-list maintenance-node-list">
{endpoints.data.map((endpoint) => {
const relatedServers = serverItems.filter((server) => server.runEndpointId === endpoint.id);
const firstRelatedServer = relatedServers[0];
return (
<article key={endpoint.id} className="resource-list-item maintenance-node-item">
<div>
<strong>{endpoint.displayName}</strong>
<span className="provider-id">{endpoint.id}</span>
</div>
<span className={cx("status-pill", endpointStatusClass(endpoint))}>{endpointStatusLabel(endpoint)}</span>
<span>
任务 {endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs}(排队 {endpoint.capacity.queuedJobs}
</span>
<span>{heartbeatReason(endpoint)}</span>
<details className="node-detail">
<summary>节点详情</summary>
<span>版本 {endpoint.version}</span>
<span>能力 {endpoint.capabilities.slice(0, 4).join(" / ") || "未上报"}</span>
<span>相关服务器 {relatedServers.length}</span>
</details>
<div className="console-row-actions">
<button type="button" className="theme-upload" onClick={() => void refreshEndpoints()}>
刷新节点
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
查看相关服务器
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
查看日志链路
</button>
</div>
</article>
);
})}
</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;
const endpoint = endpointById.get(job.runEndpointId);
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>节点 {endpoint ? endpoint.displayName : job.runEndpointId}</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 endpointStatusClass(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) <= 5) {
return "status-active";
}
if (endpoint.status === "offline") {
return "status-error";
}
return "status-disabled";
}
function endpointStatusLabel(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5) {
return "心跳延迟";
}
return endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status;
}
function heartbeatReason(endpoint: RunEndpointResponse): string {
const age = heartbeatAgeMinutes(endpoint.lastHeartbeatAt);
if (!Number.isFinite(age)) {
return `心跳时间异常:${endpoint.lastHeartbeatAt}`;
}
if (endpoint.status === "offline") {
return `心跳异常:节点离线,最后 ${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
if (age > 5) {
return `心跳异常:${Math.round(age)} 分钟未更新`;
}
if (endpoint.capacity.runningJobs >= endpoint.capacity.maxJobs) {
return "容量已满:等待任务会继续排队";
}
return `心跳正常:${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
function heartbeatAgeMinutes(value: string): number {
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) {
return Number.POSITIVE_INFINITY;
}
return (Date.now() - timestamp) / 60000;
}
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 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();
}