388 lines
18 KiB
TypeScript
388 lines
18 KiB
TypeScript
import { Activity, ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import { platformApiClient } from "../api/client";
|
||
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||
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 [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ 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 refreshEvents = useCallback(async () => {
|
||
setEvents({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listAuditEvents();
|
||
setEvents({ status: "ready", data: response.items });
|
||
} catch (error) {
|
||
setEvents({ 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 refreshEvents();
|
||
void refreshJobs();
|
||
void refreshServers();
|
||
}, [refreshEndpoints, refreshEvents, refreshJobs, refreshServers]);
|
||
|
||
useEffect(() => {
|
||
refreshAll();
|
||
}, [refreshAll]);
|
||
|
||
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
|
||
const eventItems = events.status === "ready" ? events.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 failedAuditCount = eventItems.filter((event) => event.result !== "success").length;
|
||
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>
|
||
<button type="button" className="triage-card" onClick={() => void refreshEvents()}>
|
||
<Activity size={18} />
|
||
<span>审计异常</span>
|
||
<strong>{events.status === "ready" ? `${failedAuditCount} 条异常` : "加载中"}</strong>
|
||
<small>按资源定位失败操作和平台拒绝原因。</small>
|
||
</button>
|
||
</section>
|
||
|
||
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
|
||
|
||
<ProductionGovernancePanel title="容量治理与告警闭环" />
|
||
|
||
<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="maintenance-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="operation-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="operation-item">
|
||
<div className="operation-item-head">
|
||
<strong>{job.capability}</strong>
|
||
<span className="status-pill status-error">{jobStateLabel(job.state)}</span>
|
||
</div>
|
||
<div className="operation-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="maintenance-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>
|
||
|
||
<section className="console-panel" aria-label="audit events">
|
||
<div className="panel-header">
|
||
<h2>审计事件</h2>
|
||
</div>
|
||
{events.status === "loading" && <LoadingState label="正在加载审计事件…" compact />}
|
||
{events.status === "error" && (
|
||
<ErrorState title="审计事件加载失败" reason={events.reason} diagnosticId="maintenance-audit" onRetry={() => void refreshEvents()} compact />
|
||
)}
|
||
{events.status === "ready" && events.data.length === 0 && (
|
||
<EmptyState title="暂无审计事件" description="平台还没有记录任何审计事件。" actionLabel="刷新" onAction={() => void refreshEvents()} />
|
||
)}
|
||
{events.status === "ready" && events.data.length > 0 && (
|
||
<div className="operation-list">
|
||
{events.data.slice(0, 30).map((event) => (
|
||
<div key={event.id} className="operation-item">
|
||
<div className="operation-item-head">
|
||
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
|
||
<span className={cx("status-pill", auditResultClass(event))}>{event.result}</span>
|
||
</div>
|
||
<div className="operation-meta">
|
||
<span>
|
||
事件 <code>{event.id}</code>
|
||
</span>
|
||
<span>操作者 {event.actorId}</span>
|
||
<span>
|
||
资源 {event.resourceKind}/{event.resourceId}
|
||
</span>
|
||
<span>{formatTimestamp(event.createdAt)}</span>
|
||
</div>
|
||
<div className="maintenance-actions">
|
||
<button type="button" className="theme-upload" onClick={() => void refreshEvents()}>
|
||
查看审计链路
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="theme-upload"
|
||
disabled={event.resourceKind !== "server-instance"}
|
||
onClick={() => event.resourceKind === "server-instance" && onNavigate("serverDetail", { serverId: event.resourceId })}
|
||
>
|
||
查看相关服务器
|
||
</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 auditResultClass(event: AuditEventResponse): string {
|
||
return event.result === "success" ? "status-active" : "status-error";
|
||
}
|
||
|
||
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();
|
||
}
|