131 lines
5.6 KiB
TypeScript
131 lines
5.6 KiB
TypeScript
import { Sparkles, WandSparkles } from "lucide-react";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
|
||
import { platformApiClient } from "../api/client";
|
||
import type { AuditEventResponse, RunEndpointResponse } from "../api/types";
|
||
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||
import { cx } from "../utils/classes";
|
||
|
||
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||
|
||
export function MaintenancePage() {
|
||
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
|
||
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
|
||
|
||
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 : "加载失败" });
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refreshEndpoints();
|
||
void refreshEvents();
|
||
}, [refreshEndpoints, refreshEvents]);
|
||
|
||
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={() => {
|
||
void refreshEndpoints();
|
||
void refreshEvents();
|
||
}}
|
||
>
|
||
<Sparkles size={16} />
|
||
<span>刷新</span>
|
||
</button>
|
||
</header>
|
||
|
||
<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 />
|
||
)}
|
||
{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">
|
||
{endpoints.data.map((endpoint) => (
|
||
<article key={endpoint.id} className="resource-list-item">
|
||
<div>
|
||
<strong>{endpoint.displayName}</strong>
|
||
<span className="provider-id">{endpoint.id}</span>
|
||
</div>
|
||
<span className={cx("status-pill", endpoint.status === "online" ? "status-active" : endpoint.status === "offline" ? "status-error" : "status-disabled")}>
|
||
{endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status}
|
||
</span>
|
||
<span>
|
||
任务 {endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs}(排队 {endpoint.capacity.queuedJobs})
|
||
</span>
|
||
<span>心跳 {new Date(endpoint.lastHeartbeatAt).toLocaleString()}</span>
|
||
</article>
|
||
))}
|
||
</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", event.result === "success" ? "status-active" : "status-error")}>{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>{new Date(event.createdAt).toLocaleString()}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|