Files
pikasTech-unidesk/src/components/frontend/src/app.tsx
T

2477 lines
132 KiB
TypeScript

import React from "react";
import { displayTimeLabel, fmtClock, fmtDate } from "./time";
import { createRoot } from "react-dom/client";
import { BaiduNetdiskPage } from "./baidu-netdisk";
import { ClaudeQqPage } from "./claudeqq";
import { CodeQueuePage } from "./code-queue";
import { DecisionCenterPage } from "./decision-center";
import { FileBrowserPage } from "./filebrowser";
import { FindJobPage } from "./findjob";
import { HWLAB_URL, HwlabPage } from "./hwlab";
import { MetNonlinearPage } from "./met-nonlinear";
import { MdtodoPage } from "./mdtodo";
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
import { OaEventFlowPage } from "./oa-event-flow";
import { PipelinePage } from "./pipeline";
import { ProjectManagerPage } from "./project-manager";
import { TodoNotePage } from "./todo-note";
import { TopStatusBar } from "./top-status";
import { K3sCtlPage } from "./k3sctl";
import { LoadingTitle } from "./loading-indicator";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
import { NotificationProvider, useNotification } from "./notification-context";
import { NotificationPopup, NotificationBanner } from "./notification-popup";
import { readRootJsonAttribute } from "./runtime-config";
type AnyRecord = Record<string, any>;
const cfg: AnyRecord = readRootJsonAttribute("data-config", { apiBaseUrl: "/api", authUsername: "admin" });
const environmentIdentity: AnyRecord = cfg.environment && typeof cfg.environment === "object" ? cfg.environment : {};
const initialCodeQueueOverview = readRootJsonAttribute("data-codex-overview", null);
const h = React.createElement;
const { useEffect, useMemo } = React;
const useState: any = React.useState;
const LoadingContext = React.createContext(false);
const ROUTE_REGISTRY = createRouteRegistry(MODULES);
const fastCodeQueueService = {
id: "code-queue",
name: "Code Queue",
providerId: "D601",
description: "Code Queue",
repository: { containerName: "k3s:code-queue" },
backend: {
nodeBaseUrl: "k3s://code-queue",
nodeBindHost: "k3s://unidesk/code-queue",
nodePort: 4222,
proxyMode: "k3sctl-adapter-http",
public: false,
},
deployment: {
mode: "k3sctl-managed",
adapterServiceId: "k3sctl-adapter",
k3sServiceId: "code-queue",
},
runtime: {
orchestrator: "k3sctl",
providerStatus: "loading",
providerName: "D601",
},
};
function isDocumentVisible(): boolean {
return typeof document === "undefined" || document.visibilityState !== "hidden";
}
function isDevEnvironment(identity: AnyRecord): boolean {
return identity?.environment === "dev" || identity?.namespace === "unidesk-dev";
}
function shortCommit(value: any): string {
const text = typeof value === "string" ? value : "";
return text.length >= 7 ? text.slice(0, 7) : text || "unknown";
}
function shellRefreshIntervalMs(moduleId: string, tabId: string): number {
if (moduleId === "ops" && tabId === "status") return 5_000;
if (moduleId === "nodes" && tabId === "monitor") return 5_000;
if (moduleId === "tasks" && (tabId === "dispatch" || tabId === "scheduled" || tabId === "pending")) return 5_000;
if (moduleId === "nodes" || moduleId === "ops") return 10_000;
if (moduleId === "apps") return 15_000;
if (moduleId === "tasks") return 15_000;
return 30_000;
}
async function loadTaskRawData(task: any): Promise<any> {
if (!task?._summaryOnly || !task?.id) return task;
const result = await requestJson(`${cfg.apiBaseUrl}/tasks/${encodeURIComponent(String(task.id))}`);
return result?.task || task;
}
function taskRawButtonData(task: any): any {
return task?._summaryOnly ? { ...task, _loadRaw: () => loadTaskRawData(task) } : task;
}
function fmtDuration(seconds: number): string {
if (!Number.isFinite(seconds)) return "--";
const safeSeconds = Math.max(0, seconds);
if (safeSeconds === 0) return "0s";
if (safeSeconds < 0.01) return "<0.01s";
if (safeSeconds < 0.1) return `${safeSeconds.toFixed(2)}s`;
if (safeSeconds < 1) return `${safeSeconds.toFixed(1)}s`;
if (safeSeconds < 10 && !Number.isInteger(safeSeconds)) return `${safeSeconds.toFixed(1)}s`;
if (safeSeconds < 60) return `${Math.round(safeSeconds)}s`;
const wholeSeconds = Math.floor(safeSeconds);
if (wholeSeconds < 3600) return `${Math.floor(wholeSeconds / 60)}m ${wholeSeconds % 60}s`;
return `${Math.floor(wholeSeconds / 3600)}h ${Math.floor((wholeSeconds % 3600) / 60)}m`;
}
function fmtMs(value: any): string {
const ms = Number(value);
if (!Number.isFinite(ms)) return "--";
if (ms < 1) return `${Math.max(0, ms).toFixed(1)}ms`;
if (ms < 10) return `${ms.toFixed(1)}ms`;
if (ms < 1000) return `${Math.round(ms)}ms`;
return fmtDuration(ms / 1000);
}
function fmtBytes(value: any): string {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) return "--";
const units = ["B", "KB", "MB", "GB", "TB"];
let current = bytes;
let index = 0;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function fmtPercent(value: any): string {
const number = Number(value);
return Number.isFinite(number) ? `${Math.max(0, Math.min(100, number)).toFixed(1)}%` : "--";
}
function fmtLoosePercent(value: any): string {
const number = Number(value);
return Number.isFinite(number) ? `${Math.max(0, number).toFixed(1)}%` : "--";
}
function fmtBytesRate(value: any): string {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B/s";
return `${fmtBytes(bytes)}/s`;
}
function asNumber(value: any, fallback = 0): number {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function isPendingTask(task: any): boolean {
return ["queued", "dispatched", "running"].includes(String(task?.status || "").toLowerCase());
}
function fmtRelativeAge(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return fmtDuration(Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)));
}
function timeMs(value: any): number | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.getTime();
}
function taskElapsedSeconds(task: any): number | null {
const started = timeMs(task?.createdAt);
if (started === null) return null;
const terminal = ["succeeded", "failed"].includes(String(task?.status || "").toLowerCase());
const ended = terminal ? timeMs(task?.updatedAt) : Date.now();
if (ended === null) return null;
return Math.max(0, (ended - started) / 1000);
}
function taskFailureReason(task: any): string {
if (String(task?.status || "").toLowerCase() !== "failed") return "";
const result = task?.result;
if (typeof result === "string") return result;
if (result && typeof result === "object" && !Array.isArray(result)) {
const record = result as AnyRecord;
for (const key of ["error", "reason", "message", "stderr", "detail"]) {
if (typeof record[key] === "string" && record[key].length > 0) return record[key];
}
}
return "任务失败但 provider 未返回明确原因";
}
function summarizeValue(value: any): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "是" : "否";
if (typeof value === "number") return String(value);
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}...` : value;
if (Array.isArray(value)) return `${value.length} 项`;
if (typeof value === "object") return `${Object.keys(value).length} 字段`;
return String(value);
}
function summarizeFieldValue(key: string, value: any): string {
const normalizedKey = key.replace(/[-_\s]/g, "").toLowerCase();
const looksLikeTimeKey = normalizedKey === "ts" || normalizedKey.endsWith("at") || normalizedKey.endsWith("timestamp") || normalizedKey.endsWith("heartbeat");
if ((typeof value === "string" || typeof value === "number") && looksLikeTimeKey) {
const formatted = fmtDate(value);
if (formatted !== "--") return formatted;
}
if (key === "bodyText" && typeof value === "string") {
const kind = /^\s*[{[]/.test(value) ? "JSON" : "HTTP";
return `${kind} body ${value.length} chars`;
}
return summarizeValue(value);
}
function objectEntries(value: any): Array<[string, any]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value);
}
function safeId(value: any): string {
return String(value).replace(/[^a-zA-Z0-9_-]/g, "_");
}
function recordValue(data: any, key: string): any {
return data && typeof data === "object" && !Array.isArray(data) ? data[key] : undefined;
}
function labelString(node: any, key: string, fallback = "未知"): string {
const value = recordValue(node?.labels, key);
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function nodeGatewayVersion(node: any): string {
return labelString(node, "providerGatewayVersion");
}
function nodeGatewayPolicy(node: any): string {
return labelString(node, "providerGatewayUpgradePolicy");
}
function nodeGatewayStartedAt(node: any): string {
return labelString(node, "providerGatewayStartedAt", "");
}
function nodeCapabilities(node: any): string[] {
const value = recordValue(node?.labels, "unideskCapabilities");
if (typeof value === "string") return value.split(",").map((item) => item.trim()).filter(Boolean);
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
}
function nodeHasCapability(node: any, capability: string): boolean {
return nodeCapabilities(node).includes(capability);
}
function labelBoolean(node: any, key: string): boolean {
const value = recordValue(node?.labels, key);
return value === true || value === "true" || value === "1";
}
function nodeSshAvailability(node: any): { tone: string; label: string; detail: string } {
if (!nodeHasCapability(node, "host.ssh")) return { tone: "fail", label: "不可用", detail: "未声明 host.ssh" };
if (!labelBoolean(node, "hostSshConfigured")) return { tone: "warn", label: "未配置", detail: "缺少 SSH 环境变量" };
if (!labelBoolean(node, "hostSshKeyPresent")) return { tone: "warn", label: "缺 key", detail: "私钥未挂载" };
return { tone: "ok", label: "可用", detail: labelString(node, "hostSshTarget", "host.ssh ready") };
}
function nodeUpgradeAvailability(node: any): { tone: string; label: string; detail: string } {
if (!nodeHasCapability(node, "provider.upgrade")) return { tone: "fail", label: "不可用", detail: "未声明 provider.upgrade" };
const policy = nodeGatewayPolicy(node);
if (policy !== "always-enabled") return { tone: "warn", label: "待确认", detail: `策略 ${policy}` };
return { tone: "ok", label: "可用", detail: "always-enabled" };
}
function fmtGatewayVersion(value: any): string {
const text = typeof value === "string" && value.length > 0 ? value : "未知";
if (text === "未知") return "版本未知";
return text.startsWith("v") ? text : `v${text}`;
}
function taskPayload(task: any): AnyRecord {
return task?.payload && typeof task.payload === "object" && !Array.isArray(task.payload) ? task.payload as AnyRecord : {};
}
function taskResult(task: any): AnyRecord {
return task?.result && typeof task.result === "object" && !Array.isArray(task.result) ? task.result as AnyRecord : {};
}
function taskUpgradeMode(task: any): string {
const payload = taskPayload(task);
const result = taskResult(task);
const mode = payload.mode ?? result.mode;
return mode === "schedule" ? "schedule" : "plan";
}
function taskUpgradeSource(task: any): string {
const source = taskPayload(task).source;
return typeof source === "string" && source.length > 0 ? source : "unknown";
}
function taskUpgradePolicy(task: any): string {
const result = taskResult(task);
const plan = result.plan && typeof result.plan === "object" && !Array.isArray(result.plan) ? result.plan as AnyRecord : {};
const policy = result.policy ?? plan.policy;
return typeof policy === "string" && policy.length > 0 ? policy : "--";
}
function taskUpgradeVersion(task: any): string {
const result = taskResult(task);
const plan = result.plan && typeof result.plan === "object" && !Array.isArray(result.plan) ? result.plan as AnyRecord : {};
const value = result.targetProviderGatewayVersion
?? result.providerGatewayVersion
?? plan.targetProviderGatewayVersion
?? plan.providerGatewayVersion;
return typeof value === "string" && value.length > 0 ? fmtGatewayVersion(value) : "版本未知";
}
function taskUpgradeOutcome(task: any): string {
const status = String(task?.status || "").toLowerCase();
if (status === "failed") return taskFailureReason(task);
if (isPendingTask(task)) return "等待 provider 回传升级终态";
const result = taskResult(task);
if (typeof result.updaterContainerId === "string" && result.updaterContainerId.length > 0) return `updater ${result.updaterContainerId.slice(0, 18)}`;
if (typeof result.message === "string" && result.message.length > 0) return result.message;
if (result.plan) return "升级计划已生成";
return "无升级结果摘要";
}
function providerUpgradeTasks(tasks: any[], providerId: string): any[] {
return tasks
.filter((task) => task?.providerId === providerId && task?.command === "provider.upgrade")
.sort((left, right) => (timeMs(right.updatedAt) ?? 0) - (timeMs(left.updatedAt) ?? 0));
}
function latestScheduledUpgradeTask(records: any[]): any | null {
return records.find((task) => taskUpgradeMode(task) === "schedule") || records[0] || null;
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone, onClick, testId }: AnyRecord) {
const interactive = typeof onClick === "function";
return h("article", {
className: `metric-card ${tone || ""} ${interactive ? "clickable" : ""}`,
role: interactive ? "button" : undefined,
tabIndex: interactive ? 0 : undefined,
"data-testid": testId,
onClick,
onKeyDown: interactive ? (event: any) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
} : undefined,
},
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className, loading }: AnyRecord) {
const contextLoading = React.useContext(LoadingContext);
const isLoading = Boolean(loading) || contextLoading;
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h(LoadingTitle, { title, loading: isLoading }),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
const [loading, setLoading] = useState(false);
const loadData = data && typeof data === "object" && typeof data._loadRaw === "function" ? data._loadRaw : null;
async function open(): Promise<void> {
if (!loadData) {
onOpen(title, data);
return;
}
setLoading(true);
try {
onOpen(title, await loadData());
} catch (err) {
onOpen(title, { ok: false, error: errorMessage(err, "读取原始 JSON 失败"), fallback: data });
} finally {
setLoading(false);
}
}
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
disabled: loading,
onClick: () => void open(),
}, loading ? "读取中" : "查看原始JSON");
}
function RawDialog({ raw, onClose }: AnyRecord) {
if (!raw) return null;
return h("div", { className: "modal-backdrop", role: "presentation" },
h("section", { className: "raw-dialog", role: "dialog", "aria-modal": "true", "aria-label": raw.title },
h("div", { className: "raw-dialog-head" },
h("h2", null, raw.title),
h("button", { type: "button", className: "ghost-btn", onClick: onClose }, "关闭"),
),
h("pre", { className: "raw-json", "data-testid": "raw-json" }, JSON.stringify(raw.data, null, 2)),
),
);
}
function LabelChips({ labels, limit = 8 }: AnyRecord) {
const entries = objectEntries(labels).slice(0, limit);
if (entries.length === 0) return h("span", { className: "muted" }, "无标签");
return h("div", { className: "chip-row" },
entries.map(([key, value]) => h("span", { key, className: "data-chip" },
h("b", null, key),
h("span", null, summarizeValue(value)),
)),
);
}
function GatewayVersionBadge({ node }: AnyRecord) {
const version = nodeGatewayVersion(node);
return h("span", {
className: `version-chip ${version === "未知" ? "unknown" : ""}`,
"data-testid": `gateway-version-${safeId(node?.providerId || "unknown")}`,
}, fmtGatewayVersion(version));
}
function CapabilityBadge({ title, state, testId }: AnyRecord) {
return h("span", {
className: `capability-badge ${state.tone}`,
title: state.detail,
"data-testid": testId,
},
h("b", null, title),
h("strong", null, state.label),
h("small", null, state.detail),
);
}
function NodeAvailabilityStrip({ node }: AnyRecord) {
const providerId = safeId(node?.providerId || "unknown");
return h("div", { className: "node-availability-strip" },
h(CapabilityBadge, { title: "SSH 透传", state: nodeSshAvailability(node), testId: `ssh-availability-${providerId}` }),
h(CapabilityBadge, { title: "远程更新", state: nodeUpgradeAvailability(node), testId: `upgrade-availability-${providerId}` }),
);
}
function DataSummary({ data, empty = "无数据" }: AnyRecord) {
if (data === null || data === undefined) return h("span", { className: "muted" }, empty);
if (typeof data !== "object") return h("span", { className: "summary-value" }, summarizeValue(data));
if (Array.isArray(data)) return h("span", { className: "summary-value" }, `${data.length} 项列表`);
const entries = Object.entries(data).slice(0, 5);
if (entries.length === 0) return h("span", { className: "muted" }, empty);
return h("div", { className: "summary-grid" }, entries.map(([key, value]) =>
h("span", { key, className: "summary-item" }, h("b", null, key), h("span", null, summarizeFieldValue(key, value))),
));
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function LoginScreen({ onLogin }: AnyRecord) {
const [username, setUsername] = useState(cfg.authUsername || "admin");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: any) {
event.preventDefault();
setBusy(true);
setError("");
try {
const session = await requestJson("/login", { method: "POST", body: JSON.stringify({ username, password }) });
onLogin(session);
} catch (err) {
setError(errorMessage(err, "登录失败"));
} finally {
setBusy(false);
}
}
return h("main", { className: "login-screen", "data-testid": "login-screen" },
h("section", { className: "login-card" },
h("div", { className: "login-brand" }, h("span", { className: "brand-mark" }, "UD"), h("div", null, h("h1", null, "UniDesk"), h("p", null, "Control Plane Login"))),
h("form", { className: "login-form", onSubmit: submit },
h("label", null, "账号", h("input", { name: "username", autoComplete: "username", value: username, onChange: (event: any) => setUsername(event.target.value) })),
h("label", null, "密码", h("input", { name: "password", type: "password", autoComplete: "current-password", value: password, onChange: (event: any) => setPassword(event.target.value) })),
h(UniDeskErrorBanner, { error }),
h("button", { type: "submit", disabled: busy }, busy ? "登录中" : "登录"),
),
h("div", { className: "login-note" }, "默认账号由 config.json 注入;公网入口只暴露前端登录面。"),
),
);
}
function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock, activeStatusItems = [], onNotificationToggle, unreadCount = 0, environment = {} }: AnyRecord) {
const devMode = isDevEnvironment(environment);
const statusItems = [
...(devMode ? [{ key: "environment", label: "环境", value: `${environment.namespace || "unidesk-dev"}`, tone: "warn" }] : []),
{ key: "core", label: "核心", value: connection.text, tone: connection.ok ? "ok" : "fail", testId: "conn-text" },
...(Array.isArray(activeStatusItems) ? activeStatusItems : []),
{ key: "refresh", label: "刷新", value: lastRefresh ? fmtClock(lastRefresh) : "未刷新" },
{ key: "clock", label: displayTimeLabel(), value: fmtClock(clock) },
{ key: "user", label: "用户", value: session?.user?.username || "--", tone: "user" },
];
return h("header", { className: "topbar" },
h("div", null,
h("p", { className: "eyebrow" }, "Distributed Work Platform"),
h("h1", null, "UniDesk 控制平面"),
devMode ? h("div", { className: "dev-env-ribbon", "data-testid": "dev-environment-ribbon" },
h("b", null, "DEV"),
h("span", null, environment.namespace || "unidesk-dev"),
h("span", null, environment.deployRef || "origin/master:deploy.json#environments.dev"),
h("span", null, shortCommit(environment.commit || environment.requestedCommit)),
) : null,
),
h(TopStatusBar, {
className: "global-top-status",
title: "状态",
items: statusItems,
actions: [
h("button", {
key: "notification",
type: "button",
className: `notification-icon-btn ${unreadCount > 0 ? "has-unread" : ""}`,
onClick: onNotificationToggle,
"aria-label": "通知",
},
"🔔",
unreadCount > 0 ? h("span", { key: "badge", className: "notification-badge" }, unreadCount > 99 ? "99+" : unreadCount) : null
),
h("button", { key: "refresh", type: "button", className: "ghost-btn", onClick: onRefresh }, "刷新"),
h("button", { key: "logout", type: "button", className: "ghost-btn danger", onClick: onLogout }, "退出"),
],
}),
);
}
type NavigateHandler = (moduleId: string, tabId: string, historyMode?: "push" | "replace") => void;
interface RouteAnchorProps {
moduleId: string;
tabId: string;
className: string;
active?: boolean;
title?: string;
testId?: string;
onNavigate: NavigateHandler;
children?: React.ReactNode;
}
function shouldHandleSpaRouteClick(event: React.MouseEvent<HTMLAnchorElement>): boolean {
return !event.defaultPrevented
&& event.button === 0
&& !event.metaKey
&& !event.altKey
&& !event.ctrlKey
&& !event.shiftKey
&& event.currentTarget.target !== "_blank";
}
function RouteAnchor({ moduleId, tabId, className, active = false, title, testId, onNavigate, children }: RouteAnchorProps) {
const route = pathForTarget(ROUTE_REGISTRY, moduleId, tabId);
return h("a", {
href: route,
role: "button",
className,
title,
"aria-current": active ? "page" : undefined,
"data-testid": testId,
"data-route": route,
onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!shouldHandleSpaRouteClick(event)) return;
event.preventDefault();
onNavigate(moduleId, tabId);
},
}, children);
}
function Sidebar({ activeModule, activeTabs, onNavigate, collapsed, onToggle }: AnyRecord) {
return h("aside", { className: `rail ${collapsed ? "collapsed" : ""}`, "aria-label": "主模块" },
h("div", { className: "brand" },
h("span", { className: "brand-mark" }, "UD"),
h("span", { className: "brand-text" }, "UniDesk"),
h("button", { type: "button", className: "rail-toggle", onClick: onToggle, "aria-label": collapsed ? "展开左侧边栏" : "收起左侧边栏", "data-testid": "rail-toggle" }, collapsed ? "»" : "«"),
),
MODULES.map((module: any) => {
const tabId = activeTabs[module.id] || DEFAULT_ACTIVE_TABS[module.id] || module.tabs[0]?.id || "";
return h(RouteAnchor, {
key: module.id,
moduleId: module.id,
tabId,
className: `module ${activeModule === module.id ? "active" : ""}`,
active: activeModule === module.id,
title: module.label,
onNavigate,
}, h("span", { className: "module-code" }, module.code), h("span", null, module.label));
}),
);
}
function TabBar({ module, activeTab, onNavigate }: AnyRecord) {
return h("nav", { className: "tabs", "aria-label": `${module.label} 子功能` },
module.tabs.map((tab: any) => h(RouteAnchor, {
key: tab.id,
moduleId: module.id,
tabId: tab.id,
className: `tab ${activeTab === tab.id ? "active" : ""}`,
active: activeTab === tab.id,
onNavigate,
}, tab.label)),
);
}
function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) {
const overview = data.overview || {};
const onlineNodes = data.nodes.filter((node: any) => node.status === "online");
const pendingTasks = data.pendingTasks || data.tasks.filter(isPendingTask);
const pendingCount = overview.pendingTaskCount ?? pendingTasks.length;
const recentTasks = data.tasks.slice(0, 5);
const pgdata = overview.pgdata || {};
const microserviceAvailability = overview.microserviceAvailability || {};
const microserviceTotal = asNumber(microserviceAvailability.totalCount);
const microserviceHealthy = asNumber(microserviceAvailability.healthyCount);
const microserviceUnhealthy = asNumber(microserviceAvailability.unhealthyCount);
return h("div", { className: "page-grid overview-grid", "data-testid": "overview-page" },
h(Panel, { title: "核心指标", eyebrow: "Control" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "数据库", value: overview.dbReady ? "READY" : "WAIT", hint: "PostgreSQL internal network", tone: overview.dbReady ? "ok" : "warn" }),
h(MetricCard, { label: "PGDATA", value: fmtBytes(pgdata.databaseBytes), hint: `${pgdata.volumeName || "unidesk_pgdata_10gb"} / ${pgdata.databasePretty || "--"} / budget ${pgdata.volumeSize || "--"}`, tone: "ok", testId: "pgdata-usage-card" }),
h(MetricCard, { label: "在线节点", value: overview.onlineNodeCount ?? 0, hint: `${overview.nodeCount ?? 0} registered`, tone: "ok" }),
h(MetricCard, { label: "WebSocket", value: overview.activeSocketCount ?? 0, hint: "Provider ingress sockets" }),
h(MetricCard, { label: "用户服务可用", value: microserviceTotal > 0 ? `${microserviceHealthy}/${microserviceTotal}` : "--", hint: microserviceTotal > 0 ? `healthyCount ${microserviceHealthy} · unhealthyCount ${microserviceUnhealthy}` : "strict /health probes", tone: microserviceTotal > 0 && microserviceUnhealthy === 0 ? "ok" : "warn", testId: "microservice-availability-card" }),
h(MetricCard, { label: "待处理任务", value: pendingCount, hint: pendingCount > 0 ? "点击查看具体任务" : `timeout ${fmtDuration(Math.floor((overview.taskPendingTimeoutMs ?? 0) / 1000))}`, tone: pendingCount > 0 ? "warn" : "ok", onClick: () => onNavigate("tasks", "pending"), testId: "pending-task-card" }),
),
),
h(Panel, { title: "本机 Provider", eyebrow: "Self Connected" },
onlineNodes.length === 0 ? h(EmptyState, { title: "暂无在线节点", text: "provider-gateway 未完成自接入" }) :
h("div", { className: "node-card-list" }, onlineNodes.slice(0, 4).map((node: any) => h(NodeCard, { key: node.providerId, node, onRaw }))),
),
h(Panel, {
title: "待处理任务明细",
eyebrow: `${pendingCount} Pending`,
actions: h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("tasks", "pending"), "data-testid": "pending-task-detail-link" }, "进入任务调度"),
},
pendingTasks.length === 0 ? h(EmptyState, { title: "当前无待处理", text: "queued / dispatched / running 超时后会自动转为 failed,避免总览长期卡住" }) :
h("div", { className: "compact-list" }, pendingTasks.slice(0, 5).map((task: any) => h(TaskCompactRow, { key: task.id, task, onRaw }))),
),
h(Panel, { title: "最近任务", eyebrow: "Dispatch" },
recentTasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "可以在任务调度模块发起 docker.ps 或 echo" }) :
h("div", { className: "compact-list" }, recentTasks.map((task: any) => h(TaskCompactRow, { key: task.id, task, onRaw }))),
),
);
}
function NodeCard({ node, onRaw }: AnyRecord) {
return h("article", { className: "node-card" },
h("div", { className: "node-card-head" },
h("div", null, h("strong", null, node.name), h("code", null, node.providerId)),
h(StatusBadge, { status: node.status }),
),
h("div", { className: "node-version-line" },
h(GatewayVersionBadge, { node }),
h("span", null, `升级策略 ${nodeGatewayPolicy(node)}`),
),
h(NodeAvailabilityStrip, { node }),
h(LabelChips, { labels: node.labels, limit: 6 }),
h("div", { className: "node-card-foot" },
h("span", null, `心跳 ${fmtDate(node.lastHeartbeat)}`),
h(RawButton, { title: `Provider ${node.providerId}`, data: node, onOpen: onRaw, testId: `raw-node-${safeId(node.providerId)}` }),
),
);
}
function EventsPage({ events, onRaw }: AnyRecord) {
return h(Panel, { title: "事件摘要", eyebrow: "Latest 100" },
events.length === 0 ? h(EmptyState, { title: "暂无事件", text: "Provider 注册、心跳超时和任务状态会写入事件流" }) :
h("div", { className: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "ID"), h("th", null, "类型"), h("th", null, "来源"), h("th", null, "摘要"), h("th", null, "时间"), h("th", null, "操作"))),
h("tbody", null, events.map((event: any) => h("tr", { key: event.id },
h("td", null, h("code", null, event.id)),
h("td", null, h(StatusBadge, { status: event.type }, event.type)),
h("td", null, h("code", null, event.source)),
h("td", null, h(DataSummary, { data: event.payload })),
h("td", null, fmtDate(event.createdAt)),
h("td", null, h(RawButton, { title: `Event ${event.id}`, data: event, onOpen: onRaw })),
))),
)),
);
}
function LogsPage({ logs, onRaw }: AnyRecord) {
return h(Panel, { title: "服务日志", eyebrow: "Core Recent" },
logs.length === 0 ? h(EmptyState, { title: "暂无日志", text: "backend-core 内存日志会在请求和 provider 事件后出现" }) :
h("div", { className: "log-list" }, logs.slice(-80).reverse().map((log: any, index: any) => h("article", { key: index, className: `log-row ${log.level || "info"}` },
h("span", null, fmtDate(log.ts)),
h("b", null, log.level || "info"),
h("strong", null, log.message || "log"),
h(DataSummary, { data: log.data, empty: "无附加字段" }),
h(RawButton, { title: `Log ${log.message || index}`, data: log, onOpen: onRaw }),
))),
);
}
function NodeListPage({ nodes, onRaw }: AnyRecord) {
return h(Panel, { title: "节点清单", eyebrow: `${nodes.length} Providers` },
nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "确认 provider-gateway 已连接 provider ingress" }) :
h("div", { className: "table-wrap" }, h("table", { className: "node-list-table" },
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "Provider"), h("th", null, "网关版本"), h("th", null, "运维可用性"), h("th", null, "资源标签"), h("th", null, "连接时间"), h("th", null, "最后心跳"), h("th", null, "操作"))),
h("tbody", null, nodes.map((node: any) => h("tr", { key: node.providerId },
h("td", null, h(StatusBadge, { status: node.status })),
h("td", null, h("strong", null, node.name), h("code", null, node.providerId)),
h("td", null, h("div", { className: "gateway-cell" }, h(GatewayVersionBadge, { node }), h("span", null, nodeGatewayPolicy(node)))),
h("td", null, h(NodeAvailabilityStrip, { node })),
h("td", null, h(LabelChips, { labels: node.labels, limit: 5 })),
h("td", null, fmtDate(node.connectedAt)),
h("td", null, fmtDate(node.lastHeartbeat)),
h("td", null, h(RawButton, { title: `Provider ${node.providerId}`, data: node, onOpen: onRaw, testId: `raw-node-table-${safeId(node.providerId)}` })),
))),
)),
);
}
function LabelsPage({ nodes }: AnyRecord) {
const labels = useMemo(() => {
const rows = [];
for (const node of nodes) {
for (const [key, value] of objectEntries(node.labels)) rows.push({ providerId: node.providerId, name: node.name, key, value });
}
return rows;
}, [nodes]);
return h(Panel, { title: "资源标签", eyebrow: "Structured Labels" },
labels.length === 0 ? h(EmptyState, { title: "暂无标签", text: "provider-gateway 注册消息会同步资源标签" }) :
h("div", { className: "label-matrix" }, labels.map((row: any) => h("article", { key: `${row.providerId}-${row.key}`, className: "label-card" },
h("span", null, row.key),
h("strong", null, summarizeValue(row.value)),
h("code", null, row.providerId),
))),
);
}
function HeartbeatPage({ nodes }: AnyRecord) {
return h(Panel, { title: "心跳状态", eyebrow: "Provider Liveness" },
nodes.length === 0 ? h(EmptyState, { title: "无心跳", text: "等待 provider 注册和 heartbeat" }) :
h("div", { className: "heartbeat-list" }, nodes.map((node: any) => h("article", { key: node.providerId, className: "heartbeat-row" },
h("span", { className: `pulse ${node.status}` }),
h("div", null, h("strong", null, node.name), h("code", null, node.providerId)),
h("div", null, h("span", null, "connected"), h("b", null, fmtDate(node.connectedAt))),
h("div", null, h("span", null, "last heartbeat"), h("b", null, fmtDate(node.lastHeartbeat))),
))),
);
}
function metricPointFromCurrent(current: AnyRecord): AnyRecord {
const cpu = current?.cpu || {};
const memory = current?.memory || {};
const disk = current?.disk || {};
return {
at: current?.collectedAt,
cpuPercent: asNumber(cpu.percent),
memoryPercent: asNumber(memory.percent),
diskPercent: asNumber(disk.percent),
memoryUsedBytes: asNumber(memory.usedBytes),
memoryTotalBytes: asNumber(memory.totalBytes),
diskUsedBytes: asNumber(disk.usedBytes),
diskTotalBytes: asNumber(disk.totalBytes),
load1: asNumber(cpu.load1),
};
}
function synchronizedMetricPoints(history: any[], current: AnyRecord | null): AnyRecord[] {
if (!current) return [];
const currentPoint = metricPointFromCurrent(current);
const currentAt = timeMs(currentPoint.at);
const points = (Array.isArray(history) ? history : [])
.filter((point) => point && typeof point === "object")
.filter((point) => currentAt === null || timeMs(point.at) !== currentAt);
points.push(currentPoint);
return points
.sort((left, right) => (timeMs(left.at) ?? 0) - (timeMs(right.at) ?? 0))
.slice(-60);
}
function NodeMonitorPage({ nodes, systemStatuses, tasks, onRaw, refresh }: AnyRecord) {
const [selectedProvider, setSelectedProvider] = useState("");
const merged = useMemo(() => nodes.map((node: any) => {
const status = systemStatuses.find((item: any) => item.providerId === node.providerId);
return {
...node,
systemCurrent: status?.current || null,
systemLastKnown: status?.lastKnown || null,
systemCurrentCollectedAt: status?.currentCollectedAt || null,
systemStale: Boolean(status?.stale),
systemStaleSeconds: status?.staleSeconds ?? null,
systemHistory: status?.history || [],
systemUpdatedAt: status?.updatedAt || null,
};
}), [nodes, systemStatuses]);
const active = merged.find((node: any) => node.providerId === selectedProvider) || merged[0] || null;
useEffect(() => {
if (!selectedProvider && merged[0]) setSelectedProvider(merged[0].providerId);
}, [merged.length, selectedProvider]);
if (!active) return h(EmptyState, { title: "暂无资源监控", text: "等待 provider 上报 CPU、内存和硬盘指标" });
const current = active.systemCurrent;
const history = active.systemHistory || [];
const cpu = current?.cpu || {};
const memory = current?.memory || {};
const disk = current?.disk || {};
const points = synchronizedMetricPoints(history, current);
const staleText = active.systemCurrentCollectedAt
? `最后采样 ${fmtDate(active.systemCurrentCollectedAt)},已过期 ${fmtDuration(asNumber(active.systemStaleSeconds))}`
: "最后采样时间不可用";
return h("div", { className: "monitor-page", "data-testid": "node-monitor-page" },
h("div", { className: "docker-node-strip" },
merged.map((node: any) => h("button", {
key: node.providerId,
type: "button",
className: `docker-node-tile ${active.providerId === node.providerId ? "active" : ""}`,
onClick: () => setSelectedProvider(node.providerId),
},
h("span", { className: `pulse ${node.status}` }),
h("strong", null, node.name),
h("code", null, node.providerId),
h("span", null, node.systemCurrent ? `CPU ${fmtPercent(node.systemCurrent.cpu?.percent)} / MEM ${fmtPercent(node.systemCurrent.memory?.percent)}` : node.systemStale ? "指标过期" : "等待指标"),
)),
),
h("div", { className: "monitor-layout" },
h(Panel, {
title: "任务管理器视图",
eyebrow: active.name,
className: "monitor-main-panel",
actions: current || active.systemStale ? h(RawButton, { title: `System ${active.providerId}`, data: { current, lastKnown: active.systemLastKnown, currentCollectedAt: active.systemCurrentCollectedAt, stale: active.systemStale, staleSeconds: active.systemStaleSeconds, history }, onOpen: onRaw }) : null,
},
!current ? h(EmptyState, { title: active.systemStale ? "系统指标已过期" : "系统指标未上报", text: active.systemStale ? `${staleText};等待 provider-gateway 恢复 system.status 上报` : "provider-gateway 会周期性采集 /proc 与 df,并保存历史曲线" }) :
h("div", null,
h("div", { className: "monitor-hero" },
h("div", null,
h("p", { className: "panel-eyebrow" }, "Node Performance"),
h("h3", null, active.name),
h("div", { className: "docker-meta" },
h("span", null, `${cpu.cores || 0} CPU cores`),
h("span", null, `load ${asNumber(cpu.load1).toFixed(2)} / ${asNumber(cpu.load5).toFixed(2)} / ${asNumber(cpu.load15).toFixed(2)}`),
h("span", null, `memory actual ${fmtBytes(memory.usedBytes)} / ${fmtBytes(memory.totalBytes)}`),
h("span", null, `disk ${fmtBytes(disk.usedBytes)} / ${fmtBytes(disk.totalBytes)}`),
),
),
h(StatusBadge, { status: current.ok ? "online" : "warn" }, current.ok ? "METRICS READY" : "METRICS DEGRADED"),
),
h("div", { className: "monitor-chart-grid" },
h(MetricChart, { title: "CPU", metricKey: "cpuPercent", current: cpu.percent, points, detail: `${cpu.cores || 0} cores / load ${asNumber(cpu.load1).toFixed(2)}`, tone: "cpu", testId: "metric-chart-cpu" }),
h(MetricChart, { title: "Memory", metricKey: "memoryPercent", current: memory.percent, points, detail: `${fmtBytes(memory.usedBytes)} actual / ${fmtBytes(memory.cacheBytes)} cache excluded`, tone: "memory", testId: "metric-chart-memory" }),
h(MetricChart, { title: "Disk", metricKey: "diskPercent", current: disk.percent, points, detail: `${disk.path || "/"} mounted ${disk.mount || "--"}`, tone: "disk", testId: "metric-chart-disk" }),
),
h("div", { className: "monitor-summary-grid" },
h(MetricCard, { label: "CPU 当前", value: fmtPercent(cpu.percent), hint: `history ${points.length} samples`, tone: "ok" }),
h(MetricCard, { label: "实际内存", value: fmtBytes(memory.usedBytes), hint: `${fmtPercent(memory.percent)} 不含缓存` }),
h(MetricCard, { label: "硬盘已用", value: fmtBytes(disk.usedBytes), hint: fmtPercent(disk.percent) }),
h(MetricCard, { label: "更新时间", value: fmtDate(current.collectedAt || active.systemUpdatedAt), hint: active.providerId }),
),
h(ProcessResourceTable, { current, onRaw }),
),
),
h("div", { className: "monitor-side-stack" },
h(UpgradeControl, { provider: active, refresh, onRaw }),
h(ProviderUpgradeRecordsPanel, { provider: active, tasks, onRaw, limit: 5 }),
h(Panel, { title: "采样说明", eyebrow: "Retention" },
h("div", { className: "monitor-note-list" },
h("article", null, h("b", null, "CPU"), h("span", null, "从 /proc/stat 计算相邻采样差值,首个采样用 load/cores 近似")),
h("article", null, h("b", null, "Memory"), h("span", null, "实际内存 = MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem,不把 page cache / buffer 计入占用")),
h("article", null, h("b", null, "Disk"), h("span", null, "使用 df -PB1 对配置路径采样,默认监控根文件系统")),
h("article", null, h("b", null, "Process"), h("span", null, "从 /proc/[pid] 采集进程 CPU、实际内存 PSS、RSS、线程数和磁盘 I/O 速率;PSS 不重复计算共享内存,表格默认按内存占用降序")),
),
),
),
),
);
}
type ProcessSortKey = "memory" | "cpu" | "disk" | "pid" | "name" | "user" | "threads" | "runtime";
function processMemoryBytes(row: AnyRecord): number {
return asNumber(row.memoryBytes, asNumber(row.pssBytes, asNumber(row.rssBytes)));
}
function processSortValue(row: AnyRecord, key: ProcessSortKey): string | number {
if (key === "memory") return processMemoryBytes(row);
if (key === "cpu") return asNumber(row.cpuPercent);
if (key === "disk") return asNumber(row.readBytesPerSecond) + asNumber(row.writeBytesPerSecond);
if (key === "pid") return asNumber(row.pid);
if (key === "threads") return asNumber(row.threads);
if (key === "runtime") return asNumber(row.elapsedSeconds);
if (key === "user") return String(row.user || "");
return String(row.name || row.command || "");
}
function ProcessMeter({ value, label, tone }: AnyRecord) {
const width = Math.max(1, Math.min(100, asNumber(value)));
return h("div", { className: `process-meter ${tone || ""}` },
h("span", { style: { width: `${width}%` } }),
h("b", null, label),
);
}
function ProcessResourceTable({ current, onRaw }: AnyRecord) {
const [sort, setSort] = useState({ key: "memory", direction: "desc" });
const contextLoading = React.useContext(LoadingContext);
const processSummary = current?.processSummary && typeof current.processSummary === "object" ? current.processSummary : {};
const processes = Array.isArray(current?.processes) ? current.processes : [];
const memoryMode = String(processSummary.memoryMode || "");
const memoryModeLabel = memoryMode.includes("pss_smaps_rollup")
? "PSS"
: memoryMode === "rss_minus_shared_fallback"
? "RSS-shared"
: "RSS fallback";
const rows = useMemo(() => {
const direction = sort.direction === "asc" ? 1 : -1;
return [...processes].sort((left: AnyRecord, right: AnyRecord) => {
const a = processSortValue(left, sort.key);
const b = processSortValue(right, sort.key);
if (typeof a === "string" || typeof b === "string") return String(a).localeCompare(String(b), "zh-CN") * direction;
return (a - b) * direction || asNumber(left.pid) - asNumber(right.pid);
});
}, [processes, sort.key, sort.direction]);
const sortHeader = (label: string, key: ProcessSortKey) => {
const active = sort.key === key;
const ariaSort = active ? (sort.direction === "asc" ? "ascending" : "descending") : "none";
return h("th", { "aria-sort": ariaSort },
h("button", {
type: "button",
className: `process-sort-button ${active ? "active" : ""}`,
"data-testid": `process-sort-${key}`,
onClick: () => setSort((previous: AnyRecord) => ({
key,
direction: previous.key === key && previous.direction === "desc" ? "asc" : "desc",
})),
}, label, h("span", null, active ? (sort.direction === "desc" ? "↓" : "↑") : "↕")),
);
};
return h("section", { className: "process-resource-panel", "data-testid": "process-resource-panel" },
h("div", { className: "process-resource-head" },
h("div", null,
h("p", { className: "panel-eyebrow" }, "Windows Resource Monitor Style"),
h(LoadingTitle, { title: "进程资源占用", level: 3, loading: contextLoading }),
),
h("div", { className: "process-resource-actions" },
h("span", { className: "data-chip" }, "默认按内存排序"),
h("span", { className: "data-chip" }, `内存口径 ${memoryModeLabel}`),
h("span", { className: "data-chip" }, `${asNumber(processSummary.visible, rows.length)} / ${asNumber(processSummary.total, rows.length)} 进程`),
h(RawButton, { title: "Process Resource Snapshot", data: { processSummary, processes }, onOpen: onRaw, testId: "raw-process-resources" }),
),
),
rows.length === 0 ? h(EmptyState, { title: "暂无进程资源数据", text: "等待 provider-gateway 上报 /proc/[pid] 采样;旧版 provider 需要先升级到支持进程资源表的版本" }) :
h("div", { className: "process-table-wrap" },
h("table", { className: "process-resource-table", "data-testid": "process-resource-table" },
h("thead", null, h("tr", null,
sortHeader("进程", "name"),
sortHeader("PID", "pid"),
sortHeader("用户", "user"),
h("th", null, "状态"),
sortHeader("CPU", "cpu"),
sortHeader("内存", "memory"),
h("th", null, "PSS / RSS"),
sortHeader("磁盘 I/O", "disk"),
sortHeader("线程", "threads"),
sortHeader("运行时长", "runtime"),
)),
h("tbody", null, rows.map((row: AnyRecord) => {
const diskRate = asNumber(row.readBytesPerSecond) + asNumber(row.writeBytesPerSecond);
const memoryBytes = processMemoryBytes(row);
return h("tr", {
key: `${row.pid}-${row.startedAt}`,
"data-testid": `process-row-${safeId(row.pid)}`,
"data-memory-bytes": String(memoryBytes),
"data-cpu-percent": String(asNumber(row.cpuPercent)),
"data-disk-bps": String(diskRate),
"data-pid": String(asNumber(row.pid)),
},
h("td", null,
h("div", { className: "process-name-cell" },
h("strong", null, row.name || "--"),
h("span", { className: "process-command" }, row.command || "--"),
),
),
h("td", null, h("code", null, row.pid || "--")),
h("td", null, row.user || `uid:${row.uid ?? "--"}`),
h("td", null, h("span", { className: `process-state state-${safeId(row.state || "unknown")}` }, row.state || "?")),
h("td", null, h(ProcessMeter, { value: row.cpuPercent, label: fmtLoosePercent(row.cpuPercent), tone: "cpu" })),
h("td", null, h(ProcessMeter, { value: row.memoryPercent, label: fmtPercent(row.memoryPercent), tone: "memory" })),
h("td", null,
h("div", { className: "process-io-cell" },
h("strong", null, fmtBytes(memoryBytes)),
h("span", null, `RSS ${fmtBytes(row.rssBytes)}`),
),
),
h("td", null, h("div", { className: "process-io-cell" },
h("strong", null, fmtBytesRate(diskRate)),
h("span", null, `R ${fmtBytesRate(row.readBytesPerSecond)} / W ${fmtBytesRate(row.writeBytesPerSecond)}`),
)),
h("td", null, row.threads || 0),
h("td", null, fmtDuration(asNumber(row.elapsedSeconds))),
);
})),
),
),
);
}
function MetricChart({ title, metricKey, current, points, detail, tone, testId }: AnyRecord) {
const values = points.map((point: any) => Math.max(0, Math.min(100, asNumber(point[metricKey]))));
const chartValues = values.length > 1 ? values : [values[0] || 0, values[0] || 0];
const step = chartValues.length <= 1 ? 100 : 100 / (chartValues.length - 1);
const linePoints = chartValues.map((value: any, index: any) => `${(index * step).toFixed(2)},${(46 - value * 0.42).toFixed(2)}`).join(" ");
const areaPoints = `0,48 ${linePoints} 100,48`;
return h("article", { className: `metric-chart ${tone}`, "data-testid": testId },
h("div", { className: "metric-chart-head" },
h("div", null, h("span", null, title), h("strong", null, fmtPercent(current))),
h("code", null, `${points.length} pts`),
),
h("svg", { viewBox: "0 0 100 48", preserveAspectRatio: "none", role: "img", "aria-label": `${title} usage curve` },
h("polygon", { points: areaPoints }),
h("polyline", { points: linePoints }),
h("line", { x1: "0", x2: "100", y1: "24", y2: "24" }),
),
h("div", { className: "metric-chart-foot" }, h("span", null, "0%"), h("span", null, detail), h("span", null, "100%")),
);
}
function asArray(value: any): any[] {
return Array.isArray(value) ? value : [];
}
function summaryRows(snapshot: any): any[] {
const core = asArray(snapshot?.core?.requests?.componentSummary);
const frontend = asArray(snapshot?.frontend?.requests?.componentSummary);
return [...frontend, ...core].sort((left, right) => asNumber(right.requestCount) - asNumber(left.requestCount));
}
function operationRows(snapshot: any): any[] {
const core = asArray(snapshot?.core?.operations?.summary);
const frontend = asArray(snapshot?.frontend?.operations?.summary);
return [...frontend, ...core].sort((left, right) => asNumber(right.count) - asNumber(left.count));
}
function failureRows(snapshot: any): any[] {
const core = asArray(snapshot?.core?.requests?.recentFailures).map((row: any) => ({ source: "backend", ...row }));
const frontend = asArray(snapshot?.frontend?.requests?.recentFailures).map((row: any) => ({ source: "frontend", ...row }));
return [...frontend, ...core].sort((left, right) => (timeMs(right.at) ?? 0) - (timeMs(left.at) ?? 0)).slice(0, 20);
}
function slowOperationRows(snapshot: any): any[] {
const core = asArray(snapshot?.core?.operations?.recentSlowOperations);
const frontend = asArray(snapshot?.frontend?.operations?.recentSlowOperations);
return [...frontend, ...core].sort((left, right) => asNumber(right.durationMs) - asNumber(left.durationMs)).slice(0, 20);
}
function browserMemoryBytes(frontendPerformance: any): number {
const memory = (performance as any).memory;
const used = Number(memory?.usedJSHeapSize);
if (Number.isFinite(used) && used > 0) return used;
const bundleBytes = Number(frontendPerformance?.appBundleBytes);
if (Number.isFinite(bundleBytes) && bundleBytes > 0) return bundleBytes;
return asNumber(frontendPerformance?.process?.heapUsedBytes);
}
function PerformanceMemoryChart({ points }: AnyRecord) {
const rows = asArray(points);
const values = rows.map((point: any) => asNumber(point.mb));
const max = Math.max(1, ...values);
const min = Math.max(0, Math.min(...values, 0));
const range = Math.max(1, max - min);
const chartValues = rows.length > 1 ? rows : [...rows, ...rows];
const step = chartValues.length <= 1 ? 100 : 100 / (chartValues.length - 1);
const linePoints = chartValues.map((point: any, index: number) => {
const value = asNumber(point.mb);
return `${(index * step).toFixed(2)},${(48 - ((value - min) / range) * 42).toFixed(2)}`;
}).join(" ");
const areaPoints = `0,50 ${linePoints} 100,50`;
const latest = rows.at(-1);
const first = rows[0];
return h("article", { className: "performance-memory-card", "data-testid": "performance-memory-chart" },
h("div", { className: "performance-memory-head" },
h("strong", null, `Bwebui: ${latest ? `${asNumber(latest.mb).toFixed(1)}MB` : "--"}`),
h("span", null, rows.length > 0 ? `${rows.length} samples` : "等待采样"),
),
h("svg", { viewBox: "0 0 100 50", preserveAspectRatio: "none", role: "img", "aria-label": "Bwebui memory trend" },
h("polygon", { points: areaPoints }),
h("polyline", { points: linePoints }),
h("line", { x1: "0", x2: "100", y1: "25", y2: "25" }),
),
h("div", { className: "performance-axis-row" },
h("span", null, first ? fmtClock(new Date(first.at)) : "--"),
h("span", null, "时间"),
h("span", null, latest ? fmtClock(new Date(latest.at)) : "--"),
),
h("div", { className: "performance-axis-row" },
h("span", null, `${min.toFixed(1)}`),
h("span", null, "(MB)"),
h("span", null, `${max.toFixed(1)}`),
),
);
}
function PerformancePage({ onRaw }: AnyRecord) {
const [snapshot, setSnapshot] = useState({ core: null, frontend: null });
const [memorySamples, setMemorySamples] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [codexPerf, setCodexPerf] = useState(null);
const [codexPerfLoading, setCodexPerfLoading] = useState(false);
async function load(): Promise<void> {
setLoading(true);
setError("");
try {
const [core, frontend] = await Promise.all([
requestJson(`${cfg.apiBaseUrl}/performance`, { cache: "no-store" }),
requestJson(`${cfg.apiBaseUrl}/frontend-performance`, { cache: "no-store" }),
]);
setSnapshot({ core, frontend });
const bytes = browserMemoryBytes(frontend);
setMemorySamples((prev: any[]) => [...prev, { at: new Date().toISOString(), mb: bytes / (1024 * 1024) }].slice(-80));
} catch (err) {
setError(errorMessage(err, "性能指标加载失败"));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
const timer = setInterval(() => void load(), 5000);
return () => clearInterval(timer);
}, []);
async function runCodeQueueLoadTest(): Promise<void> {
setCodexPerfLoading(true);
setError("");
setCodexPerf(null);
try {
const result = await requestJson(`${cfg.apiBaseUrl}/code-queue-load-test`, {
method: "POST",
body: JSON.stringify({
targetMs: 1000,
timeoutMs: 90000,
url: cfg.frontendPublicUrl || window.location.origin,
}),
});
setCodexPerf(result);
void load();
} catch (err) {
setError(errorMessage(err, "Code Queue Playwright 测量失败"));
} finally {
setCodexPerfLoading(false);
}
}
const components = summaryRows(snapshot);
const failures = failureRows(snapshot);
const operations = operationRows(snapshot);
const slowRows = slowOperationRows(snapshot);
const backendProcess = snapshot.core?.process || {};
const frontendProcess = snapshot.frontend?.process || {};
const codexStorage = snapshot.core?.database?.codeQueueStorage || {};
const codexTotal = asNumber(codexStorage.total);
const codexPerfResult = codexPerf?.result || {};
const codexPerfWallMs = asNumber(codexPerfResult.wallMs, NaN);
const codexPerfNetworkIdleMs = asNumber(codexPerfResult.networkIdleMs, NaN);
const codexPerfWithinTarget = codexPerfResult.withinTarget === true;
const codexPerfStatus = codexPerfLoading
? "running"
: codexPerf === null
? "idle"
: codexPerf.measurementOk === true
? codexPerfWithinTarget ? "passed" : "slow"
: "failed";
return h("div", { className: "performance-page", "data-testid": "performance-page" },
h("div", { className: "performance-hero" },
h("div", null,
h("p", { className: "panel-eyebrow" }, "Unified Performance"),
h(LoadingTitle, { title: "性能面板", loading: loading || codexPerfLoading }),
h("p", null, "按组件统计 HTTP 请求、失败率、P95 延迟,并汇总 backend/frontend 内部操作耗时。"),
),
h("div", { className: "inline-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => void runCodeQueueLoadTest(), disabled: codexPerfLoading, "data-testid": "code-queue-load-test-button" }, codexPerfLoading ? "测试中..." : "测试 Code Queue 加载"),
h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: loading, "data-testid": "performance-refresh-button" }, loading ? "刷新中" : "刷新"),
h(RawButton, { title: "Performance Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-performance" }),
),
),
h(UniDeskErrorBanner, { error }),
h("div", { className: "performance-top-grid" },
h(PerformanceMemoryChart, { points: memorySamples }),
h("div", { className: "performance-metric-stack" },
h(MetricCard, { label: "backend RSS", value: fmtBytes(backendProcess.rssBytes), hint: `heap ${fmtBytes(backendProcess.heapUsedBytes)}` }),
h(MetricCard, { label: "frontend RSS", value: fmtBytes(frontendProcess.rssBytes), hint: `bundle ${fmtBytes(snapshot.frontend?.appBundleBytes)}` }),
h(MetricCard, { label: "Codex PG 任务", value: codexTotal || "--", hint: codexStorage.ok ? "unidesk_code_queue_tasks" : "等待表初始化", tone: codexStorage.ok ? "ok" : "warn" }),
h(MetricCard, { label: "请求样本", value: asNumber(snapshot.core?.requests?.sampleCount) + asNumber(snapshot.frontend?.requests?.sampleCount), hint: "rolling window 3000" }),
),
),
h(Panel, {
title: "Code Queue 加载基准",
eyebrow: "Playwright / target <1s",
className: "codex-load-test-panel",
loading: codexPerfLoading,
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "primary-btn", onClick: () => void runCodeQueueLoadTest(), disabled: codexPerfLoading, "data-testid": "code-queue-load-test-panel-button" }, codexPerfLoading ? "正在运行 Playwright..." : "手动触发测试"),
codexPerf ? h(RawButton, { title: "Code Queue Load Test", data: codexPerf, onOpen: onRaw, testId: "raw-code-queue-load-test" }) : null,
),
},
h("div", { className: "codex-load-test-grid", "data-testid": "code-queue-load-test-result" },
h(MetricCard, {
label: "总耗时",
value: codexPerfLoading ? "运行中" : Number.isFinite(codexPerfWallMs) ? fmtMs(codexPerfWallMs) : "--",
hint: codexPerf === null ? "点击按钮启动远端 Playwright" : `目标 ${fmtMs(codexPerfResult.targetMs || 1000)} / ${codexPerfResult.url || "Code Queue"}`,
tone: codexPerfStatus === "passed" ? "ok" : codexPerfStatus === "failed" || codexPerfStatus === "slow" ? "warn" : "",
}),
h(MetricCard, {
label: "判定",
value: codexPerfLoading ? "RUNNING" : codexPerfStatus === "passed" ? "PASS <1s" : codexPerfStatus === "slow" ? "SLOW" : codexPerfStatus === "failed" ? "FAILED" : "--",
hint: codexPerf?.measurementOk === false ? String(codexPerf.error || codexPerfResult.error || "measurement failed").slice(0, 120) : "导航开始 -> DOMContentLoaded -> data-load-state=complete",
tone: codexPerfStatus === "passed" ? "ok" : codexPerfStatus === "idle" || codexPerfStatus === "running" ? "" : "fail",
}),
h(MetricCard, {
label: "Network idle",
value: Number.isFinite(codexPerfNetworkIdleMs) ? fmtMs(codexPerfNetworkIdleMs) : "--",
hint: `DOMContentLoaded ${fmtMs(codexPerfResult.domContentLoadedMs)} / ${codexPerfResult.networkIdleReached === false ? "未在 5s 内空闲" : "已空闲"}`,
tone: Number.isFinite(codexPerfNetworkIdleMs) && codexPerfNetworkIdleMs <= 1000 ? "ok" : "warn",
}),
h(MetricCard, {
label: "组件耗时",
value: Number.isFinite(asNumber(codexPerfResult.componentLoadMs, NaN)) ? fmtMs(codexPerfResult.componentLoadMs) : "--",
hint: `queue ${fmtMs(codexPerfResult.queueMs)} / detail ${fmtMs(codexPerfResult.detailMs)}`,
tone: asNumber(codexPerfResult.componentLoadMs) > 1000 ? "warn" : "ok",
}),
h(MetricCard, {
label: "Trace 规模",
value: Number.isFinite(asNumber(codexPerfResult.transcriptRows, NaN)) ? String(codexPerfResult.transcriptRows) : "--",
hint: `${codexPerfResult.visibleTaskCount ?? 0} visible tasks / ${codexPerfResult.partial ? "preview" : "complete"}`,
}),
),
codexPerfLoading ? h("div", { className: "performance-empty-line" }, "正在通过 main-server Host SSH 启动 Playwright,完成后会显示 wall time、组件耗时和最慢 API。") : null,
codexPerf && Array.isArray(codexPerfResult.slowestApi) && codexPerfResult.slowestApi.length > 0 ? h("div", { className: "table-wrap performance-table-wrap compact codex-load-api-table" },
h("table", { className: "performance-table" },
h("thead", null, h("tr", null, ["API", "状态", "耗时"].map((label) => h("th", { key: label }, label)))),
h("tbody", null, codexPerfResult.slowestApi.slice(0, 5).map((row: any, index: number) => h("tr", { key: `${row.url}-${index}` },
h("td", null, h("code", null, row.url)),
h("td", null, row.status),
h("td", null, fmtMs(row.durationMs)),
))),
),
) : null,
),
h("div", { className: "performance-grid" },
h(Panel, { title: "组件汇总", eyebrow: "Requests", loading },
components.length === 0 ? h(EmptyState, { title: "暂无请求样本", text: "刷新几次或打开页面后会自动形成组件统计" }) :
h("div", { className: "table-wrap performance-table-wrap" }, h("table", { className: "performance-table" },
h("thead", null, h("tr", null, ["组件", "请求数", "失败数", "失败率", "平均延迟", "P95"].map((label) => h("th", { key: label }, label)))),
h("tbody", null, components.map((row: any) => h("tr", { key: row.component },
h("td", null, h("code", null, row.component)),
h("td", null, row.requestCount),
h("td", null, row.failureCount),
h("td", null, fmtPercent(asNumber(row.failureRate) * 100)),
h("td", null, fmtMs(row.averageLatencyMs)),
h("td", null, fmtMs(row.p95LatencyMs)),
))),
)),
),
h(Panel, { title: "最近失败请求", eyebrow: "Failures", loading },
failures.length === 0 ? h("div", { className: "performance-empty-line" }, "最近没有失败请求") :
h("div", { className: "table-wrap performance-table-wrap compact" }, h("table", { className: "performance-table" },
h("thead", null, h("tr", null, ["时间", "来源", "组件", "状态", "路径"].map((label) => h("th", { key: label }, label)))),
h("tbody", null, failures.map((row: any, index: number) => h("tr", { key: `${row.at}-${index}` },
h("td", null, fmtDate(row.at)),
h("td", null, row.source),
h("td", null, h("code", null, row.component)),
h("td", null, h(StatusBadge, { status: "failed" }, row.status)),
h("td", null, h("code", null, row.path)),
))),
)),
),
h(Panel, { title: "内部操作汇总", eyebrow: "Operations", loading },
operations.length === 0 ? h(EmptyState, { title: "暂无内部操作样本", text: "API 查询和代理请求会自动记录内部操作耗时" }) :
h("div", { className: "table-wrap performance-table-wrap" }, h("table", { className: "performance-table" },
h("thead", null, h("tr", null, ["服务", "操作", "次数", "平均延迟", "P95"].map((label) => h("th", { key: label }, label)))),
h("tbody", null, operations.map((row: any) => h("tr", { key: `${row.service}-${row.operation}` },
h("td", null, row.service),
h("td", null, h("code", null, row.operation)),
h("td", null, row.count),
h("td", null, fmtMs(row.averageLatencyMs)),
h("td", null, fmtMs(row.p95LatencyMs)),
))),
)),
),
h(Panel, { title: "最近慢操作", eyebrow: "Slowest", loading },
slowRows.length === 0 ? h(EmptyState, { title: "暂无慢操作", text: "后端会记录最近窗口内耗时最高的内部操作" }) :
h("div", { className: "table-wrap performance-table-wrap" }, h("table", { className: "performance-table" },
h("thead", null, h("tr", null, ["时间", "操作", "耗时", "结果", "细节"].map((label) => h("th", { key: label }, label)))),
h("tbody", null, slowRows.map((row: any, index: number) => h("tr", { key: `${row.at}-${row.operation}-${index}` },
h("td", null, fmtDate(row.at)),
h("td", null, h("code", null, row.operation)),
h("td", null, fmtMs(row.durationMs)),
h("td", null, row.ok ? "成功" : "失败"),
h("td", null, row.detail || "-"),
))),
)),
),
),
);
}
function UpgradeControl({ provider, refresh, onRaw }: AnyRecord) {
const [busyMode, setBusyMode] = useState("");
const [result, setResult] = useState(null);
const [error, setError] = useState("");
async function run(mode: string): Promise<void> {
setBusyMode(mode);
setError("");
try {
const response = await requestJson(`${cfg.apiBaseUrl}/dispatch`, {
method: "POST",
body: JSON.stringify({
providerId: provider.providerId,
command: "provider.upgrade",
payload: { mode, source: "frontend-resource-monitor", requestedAt: new Date().toISOString() },
}),
});
setResult({ mode, ...response });
await refresh();
} catch (err) {
setError(errorMessage(err, "升级命令下发失败"));
} finally {
setBusyMode("");
}
}
return h(Panel, { title: "Provider Gateway 升级", eyebrow: "Remote Control", loading: Boolean(busyMode) },
h("div", { className: "upgrade-control", "data-testid": "provider-upgrade-control" },
h("p", null, "通过 UniDesk WebSocket 向当前计算节点下发 provider.upgrade;预检只生成升级计划,执行升级会调度节点本地 updater 容器。"),
h("div", { className: "upgrade-target-line" },
h("span", null, "指定 Provider"),
h("code", null, provider.providerId),
h(GatewayVersionBadge, { node: provider }),
),
h("div", { className: "upgrade-actions" },
h("button", { type: "button", className: "ghost-btn", disabled: Boolean(busyMode), onClick: () => run("plan"), "data-testid": "upgrade-plan-button" }, busyMode === "plan" ? "预检中" : "预检升级"),
h("button", { type: "button", className: "ghost-btn danger", disabled: Boolean(busyMode), onClick: () => run("schedule"), "data-testid": "upgrade-schedule-button" }, busyMode === "schedule" ? "调度中" : "执行升级"),
),
h(UniDeskErrorBanner, { error }),
result ? h("div", { className: "upgrade-result" },
h(StatusBadge, { status: result.status || "queued" }, result.status || "queued"),
h("span", null, `${result.mode === "schedule" ? "执行升级" : "预检升级"} 已下发`),
h("span", null, `指定版本 ${fmtGatewayVersion(nodeGatewayVersion(provider))}`),
h("code", null, result.taskId || "--"),
h(RawButton, { title: "Provider Upgrade Dispatch", data: result, onOpen: onRaw }),
) : h("span", { className: "muted" }, "升级任务结果会进入任务历史;执行升级可能导致 provider 短暂重连。"),
),
);
}
function UpgradeRecordsTable({ records, onRaw, compact = false }: AnyRecord) {
if (records.length === 0) {
return h(EmptyState, { title: "暂无远程更新记录", text: "该节点还没有 provider.upgrade 任务;执行预检或升级后会在这里形成结构化记录" });
}
return h("div", { className: `upgrade-record-table-wrap table-wrap ${compact ? "compact" : ""}` }, h("table", { className: "upgrade-record-table" },
h("thead", null, h("tr", null,
h("th", null, "状态"),
h("th", null, "模式"),
h("th", null, "任务"),
h("th", null, "来源"),
h("th", null, "耗时"),
h("th", null, "策略"),
h("th", null, "Gateway 版本"),
h("th", null, "结果记录"),
h("th", null, "更新时间"),
h("th", null, "操作"),
)),
h("tbody", null, records.map((task: any) => h("tr", { key: task.id, "data-testid": `gateway-upgrade-record-${safeId(task.id)}` },
h("td", null, h(StatusBadge, { status: task.status })),
h("td", null, h("span", { className: `mode-chip ${taskUpgradeMode(task)}` }, taskUpgradeMode(task) === "schedule" ? "执行升级" : "预检")),
h("td", null, h("strong", null, "provider.upgrade"), h("code", null, task.id)),
h("td", null, taskUpgradeSource(task)),
h("td", null, h(TaskDurationCell, { task })),
h("td", null, taskUpgradePolicy(task)),
h("td", null, h("span", { className: "version-chip" }, taskUpgradeVersion(task))),
h("td", null, h("span", { className: `upgrade-outcome ${String(task.status || "").toLowerCase()}` }, taskUpgradeOutcome(task))),
h("td", null, fmtDate(task.updatedAt)),
h("td", null, h(RawButton, { title: `Provider Upgrade Task ${task.id}`, data: taskRawButtonData(task), onOpen: onRaw })),
))),
));
}
function ProviderUpgradeRecordsPanel({ provider, tasks, onRaw, limit = 5 }: AnyRecord) {
const records = providerUpgradeTasks(tasks, provider.providerId).slice(0, limit);
return h(Panel, {
title: "远程更新记录",
eyebrow: provider.providerId,
actions: h(GatewayVersionBadge, { node: provider }),
className: "provider-upgrade-records-panel",
},
h("div", { "data-testid": `provider-upgrade-records-${safeId(provider.providerId)}` },
h(UpgradeRecordsTable, { records, onRaw, compact: true }),
),
);
}
function GatewayVersionPage({ nodes, tasks, onRaw }: AnyRecord) {
const rows = useMemo(() => nodes.map((node: any) => {
const records = providerUpgradeTasks(tasks, node.providerId);
return { node, records, latest: latestScheduledUpgradeTask(records), capabilities: nodeCapabilities(node) };
}), [nodes, tasks]);
const totalRecords = rows.reduce((sum: number, row: any) => sum + row.records.length, 0);
return h("div", { className: "gateway-page", "data-testid": "gateway-version-page" },
h(Panel, { title: "Provider Gateway 版本", eyebrow: `${nodes.length} Providers / ${totalRecords} 更新记录` },
nodes.length === 0 ? h(EmptyState, { title: "暂无 Provider 节点", text: "等待 provider-gateway 注册后显示版本号和升级记录" }) :
h("div", { className: "table-wrap gateway-version-table-wrap" }, h("table", { className: "gateway-version-table" },
h("thead", null, h("tr", null,
h("th", null, "状态"),
h("th", null, "Provider"),
h("th", null, "Gateway 版本"),
h("th", null, "升级策略"),
h("th", null, "运维可用性"),
h("th", null, "运行时间"),
h("th", null, "能力"),
h("th", null, "最近远程更新"),
h("th", null, "操作"),
)),
h("tbody", null, rows.map((row: any) => h("tr", { key: row.node.providerId },
h("td", null, h(StatusBadge, { status: row.node.status })),
h("td", null, h("strong", null, row.node.name), h("code", null, row.node.providerId)),
h("td", null, h(GatewayVersionBadge, { node: row.node })),
h("td", null, nodeGatewayPolicy(row.node)),
h("td", null, h(NodeAvailabilityStrip, { node: row.node })),
h("td", null, nodeGatewayStartedAt(row.node) ? fmtDate(nodeGatewayStartedAt(row.node)) : "待新版上报"),
h("td", null, h("div", { className: "capability-row" },
row.capabilities.length === 0 ? h("span", { className: "muted" }, "未声明") :
row.capabilities.slice(0, 5).map((item: string) => h("span", { key: item, className: "data-chip" }, item)),
)),
h("td", null, row.latest ? h("div", { className: "latest-upgrade-cell" },
h(StatusBadge, { status: row.latest.status }),
h("span", null, `${taskUpgradeMode(row.latest) === "schedule" ? "执行升级" : "预检"} / ${fmtDate(row.latest.updatedAt)}`),
h("small", null, `Gateway ${taskUpgradeVersion(row.latest)}`),
h("small", null, taskUpgradeOutcome(row.latest)),
) : h("span", { className: "muted" }, "暂无记录")),
h("td", null, h(RawButton, { title: `Provider ${row.node.providerId}`, data: row.node, onOpen: onRaw })),
))),
)),
),
h(Panel, { title: "远程更新记录", eyebrow: "Structured provider.upgrade records" },
nodes.length === 0 ? h(EmptyState, { title: "暂无记录", text: "没有 provider 节点时不会生成远程更新记录" }) :
h("div", { className: "gateway-record-grid" }, rows.map((row: any) => h("article", {
key: row.node.providerId,
className: "gateway-record-card",
"data-testid": `gateway-records-${safeId(row.node.providerId)}`,
},
h("div", { className: "gateway-record-head" },
h("div", null, h("strong", null, row.node.name), h("code", null, row.node.providerId)),
h(GatewayVersionBadge, { node: row.node }),
),
h("div", { className: "gateway-record-meta" },
h("span", null, `心跳 ${fmtDate(row.node.lastHeartbeat)}`),
h("span", null, `策略 ${nodeGatewayPolicy(row.node)}`),
h("span", null, `${row.records.length} 条记录`),
),
h(UpgradeRecordsTable, { records: row.records.slice(0, 8), onRaw, compact: true }),
))),
),
);
}
function dockerStateTone(state: string): string {
if (state === "running") return "online";
if (state === "paused" || state === "restarting") return "warn";
if (state === "exited" || state === "dead") return "offline";
return "internal";
}
function isHashVolumeName(name: string): boolean {
return /^[a-f0-9]{48,64}$/i.test(name);
}
function isDatabaseVolume(volume: AnyRecord): boolean {
const name = String(volume?.name || "");
const labels = String(volume?.labels || "");
return name === "unidesk_pgdata_10gb" || labels.includes("com.docker.compose.volume=unidesk_pgdata_10gb") || name.toLowerCase().includes("pgdata");
}
function volumeRank(volume: AnyRecord): number {
const name = String(volume?.name || "");
const labels = String(volume?.labels || "");
if (isDatabaseVolume(volume)) return 0;
if (labels.includes("com.docker.compose.project=unidesk")) return 1;
if (!isHashVolumeName(name)) return 2;
return 3;
}
function sortVolumes(volumes: AnyRecord[]): AnyRecord[] {
return [...volumes].sort((left, right) => {
const rankDelta = volumeRank(left) - volumeRank(right);
if (rankDelta !== 0) return rankDelta;
return String(left.name || "").localeCompare(String(right.name || ""));
});
}
function DockerStatusPage({ nodes, dockerStatuses, onRaw }: AnyRecord) {
const [selectedProvider, setSelectedProvider] = useState("");
const merged = useMemo(() => nodes.map((node: any) => {
const status = dockerStatuses.find((item: any) => item.providerId === node.providerId);
return { ...node, dockerStatus: status?.dockerStatus || null, dockerUpdatedAt: status?.updatedAt || null };
}), [nodes, dockerStatuses]);
const active = merged.find((node: any) => node.providerId === selectedProvider) || merged[0] || null;
useEffect(() => {
if (!selectedProvider && merged[0]) setSelectedProvider(merged[0].providerId);
}, [merged.length, selectedProvider]);
if (!active) {
return h(EmptyState, { title: "暂无 Docker 节点", text: "等待 provider 上报 Docker daemon 状态" });
}
const status = active.dockerStatus;
const isMainServer = active.providerId === "main-server";
const counts = status?.counts || {};
const daemon = status?.daemon || {};
const containers = status?.containers || [];
const images = status?.images || [];
const volumes = sortVolumes(status?.volumes || []);
const databaseVolume = isMainServer ? volumes.find(isDatabaseVolume) : null;
const networks = status?.networks || [];
const runningContainers = containers.filter((item: any) => item.state === "running");
const stoppedContainers = containers.filter((item: any) => item.state !== "running");
return h("div", { className: "docker-page", "data-testid": "docker-status-page" },
h("div", { className: "docker-node-strip" },
merged.map((node: any) => h("button", {
key: node.providerId,
type: "button",
className: `docker-node-tile ${active.providerId === node.providerId ? "active" : ""}`,
onClick: () => setSelectedProvider(node.providerId),
},
h("span", { className: `pulse ${node.status}` }),
h("strong", null, node.name),
h("code", null, node.providerId),
h("span", null, node.dockerStatus ? `Docker ${node.dockerStatus.ok ? "ready" : "degraded"}` : "等待上报"),
)),
),
h("div", { className: "docker-layout" },
h(Panel, {
title: "Docker Desktop 视图",
eyebrow: active.name,
className: "docker-main-panel",
actions: status ? h(RawButton, { title: `Docker ${active.providerId}`, data: status, onOpen: onRaw }) : null,
},
!status ? h(EmptyState, { title: "Docker 状态未上报", text: "provider-gateway 会在连接后周期性采集 docker info / ps / images / volume / network" }) :
h("div", null,
h("div", { className: "docker-hero" },
h("div", null,
h("p", { className: "panel-eyebrow" }, "Daemon"),
h("h3", null, daemon.name || active.providerId),
h("div", { className: "docker-meta" },
h("span", null, daemon.serverVersion ? `Engine ${daemon.serverVersion}` : "Engine --"),
h("span", null, daemon.operatingSystem || "OS --"),
h("span", null, daemon.architecture || "arch --"),
h("span", null, `${daemon.cpus || 0} CPU / ${fmtBytes(daemon.memoryBytes)}`),
),
),
h(StatusBadge, { status: status.ok ? "online" : "warn" }, status.ok ? "Docker Ready" : "Docker Degraded"),
),
h("div", { className: "docker-metrics" },
h(MetricCard, { label: "Containers", value: counts.containers ?? containers.length, hint: `${counts.running ?? runningContainers.length} running / ${counts.stopped ?? stoppedContainers.length} stopped`, tone: "ok" }),
h(MetricCard, { label: "Images", value: counts.images ?? images.length, hint: `${counts.daemonImages ?? counts.images ?? images.length} daemon images` }),
h(MetricCard, { label: "Volumes", value: counts.volumes ?? volumes.length, hint: isMainServer ? (databaseVolume ? "database volume visible" : "database volume missing") : "node local volumes", tone: databaseVolume ? "ok" : "" }),
h(MetricCard, { label: "Networks", value: counts.networks ?? networks.length, hint: daemon.driver ? `driver ${daemon.driver}` : "docker networks" }),
),
isMainServer ? h(DatabaseVolumeCard, { volume: databaseVolume, volumeCount: volumes.length }) : null,
h("div", { className: "docker-section-head" },
h("h3", null, "Containers"),
h("span", null, `updated ${fmtDate(active.dockerUpdatedAt || status.collectedAt)}`),
),
h("div", { className: "docker-container-table table-wrap", "data-testid": "docker-container-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "容器"), h("th", null, "镜像"), h("th", null, "端口"), h("th", null, "运行时间"), h("th", null, "重启策略"), h("th", null, "PID"), h("th", null, "大小"))),
h("tbody", null, containers.length === 0 ? h("tr", null, h("td", { colSpan: 8 }, "暂无容器")) : containers.map((item: any) => h("tr", { key: `${item.id}-${item.name}` },
h("td", null, h(StatusBadge, { status: dockerStateTone(item.state) }, item.state || "unknown")),
h("td", null, h("strong", null, item.name || "--"), h("code", null, item.id || "--")),
h("td", null, item.image || "--"),
h("td", null, item.ports || h("span", { className: "muted" }, "未发布")),
h("td", null, item.runningFor || item.status || "--"),
h("td", null, item.restartPolicy ? h(StatusBadge, { status: item.restartPolicy === "always" ? "online" : "warn" }, item.restartPolicy) : "--"),
h("td", null, item.pidMode ? h("code", null, item.pidMode) : "--"),
h("td", null, item.size || "--"),
))),
)),
),
),
h("div", { className: "docker-side-stack" },
h(DockerSidePanel, { title: "Images", items: images, render: (image: any) => h("article", { key: `${image.id}-${image.repository}`, className: "docker-side-row" }, h("strong", null, `${image.repository}:${image.tag}`), h("span", null, image.size || "--"), h("code", null, image.id || "--")) }),
h(DockerSidePanel, { title: "Volumes", items: volumes, limit: volumes.length, render: (volume: any) => h("article", { key: volume.name, className: `docker-side-row volume-row ${isMainServer && isDatabaseVolume(volume) ? "database-volume" : ""}`, "data-testid": isMainServer && isDatabaseVolume(volume) ? "database-volume-row" : undefined },
h("strong", null, volume.name),
h("span", null, isMainServer && isDatabaseVolume(volume) ? "PostgreSQL" : isHashVolumeName(String(volume.name || "")) ? "anonymous" : "named"),
h("code", null, volume.mountpoint || volume.driver || volume.scope || "--"),
) }),
h(DockerSidePanel, { title: "Networks", items: networks, render: (network: any) => h("article", { key: network.id || network.name, className: "docker-side-row" }, h("strong", null, network.name), h("span", null, network.driver || "--"), h("code", null, network.id || "--")) }),
),
),
);
}
function DatabaseVolumeCard({ volume, volumeCount }: AnyRecord) {
return h("section", { className: `docker-volume-focus ${volume ? "ready" : "missing"}`, "data-testid": "database-volume-card" },
h("div", { className: "volume-focus-head" },
h("span", { className: "panel-eyebrow" }, "Database Named Volume"),
h(StatusBadge, { status: volume ? "online" : "warn" }, volume ? "FOUND" : "MISSING"),
),
volume ? h("div", { className: "volume-focus-body" },
h("strong", null, volume.name),
h("span", null, "PostgreSQL data volume for unidesk-database"),
h("div", { className: "volume-route" },
h("code", null, volume.mountpoint || "/var/lib/docker/volumes/unidesk_pgdata_10gb/_data"),
h("span", null, "->"),
h("code", null, "unidesk-database:/var/lib/postgresql/data"),
),
h("div", { className: "docker-meta compact" },
h("span", null, `driver ${volume.driver || "--"}`),
h("span", null, `scope ${volume.scope || "--"}`),
h("span", null, `${volumeCount} volumes reported`),
),
) : h("div", { className: "volume-focus-body" },
h("strong", null, "unidesk_pgdata_10gb"),
h("span", null, "当前 Docker 快照没有发现数据库命名卷;请检查 provider-gateway 的 Docker volume 上报。"),
),
);
}
function DockerSidePanel({ title, items, render, limit }: AnyRecord) {
const visibleItems = items.slice(0, limit ?? 12);
const hiddenCount = Math.max(0, items.length - visibleItems.length);
return h(Panel, { title, eyebrow: `${items.length} items`, className: "docker-side-panel" },
items.length === 0 ? h(EmptyState, { title: `暂无 ${title}`, text: "等待 Docker 状态采集" }) :
h("div", { className: "docker-side-list" },
visibleItems.map(render),
hiddenCount > 0 ? h("div", { className: "docker-side-more" }, `+ ${hiddenCount} more`) : null,
),
);
}
function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord) {
const privateServices = microservices.filter((service: any) => microserviceBackend(service).public === false);
return h("div", { className: "microservice-page", "data-testid": "microservice-catalog-page" },
h(Panel, { title: "用户服务目录", eyebrow: "Provider Mounted User Services" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "服务总数", value: microservices.length, hint: "config.json 用户服务登记" }),
h(MetricCard, { label: "私有后端", value: privateServices.length, hint: "不直接暴露公网", tone: "ok" }),
h(MetricCard, { label: "D601 服务", value: microservices.filter((service: any) => service.providerId === "D601").length, hint: "compute-node docker" }),
h(MetricCard, { label: "集成前端", value: microservices.filter((service: any) => service.frontend?.integrated).length, hint: "UniDesk React 页面" }),
),
),
h(Panel, { title: "外部静态入口", eyebrow: "External Link" },
h("div", { className: "endpoint-list hwlab-entry-list" },
h("article", { className: "hwlab-entry-card", "data-testid": "hwlab-entry-card" },
h("b", null, "HWLAB"),
h("span", null, HWLAB_URL),
h("span", null, "external static link"),
h("div", { className: "microservice-actions hwlab-entry-actions" },
h("button", {
type: "button",
className: "ghost-btn",
"data-testid": "hwlab-open-current-from-catalog",
onClick: () => window.location.assign(HWLAB_URL),
}, "当前窗口"),
h("a", {
className: "ghost-btn",
href: HWLAB_URL,
target: "_blank",
rel: "noreferrer",
"data-testid": "hwlab-open-new-from-catalog",
}, "新窗口"),
),
),
),
h("p", { className: "muted paragraph" }, "该入口仅用于外部静态跳转,不会进入 microservice health、proxy 或运行态探测。"),
),
h(Panel, { title: "服务映射", eyebrow: "Repo Reference + Runtime" },
microservices.length === 0 ? h(EmptyState, { title: "暂无用户服务", text: "在 config.json 的 microservices 中登记用户服务的 provider、仓库引用和后端映射" }) :
h("div", { className: "table-wrap" }, h("table", { className: "microservice-table" },
h("thead", null, h("tr", null, h("th", null, "服务"), h("th", null, "Provider"), h("th", null, "代码引用"), h("th", null, "Docker 引用"), h("th", null, "后端映射"), h("th", null, "开发入口"), h("th", null, "运行态"), h("th", null, "操作"))),
h("tbody", null, microservices.map((service: any) => {
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const availability = runtime.availability || {};
const availabilityStatus = availability.status || (runtime.providerStatus === "online" ? "unknown" : "unhealthy");
return h("tr", { key: service.id, "data-testid": `microservice-row-${safeId(service.id)}` },
h("td", null, h("strong", null, service.name), h("code", null, service.id)),
h("td", null, h("strong", null, runtime.providerName || service.providerId), h("code", null, service.providerId)),
h("td", null, h("span", null, repository.url || "--"), h("code", null, repository.commitId || "--")),
h("td", null, h("span", null, repository.composeFile || "--"), h("code", null, `${repository.composeService || "--"} / ${repository.containerName || "--"}`)),
h("td", null,
h(StatusBadge, { status: backend.public ? "warn" : "online" }, backend.public ? "public" : "private"),
h("code", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"} -> ${backend.proxyMode || "--"}`),
),
h("td", null, h("span", null, service.development?.sshPassthrough ? "SSH 透传" : "未配置"), h("code", null, service.development?.worktreePath || "--")),
h("td", null,
h(StatusBadge, { status: availabilityStatus === "healthy" ? "online" : availabilityStatus === "unknown" ? "warn" : "failed" }, availabilityStatus),
h("span", null, availability.reason || runtime.providerStatus || "unknown"),
h(DataSummary, { data: runtime.container, empty: "容器快照未上报" }),
),
h("td", null,
h("div", { className: "microservice-actions" },
service.id === "findjob" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "findjob"), "data-testid": "open-findjob-button" }, "打开") : null,
service.id === "pipeline" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "pipeline"), "data-testid": "open-pipeline-button" }, "打开") : null,
service.id === "todo-note" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "todo-note"), "data-testid": "open-todo-note-button" }, "打开") : null,
service.id === "met-nonlinear" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "met-nonlinear"), "data-testid": "open-met-nonlinear-button" }, "打开") : null,
service.id === "claudeqq" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "claudeqq"), "data-testid": "open-claudeqq-button" }, "打开") : null,
service.id === "baidu-netdisk" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "baidu-netdisk"), "data-testid": "open-baidu-netdisk-button" }, "打开") : null,
service.id === "oa-event-flow" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "oa-event-flow"), "data-testid": "open-oa-event-flow-button" }, "打开") : null,
service.id === "k3sctl-adapter" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "k3sctl"), "data-testid": "open-k3sctl-button" }, "打开") : null,
service.id === "code-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "open-code-queue-button" }, "打开") : null,
service.id === "mdtodo" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "mdtodo"), "data-testid": "open-mdtodo-button" }, "打开") : null,
service.id === "decision-center" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "decision-center"), "data-testid": "open-decision-center-button" }, "打开") : null,
service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null,
h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }),
),
),
);
})),
)),
),
);
}
function DispatchPage({ nodes, onDispatched, onRaw }: AnyRecord) {
const onlineNodes = nodes.filter((node: any) => node.status === "online");
const [providerId, setProviderId] = useState(onlineNodes[0]?.providerId || nodes[0]?.providerId || "");
const [command, setCommand] = useState("docker.ps");
const [source, setSource] = useState("frontend");
const [note, setNote] = useState("operator-check");
const [priority, setPriority] = useState("normal");
const [rawOpen, setRawOpen] = useState(false);
const [rawPayload, setRawPayload] = useState("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState("");
useEffect(() => {
if (!providerId && (onlineNodes[0]?.providerId || nodes[0]?.providerId)) setProviderId(onlineNodes[0]?.providerId || nodes[0].providerId);
}, [nodes.length, onlineNodes.length, providerId]);
function structuredPayload(): AnyRecord {
return { source, note, priority };
}
function revealRawPayload(): void {
setRawPayload(JSON.stringify(structuredPayload(), null, 2));
setRawOpen(true);
}
async function submit(event: any) {
event.preventDefault();
setBusy(true);
setError("");
try {
const payload = rawOpen ? JSON.parse(rawPayload || "{}") : structuredPayload();
const response = await requestJson(`${cfg.apiBaseUrl}/dispatch`, {
method: "POST",
body: JSON.stringify({ providerId, command, payload }),
});
setResult(response);
await onDispatched();
} catch (err) {
setError(errorMessage(err, "下发失败"));
} finally {
setBusy(false);
}
}
return h("div", { className: "page-grid dispatch-grid" },
h(Panel, { title: "下发任务", eyebrow: "Real WebSocket Dispatch" },
h("form", { className: "dispatch-form", onSubmit: submit },
h("label", null, "Provider", h("select", { value: providerId, onChange: (event: any) => setProviderId(event.target.value) },
nodes.map((node: any) => h("option", { key: node.providerId, value: node.providerId }, `${node.name} / ${node.providerId}`)),
)),
h("label", null, "Command", h("select", { value: command, onChange: (event: any) => setCommand(event.target.value) },
h("option", { value: "docker.ps" }, "docker.ps"),
h("option", { value: "host.ssh" }, "host.ssh"),
h("option", { value: "microservice.http" }, "microservice.http"),
h("option", { value: "echo" }, "echo"),
)),
h("label", null, "来源", h("input", { value: source, onChange: (event: any) => setSource(event.target.value) })),
h("label", null, "备注", h("input", { value: note, onChange: (event: any) => setNote(event.target.value) })),
h("label", null, "优先级", h("select", { value: priority, onChange: (event: any) => setPriority(event.target.value) },
h("option", { value: "normal" }, "normal"),
h("option", { value: "low" }, "low"),
h("option", { value: "urgent" }, "urgent"),
)),
h("div", { className: "dispatch-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: revealRawPayload }, "查看原始JSON"),
h("button", { type: "submit", disabled: busy || !providerId }, busy ? "下发中" : "下发任务"),
),
rawOpen ? h("label", { className: "raw-editor-label" }, "高级 Payload", h("textarea", { className: "raw-editor", value: rawPayload, onChange: (event: any) => setRawPayload(event.target.value) })) : null,
h(UniDeskErrorBanner, { error, wide: true }),
),
),
h(Panel, { title: "下发结果", eyebrow: "Response" },
result ? h("div", { className: "result-card" },
h(StatusBadge, { status: result.status || "queued" }, result.status || "queued"),
h("dl", null,
h("dt", null, "Task ID"), h("dd", null, h("code", null, result.taskId || "--")),
h("dt", null, "Provider 在线"), h("dd", null, summarizeValue(result.providerOnline)),
),
h(RawButton, { title: "Dispatch Response", data: result, onOpen: onRaw }),
) : h(EmptyState, { title: "等待操作", text: "任务响应会以结构化结果卡展示" }),
),
);
}
function TaskCompactRow({ task, onRaw }: AnyRecord) {
return h("article", { className: "compact-row" },
h(StatusBadge, { status: task.status }),
h("div", null, h("strong", null, task.command), h("code", null, task.id)),
h("span", null, isPendingTask(task) ? `已等待 ${fmtRelativeAge(task.updatedAt)}` : `耗时 ${fmtDuration(taskElapsedSeconds(task) ?? 0)}`),
h(RawButton, { title: `Task ${task.id}`, data: taskRawButtonData(task), onOpen: onRaw }),
);
}
function TaskDurationCell({ task }: AnyRecord) {
const elapsed = taskElapsedSeconds(task);
const pending = isPendingTask(task);
return h("div", { className: "task-duration" },
h("strong", null, elapsed === null ? "--" : fmtDuration(elapsed)),
h("span", null, pending ? `已运行 / 创建 ${fmtDate(task.createdAt)}` : `创建 ${fmtDate(task.createdAt)}`),
);
}
function TaskDiagnosticCell({ task }: AnyRecord) {
const status = String(task?.status || "").toLowerCase();
const result = task?.result;
const resultRecord = result && typeof result === "object" && !Array.isArray(result) ? result as AnyRecord : {};
const metaKeys = ["exitCode", "code", "signal", "timeoutMs", "previousStatus", "mode"];
const metas = metaKeys.filter((key) => resultRecord[key] !== undefined && resultRecord[key] !== null);
if (status === "failed") {
const reason = taskFailureReason(task);
return h("div", { className: "task-diagnostic failed" },
h("b", null, "失败原因"),
h("span", { className: "diagnostic-reason" }, summarizeValue(reason)),
metas.length > 0 ? h("div", { className: "diagnostic-meta" }, metas.map((key) =>
h("span", { key, className: "data-chip" }, h("b", null, key), h("span", null, summarizeValue(resultRecord[key]))),
)) : null,
);
}
if (isPendingTask(task)) {
return h("div", { className: "task-diagnostic warn" },
h("b", null, "等待终态"),
h("span", null, `最后更新 ${fmtRelativeAge(task.updatedAt)} 前`),
);
}
return h("div", { className: "task-diagnostic ok" },
h("b", null, "完成摘要"),
h(DataSummary, { data: result, empty: "无执行输出" }),
);
}
function TaskPendingPage({ tasks, onRaw }: AnyRecord) {
const pending = tasks.filter(isPendingTask);
return h("div", { "data-testid": "pending-task-page" },
h(Panel, { title: "待处理任务", eyebrow: `${pending.length} Pending` },
pending.length === 0 ? h(EmptyState, { title: "当前无待处理任务", text: "queued / dispatched / running 会在超时后自动转为 failed;历史记录仍可在任务历史中查看" }) :
h("div", { className: "table-wrap", "data-testid": "pending-task-table" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "任务"), h("th", null, "Provider"), h("th", null, "已等待"), h("th", null, "载荷摘要"), h("th", null, "操作"))),
h("tbody", null, pending.map((task: any) => h("tr", { key: task.id },
h("td", null, h(StatusBadge, { status: task.status })),
h("td", null, h("strong", null, task.command), h("code", null, task.id)),
h("td", null, h("code", null, task.providerId)),
h("td", null, fmtRelativeAge(task.updatedAt)),
h("td", null, h(DataSummary, { data: task.payload })),
h("td", null, h(RawButton, { title: `Pending Task ${task.id}`, data: taskRawButtonData(task), onOpen: onRaw })),
))),
)),
),
);
}
function TaskHistoryPage({ tasks, onRaw }: AnyRecord) {
return h("div", { "data-testid": "task-history-page" },
h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` },
tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) :
h("div", { className: "table-wrap" }, h("table", { className: "task-history-table" },
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "任务"), h("th", null, "Provider"), h("th", null, "任务耗时"), h("th", null, "载荷摘要"), h("th", null, "诊断信息"), h("th", null, "更新时间"), h("th", null, "操作"))),
h("tbody", null, tasks.map((task: any) => h("tr", { key: task.id, "data-testid": `task-row-${safeId(task.id)}` },
h("td", null, h(StatusBadge, { status: task.status })),
h("td", null, h("strong", null, task.command), h("code", null, task.id)),
h("td", null, h("code", null, task.providerId)),
h("td", null, h(TaskDurationCell, { task })),
h("td", null, h(DataSummary, { data: task.payload })),
h("td", null, h(TaskDiagnosticCell, { task })),
h("td", null, fmtDate(task.updatedAt)),
h("td", null, h(RawButton, { title: `Task ${task.id}`, data: taskRawButtonData(task), onOpen: onRaw })),
))),
)),
),
);
}
function TaskResultsPage({ tasks, onRaw }: AnyRecord) {
const finished = tasks.filter((task: any) => ["succeeded", "failed"].includes(task.status));
return h(Panel, { title: "执行结果", eyebrow: "Finished Tasks" },
finished.length === 0 ? h(EmptyState, { title: "暂无结果", text: "任务完成后展示 provider 返回的结构化摘要" }) :
h("div", { className: "result-grid" }, finished.map((task: any) => h("article", { key: task.id, className: "result-card" },
h("div", { className: "node-card-head" }, h("strong", null, task.command), h(StatusBadge, { status: task.status })),
h("code", null, task.id),
h(DataSummary, { data: task.result, empty: "无执行输出" }),
h(RawButton, { title: `Task Result ${task.id}`, data: taskRawButtonData(task), onOpen: onRaw }),
))),
);
}
function scheduleSpecLabel(schedule: any): string {
if (!schedule || typeof schedule !== "object") return "--";
if (schedule.type === "interval") return `每 ${fmtDuration(Number(schedule.everySeconds || 0))}`;
return `每天 ${schedule.timeOfDay || "03:00"} UTC`;
}
function scheduleActionLabel(action: any): string {
if (!action || typeof action !== "object") return "--";
if (action.type === "pgdata_backup") return `PGDATA -> ${action.remoteBaseDir || "/SERVER_DATA/UNIDESK_PG_DATA"}`;
if (action.type === "dispatch") return `${action.providerId || "--"} / ${action.command || "--"}`;
return String(action.type || "--");
}
function scheduleRunTone(status: any): string {
const value = String(status || "").toLowerCase();
if (value === "succeeded") return "online";
if (value === "failed") return "failed";
if (value === "running" || value === "queued") return "warn";
return value;
}
function scheduleRunDuration(run: any): string {
const ms = Number(run?.durationMs);
if (Number.isFinite(ms) && ms >= 0) return fmtDuration(ms / 1000);
const started = timeMs(run?.startedAt || run?.createdAt);
if (started === null) return "--";
const finished = timeMs(run?.finishedAt);
const ended = finished ?? Date.now();
return fmtDuration(Math.max(0, (ended - started) / 1000));
}
function defaultScheduleForm(nodes: any[]): AnyRecord {
return {
id: "unidesk-pgdata-baidu-daily",
name: "PGDATA daily Baidu Netdisk backup",
description: "Daily PostgreSQL physical base backup uploaded to Baidu Netdisk /SERVER_DATA with monthly rotation.",
enabled: true,
timeOfDay: "03:30",
actionType: "pgdata_backup",
providerId: nodes[0]?.providerId || "main-server",
command: "echo",
payloadJson: JSON.stringify({ source: "scheduled-task", message: "hello from scheduler" }, null, 2),
remoteBaseDir: "/SERVER_DATA/UNIDESK_PG_DATA",
stagingSubdir: "server-data/unidesk-pg-data",
timeoutMs: "3600000",
};
}
function TaskScheduledPage({ schedules, scheduleRuns, nodes, refresh, onRaw }: AnyRecord) {
const [form, setForm] = useState(defaultScheduleForm(nodes || []));
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const sortedRuns = [...(scheduleRuns || [])].sort((left: any, right: any) => (timeMs(right.updatedAt) ?? 0) - (timeMs(left.updatedAt) ?? 0));
function update(key: string, value: any): void {
setForm((previous: AnyRecord) => ({ ...previous, [key]: value }));
}
function editSchedule(schedule: any): void {
const action = schedule?.action || {};
setForm({
id: schedule?.id || "",
name: schedule?.name || "",
description: schedule?.description || "",
enabled: schedule?.enabled !== false,
timeOfDay: schedule?.schedule?.timeOfDay || "03:30",
actionType: action.type || "dispatch",
providerId: action.providerId || nodes[0]?.providerId || "main-server",
command: action.command || "echo",
payloadJson: JSON.stringify(action.payload || { source: "scheduled-task" }, null, 2),
remoteBaseDir: action.remoteBaseDir || "/SERVER_DATA/UNIDESK_PG_DATA",
stagingSubdir: action.stagingSubdir || "server-data/unidesk-pg-data",
timeoutMs: String(action.timeoutMs || 3600000),
});
setNotice(`正在编辑 ${schedule?.id || ""}`);
}
function scheduleBody(): AnyRecord {
const base = {
id: form.id,
name: form.name,
description: form.description,
enabled: form.enabled,
concurrencyPolicy: "skip",
schedule: { type: "daily", timeOfDay: form.timeOfDay, timezone: "Etc/UTC" },
};
if (form.actionType === "pgdata_backup") {
return {
...base,
action: {
type: "pgdata_backup",
volumeName: "unidesk_pgdata_10gb",
remoteBaseDir: form.remoteBaseDir,
stagingSubdir: form.stagingSubdir,
timeoutMs: Number(form.timeoutMs) || 3600000,
cleanupLocal: true,
},
};
}
return {
...base,
action: {
type: "dispatch",
providerId: form.providerId,
command: form.command,
payload: JSON.parse(form.payloadJson || "{}"),
timeoutMs: Number(form.timeoutMs) || 600000,
},
};
}
async function save(event: any): Promise<void> {
event.preventDefault();
setBusy(true);
setError("");
setNotice("");
try {
const body = scheduleBody();
const id = encodeURIComponent(String(body.id));
await requestJson(`${cfg.apiBaseUrl}/schedules/${id}`, { method: "PUT", body: JSON.stringify(body) });
setNotice("定时任务已保存");
await refresh();
} catch (err) {
setError(errorMessage(err, "保存定时任务失败"));
} finally {
setBusy(false);
}
}
async function deleteSchedule(schedule: any): Promise<void> {
if (!schedule?.id) return;
setBusy(true);
setError("");
setNotice("");
try {
await requestJson(`${cfg.apiBaseUrl}/schedules/${encodeURIComponent(schedule.id)}`, { method: "DELETE" });
setNotice(`已删除 ${schedule.id}`);
await refresh();
} catch (err) {
setError(errorMessage(err, "删除定时任务失败"));
} finally {
setBusy(false);
}
}
async function runNow(schedule: any): Promise<void> {
if (!schedule?.id) return;
setBusy(true);
setError("");
setNotice("");
try {
const response = await requestJson(`${cfg.apiBaseUrl}/schedules/${encodeURIComponent(schedule.id)}/run`, { method: "POST", body: "{}" });
setNotice(`已触发 ${schedule.id} / ${response?.run?.id || "run"}`);
await refresh();
} catch (err) {
setError(errorMessage(err, "触发定时任务失败"));
} finally {
setBusy(false);
}
}
return h("div", { className: "page-grid scheduled-task-page", "data-testid": "scheduled-task-page" },
h(Panel, { title: "定时任务", eyebrow: `${(schedules || []).length} Schedules` },
(schedules || []).length === 0 ? h(EmptyState, { title: "暂无定时任务", text: "创建 daily / dispatch / PGDATA backup 任务后会在这里展示下一次执行时间和最近结果" }) :
h("div", { className: "schedule-card-grid" }, (schedules || []).map((schedule: any) => h("article", { key: schedule.id, className: "schedule-card", "data-testid": `schedule-row-${safeId(schedule.id)}` },
h("div", { className: "node-card-head" }, h("strong", null, schedule.name || schedule.id), h(StatusBadge, { status: schedule.enabled ? "online" : "warn" }, schedule.enabled ? "enabled" : "disabled")),
h("code", null, schedule.id),
h("dl", null,
h("dt", null, "计划"), h("dd", null, scheduleSpecLabel(schedule.schedule)),
h("dt", null, "动作"), h("dd", null, scheduleActionLabel(schedule.action)),
h("dt", null, "下次执行"), h("dd", null, fmtDate(schedule.nextRunAt)),
h("dt", null, "最近执行"), h("dd", null, schedule.lastRunAt ? `${fmtDate(schedule.lastRunAt)} / ${schedule.lastRunId || "--"}` : "--"),
),
h("div", { className: "dispatch-actions" },
h("button", { type: "button", className: "ghost-btn", disabled: busy, onClick: () => editSchedule(schedule) }, "编辑"),
h("button", { type: "button", className: "ghost-btn", disabled: busy, onClick: () => runNow(schedule), "data-testid": `schedule-run-${safeId(schedule.id)}` }, "手动触发"),
h("button", { type: "button", className: "ghost-btn danger", disabled: busy, onClick: () => deleteSchedule(schedule) }, "删除"),
h(RawButton, { title: `Schedule ${schedule.id}`, data: schedule, onOpen: onRaw }),
),
))),
),
h(Panel, { title: form.id ? "配置定时任务" : "新建定时任务", eyebrow: "CRUD" },
h("form", { className: "dispatch-form schedule-form", onSubmit: save },
h("label", null, "ID", h("input", { value: form.id, onChange: (event: any) => update("id", event.target.value) })),
h("label", null, "名称", h("input", { value: form.name, onChange: (event: any) => update("name", event.target.value) })),
h("label", null, "每日执行时间 UTC", h("input", { value: form.timeOfDay, placeholder: "03:30", onChange: (event: any) => update("timeOfDay", event.target.value) })),
h("label", null, "启用", h("select", { value: form.enabled ? "true" : "false", onChange: (event: any) => update("enabled", event.target.value === "true") },
h("option", { value: "true" }, "enabled"),
h("option", { value: "false" }, "disabled"),
)),
h("label", null, "动作类型", h("select", { value: form.actionType, onChange: (event: any) => update("actionType", event.target.value) },
h("option", { value: "pgdata_backup" }, "PGDATA 备份到百度网盘"),
h("option", { value: "dispatch" }, "Provider Dispatch"),
)),
form.actionType === "pgdata_backup" ? [
h("label", { key: "remote" }, "网盘根目录", h("input", { value: form.remoteBaseDir, onChange: (event: any) => update("remoteBaseDir", event.target.value) })),
h("label", { key: "staging" }, "本地 staging 子目录", h("input", { value: form.stagingSubdir, onChange: (event: any) => update("stagingSubdir", event.target.value) })),
] : [
h("label", { key: "provider" }, "Provider", h("select", { value: form.providerId, onChange: (event: any) => update("providerId", event.target.value) },
(nodes || []).map((node: any) => h("option", { key: node.providerId, value: node.providerId }, `${node.name} / ${node.providerId}`)),
)),
h("label", { key: "command" }, "Command", h("select", { value: form.command, onChange: (event: any) => update("command", event.target.value) },
h("option", { value: "echo" }, "echo"),
h("option", { value: "docker.ps" }, "docker.ps"),
h("option", { value: "host.ssh" }, "host.ssh"),
h("option", { value: "microservice.http" }, "microservice.http"),
)),
h("label", { key: "payload", className: "raw-editor-label" }, "Payload JSON", h("textarea", { className: "raw-editor", value: form.payloadJson, onChange: (event: any) => update("payloadJson", event.target.value) })),
],
h("label", null, "超时 ms", h("input", { value: form.timeoutMs, onChange: (event: any) => update("timeoutMs", event.target.value) })),
h("label", { className: "raw-editor-label" }, "描述", h("textarea", { className: "raw-editor compact", value: form.description, onChange: (event: any) => update("description", event.target.value) })),
h("div", { className: "dispatch-actions" },
h("button", { type: "button", className: "ghost-btn", disabled: busy, onClick: () => setForm(defaultScheduleForm(nodes || [])) }, "重置"),
h("button", { type: "submit", disabled: busy || !form.id }, busy ? "保存中" : "保存任务"),
),
notice ? h("p", { className: "muted paragraph" }, notice) : null,
h(UniDeskErrorBanner, { error, wide: true }),
),
),
h(Panel, { title: "历史执行记录", eyebrow: `${sortedRuns.length} Runs` },
sortedRuns.length === 0 ? h(EmptyState, { title: "暂无执行记录", text: "定时触发或手动触发后会生成 run history" }) :
h("div", { className: "table-wrap" }, h("table", { className: "task-history-table schedule-run-table" },
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "任务"), h("th", null, "触发"), h("th", null, "耗时"), h("th", null, "结果摘要"), h("th", null, "更新时间"), h("th", null, "操作"))),
h("tbody", null, sortedRuns.map((run: any) => h("tr", { key: run.id, "data-testid": `schedule-run-row-${safeId(run.id)}` },
h("td", null, h(StatusBadge, { status: scheduleRunTone(run.status) }, run.status)),
h("td", null, h("strong", null, run.scheduleId), h("code", null, run.id), run.taskId ? h("code", null, run.taskId) : null),
h("td", null, run.trigger || "--"),
h("td", null, scheduleRunDuration(run)),
h("td", null, h(DataSummary, { data: run.result || run.error, empty: "无结果" })),
h("td", null, fmtDate(run.updatedAt)),
h("td", null, h(RawButton, { title: `Schedule Run ${run.id}`, data: run, onOpen: onRaw })),
))),
)),
),
);
}
function TopologyPage({ data }: AnyRecord) {
const overview = data.overview || {};
return h("div", { className: "page-grid topology-grid" },
h(Panel, { title: "公开入口", eyebrow: "Public" },
h("div", { className: "endpoint-list" },
h("article", null, h("b", null, "Frontend"), h("span", null, cfg.frontendPublicUrl || window.location.origin), h(StatusBadge, { status: "online" }, "public")),
h("article", null, h("b", null, "Provider Ingress"), h("span", null, cfg.providerIngressPublicUrl || "ws://public/ws/provider"), h(StatusBadge, { status: "online" }, "public")),
),
),
h(Panel, { title: "内部服务", eyebrow: "Docker Network Only" },
h("div", { className: "endpoint-list" },
h("article", null, h("b", null, "backend-core API"), h("span", null, "http://backend-core:8080"), h(StatusBadge, { status: "internal" }, "internal")),
h("article", null, h("b", null, "database"), h("span", null, "postgres://database:5432/unidesk"), h(StatusBadge, { status: "internal" }, "internal")),
),
),
h(Panel, { title: "运行态", eyebrow: "Runtime" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "DB Ready", value: overview.dbReady ? "YES" : "NO", hint: "internal health" }),
h(MetricCard, { label: "Online Nodes", value: overview.onlineNodeCount ?? 0, hint: "provider-gateway self-link" }),
),
),
);
}
function AuthPage({ session }: AnyRecord) {
return h(Panel, { title: "认证策略", eyebrow: "Frontend Login" },
h("div", { className: "policy-grid" },
h("article", null, h("span", null, "默认账号"), h("strong", null, cfg.authUsername || "admin")),
h("article", null, h("span", null, "当前会话"), h("strong", null, session?.user?.username || "--")),
h("article", null, h("span", null, "Session TTL"), h("strong", null, `${cfg.sessionTtlSeconds || 0}s`)),
h("article", null, h("span", null, "API 访问"), h("strong", null, "同源 Cookie 保护")),
),
h("p", { className: "muted paragraph" }, "浏览器只访问 frontend 同源接口;frontend 容器使用 Docker 内网代理 backend-core API。"),
);
}
function SecurityPage() {
return h(Panel, { title: "安全边界", eyebrow: "Exposure Rule" },
h("div", { className: "security-board" },
h("article", { className: "allow" }, h("b", null, "允许公网"), h("span", null, "frontend 登录入口"), h("span", null, "provider ingress WebSocket/health")),
h("article", { className: "deny" }, h("b", null, "禁止公网"), h("span", null, "backend-core REST API"), h("span", null, "PostgreSQL database")),
h("article", null, h("b", null, "数据库卷"), h("span", null, "named volume unidesk_pgdata_10gb"), h("span", null, "CLI stop/start 不删除数据卷")),
),
);
}
function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNavigate }: AnyRecord) {
if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, onRaw, onNavigate });
if (activeModule === "ops" && activeTab === "performance") return h(PerformancePage, { onRaw });
if (activeModule === "ops" && activeTab === "events") return h(EventsPage, { events: data.events, onRaw });
if (activeModule === "ops" && activeTab === "logs") return h(LogsPage, { logs: data.logs, onRaw });
if (activeModule === "nodes" && activeTab === "list") return h(NodeListPage, { nodes: data.nodes, onRaw });
if (activeModule === "nodes" && activeTab === "monitor") return h(NodeMonitorPage, { nodes: data.nodes, systemStatuses: data.systemStatuses, tasks: data.tasks, onRaw, refresh });
if (activeModule === "nodes" && activeTab === "docker") return h(DockerStatusPage, { nodes: data.nodes, dockerStatuses: data.dockerStatuses, onRaw });
if (activeModule === "nodes" && activeTab === "gateway") return h(GatewayVersionPage, { nodes: data.nodes, tasks: data.tasks, onRaw });
if (activeModule === "nodes" && activeTab === "labels") return h(LabelsPage, { nodes: data.nodes });
if (activeModule === "nodes" && activeTab === "heartbeats") return h(HeartbeatPage, { nodes: data.nodes });
if (activeModule === "tasks" && activeTab === "dispatch") return h(DispatchPage, { nodes: data.nodes, onDispatched: refresh, onRaw });
if (activeModule === "tasks" && activeTab === "scheduled") return h(TaskScheduledPage, { schedules: data.schedules, scheduleRuns: data.scheduleRuns, nodes: data.nodes, refresh, onRaw });
if (activeModule === "tasks" && activeTab === "pending") return h(TaskPendingPage, { tasks: data.pendingTasks, onRaw });
if (activeModule === "tasks" && activeTab === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw });
if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw });
if (activeModule === "apps" && activeTab === "catalog") return h(MicroserviceCatalogPage, { microservices: data.microservices, onRaw, onNavigate });
if (activeModule === "apps" && activeTab === "hwlab") return h(HwlabPage, {});
if (activeModule === "apps" && activeTab === "todo-note") return h(TodoNotePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "met-nonlinear") return h(MetNonlinearPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "claudeqq") return h(ClaudeQqPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "baidu-netdisk") return h(BaiduNetdiskPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "filebrowser") return h(FileBrowserPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "oa-event-flow") return h(OaEventFlowPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "k3sctl") return h(K3sCtlPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, onNavigate });
if (activeModule === "apps" && activeTab === "code-queue") return h(CodeQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodeQueueOverview });
if (activeModule === "apps" && activeTab === "mdtodo") return h(MdtodoPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "decision-center") return h(DecisionCenterPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
if (activeModule === "config" && activeTab === "security") return h(SecurityPage);
return h(EmptyState, { title: "未找到页面", text: "请选择左侧主模块和顶部子功能标签" });
}
function Shell({ session, onLogout }: AnyRecord) {
const initialRouteTarget = resolveRouteTarget(ROUTE_REGISTRY, window.location.pathname);
const [activeModule, setActiveModule] = useState(initialRouteTarget.moduleId);
const [activeTabs, setActiveTabs] = useState({ ...DEFAULT_ACTIVE_TABS, [initialRouteTarget.moduleId]: initialRouteTarget.tabId });
const [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], microservices: [], events: [], tasks: [], pendingTasks: [], schedules: [], scheduleRuns: [], logs: [] });
const [connection, setConnection] = useState({ ok: false, text: "连接中" });
const [lastRefresh, setLastRefresh] = useState(null);
const [clock, setClock] = useState(new Date());
const [raw, setRaw] = useState(null);
const [railCollapsed, setRailCollapsed] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const refreshInFlightRef = React.useRef(false);
const module = ROUTE_REGISTRY.moduleById[activeModule] || ROUTE_REGISTRY.modules[0];
const activeTab = activeTabs[activeModule] || DEFAULT_ACTIVE_TABS[activeModule] || module.tabs[0].id;
const microservices = Array.isArray(data.microservices) ? data.microservices : [];
const effectiveMicroservices = microservices.length === 0 && activeModule === "apps" && activeTab === "code-queue"
? [fastCodeQueueService]
: microservices;
const effectiveData = effectiveMicroservices === microservices ? data : { ...data, microservices: effectiveMicroservices };
const activeService = activeModule === "apps"
? effectiveMicroservices.find((service: any) => String(service?.id || "") === (activeTab === "k3sctl" ? "k3sctl-adapter" : activeTab))
: null;
const activeServiceRuntime = activeService ? microserviceRuntime(activeService) : {};
const activeTabTitle = module.tabs.find((tab: any) => tab.id === activeTab)?.label || activeTab;
const activeStatusItems = activeService ? [{
key: "microservice",
label: "用户服务",
value: `${activeTabTitle} ${activeServiceRuntime.providerStatus === "online" ? "在线" : activeServiceRuntime.providerStatus || "未知"}`,
tone: activeServiceRuntime.providerStatus === "online" ? "ok" : "warn",
testId: "active-microservice-status",
}] : [];
async function refresh(): Promise<void> {
if (refreshInFlightRef.current) return;
refreshInFlightRef.current = true;
setRefreshing(true);
try {
const requests: Array<[string, Promise<any>]> = [];
const add = (key: string, path: string): void => {
requests.push([key, requestJson(path)]);
};
const isOverview = activeModule === "ops" && activeTab === "status";
const needsOverviewSummary = isOverview || (activeModule === "config" && activeTab === "topology");
const needsNodes = isOverview || activeModule === "nodes" || (activeModule === "tasks" && (activeTab === "dispatch" || activeTab === "scheduled"));
const needsMicroservices = activeModule === "apps" && activeTab !== "code-queue";
if (needsOverviewSummary) add("overview", `${cfg.apiBaseUrl}/overview`);
if (needsNodes) add("nodes", `${cfg.apiBaseUrl}/nodes`);
if (activeModule === "nodes" && activeTab === "monitor") {
add("systemStatuses", `${cfg.apiBaseUrl}/nodes/system-status?limit=60`);
add("tasks", `${cfg.apiBaseUrl}/tasks?limit=120&summary=1`);
} else if (activeModule === "nodes" && activeTab === "docker") {
add("dockerStatuses", `${cfg.apiBaseUrl}/nodes/docker-status`);
} else if (activeModule === "nodes" && activeTab === "gateway") {
add("tasks", `${cfg.apiBaseUrl}/tasks?limit=300&summary=1`);
} else if (activeModule === "tasks" && activeTab === "scheduled") {
add("schedules", `${cfg.apiBaseUrl}/schedules?limit=100`);
add("scheduleRuns", `${cfg.apiBaseUrl}/schedules/runs?limit=100`);
} else if (activeModule === "tasks" && activeTab === "pending") {
add("pendingTasks", `${cfg.apiBaseUrl}/tasks?status=pending&limit=100&summary=1`);
} else if (activeModule === "tasks" && (activeTab === "history" || activeTab === "results")) {
add("tasks", `${cfg.apiBaseUrl}/tasks?limit=300&summary=1`);
} else if (isOverview) {
add("tasks", `${cfg.apiBaseUrl}/tasks?limit=8&lite=1`);
add("pendingTasks", `${cfg.apiBaseUrl}/tasks?status=pending&limit=20&lite=1`);
}
if (needsMicroservices) add("microservices", `${cfg.apiBaseUrl}/microservices`);
if (activeModule === "ops" && activeTab === "events") add("events", `${cfg.apiBaseUrl}/events?limit=100`);
if (activeModule === "ops" && activeTab === "logs") add("logs", "/logs?limit=100");
await Promise.all(requests.map(async ([key, promise]) => {
const value = await promise;
const patch: AnyRecord = {};
if (key === "overview") patch.overview = value;
if (key === "nodes") patch.nodes = value.nodes || [];
if (key === "systemStatuses") patch.systemStatuses = value.systemStatuses || [];
if (key === "dockerStatuses") patch.dockerStatuses = value.dockerStatuses || [];
if (key === "microservices") patch.microservices = value.microservices || [];
if (key === "events") patch.events = value.events || [];
if (key === "tasks") patch.tasks = value.tasks || [];
if (key === "pendingTasks") patch.pendingTasks = value.tasks || [];
if (key === "schedules") patch.schedules = value.schedules || [];
if (key === "scheduleRuns") patch.scheduleRuns = value.runs || [];
if (key === "logs") patch.logs = value.logs || [];
setData((previous: AnyRecord) => ({ ...previous, ...patch }));
}));
setConnection({ ok: true, text: "核心在线" });
setLastRefresh(new Date());
} catch (err) {
setConnection({ ok: false, text: errorMessage(err, "连接失败") });
if ((err as { status?: number }).status === 401) onLogout(false);
} finally {
refreshInFlightRef.current = false;
setRefreshing(false);
}
}
useEffect(() => {
const tick = (): void => {
if (!isDocumentVisible()) return;
void refresh();
};
tick();
const timer = setInterval(tick, shellRefreshIntervalMs(activeModule, activeTab));
const onVisible = (): void => {
if (isDocumentVisible()) tick();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
clearInterval(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, [activeModule, activeTab]);
useEffect(() => {
const timer = setInterval(() => setClock(new Date()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const canonicalPath = canonicalizeKnownRoute(ROUTE_REGISTRY, window.location.pathname);
if (canonicalPath && window.location.pathname !== canonicalPath) {
window.history.replaceState(null, "", canonicalPath);
}
}, []);
useEffect(() => {
const onPopState = (): void => {
const next = resolveRouteTarget(ROUTE_REGISTRY, window.location.pathname);
setActiveModule(next.moduleId);
setActiveTabs((prev: AnyRecord) => ({ ...prev, [next.moduleId]: next.tabId }));
setRaw(null);
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, [activeModule, activeTab]);
function navigate(moduleId: string, tabId: string, historyMode: "push" | "replace" = "push"): void {
const safeModule = ROUTE_REGISTRY.moduleById[moduleId] ? moduleId : ROUTE_REGISTRY.fallbackTarget.moduleId;
const safeTab = ROUTE_REGISTRY.moduleById[safeModule]?.tabs.some((tab: any) => tab.id === tabId)
? tabId
: DEFAULT_ACTIVE_TABS[safeModule] || ROUTE_REGISTRY.moduleById[safeModule]?.tabs[0]?.id || ROUTE_REGISTRY.fallbackTarget.tabId;
setActiveModule(safeModule);
setActiveTabs((prev: any) => ({ ...prev, [safeModule]: safeTab }));
const nextPath = pathForTarget(ROUTE_REGISTRY, safeModule, safeTab);
if (window.location.pathname !== nextPath) {
const method = historyMode === "replace" ? "replaceState" : "pushState";
window.history[method](null, "", nextPath);
}
}
function openRaw(title: string, rawData: any): void {
setRaw({ title, data: rawData });
}
const [notificationOpen, setNotificationOpen] = useState(false);
const { unreadCount, notifications } = useNotification();
const latestNotification = notifications.length > 0 ? notifications[notifications.length - 1] : null;
const devMode = isDevEnvironment(environmentIdentity);
return h("div", { className: `shell ${railCollapsed ? "rail-collapsed" : ""} ${devMode ? "dev-shell" : ""}`, "data-testid": "app-shell" },
h(Sidebar, { activeModule, activeTabs, onNavigate: navigate, collapsed: railCollapsed, onToggle: () => setRailCollapsed((value: boolean) => !value) }),
h("main", { className: "workspace" },
h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock, activeStatusItems, onNotificationToggle: () => setNotificationOpen((v: boolean) => !v), unreadCount, environment: environmentIdentity }),
h(TabBar, { module, activeTab, onNavigate: navigate }),
h(LoadingContext.Provider, { value: refreshing },
h(WorkArea, { activeModule, activeTab, data: effectiveData, session, refresh, onRaw: openRaw, onNavigate: navigate }),
),
),
h(RawDialog, { raw, onClose: () => setRaw(null) }),
latestNotification && h(NotificationBanner, { key: latestNotification.id, notification: latestNotification }),
notificationOpen && h(NotificationPopup, { onClose: () => setNotificationOpen(false) }),
);
}
function App() {
const [checking, setChecking] = useState(true);
const [session, setSession] = useState(null);
async function loadSession(): Promise<void> {
setChecking(true);
try {
const current = await requestJson("/api/session");
setSession(current.authenticated ? current : null);
} catch {
setSession(null);
} finally {
setChecking(false);
}
}
async function logout(callServer: boolean): Promise<void> {
if (callServer) {
try { await requestJson("/logout", { method: "POST" }); } catch { /* ignore logout network errors */ }
}
setSession(null);
}
useEffect(() => { loadSession(); }, []);
if (checking) return h("main", { className: "loading-screen" }, h("div", { className: "brand-mark" }, "UD"), h("span", null, "加载会话"));
if (!session) return h(LoginScreen, { onLogin: setSession });
return h(NotificationProvider, null, h(Shell, { session, onLogout: logout }));
}
const rootElement = document.getElementById("root");
if (rootElement === null) throw new Error("root element not found");
createRoot(rootElement).render(h(App));