Files
pikasTech-unidesk/src/components/frontend/public/app.js
T
2026-05-04 11:40:56 +00:00

627 lines
29 KiB
JavaScript

const cfg = window.UNIDESK_CONFIG || { apiBaseUrl: "/api", authUsername: "admin" };
const h = React.createElement;
const { useEffect, useMemo, useState } = React;
const MODULES = [
{ id: "ops", label: "运行总览", code: "OPS", tabs: [
{ id: "status", label: "态势总览" },
{ id: "events", label: "事件摘要" },
{ id: "logs", label: "服务日志" },
] },
{ id: "nodes", label: "资源节点", code: "NODE", tabs: [
{ id: "list", label: "节点清单" },
{ id: "labels", label: "资源标签" },
{ id: "heartbeats", label: "心跳状态" },
] },
{ id: "tasks", label: "任务调度", code: "TASK", tabs: [
{ id: "dispatch", label: "下发任务" },
{ id: "history", label: "任务历史" },
{ id: "results", label: "执行结果" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
{ id: "topology", label: "连接拓扑" },
{ id: "auth", label: "认证策略" },
{ id: "security", label: "安全边界" },
] },
];
function fmtDate(value) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function fmtClock(value) {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function fmtDuration(seconds) {
if (!Number.isFinite(seconds)) return "--";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function summarizeValue(value) {
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 objectEntries(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value);
}
function safeId(value) {
return String(value).replace(/[^a-zA-Z0-9_-]/g, "_");
}
async function requestJson(path, options = {}) {
const headers = new Headers(options.headers || {});
if (options.body && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { text };
}
if (!response.ok || body?.ok === false) {
const message = body?.error?.message || body?.error || `HTTP ${response.status}`;
const error = new Error(message);
error.status = response.status;
throw error;
}
return body;
}
function StatusBadge({ status, children }) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }) {
return h("article", { className: `metric-card ${tone || ""}` },
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 }) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h("h2", null, title),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function RawDialog({ raw, onClose }) {
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 }) {
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 DataSummary({ data, empty = "无数据" }) {
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, summarizeValue(value))),
));
}
function EmptyState({ title, text }) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function LoginScreen({ onLogin }) {
const [username, setUsername] = useState(cfg.authUsername || "admin");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event) {
event.preventDefault();
setBusy(true);
setError("");
try {
const session = await requestJson("/login", { method: "POST", body: JSON.stringify({ username, password }) });
onLogin(session);
} catch (err) {
setError(err.message || "登录失败");
} 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) => setUsername(event.target.value) })),
h("label", null, "密码", h("input", { name: "password", type: "password", autoComplete: "current-password", value: password, onChange: (event) => setPassword(event.target.value) })),
error ? h("div", { className: "form-error" }, error) : null,
h("button", { type: "submit", disabled: busy }, busy ? "登录中" : "登录"),
),
h("div", { className: "login-note" }, "默认账号由 config.json 注入;公网入口只暴露前端登录面。"),
),
);
}
function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }) {
return h("header", { className: "topbar" },
h("div", null, h("p", { className: "eyebrow" }, "Distributed Work Platform"), h("h1", null, "UniDesk 控制平面")),
h("div", { className: "status-strip" },
h("span", { className: `dot ${connection.ok ? "ok" : "fail"}` }),
h("span", { "data-testid": "conn-text" }, connection.text),
h("span", null, lastRefresh ? `刷新 ${fmtClock(lastRefresh)}` : "未刷新"),
h("span", null, fmtClock(clock)),
h("span", { className: "user-pill" }, session?.user?.username || "--"),
h("button", { type: "button", className: "ghost-btn", onClick: onRefresh }, "刷新"),
h("button", { type: "button", className: "ghost-btn danger", onClick: onLogout }, "退出"),
),
);
}
function Sidebar({ activeModule, onChange }) {
return h("aside", { className: "rail", "aria-label": "主模块" },
h("div", { className: "brand" }, h("span", { className: "brand-mark" }, "UD"), h("span", { className: "brand-text" }, "UniDesk")),
MODULES.map((module) => h("button", {
key: module.id,
type: "button",
className: `module ${activeModule === module.id ? "active" : ""}`,
onClick: () => onChange(module.id),
}, h("span", { className: "module-code" }, module.code), h("span", null, module.label))),
);
}
function TabBar({ module, activeTab, onChange }) {
return h("nav", { className: "tabs", "aria-label": `${module.label} 子功能` },
module.tabs.map((tab) => h("button", {
key: tab.id,
type: "button",
className: `tab ${activeTab === tab.id ? "active" : ""}`,
onClick: () => onChange(tab.id),
}, tab.label)),
);
}
function OverviewPage({ data, onRaw }) {
const overview = data.overview || {};
const onlineNodes = data.nodes.filter((node) => node.status === "online");
const recentTasks = data.tasks.slice(0, 5);
return h("div", { className: "page-grid overview-grid" },
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: "在线节点", 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: overview.pendingTaskCount ?? 0, hint: `uptime ${fmtDuration(overview.uptimeSeconds ?? 0)}` }),
),
),
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) => h(NodeCard, { key: node.providerId, node, onRaw }))),
),
h(Panel, { title: "最近任务", eyebrow: "Dispatch" },
recentTasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "可以在任务调度模块发起 docker.ps 或 echo" }) :
h("div", { className: "compact-list" }, recentTasks.map((task) => h(TaskCompactRow, { key: task.id, task, onRaw }))),
),
);
}
function NodeCard({ node, onRaw }) {
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(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 }) {
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) => 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 }) {
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, index) => 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 }) {
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", null,
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("tbody", null, nodes.map((node) => 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(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 }) {
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) => 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 }) {
return h(Panel, { title: "心跳状态", eyebrow: "Provider Liveness" },
nodes.length === 0 ? h(EmptyState, { title: "无心跳", text: "等待 provider 注册和 heartbeat" }) :
h("div", { className: "heartbeat-list" }, nodes.map((node) => 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 DispatchPage({ nodes, onDispatched, onRaw }) {
const onlineNodes = nodes.filter((node) => 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() {
return { source, note, priority };
}
function revealRawPayload() {
setRawPayload(JSON.stringify(structuredPayload(), null, 2));
setRawOpen(true);
}
async function submit(event) {
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(err.message || "下发失败");
} 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) => setProviderId(event.target.value) },
nodes.map((node) => h("option", { key: node.providerId, value: node.providerId }, `${node.name} / ${node.providerId}`)),
)),
h("label", null, "Command", h("select", { value: command, onChange: (event) => setCommand(event.target.value) },
h("option", { value: "docker.ps" }, "docker.ps"),
h("option", { value: "echo" }, "echo"),
)),
h("label", null, "来源", h("input", { value: source, onChange: (event) => setSource(event.target.value) })),
h("label", null, "备注", h("input", { value: note, onChange: (event) => setNote(event.target.value) })),
h("label", null, "优先级", h("select", { value: priority, onChange: (event) => 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) => setRawPayload(event.target.value) })) : null,
error ? h("div", { className: "form-error wide" }, error) : null,
),
),
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 }) {
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, fmtDate(task.updatedAt)),
h(RawButton, { title: `Task ${task.id}`, data: task, onOpen: onRaw }),
);
}
function TaskHistoryPage({ tasks, onRaw }) {
return h(Panel, { title: "任务历史", eyebrow: `${tasks.length} Tasks` },
tasks.length === 0 ? h(EmptyState, { title: "暂无任务", text: "下发任务后会在这里看到生命周期" }) :
h("div", { className: "table-wrap" }, 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, tasks.map((task) => 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, h(DataSummary, { data: task.payload })),
h("td", null, fmtDate(task.updatedAt)),
h("td", null, h(RawButton, { title: `Task ${task.id}`, data: task, onOpen: onRaw })),
))),
)),
);
}
function TaskResultsPage({ tasks, onRaw }) {
const finished = tasks.filter((task) => ["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) => 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: task, onOpen: onRaw }),
))),
);
}
function TopologyPage({ data }) {
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 }) {
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 }) {
if (activeModule === "ops" && activeTab === "status") return h(OverviewPage, { data, 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 === "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 === "history") return h(TaskHistoryPage, { tasks: data.tasks, onRaw });
if (activeModule === "tasks" && activeTab === "results") return h(TaskResultsPage, { tasks: data.tasks, onRaw });
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 }) {
const [activeModule, setActiveModule] = useState("ops");
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", config: "topology" });
const [data, setData] = useState({ overview: null, nodes: [], events: [], tasks: [], 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 module = MODULES.find((item) => item.id === activeModule) || MODULES[0];
const activeTab = activeTabs[activeModule] || module.tabs[0].id;
async function refresh() {
try {
const [overview, nodes, events, tasks, logs] = await Promise.all([
requestJson(`${cfg.apiBaseUrl}/overview`),
requestJson(`${cfg.apiBaseUrl}/nodes`),
requestJson(`${cfg.apiBaseUrl}/events?limit=100`),
requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`),
requestJson("/logs?limit=100"),
]);
setData({
overview,
nodes: nodes.nodes || [],
events: events.events || [],
tasks: tasks.tasks || [],
logs: logs.logs || [],
});
setConnection({ ok: true, text: "核心在线" });
setLastRefresh(new Date());
} catch (err) {
setConnection({ ok: false, text: err.message || "连接失败" });
if (err.status === 401) onLogout(false);
}
}
useEffect(() => {
refresh();
const timer = setInterval(refresh, 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const timer = setInterval(() => setClock(new Date()), 1000);
return () => clearInterval(timer);
}, []);
function setTab(tab) {
setActiveTabs((prev) => ({ ...prev, [activeModule]: tab }));
}
function openRaw(title, rawData) {
setRaw({ title, data: rawData });
}
return h("div", { className: "shell", "data-testid": "app-shell" },
h(Sidebar, { activeModule, onChange: setActiveModule }),
h("main", { className: "workspace" },
h(TopBar, { connection, lastRefresh, onRefresh: refresh, onLogout: () => onLogout(true), session, clock }),
h(TabBar, { module, activeTab, onChange: setTab }),
h(WorkArea, { activeModule, activeTab, data, session, refresh, onRaw: openRaw }),
),
h(RawDialog, { raw, onClose: () => setRaw(null) }),
);
}
function App() {
const [checking, setChecking] = useState(true);
const [session, setSession] = useState(null);
async function loadSession() {
setChecking(true);
try {
const current = await requestJson("/api/session");
setSession(current.authenticated ? current : null);
} catch {
setSession(null);
} finally {
setChecking(false);
}
}
async function logout(callServer) {
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(Shell, { session, onLogout: logout });
}
ReactDOM.createRoot(document.getElementById("root")).render(h(App));