2477 lines
132 KiB
TypeScript
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));
|