feat: improve frontend routing and pipeline gantt defaults

This commit is contained in:
Codex
2026-05-07 07:16:44 +00:00
parent 0aa5c1e021
commit 5945c720a6
8 changed files with 2958 additions and 512 deletions
+46 -52
View File
@@ -2,6 +2,7 @@ import React from "react";
import { createRoot } from "react-dom/client";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
import { PipelinePage } from "./pipeline";
import { TodoNotePage } from "./todo-note";
@@ -22,45 +23,12 @@ const cfg: AnyRecord = readClientConfig();
const h = React.createElement;
const { useEffect, useMemo } = React;
const useState: any = React.useState;
const ROUTE_REGISTRY = createRouteRegistry(MODULES);
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: "态势总览" },
{ id: "events", label: "事件摘要" },
{ id: "logs", label: "服务日志" },
] },
{ id: "nodes", label: "资源节点", code: "NODE", tabs: [
{ id: "list", label: "节点清单" },
{ id: "monitor", label: "资源监控" },
{ id: "docker", label: "Docker 状态" },
{ id: "gateway", label: "网关版本" },
{ id: "labels", label: "资源标签" },
{ id: "heartbeats", label: "心跳状态" },
] },
{ id: "tasks", label: "任务调度", code: "TASK", tabs: [
{ id: "dispatch", label: "下发任务" },
{ id: "pending", label: "待处理任务" },
{ id: "history", label: "任务历史" },
{ id: "results", label: "执行结果" },
] },
{ id: "apps", label: "微服务", code: "APP", tabs: [
{ id: "catalog", label: "服务目录" },
{ id: "todo-note", label: "Todo Note" },
{ id: "findjob", label: "FindJob" },
{ id: "pipeline", label: "Pipeline" },
{ id: "met-nonlinear", label: "MET Nonlinear" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
{ id: "topology", label: "连接拓扑" },
{ id: "auth", label: "认证策略" },
{ id: "security", label: "安全边界" },
] },
];
function fmtDate(value: any): string {
if (!value) return "--";
const date = new Date(value);
@@ -496,7 +464,7 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock }
);
}
function Sidebar({ activeModule, onChange, collapsed, onToggle }: AnyRecord) {
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"),
@@ -507,19 +475,21 @@ function Sidebar({ activeModule, onChange, collapsed, onToggle }: AnyRecord) {
key: module.id,
type: "button",
className: `module ${activeModule === module.id ? "active" : ""}`,
onClick: () => onChange(module.id),
onClick: () => onNavigate(module.id, activeTabs[module.id] || DEFAULT_ACTIVE_TABS[module.id] || module.tabs[0]?.id || ""),
title: module.label,
"data-route": pathForTarget(ROUTE_REGISTRY, module.id, activeTabs[module.id] || DEFAULT_ACTIVE_TABS[module.id] || module.tabs[0]?.id || ""),
}, h("span", { className: "module-code" }, module.code), h("span", null, module.label))),
);
}
function TabBar({ module, activeTab, onChange }: AnyRecord) {
function TabBar({ module, activeTab, onNavigate }: AnyRecord) {
return h("nav", { className: "tabs", "aria-label": `${module.label} 子功能` },
module.tabs.map((tab: any) => h("button", {
key: tab.id,
type: "button",
className: `tab ${activeTab === tab.id ? "active" : ""}`,
onClick: () => onChange(tab.id),
onClick: () => onNavigate(module.id, tab.id),
"data-route": pathForTarget(ROUTE_REGISTRY, module.id, tab.id),
}, tab.label)),
);
}
@@ -531,7 +501,7 @@ function OverviewPage({ data, onRaw, onNavigate }: AnyRecord) {
const pendingCount = overview.pendingTaskCount ?? pendingTasks.length;
const recentTasks = data.tasks.slice(0, 5);
const pgdata = overview.pgdata || {};
return h("div", { className: "page-grid overview-grid" },
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" }),
@@ -1518,8 +1488,9 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
}
function Shell({ session, onLogout }: AnyRecord) {
const [activeModule, setActiveModule] = useState("ops");
const [activeTabs, setActiveTabs] = useState({ ops: "status", nodes: "list", tasks: "dispatch", apps: "catalog", config: "topology" });
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: [], logs: [] });
const [connection, setConnection] = useState({ ok: false, text: "连接中" });
const [lastRefresh, setLastRefresh] = useState(null);
@@ -1527,8 +1498,8 @@ function Shell({ session, onLogout }: AnyRecord) {
const [raw, setRaw] = useState(null);
const [railCollapsed, setRailCollapsed] = useState(false);
const module = MODULES.find((item: any) => item.id === activeModule) || MODULES[0];
const activeTab = activeTabs[activeModule] || module.tabs[0].id;
const module = ROUTE_REGISTRY.moduleById[activeModule] || ROUTE_REGISTRY.modules[0];
const activeTab = activeTabs[activeModule] || DEFAULT_ACTIVE_TABS[activeModule] || module.tabs[0].id;
async function refresh(): Promise<void> {
try {
@@ -1573,17 +1544,40 @@ function Shell({ session, onLogout }: AnyRecord) {
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 setTab(tab: string): void {
setActiveTabs((prev: any) => ({ ...prev, [activeModule]: tab }));
}
function navigate(moduleId: string, tabId: string): void {
setActiveModule(moduleId);
setActiveTabs((prev: any) => ({ ...prev, [moduleId]: tabId }));
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 {
@@ -1591,10 +1585,10 @@ function Shell({ session, onLogout }: AnyRecord) {
}
return h("div", { className: `shell ${railCollapsed ? "rail-collapsed" : ""}`, "data-testid": "app-shell" },
h(Sidebar, { activeModule, onChange: setActiveModule, collapsed: railCollapsed, onToggle: () => setRailCollapsed((value: boolean) => !value) }),
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 }),
h(TabBar, { module, activeTab, onChange: setTab }),
h(TabBar, { module, activeTab, onNavigate: navigate }),
h(WorkArea, { activeModule, activeTab, data, session, refresh, onRaw: openRaw, onNavigate: navigate }),
),
h(RawDialog, { raw, onClose: () => setRaw(null) }),
+15 -1
View File
@@ -254,7 +254,14 @@ async function proxyApi(req: Request, url: URL): Promise<Response> {
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = await req.arrayBuffer();
}
const upstream = await fetch(upstreamUrl, init);
let upstream: Response;
try {
upstream = await fetch(upstreamUrl, init);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger("warn", "proxy_upstream_failed", { path: url.pathname, upstreamUrl: upstreamUrl.toString(), error: message });
return jsonResponse({ ok: false, error: { message: "upstream proxy failed", detail: message } }, 502);
}
const responseHeaders = new Headers();
const upstreamContentType = upstream.headers.get("content-type");
if (upstreamContentType !== null) responseHeaders.set("content-type", upstreamContentType);
@@ -281,6 +288,10 @@ async function staticResponse(pathname: string): Promise<Response> {
return new Response(file, { headers: { "content-type": contentType(pathname) } });
}
function isStaticAssetPath(pathname: string): boolean {
return /\/[^/]+\.[a-z0-9]+$/iu.test(pathname);
}
const server = Bun.serve({
port: config.port,
hostname: "0.0.0.0",
@@ -302,6 +313,9 @@ const server = Bun.serve({
if (safePath.includes("..") || safePath.includes("\0")) {
return jsonResponse({ ok: false, error: "invalid path" }, 400);
}
if (!isStaticAssetPath(url.pathname)) {
return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } });
}
return staticResponse(url.pathname);
} catch (error) {
logger("error", "request_failed", { path: url.pathname, error: error instanceof Error ? error.message : String(error) });
+206
View File
@@ -0,0 +1,206 @@
export interface UniDeskTabDefinition {
id: string;
label: string;
routeSegment?: string;
}
export interface UniDeskModuleDefinition {
id: string;
label: string;
code: string;
routeSegment?: string;
tabs: UniDeskTabDefinition[];
}
export interface UniDeskRouteTarget {
moduleId: string;
tabId: string;
}
export interface UniDeskRoutedTabDefinition extends UniDeskTabDefinition {
routeSegment: string;
canonicalPath: string;
aliases: string[];
}
export interface UniDeskRoutedModuleDefinition extends UniDeskModuleDefinition {
routeSegment: string;
canonicalPath: string;
tabs: UniDeskRoutedTabDefinition[];
}
export interface UniDeskRouteRegistry {
modules: UniDeskRoutedModuleDefinition[];
moduleById: Record<string, UniDeskRoutedModuleDefinition>;
defaultActiveTabs: Record<string, string>;
routeMap: Record<string, UniDeskRouteTarget>;
canonicalPathByTarget: Record<string, string>;
fallbackTarget: UniDeskRouteTarget;
}
export const MODULES: UniDeskModuleDefinition[] = [
{ 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: "monitor", label: "资源监控" },
{ id: "docker", label: "Docker 状态" },
{ id: "gateway", label: "网关版本" },
{ id: "labels", label: "资源标签" },
{ id: "heartbeats", label: "心跳状态" },
] },
{ id: "tasks", label: "任务调度", code: "TASK", tabs: [
{ id: "dispatch", label: "下发任务" },
{ id: "pending", label: "待处理任务" },
{ id: "history", label: "任务历史" },
{ id: "results", label: "执行结果" },
] },
{ id: "apps", label: "微服务", code: "APP", routeSegment: "app", tabs: [
{ id: "catalog", label: "服务目录" },
{ id: "todo-note", label: "Todo Note" },
{ id: "findjob", label: "FindJob" },
{ id: "pipeline", label: "Pipeline" },
{ id: "met-nonlinear", label: "MET Nonlinear" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
{ id: "topology", label: "连接拓扑" },
{ id: "auth", label: "认证策略" },
{ id: "security", label: "安全边界" },
] },
];
export const DEFAULT_ACTIVE_TABS: Record<string, string> = Object.fromEntries(
MODULES.map((module) => [module.id, module.tabs[0]?.id ?? ""]),
);
function normalizeDecodedSegment(segment: string): string {
const trimmed = String(segment || "").trim();
if (!trimmed) return "";
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
function normalizeRoutePath(pathname: string): string {
const raw = String(pathname || "/");
const [pathOnly] = raw.split(/[?#]/u, 1);
if (pathOnly === "/") return "/";
const segments = pathOnly.split("/").map(normalizeDecodedSegment).filter(Boolean);
const joined = `/${segments.join("/")}`;
return joined.endsWith("/") ? joined : `${joined}/`;
}
function hashText(value: string): string {
let hash = 2166136261;
for (const char of value) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash >>> 0).toString(36);
}
function asciiSlug(value: string): string {
return String(value || "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
}
function unicodeSlug(value: string): string {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[\s/\\?#%]+/gu, "-")
.replace(/-+/gu, "-")
.replace(/^-+|-+$/gu, "");
}
function routeSegmentFor(definition: { id?: string; label?: string; routeSegment?: string }): string {
const explicit = asciiSlug(definition.routeSegment || "") || unicodeSlug(definition.routeSegment || "");
if (explicit) return explicit;
const stableId = asciiSlug(definition.id || "");
if (stableId) return stableId;
const readableLabel = asciiSlug(definition.label || "") || unicodeSlug(definition.label || "");
if (readableLabel) return readableLabel;
return `route-${hashText(JSON.stringify(definition))}`;
}
function targetKey(moduleId: string, tabId: string): string {
return `${moduleId}:${tabId}`;
}
export function createRouteRegistry(definitions: UniDeskModuleDefinition[]): UniDeskRouteRegistry {
const prebuiltModules = definitions.map((module) => {
const routeSegment = routeSegmentFor(module);
return {
...module,
routeSegment,
tabs: module.tabs.map((tab) => ({ ...tab, routeSegment: routeSegmentFor(tab) })),
};
});
const routeMap: Record<string, UniDeskRouteTarget> = {};
const canonicalPathByTarget: Record<string, string> = {};
const defaultActiveTabs: Record<string, string> = {};
const routedModules: UniDeskRoutedModuleDefinition[] = prebuiltModules.map((module) => {
const defaultTabId = module.tabs[0]?.id ?? "";
defaultActiveTabs[module.id] = defaultTabId;
const routedTabs: UniDeskRoutedTabDefinition[] = module.tabs.map((tab) => {
const canonicalPath = `/${module.routeSegment}/${tab.routeSegment}/`;
const aliases = [canonicalPath];
const target = { moduleId: module.id, tabId: tab.id };
for (const alias of aliases) routeMap[normalizeRoutePath(alias)] = target;
canonicalPathByTarget[targetKey(module.id, tab.id)] = canonicalPath;
return { ...tab, canonicalPath, aliases };
});
const moduleCanonical = `/${module.routeSegment}/`;
const defaultTarget = { moduleId: module.id, tabId: defaultTabId };
routeMap[normalizeRoutePath(moduleCanonical)] = defaultTarget;
return {
...module,
routeSegment: module.routeSegment,
canonicalPath: moduleCanonical,
tabs: routedTabs,
};
});
const fallbackModule = routedModules[0];
const fallbackTarget = {
moduleId: fallbackModule?.id || "",
tabId: fallbackModule?.tabs[0]?.id || "",
};
routeMap["/"] = fallbackTarget;
return {
modules: routedModules,
moduleById: Object.fromEntries(routedModules.map((module) => [module.id, module])),
defaultActiveTabs,
routeMap,
canonicalPathByTarget,
fallbackTarget,
};
}
export function resolveRouteTarget(registry: UniDeskRouteRegistry, pathname: string): UniDeskRouteTarget {
return registry.routeMap[normalizeRoutePath(pathname)] || registry.fallbackTarget;
}
export function pathForTarget(registry: UniDeskRouteRegistry, moduleId: string, tabId: string): string {
return registry.canonicalPathByTarget[targetKey(moduleId, tabId)]
|| registry.canonicalPathByTarget[targetKey(registry.fallbackTarget.moduleId, registry.fallbackTarget.tabId)]
|| "/";
}
export function canonicalizeKnownRoute(registry: UniDeskRouteRegistry, pathname: string): string | null {
const target = registry.routeMap[normalizeRoutePath(pathname)];
if (!target) return null;
return pathForTarget(registry, target.moduleId, target.tabId);
}
File diff suppressed because it is too large Load Diff