feat: improve frontend routing and pipeline gantt defaults
This commit is contained in:
@@ -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) }),
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user