feat: add resource monitoring and provider upgrade

This commit is contained in:
Codex
2026-05-04 13:04:39 +00:00
parent 8726611b6f
commit 2a03f6343c
27 changed files with 1807 additions and 130 deletions
+1 -2
View File
@@ -5,10 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UniDesk Control Plane</title>
<link rel="stylesheet" href="/style.css" />
<script>window.UNIDESK_CONFIG = __UNIDESK_CONFIG__;</script>
</head>
<body>
<div id="root"></div>
<div id="root" data-config="__UNIDESK_CONFIG__"></div>
<script src="/vendor/react.production.min.js" defer></script>
<script src="/vendor/react-dom.production.min.js" defer></script>
<script src="/app.js" defer></script>
+277 -3
View File
@@ -302,6 +302,279 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
.status-badge.running, .status-badge.dispatched, .status-badge.accepted, .status-badge.internal { color: var(--accent-2); border-color: rgba(78, 183, 168, 0.45); }
.status-badge.queued, .status-badge.warn { color: var(--warn); border-color: rgba(215, 161, 58, 0.45); }
.docker-page {
display: grid;
gap: 10px;
}
.docker-node-strip {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 8px;
}
.docker-node-tile {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 8px;
align-items: center;
padding: 8px;
border: 1px solid var(--line);
color: var(--muted);
background: rgba(12, 18, 24, 0.62);
text-align: left;
}
.docker-node-tile.active, .docker-node-tile:hover {
color: var(--text);
border-color: var(--accent-2);
background: rgba(78, 183, 168, 0.1);
}
.docker-node-tile code, .docker-node-tile span:last-child {
grid-column: 2;
color: var(--muted);
}
.docker-layout {
display: grid;
grid-template-columns: minmax(620px, 1.65fr) minmax(280px, 0.75fr);
gap: 10px;
align-items: start;
}
.docker-hero {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px;
margin-bottom: 8px;
border: 1px solid var(--line-soft);
background:
linear-gradient(90deg, rgba(78, 183, 168, 0.12), transparent 42%),
var(--panel-3);
}
.docker-hero h3 {
margin: 0;
font-size: 20px;
letter-spacing: 0.04em;
}
.docker-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 7px;
color: var(--muted);
}
.docker-meta span {
padding: 2px 6px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.03);
}
.docker-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
}
.docker-section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
height: 34px;
color: var(--muted);
}
.docker-section-head h3 {
margin: 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text);
}
.docker-container-table {
max-height: calc(100vh - 348px);
}
.docker-side-stack {
display: grid;
gap: 10px;
}
.docker-side-panel .panel-body {
padding: 8px;
}
.docker-side-list {
display: grid;
gap: 6px;
max-height: 230px;
overflow: auto;
}
.docker-side-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 8px;
padding: 7px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.docker-side-row strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docker-side-row span {
color: var(--muted);
}
.docker-side-row code {
grid-column: 1 / -1;
color: #bcd2d7;
overflow: hidden;
text-overflow: ellipsis;
}
.monitor-page {
display: grid;
gap: 10px;
}
.monitor-layout {
display: grid;
grid-template-columns: minmax(680px, 1.6fr) minmax(310px, 0.75fr);
gap: 10px;
align-items: start;
}
.monitor-hero {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px;
margin-bottom: 8px;
border: 1px solid var(--line-soft);
background:
linear-gradient(90deg, rgba(215, 161, 58, 0.12), transparent 40%),
linear-gradient(180deg, rgba(78, 183, 168, 0.08), transparent),
var(--panel-3);
}
.monitor-hero h3 {
margin: 0;
font-size: 20px;
letter-spacing: 0.04em;
}
.monitor-chart-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.metric-chart {
min-width: 0;
padding: 8px;
border: 1px solid var(--line-soft);
background: #0d161d;
}
.metric-chart-head, .metric-chart-foot {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.metric-chart-head span {
display: block;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.metric-chart-head strong {
font-size: 24px;
letter-spacing: 0.03em;
}
.metric-chart-head code {
color: var(--faint);
}
.metric-chart svg {
width: 100%;
height: 116px;
margin: 8px 0;
border: 1px solid var(--line-soft);
background:
linear-gradient(180deg, transparent 48%, rgba(255,255,255,0.05) 49%, transparent 50%),
repeating-linear-gradient(90deg, rgba(255,255,255,0.04) 0, rgba(255,255,255,0.04) 1px, transparent 1px, transparent 12px),
#091015;
}
.metric-chart polygon {
fill: rgba(78, 183, 168, 0.16);
}
.metric-chart polyline {
fill: none;
stroke: var(--accent-2);
stroke-width: 1.8;
vector-effect: non-scaling-stroke;
}
.metric-chart line {
stroke: rgba(255,255,255,0.12);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.metric-chart.cpu polyline { stroke: var(--accent); }
.metric-chart.cpu polygon { fill: rgba(215, 161, 58, 0.16); }
.metric-chart.disk polyline { stroke: #9db7ff; }
.metric-chart.disk polygon { fill: rgba(157, 183, 255, 0.15); }
.metric-chart-foot {
color: var(--muted);
font-size: 11px;
}
.monitor-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.monitor-side-stack {
display: grid;
gap: 10px;
}
.monitor-note-list, .upgrade-control {
display: grid;
gap: 8px;
}
.monitor-note-list article {
display: grid;
gap: 3px;
padding: 7px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.monitor-note-list span, .upgrade-control p {
margin: 0;
color: var(--muted);
}
.upgrade-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.upgrade-result {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 8px;
align-items: center;
padding: 7px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.upgrade-result code {
grid-column: 1 / -1;
color: #bcd2d7;
}
.chip-row, .summary-grid {
display: flex;
flex-wrap: wrap;
@@ -492,10 +765,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
}
@media (max-width: 1120px) {
.metric-grid, .policy-grid, .security-board { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dispatch-form { grid-template-columns: 1fr 1fr; }
.dispatch-actions { align-items: center; }
.page-grid { grid-template-columns: 1fr; }
.page-grid, .docker-layout, .monitor-layout { grid-template-columns: 1fr; }
.overview-grid .panel:nth-child(3), .dispatch-grid .panel:first-child, .topology-grid .panel:nth-child(3) { grid-column: 1; }
}
@@ -517,7 +790,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.workspace { padding: 10px; }
.topbar { align-items: flex-start; flex-direction: column; }
.status-strip { flex-wrap: wrap; white-space: normal; }
.metric-grid, .policy-grid, .security-board, .dispatch-form { grid-template-columns: 1fr; }
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: 1fr; }
.compact-row, .heartbeat-row, .log-row, .endpoint-list article { grid-template-columns: 1fr; align-items: start; }
.docker-hero, .monitor-hero { flex-direction: column; }
.tab { min-width: 104px; }
}
@@ -1,7 +1,35 @@
const cfg = window.UNIDESK_CONFIG || { apiBaseUrl: "/api", authUsername: "admin" };
export {};
declare const React: {
createElement: (...args: any[]) => any;
useEffect: (...args: any[]) => any;
useMemo: (...args: any[]) => any;
useState: (...args: any[]) => any;
};
declare const ReactDOM: { createRoot: (element: Element | null) => { render: (node: any) => void } };
type AnyRecord = Record<string, any>;
type ReactNode = any;
function readClientConfig(): AnyRecord {
const raw = document.getElementById("root")?.getAttribute("data-config");
if (!raw) return { apiBaseUrl: "/api", authUsername: "admin" };
try {
const parsed = JSON.parse(raw) as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as AnyRecord : {};
} catch {
return { apiBaseUrl: "/api", authUsername: "admin" };
}
}
const cfg: AnyRecord = readClientConfig();
const h = React.createElement;
const { useEffect, useMemo, useState } = React;
function errorMessage(error: unknown, fallback = "操作失败"): string {
return error instanceof Error ? error.message : String(error || fallback);
}
const MODULES = [
{ id: "ops", label: "运行总览", code: "OPS", tabs: [
{ id: "status", label: "态势总览" },
@@ -10,6 +38,8 @@ const MODULES = [
] },
{ id: "nodes", label: "资源节点", code: "NODE", tabs: [
{ id: "list", label: "节点清单" },
{ id: "monitor", label: "资源监控" },
{ id: "docker", label: "Docker 状态" },
{ id: "labels", label: "资源标签" },
{ id: "heartbeats", label: "心跳状态" },
] },
@@ -25,25 +55,48 @@ const MODULES = [
] },
];
function fmtDate(value) {
function fmtDate(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function fmtClock(value) {
function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function fmtDuration(seconds) {
function fmtDuration(seconds: number): string {
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) {
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 asNumber(value: any, fallback = 0): number {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function summarizeValue(value: any): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "是" : "否";
if (typeof value === "number") return String(value);
@@ -53,16 +106,16 @@ function summarizeValue(value) {
return String(value);
}
function objectEntries(value) {
function objectEntries(value: any): Array<[string, any]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
return Object.entries(value);
}
function safeId(value) {
function safeId(value: any): string {
return String(value).replace(/[^a-zA-Z0-9_-]/g, "_");
}
async function requestJson(path, options = {}) {
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
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 });
@@ -76,18 +129,18 @@ async function requestJson(path, options = {}) {
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;
(error as Error & { status?: number }).status = response.status;
throw error;
}
return body;
}
function StatusBadge({ status, children }) {
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 }) {
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
@@ -95,7 +148,7 @@ function MetricCard({ label, value, hint, tone }) {
);
}
function Panel({ title, eyebrow, actions, children, className }) {
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
@@ -108,7 +161,7 @@ function Panel({ title, eyebrow, actions, children, className }) {
);
}
function RawButton({ title, data, onOpen, testId }) {
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
@@ -117,7 +170,7 @@ function RawButton({ title, data, onOpen, testId }) {
}, "查看原始JSON");
}
function RawDialog({ raw, onClose }) {
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 },
@@ -130,7 +183,7 @@ function RawDialog({ raw, onClose }) {
);
}
function LabelChips({ labels, limit = 8 }) {
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" },
@@ -141,7 +194,7 @@ function LabelChips({ labels, limit = 8 }) {
);
}
function DataSummary({ data, empty = "无数据" }) {
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} 项列表`);
@@ -152,17 +205,17 @@ function DataSummary({ data, empty = "无数据" }) {
));
}
function EmptyState({ title, text }) {
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function LoginScreen({ onLogin }) {
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) {
async function submit(event: any) {
event.preventDefault();
setBusy(true);
setError("");
@@ -170,7 +223,7 @@ function LoginScreen({ onLogin }) {
const session = await requestJson("/login", { method: "POST", body: JSON.stringify({ username, password }) });
onLogin(session);
} catch (err) {
setError(err.message || "登录失败");
setError(errorMessage(err, "登录失败"));
} finally {
setBusy(false);
}
@@ -180,8 +233,8 @@ function LoginScreen({ onLogin }) {
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) })),
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) })),
error ? h("div", { className: "form-error" }, error) : null,
h("button", { type: "submit", disabled: busy }, busy ? "登录中" : "登录"),
),
@@ -190,7 +243,7 @@ function LoginScreen({ onLogin }) {
);
}
function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }) {
function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }: AnyRecord) {
return h("header", { className: "topbar" },
h("div", null, h("p", { className: "eyebrow" }, "Distributed Work Platform"), h("h1", null, "UniDesk 控制平面")),
h("div", { className: "status-strip" },
@@ -205,10 +258,10 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }
);
}
function Sidebar({ activeModule, onChange }) {
function Sidebar({ activeModule, onChange }: AnyRecord) {
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", {
MODULES.map((module: any) => h("button", {
key: module.id,
type: "button",
className: `module ${activeModule === module.id ? "active" : ""}`,
@@ -217,9 +270,9 @@ function Sidebar({ activeModule, onChange }) {
);
}
function TabBar({ module, activeTab, onChange }) {
function TabBar({ module, activeTab, onChange }: AnyRecord) {
return h("nav", { className: "tabs", "aria-label": `${module.label} 子功能` },
module.tabs.map((tab) => h("button", {
module.tabs.map((tab: any) => h("button", {
key: tab.id,
type: "button",
className: `tab ${activeTab === tab.id ? "active" : ""}`,
@@ -228,9 +281,9 @@ function TabBar({ module, activeTab, onChange }) {
);
}
function OverviewPage({ data, onRaw }) {
function OverviewPage({ data, onRaw }: AnyRecord) {
const overview = data.overview || {};
const onlineNodes = data.nodes.filter((node) => node.status === "online");
const onlineNodes = data.nodes.filter((node: any) => node.status === "online");
const recentTasks = data.tasks.slice(0, 5);
return h("div", { className: "page-grid overview-grid" },
h(Panel, { title: "核心指标", eyebrow: "Control" },
@@ -243,16 +296,16 @@ function OverviewPage({ data, onRaw }) {
),
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("div", { className: "node-card-list" }, onlineNodes.slice(0, 4).map((node: any) => 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 }))),
h("div", { className: "compact-list" }, recentTasks.map((task: any) => h(TaskCompactRow, { key: task.id, task, onRaw }))),
),
);
}
function NodeCard({ node, 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)),
@@ -266,12 +319,12 @@ function NodeCard({ node, onRaw }) {
);
}
function EventsPage({ events, onRaw }) {
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) => h("tr", { key: event.id },
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)),
@@ -283,10 +336,10 @@ function EventsPage({ events, onRaw }) {
);
}
function LogsPage({ logs, 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, index) => h("article", { key: index, className: `log-row ${log.level || "info"}` },
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"),
@@ -296,12 +349,12 @@ function LogsPage({ logs, onRaw }) {
);
}
function NodeListPage({ nodes, 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", 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("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(LabelChips, { labels: node.labels, limit: 5 })),
@@ -313,7 +366,7 @@ function NodeListPage({ nodes, onRaw }) {
);
}
function LabelsPage({ nodes }) {
function LabelsPage({ nodes }: AnyRecord) {
const labels = useMemo(() => {
const rows = [];
for (const node of nodes) {
@@ -323,7 +376,7 @@ function LabelsPage({ nodes }) {
}, [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("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),
@@ -331,10 +384,10 @@ function LabelsPage({ nodes }) {
);
}
function HeartbeatPage({ nodes }) {
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) => h("article", { key: node.providerId, className: "heartbeat-row" },
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))),
@@ -343,8 +396,267 @@ function HeartbeatPage({ nodes }) {
);
}
function DispatchPage({ nodes, onDispatched, onRaw }) {
const onlineNodes = nodes.filter((node) => node.status === "online");
function NodeMonitorPage({ nodes, systemStatuses, 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, 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 = history.length > 0 ? history : current ? [{
at: current.collectedAt,
cpuPercent: asNumber(cpu.percent),
memoryPercent: asNumber(memory.percent),
diskPercent: asNumber(disk.percent),
}] : [];
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)}` : "等待指标"),
)),
),
h("div", { className: "monitor-layout" },
h(Panel, {
title: "任务管理器视图",
eyebrow: active.name,
className: "monitor-main-panel",
actions: current ? h(RawButton, { title: `System ${active.providerId}`, data: { current, history }, onOpen: onRaw }) : null,
},
!current ? h(EmptyState, { title: "系统指标未上报", text: "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 ${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)} used / ${fmtBytes(memory.availableBytes)} free`, 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(active.systemUpdatedAt || current.collectedAt), hint: active.providerId }),
),
),
),
h("div", { className: "monitor-side-stack" },
h(UpgradeControl, { provider: active, refresh, onRaw }),
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 与 MemAvailable 计算已用比例")),
h("article", null, h("b", null, "Disk"), h("span", null, "使用 df -PB1 对配置路径采样,默认监控根文件系统")),
),
),
),
),
);
}
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 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" },
h("div", { className: "upgrade-control", "data-testid": "provider-upgrade-control" },
h("p", null, "通过 UniDesk WebSocket 向当前计算节点下发 provider.upgrade;预检只生成升级计划,执行升级会调度节点本地 updater 容器。"),
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" ? "调度中" : "执行升级"),
),
error ? h("div", { className: "form-error" }, error) : null,
result ? h("div", { className: "upgrade-result" },
h(StatusBadge, { status: result.status || "queued" }, result.status || "queued"),
h("span", null, `${result.mode === "schedule" ? "执行升级" : "预检升级"} 已下发`),
h("code", null, result.taskId || "--"),
h(RawButton, { title: "Provider Upgrade Dispatch", data: result, onOpen: onRaw }),
) : h("span", { className: "muted" }, "升级任务结果会进入任务历史;执行升级可能导致 provider 短暂重连。"),
),
);
}
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 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 counts = status?.counts || {};
const daemon = status?.daemon || {};
const containers = status?.containers || [];
const images = status?.images || [];
const volumes = status?.volumes || [];
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: "local volumes" }),
h(MetricCard, { label: "Networks", value: counts.networks ?? networks.length, hint: daemon.driver ? `driver ${daemon.driver}` : "docker networks" }),
),
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("tbody", null, containers.length === 0 ? h("tr", null, h("td", { colSpan: 6 }, "暂无容器")) : 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.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, render: (volume: any) => h("article", { key: volume.name, className: "docker-side-row" }, h("strong", null, volume.name), h("span", null, volume.driver || "--"), h("code", null, 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 DockerSidePanel({ title, items, render }: AnyRecord) {
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" }, items.slice(0, 12).map(render)),
);
}
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");
@@ -360,16 +672,16 @@ function DispatchPage({ nodes, onDispatched, onRaw }) {
if (!providerId && (onlineNodes[0]?.providerId || nodes[0]?.providerId)) setProviderId(onlineNodes[0]?.providerId || nodes[0].providerId);
}, [nodes.length, onlineNodes.length, providerId]);
function structuredPayload() {
function structuredPayload(): AnyRecord {
return { source, note, priority };
}
function revealRawPayload() {
function revealRawPayload(): void {
setRawPayload(JSON.stringify(structuredPayload(), null, 2));
setRawOpen(true);
}
async function submit(event) {
async function submit(event: any) {
event.preventDefault();
setBusy(true);
setError("");
@@ -382,7 +694,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }) {
setResult(response);
await onDispatched();
} catch (err) {
setError(err.message || "下发失败");
setError(errorMessage(err, "下发失败"));
} finally {
setBusy(false);
}
@@ -391,16 +703,16 @@ function DispatchPage({ nodes, onDispatched, onRaw }) {
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, "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) => setCommand(event.target.value) },
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: "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("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"),
@@ -409,7 +721,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }) {
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,
rawOpen ? h("label", { className: "raw-editor-label" }, "高级 Payload", h("textarea", { className: "raw-editor", value: rawPayload, onChange: (event: any) => setRawPayload(event.target.value) })) : null,
error ? h("div", { className: "form-error wide" }, error) : null,
),
),
@@ -426,7 +738,7 @@ function DispatchPage({ nodes, onDispatched, onRaw }) {
);
}
function TaskCompactRow({ task, onRaw }) {
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)),
@@ -435,12 +747,12 @@ function TaskCompactRow({ task, onRaw }) {
);
}
function TaskHistoryPage({ tasks, onRaw }) {
function TaskHistoryPage({ tasks, onRaw }: AnyRecord) {
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("tbody", null, tasks.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)),
@@ -452,11 +764,11 @@ function TaskHistoryPage({ tasks, onRaw }) {
);
}
function TaskResultsPage({ tasks, onRaw }) {
const finished = tasks.filter((task) => ["succeeded", "failed"].includes(task.status));
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) => h("article", { key: task.id, className: "result-card" },
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: "无执行输出" }),
@@ -465,7 +777,7 @@ function TaskResultsPage({ tasks, onRaw }) {
);
}
function TopologyPage({ data }) {
function TopologyPage({ data }: AnyRecord) {
const overview = data.overview || {};
return h("div", { className: "page-grid topology-grid" },
h(Panel, { title: "公开入口", eyebrow: "Public" },
@@ -489,7 +801,7 @@ function TopologyPage({ data }) {
);
}
function AuthPage({ session }) {
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")),
@@ -511,11 +823,13 @@ function SecurityPage() {
);
}
function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }) {
function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }: AnyRecord) {
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 === "monitor") return h(NodeMonitorPage, { nodes: data.nodes, systemStatuses: data.systemStatuses, onRaw, refresh });
if (activeModule === "nodes" && activeTab === "docker") return h(DockerStatusPage, { nodes: data.nodes, dockerStatuses: data.dockerStatuses, 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 });
@@ -527,23 +841,25 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw }) {
return h(EmptyState, { title: "未找到页面", text: "请选择左侧主模块和顶部子功能标签" });
}
function Shell({ session, onLogout }) {
function Shell({ session, onLogout }: AnyRecord) {
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 [data, setData] = useState({ overview: null, nodes: [], systemStatuses: [], dockerStatuses: [], 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 module = MODULES.find((item: any) => item.id === activeModule) || MODULES[0];
const activeTab = activeTabs[activeModule] || module.tabs[0].id;
async function refresh() {
async function refresh(): Promise<void> {
try {
const [overview, nodes, events, tasks, logs] = await Promise.all([
const [overview, nodes, systemStatuses, dockerStatuses, events, tasks, logs] = await Promise.all([
requestJson(`${cfg.apiBaseUrl}/overview`),
requestJson(`${cfg.apiBaseUrl}/nodes`),
requestJson(`${cfg.apiBaseUrl}/nodes/system-status?limit=120`),
requestJson(`${cfg.apiBaseUrl}/nodes/docker-status`),
requestJson(`${cfg.apiBaseUrl}/events?limit=100`),
requestJson(`${cfg.apiBaseUrl}/tasks?limit=100`),
requestJson("/logs?limit=100"),
@@ -551,6 +867,8 @@ function Shell({ session, onLogout }) {
setData({
overview,
nodes: nodes.nodes || [],
systemStatuses: systemStatuses.systemStatuses || [],
dockerStatuses: dockerStatuses.dockerStatuses || [],
events: events.events || [],
tasks: tasks.tasks || [],
logs: logs.logs || [],
@@ -558,8 +876,8 @@ function Shell({ session, onLogout }) {
setConnection({ ok: true, text: "核心在线" });
setLastRefresh(new Date());
} catch (err) {
setConnection({ ok: false, text: err.message || "连接失败" });
if (err.status === 401) onLogout(false);
setConnection({ ok: false, text: errorMessage(err, "连接失败") });
if ((err as { status?: number }).status === 401) onLogout(false);
}
}
@@ -574,11 +892,11 @@ function Shell({ session, onLogout }) {
return () => clearInterval(timer);
}, []);
function setTab(tab) {
setActiveTabs((prev) => ({ ...prev, [activeModule]: tab }));
function setTab(tab: string): void {
setActiveTabs((prev: any) => ({ ...prev, [activeModule]: tab }));
}
function openRaw(title, rawData) {
function openRaw(title: string, rawData: any): void {
setRaw({ title, data: rawData });
}
@@ -597,7 +915,7 @@ function App() {
const [checking, setChecking] = useState(true);
const [session, setSession] = useState(null);
async function loadSession() {
async function loadSession(): Promise<void> {
setChecking(true);
try {
const current = await requestJson("/api/session");
@@ -609,7 +927,7 @@ function App() {
}
}
async function logout(callServer) {
async function logout(callServer: boolean): Promise<void> {
if (callServer) {
try { await requestJson("/logout", { method: "POST" }); } catch { /* ignore logout network errors */ }
}
+35 -7
View File
@@ -27,17 +27,34 @@ const config = readConfig();
const logger = createLogger("frontend", config.logFile);
const publicDir = join(import.meta.dir, "..", "public");
const vendorDir = join(import.meta.dir, "..", "node_modules");
const appBundle = await buildFrontendApp();
const clientConfig = JSON.stringify({
frontendPublicUrl: config.frontendPublicUrl,
providerIngressPublicUrl: config.providerIngressPublicUrl,
authUsername: config.authUsername,
sessionTtlSeconds: config.sessionTtlSeconds,
apiBaseUrl: "/api",
});
const indexHtml = readFileSync(join(publicDir, "index.html"), "utf8").replace(
"__UNIDESK_CONFIG__",
JSON.stringify({
frontendPublicUrl: config.frontendPublicUrl,
providerIngressPublicUrl: config.providerIngressPublicUrl,
authUsername: config.authUsername,
sessionTtlSeconds: config.sessionTtlSeconds,
apiBaseUrl: "/api",
}),
escapeHtmlAttribute(clientConfig),
);
async function buildFrontendApp(): Promise<string> {
const result = await Bun.build({
entrypoints: [join(import.meta.dir, "app.tsx")],
target: "browser",
format: "iife",
minify: false,
sourcemap: "none",
});
if (!result.success || result.outputs.length === 0) {
const messages = result.logs.map((item) => item.message).join("; ");
throw new Error(`frontend app.tsx build failed: ${messages || "no output"}`);
}
return result.outputs[0].text();
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
@@ -106,6 +123,14 @@ function jsonResponse(body: unknown, status = 200, extraHeaders?: HeadersInit):
});
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function signPayload(payload: string): string {
return createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
}
@@ -238,6 +263,9 @@ function vendorPath(pathname: string): string | null {
}
async function staticResponse(pathname: string): Promise<Response> {
if (pathname === "/app.js") {
return new Response(appBundle, { headers: { "content-type": "text/javascript; charset=utf-8" } });
}
const vendor = vendorPath(pathname);
const filePath = vendor ?? join(publicDir, pathname.replace(/^\/+/, ""));
const file = Bun.file(filePath);
+2 -1
View File
@@ -4,6 +4,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
@@ -13,5 +14,5 @@
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "src/**/*.tsx"]
}