From cd6e1f10690fb7d4c6b4e28177c84c8957a8752f Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 12 Sep 2026 16:55:09 +0800 Subject: [PATCH] Redesign workbench FLEET into aligned donuts and a clickable sched dartboard. Drop fabric reconcile as redundant with topology, open the traffic wall from the sched card, and surface scheduler tick ages in metrics types. Co-authored-by: Cursor --- web/src/i18n/en.ts | 49 +- web/src/i18n/zh.ts | 49 +- web/src/index.css | 1512 +++++++++++++++++++------------ web/src/pages/WorkbenchPage.tsx | 981 +++++++++++++++++--- web/src/services/api.ts | 12 +- 5 files changed, 1879 insertions(+), 724 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index b5fa301..cc9d7c6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -28,7 +28,7 @@ const en = { backToWorkbench: "Back to Workbench", openInNewTab: "Open in new tab", openModule: "Open or switch to this module tab", - monitoring: "System integration", + monitoring: "Integration", operations: "Operations", system: "System", welcomeEyebrow: "Workbench", @@ -39,11 +39,52 @@ const en = { uptime: "API uptime", health: "System health", tasks: "Active tasks", - healthOk: "Schedulers healthy", - healthWarn: "Check schedulers", - healthUnknown: "Metrics unavailable", + healthOk: "OK", + healthWarn: "CHECK", + healthUnknown: "N/A", }, statusTitle: "Runtime status", + hostHealth: "HOST", + platformCapacity: "Platform capacity", + channels: "CHANNELS", + fleet: "FLEET", + fleetNe: "NE", + fleetNeUnit: "units", + fleetNeKnown: "Known", + fleetNePass: "Pass", + fleetNeFail: "Fail", + fleetNeTesting: "Testing", + fleetNeUnknown: "Untested", + fleetNeNoTag: "No tag", + fleetNeTags: "Top tags", + fleetTopo: "Topology", + fleetTopoNodes: "nodes", + fleetTopoEdges: "edges", + fleetTopoOther: "Other regions", + fleetTopoUnassigned: "Unassigned", + fleetTopoNoRegion: "No regions", + fleetTopoDiscover: "Last discover", + fleetTopoUpdated: "Summary updated", + fleetSched: "Schedulers", + fleetSchedConfig: "Config sync", + fleetSchedLldp: "LLDP", + fleetSchedPort: "Port traffic", + fleetSchedNeCollect: "NE collect", + fleetSchedFabric: "Fabric reconcile", + fleetSchedOn: "ON", + fleetSchedOff: "OFF", + fleetSchedMode: "Mode", + fleetSchedHeartbeat: "Heartbeat", + fleetSchedFresh: "Fresh", + fleetSchedStale: "Stale", + + sidePanel: "Console side panel", + consoleId: "OPS CONSOLE", + refreshedAt: "Updated {{time}}", + taskRail: "ACTIVE TASKS", + taskRailAll: "ALL", + taskRailEmpty: "No active tasks", + taskTotal: "POOL {{count}}", announceTitle: "Announcements", recentTitle: "Recent operations", recentMore: "View all", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index ce184a6..521356c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -31,7 +31,7 @@ const zh = { monitoring: "系统对接", operations: "运维", system: "系统管理", - welcomeEyebrow: "工作台", + welcomeEyebrow: "Workbench", welcomeTitle: "欢迎回来,{{name}}", welcomeSub: "在此总览同步、拓扑与后台任务状态。", today: "今天 · {{date}}", @@ -39,11 +39,52 @@ const zh = { uptime: "API 运行时长", health: "系统健康", tasks: "进行中任务", - healthOk: "调度正常", - healthWarn: "请检查调度", - healthUnknown: "指标暂不可用", + healthOk: "OK", + healthWarn: "CHECK", + healthUnknown: "N/A", }, statusTitle: "运行状态", + hostHealth: "HOST", + platformCapacity: "平台容量", + channels: "CHANNELS", + fleet: "FLEET", + fleetNe: "网元", + fleetNeUnit: "台", + fleetNeKnown: "已知", + fleetNePass: "已连通", + fleetNeFail: "失败", + fleetNeTesting: "测试中", + fleetNeUnknown: "未测试", + fleetNeNoTag: "无标签", + fleetNeTags: "热门标签", + fleetTopo: "拓扑", + fleetTopoNodes: "节点", + fleetTopoEdges: "边", + fleetTopoOther: "其他区域", + fleetTopoUnassigned: "未分区", + fleetTopoNoRegion: "暂无区域", + fleetTopoDiscover: "最近发现", + fleetTopoUpdated: "摘要更新", + fleetSched: "调度", + fleetSchedConfig: "配置同步", + fleetSchedLldp: "LLDP", + fleetSchedPort: "端口流量", + fleetSchedNeCollect: "网元采集", + fleetSchedFabric: "拓扑对账", + fleetSchedOn: "ON", + fleetSchedOff: "OFF", + fleetSchedMode: "模式", + fleetSchedHeartbeat: "心跳", + fleetSchedFresh: "新鲜", + fleetSchedStale: "过期", + + sidePanel: "控制台侧栏", + consoleId: "OPS CONSOLE", + refreshedAt: "更新于 {{time}}", + taskRail: "ACTIVE TASKS", + taskRailAll: "ALL", + taskRailEmpty: "No active tasks", + taskTotal: "POOL {{count}}", announceTitle: "系统公告", recentTitle: "最近操作", recentMore: "查看全部", diff --git a/web/src/index.css b/web/src/index.css index dbc8643..95b1600 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -183,683 +183,1069 @@ body { color: var(--nm-text, #1e293b); } + .workbench { width: 100%; max-width: none; + flex: 1 1 auto; + min-height: 0; display: flex; flex-direction: column; - gap: 18px; + gap: 14px; +} + +.workbench--console { + position: relative; + --wb-corner: rgba(56, 189, 248, 0.55); + --wb-ink: #e2e8f0; + --wb-muted: #94a3b8; + --wb-cyan: #7dd3fc; } .app-main:has(> .workbench) { - padding: 16px 20px 28px; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + padding: 16px 20px 20px; background: - radial-gradient(1200px 480px at 12% -10%, rgba(37, 99, 235, 0.18), transparent 55%), - radial-gradient(900px 420px at 88% 0%, rgba(14, 165, 233, 0.1), transparent 50%), + linear-gradient(180deg, rgba(8, 14, 28, 0.92), rgba(7, 11, 20, 0.98)), + repeating-linear-gradient( + 0deg, + transparent, + transparent 23px, + rgba(148, 163, 184, 0.035) 23px, + rgba(148, 163, 184, 0.035) 24px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 23px, + rgba(148, 163, 184, 0.03) 23px, + rgba(148, 163, 184, 0.03) 24px + ), var(--nm-chrome, #070b14); } -.wb-hero { +.wb-panel { position: relative; - display: flex; - flex-wrap: nowrap; - align-items: center; - gap: 24px; - padding: 16px 22px; - border-radius: 16px; - border: 1px solid var(--nm-glass-border); + border: 1px solid rgba(56, 189, 248, 0.14); + border-radius: 4px; background: - linear-gradient(135deg, rgba(15, 23, 42, 0.82), rgba(15, 23, 42, 0.55)), - radial-gradient(ellipse 80% 120% at 85% 20%, rgba(56, 189, 248, 0.12), transparent 55%), - radial-gradient(ellipse 60% 80% at 10% 90%, rgba(37, 99, 235, 0.16), transparent 50%); - overflow: hidden; - min-height: 0; - width: 100%; + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 0 / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 0 / 2px 11px no-repeat, + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 0 / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 0 / 2px 11px no-repeat, + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 100% / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 100% / 2px 11px no-repeat, + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 100% / 11px 2px no-repeat, + linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 100% / 2px 11px no-repeat, + linear-gradient(165deg, rgba(15, 23, 42, 0.78), rgba(2, 8, 23, 0.58)); } -.wb-hero__copy { - position: relative; - z-index: 1; +.wb-panel::before, +.wb-panel::after { + content: none; +} + +.wb-head { display: flex; flex-direction: column; - justify-content: center; - gap: 4px; - flex: 0 0 auto; - min-width: 0; - max-width: 240px; + gap: 0; } -.wb-hero__status { - position: relative; - z-index: 1; - flex: 1 1 auto; +.wb-head__title-row { display: flex; align-items: center; - min-width: 0; - width: 100%; + gap: 12px; + flex-wrap: wrap; } -.wb-hero__eyebrow { +.wb-head__title { margin: 0; - font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.14em; + color: #38bdf8; + line-height: 1.2; +} + +.wb-head__health { + display: inline-flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 8px; + border-radius: 3px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + border: 1px solid transparent; +} + +.wb-head__health-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 8px currentColor; +} + +.wb-head__health.is-ok { + color: #4ade80; + background: rgba(34, 197, 94, 0.08); + border-color: rgba(34, 197, 94, 0.28); +} + +.wb-head__health.is-busy { + color: #fbbf24; + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.3); +} + +.wb-head__sub { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: var(--nm-muted, #94a3b8); + max-width: 52ch; +} + +.wb-strip { + display: flex; + flex-direction: column; + gap: 8px; + padding: 11px 14px; +} + +.wb-strip__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 18px; + min-width: 0; +} + +.wb-strip__tag { + flex: 0 0 auto; + min-width: 3.5em; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; letter-spacing: 0.08em; text-transform: uppercase; color: #7dd3fc; - font-weight: 600; } -.wb-hero__title { - margin: 0; - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - font-size: clamp(20px, 2.2vw, 26px); - font-weight: 700; - letter-spacing: -0.02em; - color: #f8fafc; - line-height: 1.2; -} - -.wb-hero__sub { - margin: 0; - max-width: 42ch; - font-size: 13px; - line-height: 1.5; +.wb-strip__tag--soft { color: #94a3b8; } -.wb-hero__date { - margin: 2px 0 0; - font-size: 12px; - color: #64748b; -} - -.wb-hero__clock { - position: relative; - z-index: 1; +.wb-strip__divider { flex: 0 0 auto; + width: 1px; + height: 28px; + margin: 0 2px; + background: linear-gradient( + 180deg, + transparent, + rgba(56, 189, 248, 0.35), + transparent + ); +} + +.wb-stat, +.app-main .wb-stat { + display: inline-grid; + grid-template-columns: auto auto; + grid-template-rows: auto auto; + column-gap: 8px; + row-gap: 3px; + align-items: baseline; + flex: 0 0 auto; + width: auto; min-width: 0; + margin: 0; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + color: inherit; + text-align: left; + border-radius: 0; } -.wb-hero__clock-panel { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 8px; - min-width: 220px; - padding: 12px 14px 10px; - border-radius: 10px; - border: 1px solid rgba(34, 197, 94, 0.22); - background: linear-gradient(180deg, rgba(6, 18, 12, 0.95) 0%, rgba(2, 10, 8, 0.98) 100%); - box-shadow: - inset 0 0 0 1px rgba(34, 197, 94, 0.08), - inset 0 10px 28px rgba(0, 0, 0, 0.55), - 0 0 18px rgba(34, 197, 94, 0.08); +.wb-stat__label { + grid-column: 1; + grid-row: 1; + font-size: 12px; + font-weight: 500; + color: #94a3b8; } -.wb-hero__clock-digits { - display: flex; - align-items: center; - justify-content: center; - gap: 3px; - font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, monospace; - font-variant-numeric: tabular-nums; - line-height: 1; -} - -.wb-hero__digit { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.05em; - min-width: 22px; - padding: 4px 0 2px; - border-radius: 4px; - background: rgba(0, 0, 0, 0.35); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); - font-size: clamp(26px, 2.8vw, 34px); - font-weight: 700; - letter-spacing: 0.02em; - color: #4ade80; - text-shadow: - 0 0 6px rgba(74, 222, 128, 0.55), - 0 0 14px rgba(34, 197, 94, 0.35); -} - -.wb-hero__colon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 0.45em; - margin: 0 1px; - font-size: clamp(24px, 2.6vw, 32px); - font-weight: 700; - color: rgba(74, 222, 128, 0.22); - transition: color 0.12s linear, text-shadow 0.12s linear; -} - -.wb-hero__colon.is-on { - color: #4ade80; - text-shadow: - 0 0 6px rgba(74, 222, 128, 0.55), - 0 0 14px rgba(34, 197, 94, 0.35); -} - -.wb-hero__clock-date { - display: block; - text-align: center; +.wb-stat__value { + grid-column: 2; + grid-row: 1; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; - font-weight: 600; - letter-spacing: 0.08em; - color: rgba(74, 222, 128, 0.72); - text-shadow: 0 0 8px rgba(34, 197, 94, 0.25); - line-height: 1.2; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #e2e8f0; } -.wb-hero__clock-sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; +.wb-stat__track { + grid-column: 1 / -1; + grid-row: 2; + display: block; + width: 84px; + height: 2px; + border-radius: 0; + background: rgba(148, 163, 184, 0.16); overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; } -.wb-hero__kpis { - position: relative; - z-index: 1; - min-width: 0; +.wb-stat__fill { + display: block; + height: 100%; + background: #64748b; } -.wb-kpi { +.wb-stat.is-ok .wb-stat__fill { + background: #22c55e; +} + +.wb-stat.is-mid .wb-stat__fill { + background: #38bdf8; +} + +.wb-stat.is-high .wb-stat__fill { + background: #f59e0b; +} + +.wb-stat.is-muted { + opacity: 0.55; +} + +.wb-body { + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(300px, 26vw, 380px); + gap: 14px; + align-items: stretch; + flex: 1 1 auto; + min-height: 0; +} + +.wb-main { display: flex; flex-direction: column; - gap: 4px; - padding: 12px 14px; - border-radius: 12px; - border: 1px solid rgba(148, 163, 184, 0.2); - background: rgba(2, 8, 23, 0.45); - backdrop-filter: blur(8px); + gap: 12px; + min-width: 0; + height: 100%; } -.wb-kpi__label { - font-size: 11px; - color: #94a3b8; - font-weight: 500; -} - -.wb-kpi__value { - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - font-size: 20px; - font-weight: 700; - color: #f1f5f9; - letter-spacing: -0.02em; - line-height: 1.15; -} - -.wb-kpi__hint { - font-size: 11px; - color: #64748b; -} - -.wb-kpi--live .wb-kpi__value { - color: #4ade80; +.wb-modules { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 16px 16px; + flex: 1 1 auto; + min-height: 0; + justify-content: center; } .wb-section { margin: 0; -} - -.wb-section__head { display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; + flex-direction: column; + gap: 10px; } .wb-section__title { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-tiles { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + align-items: stretch; + width: 100%; +} + +.wb-tile, +.app-main .wb-tile { + display: inline-flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: none; + min-height: 64px; + margin: 0; + padding: 12px 14px; + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 4px; + background: rgba(15, 23, 42, 0.5); + box-shadow: none; + cursor: pointer; + text-align: left; + color: inherit; + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; +} + +.wb-tile:hover, +.wb-tile:focus-visible { + border-color: rgba(56, 189, 248, 0.42); + background: rgba(30, 41, 59, 0.72); + box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.1); + outline: none; + transform: translateY(-1px); +} + +.wb-tile__label { + font-size: 13px; + font-weight: 600; + color: #e2e8f0; + line-height: 1.25; +} + +.wb-tile .wb-card__icon { + opacity: 0.9; +} + + +.wb-fleet { + margin-top: 0; + flex: 0 0 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px 12px; +} + +.wb-fleet__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-bottom: 2px; + border-bottom: 1px solid rgba(148, 163, 184, 0.1); +} + +.wb-fleet__title { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-fleet__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + min-height: 0; + align-items: stretch; +} + +.wb-fleet__card { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 8px; + width: 100%; + max-width: 100%; + min-width: 0; + margin: 0; + padding: 10px 10px 8px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 4px; + background: rgba(8, 15, 30, 0.45); + color: inherit; + text-align: left; + cursor: default; + overflow: hidden; + transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease; +} + +button.wb-fleet__card { + cursor: pointer; + font: inherit; + appearance: none; + -webkit-appearance: none; +} + +button.wb-fleet__card:hover, +button.wb-fleet__card:focus-visible { + border-color: rgba(56, 189, 248, 0.4); + background: rgba(20, 32, 52, 0.72); + outline: none; + transform: translateY(-1px); +} + +button.wb-fleet__card:disabled { + cursor: default; + opacity: 0.72; +} + +button.wb-fleet__card:disabled:hover, +button.wb-fleet__card:disabled:focus-visible { + border-color: rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.45); +} + +.wb-fleet__card--sched { + cursor: pointer; +} + +.wb-fleet__card--sched:hover, +.wb-fleet__card--sched:focus-visible { + border-color: rgba(56, 189, 248, 0.4); + background: rgba(20, 32, 52, 0.72); + outline: none; + transform: translateY(-1px); +} + +.wb-fleet__card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 18px; +} + +.wb-fleet__card-label { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7dd3fc; +} + +.wb-fleet__card-badge { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.02em; + color: #64748b; + white-space: nowrap; +} + +.wb-fleet__card-badge.is-ok { + color: #4ade80; +} + +.wb-fleet__card-badge.is-warn { + color: #fbbf24; +} + +.wb-fleet__viz { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 6px 12px; + align-items: center; + min-height: 0; +} + +.wb-fleet__donut { + position: relative; + width: 84px; + height: 84px; + border-radius: 50%; + flex: 0 0 auto; + margin: 0; + box-shadow: + inset 0 0 0 1px rgba(56, 189, 248, 0.12), + 0 0 14px rgba(56, 189, 248, 0.05); +} + +.wb-fleet__donut--dart { + background: transparent; + box-shadow: none; +} + +.wb-fleet__dart { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.wb-fleet__dart-slice { + cursor: pointer; + transform-origin: 50% 50%; + transition: filter 0.14s ease, opacity 0.14s ease; + outline: none; +} + +.wb-fleet__dart-slice:hover, +.wb-fleet__dart-slice:focus-visible { + filter: brightness(1.18) drop-shadow(0 0 6px rgba(56, 189, 248, 0.45)); +} + +.wb-fleet__dart-slice:not(.is-on) { + opacity: 0.55; +} + +.wb-fleet__dart-slice:not(.is-on):hover, +.wb-fleet__dart-slice:not(.is-on):focus-visible { + opacity: 0.85; +} + +.wb-fleet__donut-hole { + position: absolute; + inset: 20%; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + background: + radial-gradient(circle at 35% 30%, rgba(56, 189, 248, 0.08), transparent 55%), + linear-gradient(165deg, rgba(15, 23, 42, 0.98), rgba(2, 8, 23, 0.96)); + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.9); +} + +.wb-fleet__donut-hole--dart { + inset: 28%; + pointer-events: none; +} + +.wb-fleet__donut-value { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + color: #e2e8f0; + letter-spacing: -0.02em; +} + +.wb-fleet__donut-unit { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 9px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #64748b; +} + +.wb-fleet__rows { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + margin-top: 0; + min-width: 0; + min-height: 84px; +} + +.wb-fleet__row, +button.wb-fleet__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + width: 100%; + margin: 0; + padding: 1px 0; + border: 0; + background: transparent; + font: inherit; + font-size: 12px; + line-height: 1.35; + color: #94a3b8; + text-align: left; + cursor: default; +} + +button.wb-fleet__row { + cursor: pointer; + border-radius: 3px; + padding: 1px 4px; + margin: 0 -4px; +} + +button.wb-fleet__row:hover, +button.wb-fleet__row:focus-visible { + color: #e2e8f0; + background: rgba(56, 189, 248, 0.08); + outline: none; +} + +.wb-fleet__legend { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.wb-fleet__legend-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wb-fleet__swatch { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: #64748b; + flex: 0 0 auto; +} + +.wb-fleet__swatch.is-ok { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.45); } +.wb-fleet__swatch.is-mid { background: #38bdf8; box-shadow: 0 0 6px rgba(56, 189, 248, 0.4); } +.wb-fleet__swatch.is-warn { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.4); } +.wb-fleet__swatch.is-bad { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); } +.wb-fleet__swatch.is-muted { background: #64748b; } + +.wb-fleet__row strong { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #e2e8f0; + text-align: right; + white-space: nowrap; +} + +.wb-fleet__row strong.is-ok { color: #4ade80; } +.wb-fleet__row strong.is-mid { color: #7dd3fc; } +.wb-fleet__row strong.is-warn { color: #fbbf24; } +.wb-fleet__row strong.is-bad { color: #f87171; } +.wb-fleet__row strong.is-muted { color: #64748b; } + +.wb-fleet__foot { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 4px 10px; + margin-top: 0; + padding-top: 6px; + border-top: 1px solid rgba(148, 163, 184, 0.1); + min-height: 22px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; + line-height: 1.3; + color: #64748b; +} + +.wb-fleet__foot em { + font-style: normal; + font-weight: 650; + color: #94a3b8; +} + +.wb-fleet__foot em.is-ok { + color: #4ade80; +} + +.wb-fleet__foot em.is-warn { + color: #fbbf24; +} + +.wb-fleet__row-age { + margin-left: 4px; + font-weight: 550; + color: #64748b; +} + +.wb-fleet__row.is-on .wb-fleet__legend-text { + color: #e2e8f0; +} + +.wb-fleet__row--sched, +button.wb-fleet__row--sched { + display: grid; + grid-template-columns: minmax(0, 1fr) 2.75em 2.75em; + align-items: center; + justify-content: stretch; + column-gap: 6px; +} + +.wb-fleet__sched-state { + display: block; + width: 100%; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-align: left; + color: #64748b; +} + +.wb-fleet__sched-state.is-ok { + color: #4ade80; +} + +.wb-fleet__sched-state.is-muted { + color: #64748b; +} + +.wb-fleet__sched-age { + display: block; + width: 100%; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 550; + text-align: right; + color: #64748b; +} + +.wb-side { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; + height: 100%; + align-self: stretch; +} + +.wb-rail { + min-width: 0; + padding: 12px 14px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.wb-rail--tasks { + flex: 1 1 auto; + min-height: 0; + height: 100%; +} + +.wb-rail--channels { + display: none; +} + +.wb-rail__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.wb-rail__title { margin: 0; display: inline-flex; align-items: center; gap: 8px; - font-size: 13px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; font-weight: 650; - color: #cbd5e1; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #7dd3fc; } -.wb-section__title::before { - content: ""; - width: 3px; - height: 12px; - border-radius: 2px; - background: #64748b; -} - -.wb-section--monitoring .wb-section__title::before { - background: var(--nm-accent, #3b82f6); -} - -.wb-section--operations .wb-section__title::before { - background: #14b8a6; -} - -.wb-section--system .wb-section__title::before { - background: #f59e0b; -} - -.wb-section__count { +.wb-rail__count { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; - border-radius: 999px; - background: rgba(148, 163, 184, 0.14); - color: #94a3b8; + border-radius: 3px; + background: rgba(56, 189, 248, 0.14); + color: #e0f2fe; font-size: 11px; - font-weight: 650; - line-height: 1; -} - -.wb-grid { - display: flex; - flex-wrap: wrap; - gap: 12px; - align-items: stretch; -} - -.wb-card, -.app-main .wb-card { - display: inline-flex; - align-items: center; - gap: 10px; - width: auto; - min-width: 148px; - max-width: 100%; - min-height: 0; - margin: 0; - padding: 12px 16px 12px 12px; - border: 1px solid var(--nm-glass-border); - border-radius: 12px; - background: rgba(15, 23, 42, 0.65); - box-shadow: none; - cursor: pointer; - text-align: left; - text-decoration: none; - color: inherit; - transition: border-color 0.14s ease, background 0.14s ease, transform 0.14s ease; -} - -.wb-card:hover, -.wb-card:focus-visible { - border-color: rgba(59, 130, 246, 0.55); - background: rgba(30, 41, 59, 0.85); - transform: translateY(-1px); - outline: none; -} - -.wb-card--blue:hover, -.wb-card--blue:focus-visible { - border-color: rgba(59, 130, 246, 0.65); -} - -.wb-card--green:hover, -.wb-card--green:focus-visible { - border-color: rgba(34, 197, 94, 0.55); -} - -.wb-card--amber:hover, -.wb-card--amber:focus-visible { - border-color: rgba(245, 158, 11, 0.55); -} - -.wb-card--slate:hover, -.wb-card--slate:focus-visible { - border-color: rgba(148, 163, 184, 0.45); -} - -.wb-card__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 38px; - border-radius: 0; - flex-shrink: 0; - clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%); -} - -.wb-card__icon svg { - width: 15px; - height: 15px; -} - -.wb-card__icon--blue { - background: rgba(59, 130, 246, 0.22); - color: #60a5fa; -} - -.wb-card__icon--green { - background: rgba(34, 197, 94, 0.18); - color: #4ade80; -} - -.wb-card__icon--amber { - background: rgba(245, 158, 11, 0.18); - color: #fbbf24; -} - -.wb-card__icon--slate { - background: rgba(148, 163, 184, 0.16); - color: #cbd5e1; -} - -.wb-card__label { - min-width: 0; - font-size: 13px; - font-weight: 650; - color: #f1f5f9; - line-height: 1.25; - white-space: nowrap; - padding-right: 2px; -} - -.wb-row { - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); - gap: 14px; -} - -.wb-panel { - border: 1px solid var(--nm-glass-border); - border-radius: 14px; - background: rgba(15, 23, 42, 0.68); - padding: 16px 18px; - min-width: 0; -} - -.wb-panel__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 14px; -} - -.wb-panel__title { - margin: 0; - font-size: 14px; - font-weight: 650; - color: #e2e8f0; -} - -.wb-panel__link { - font-size: 12px; - color: #60a5fa; - text-decoration: none; -} - -.wb-panel__link:hover { - text-decoration: underline; -} - -.wb-gauges { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; -} - -/* Must follow .wb-gauges so flex wins over grid (was wrapping into 4+2). */ -.wb-gauges.wb-gauges--hero { - display: flex !important; - flex-direction: row; - flex-wrap: nowrap !important; - align-items: flex-start; - justify-content: space-between; - gap: 8px; - width: 100%; - max-width: none; - grid-template-columns: unset; - overflow: visible; -} - -.wb-gauges.wb-gauges--hero .wb-gauge { - flex: 1 1 0; - width: auto; - min-width: 0; - max-width: none; - gap: 6px; -} - -.wb-gauges.wb-gauges--hero .wb-gauge__ring { - width: 56px; - height: 56px; -} - -.wb-gauges.wb-gauges--hero .wb-gauge__ring::before { - width: 40px; - height: 40px; -} - -.wb-gauges.wb-gauges--hero .wb-gauge__value { - font-size: 11px; -} - -.wb-gauges.wb-gauges--hero .wb-gauge__label { - font-size: 12px; -} - -.wb-gauges.wb-gauges--hero .wb-gauge__hint { - font-size: 10px; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.wb-gauge { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - text-align: center; -} - -.wb-gauge__ring { - --pct: 0; - --tone: #3b82f6; - width: 72px; - height: 72px; - border-radius: 50%; - background: conic-gradient(var(--tone) calc(var(--pct) * 1%), rgba(148, 163, 184, 0.16) 0); - display: grid; - place-items: center; - position: relative; -} - -.wb-gauge__ring::before { - content: ""; - width: 52px; - height: 52px; - border-radius: 50%; - background: #0b1220; - grid-area: 1 / 1; - z-index: 0; -} - -.wb-gauge__value { - position: relative; - z-index: 1; - grid-area: 1 / 1; - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - font-size: 13px; font-weight: 700; - color: #f8fafc; - line-height: 1; + letter-spacing: 0; + text-transform: none; } -.wb-gauge__wrap { - position: relative; - display: grid; - place-items: center; -} - -.wb-gauge__label { - font-size: 12px; - font-weight: 600; - color: #cbd5e1; -} - -.wb-gauge__hint { +.wb-rail__meta { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; color: #64748b; } -.wb-announce { +.wb-rail__all, +.app-main .wb-rail__all { + height: 26px; + min-height: 26px; + padding: 0 8px; + font-size: 12px; + color: #7dd3fc; + background: transparent; + border: 0; + box-shadow: none; + border-radius: 3px; +} + +.wb-rail__all:hover, +.wb-rail__all:focus-visible { + color: #e0f2fe; + background: rgba(56, 189, 248, 0.1); + outline: none; +} + +.wb-rail__empty { + margin: 0; + padding: 12px 0; + font-size: 12px; + line-height: 1.45; + color: #64748b; +} + +.wb-rail__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; - gap: 0; + gap: 6px; + overflow: auto; + min-height: 0; + flex: 1 1 auto; } -.wb-announce__item { +.wb-rail__item, +.app-main .wb-rail__item, +.app-main button.wb-rail__item { display: flex; + flex-direction: column; align-items: flex-start; - justify-content: space-between; - gap: 12px; - padding: 10px 0; - border-bottom: 1px solid rgba(148, 163, 184, 0.12); -} - -.wb-announce__item:last-child { - border-bottom: none; - padding-bottom: 0; -} - -.wb-announce__text { - margin: 0; - font-size: 13px; - color: #cbd5e1; - line-height: 1.4; -} - -.wb-announce__date { - flex-shrink: 0; - font-size: 11px; - color: #64748b; -} - -.wb-recent-table { + justify-content: flex-start; + gap: 4px; width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -.wb-recent-table th { + height: auto; + min-height: 0; + margin: 0; + padding: 10px 10px; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 4px; + background: rgba(2, 8, 23, 0.35); + box-shadow: none; + color: inherit; text-align: left; - font-weight: 600; - color: #64748b; - padding: 0 8px 8px 0; - border-bottom: 1px solid rgba(148, 163, 184, 0.14); -} - -.wb-recent-table td { - padding: 9px 8px 9px 0; - color: #cbd5e1; - border-bottom: 1px solid rgba(148, 163, 184, 0.08); - vertical-align: top; -} - -.wb-recent-table tr:last-child td { - border-bottom: none; -} - -.wb-recent-table .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 11px; -} - -.wb-quick-ops { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.wb-quick-ops__btn, -.app-main .wb-quick-ops__btn { - display: flex; - align-items: center; - justify-content: center; - min-height: 52px; - padding: 12px 14px; - border-radius: 10px; - border: 1px solid var(--nm-glass-border); - background: rgba(2, 8, 23, 0.4); - color: #e2e8f0; - font-size: 13px; - font-weight: 600; cursor: pointer; - text-align: center; - transition: border-color 0.14s ease, background 0.14s ease; + white-space: normal; + line-height: 1.4; + overflow: visible; } -.wb-quick-ops__btn:hover, -.wb-quick-ops__btn:focus-visible { - border-color: rgba(59, 130, 246, 0.5); - background: rgba(30, 41, 59, 0.8); +.wb-rail__item:hover, +.wb-rail__item:focus-visible, +.app-main button.wb-rail__item:hover, +.app-main button.wb-rail__item:focus-visible { + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.28); outline: none; } -.wb-empty { - margin: 0; - padding: 12px 0; - font-size: 13px; +.wb-rail__item-title { + display: block; + width: 100%; + font-size: 12px; + font-weight: 600; + color: #e2e8f0; + line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.wb-rail__item-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + width: 100%; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + color: #94a3b8; + line-height: 1.4; +} + +.wb-rail__status { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 650; +} + +.wb-rail__status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 6px currentColor; + flex-shrink: 0; +} + +.wb-rail__status--running { + color: #38bdf8; +} + +.wb-rail__status--ok { + color: #4ade80; +} + +.wb-rail__status--paused { + color: #fbbf24; +} + +.wb-rail__status--stopped { + color: #f87171; +} + +.wb-rail__status--other { + color: #94a3b8; +} + +.wb-rail__progress { color: #64748b; + font-weight: 500; +} + +.wb-channels { + display: flex; + flex-direction: column; + gap: 10px; +} + +.wb-channel { + display: flex; + flex-direction: column; + gap: 5px; +} + +.wb-channel__top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.wb-channel__label { + font-size: 12px; + color: #94a3b8; +} + +.wb-channel__value { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + font-weight: 700; + color: #e2e8f0; + font-variant-numeric: tabular-nums; +} + +.wb-channel__sep { + margin: 0 2px; + color: #64748b; + font-weight: 500; +} + +.wb-channel__track { + display: block; + height: 3px; + background: rgba(148, 163, 184, 0.14); + overflow: hidden; +} + +.wb-channel__fill { + display: block; + height: 100%; + background: #38bdf8; +} + +.wb-channel.is-ok .wb-channel__fill { + background: #22c55e; +} + +.wb-channel.is-mid .wb-channel__fill { + background: #38bdf8; +} + +.wb-channel.is-high .wb-channel__fill { + background: #f59e0b; +} + +.wb-channel.is-muted { + opacity: 0.55; } @media (max-width: 960px) { - .wb-hero { - flex-wrap: wrap; - } - - .wb-hero__clock-panel { - min-width: 0; - width: 100%; - } - - .wb-hero__kpis { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .wb-row { + .wb-body { grid-template-columns: 1fr; } - .wb-gauges { - grid-template-columns: repeat(2, minmax(0, 1fr)); + .wb-side { + min-height: 0; } - .wb-gauges.wb-gauges--hero { - flex-wrap: nowrap; + .wb-rail--tasks { + flex: 0 0 auto; + max-height: 280px; + } + + .wb-fleet { + margin-top: 0; + flex: 0 0 auto; + } + + .wb-fleet__grid { + grid-template-columns: 1fr; + } + + .wb-fleet__viz { + grid-template-columns: 72px minmax(0, 1fr); + } + + .wb-fleet__donut { + width: 72px; + height: 72px; + } + + .wb-fleet__rows { + min-height: 72px; + } + + .wb-modules { + justify-content: flex-start; + } + + .wb-tile, + .app-main .wb-tile { + min-height: 52px; + } + + .wb-strip__divider { + display: none; + } + + .wb-tiles { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 640px) { - .wb-hero__kpis { + .wb-tiles { grid-template-columns: 1fr; } - .wb-gauges { - grid-template-columns: 1fr 1fr; - } - - .wb-quick-ops { - grid-template-columns: 1fr; + .wb-strip__tag { + width: 100%; } } diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/pages/WorkbenchPage.tsx index 1ea5e74..c748cf3 100644 --- a/web/src/pages/WorkbenchPage.tsx +++ b/web/src/pages/WorkbenchPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@heroui/react"; import { useI18n } from "../i18n"; @@ -6,10 +6,249 @@ import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon"; import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules"; import { openOrFocusModule } from "../utils/moduleWindows"; import { useAuth } from "../auth/AuthContext"; -import { fetchOpsTasks, fetchRuntimeMetrics } from "../services/api"; +import { fetchFabricSummary, fetchManagedNeStats, fetchOpsTasks, fetchRuntimeMetrics, fetchTopologyTree, type OpsTaskItem } from "../services/api"; +import { formatSystemTime } from "../utils/time"; +import { isUmeWorldContainer, isWorldDrillFolder } from "./topology/treeUtils"; +import type { TopologyTreeFolderItem } from "../types"; const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"]; +type DisplaySection = { + key: "operations" | "system"; + mods: ModuleDefinition[]; +}; + +function formatCount(n: number | string): string { + if (typeof n === "string") return n; + if (!Number.isFinite(n)) return "—"; + if (Math.abs(n) >= 10000) { + return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); + } + return String(n); +} + +function formatAgeSec(sec: number | null | undefined): string { + if (sec == null || !Number.isFinite(sec)) return "—"; + const n = Math.max(0, sec); + if (n < 60) return `${Math.round(n)}s`; + if (n < 3600) return `${Math.max(1, Math.round(n / 60))}m`; + return `${(n / 3600).toFixed(n >= 10 ? 0 : 1)}h`; +} + +function formatCompactTime(value: string | null | undefined): string { + if (!value) return "—"; + const full = formatSystemTime(value); + if (!full) return "—"; + // Keep date+time but drop seconds when present: "2026/9/12 15:42:01" → "9/12 15:42" + const m = full.match(/(\d{1,4}[/-]\d{1,2}[/-]\d{1,2})\s+(\d{1,2}:\d{2})(?::\d{2})?/); + if (m) { + const date = m[1].replace(/^\d{4}[/-]/, ""); + return `${date} ${m[2]}`; + } + return full; +} + +const FLEET_TONE_COLOR: Record<"ok" | "mid" | "warn" | "bad" | "muted", string> = { + ok: "#22c55e", + mid: "#38bdf8", + warn: "#f59e0b", + bad: "#ef4444", + muted: "#64748b", +}; + +const REGION_PALETTE = [ + "#38bdf8", + "#22d3ee", + "#818cf8", + "#34d399", + "#fbbf24", + "#fb7185", + "#a78bfa", + "#2dd4bf", +]; + +type FleetSlice = { + key: string; + n: number; + label?: string; + tone?: "ok" | "mid" | "warn" | "bad" | "muted"; + color?: string; +}; + +function sliceColor(p: FleetSlice): string { + if (p.color) return p.color; + if (p.tone) return FLEET_TONE_COLOR[p.tone]; + return FLEET_TONE_COLOR.muted; +} + +function buildDonutGradient(parts: FleetSlice[]): string { + const raw = parts.map((p) => Math.max(0, p.n)); + const total = raw.reduce((s, n) => s + n, 0); + if (total <= 0) { + return `conic-gradient(${FLEET_TONE_COLOR.muted} 0deg 360deg)`; + } + + // Tiny non-zero slices get a minimum arc so they stay glanceable on skewed fleets. + const MIN_SHARE = 0.022; + const shares = raw.map((n) => { + if (n <= 0) return 0; + return Math.max(n / total, MIN_SHARE); + }); + const shareSum = shares.reduce((s, n) => s + n, 0); + let cursor = 0; + const stops: string[] = []; + parts.forEach((p, i) => { + const share = shares[i] / shareSum; + if (share <= 0) return; + const start = cursor * 360; + cursor += share; + const end = cursor * 360; + stops.push(`${sliceColor(p)} ${start.toFixed(2)}deg ${end.toFixed(2)}deg`); + }); + return `conic-gradient(${stops.join(", ")})`; +} + +function FleetDonut({ + parts, + center, + unit, +}: { + parts: FleetSlice[]; + center: ReactNode; + unit?: ReactNode; +}) { + return ( + + ); +} + +const SCHED_DISC_COLORS = ["#38bdf8", "#22d3ee", "#818cf8", "#34d399"]; + +function polarPoint(cx: number, cy: number, r: number, deg: number): [number, number] { + const rad = ((deg - 90) * Math.PI) / 180; + return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; +} + +function donutSlicePath( + cx: number, + cy: number, + rOuter: number, + rInner: number, + startDeg: number, + endDeg: number, +): string { + const sweep = endDeg - startDeg; + const large = sweep > 180 ? 1 : 0; + const [x1, y1] = polarPoint(cx, cy, rOuter, startDeg); + const [x2, y2] = polarPoint(cx, cy, rOuter, endDeg); + const [x3, y3] = polarPoint(cx, cy, rInner, endDeg); + const [x4, y4] = polarPoint(cx, cy, rInner, startDeg); + return [ + `M ${x1.toFixed(2)} ${y1.toFixed(2)}`, + `A ${rOuter} ${rOuter} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`, + `L ${x3.toFixed(2)} ${y3.toFixed(2)}`, + `A ${rInner} ${rInner} 0 ${large} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`, + "Z", + ].join(" "); +} + +type SchedDiscItem = { + key: string; + label: string; + on: boolean; + age: string; + moduleId: string; + path: string; +}; + +function SchedDartDisc({ + items, + center, + unit, + onSelect, +}: { + items: SchedDiscItem[]; + center: ReactNode; + unit?: ReactNode; + onSelect: (item: SchedDiscItem) => void; +}) { + // Match FleetDonut footprint (84px) so the three FLEET cards share one visual rhythm. + const size = 84; + const cx = size / 2; + const cy = size / 2; + const rOuter = 39; + const rInner = 25; + const gap = items.length > 1 ? 2.5 : 0; + const slice = items.length > 0 ? 360 / items.length : 360; + + return ( +
+ + {items.map((item, i) => { + const start = i * slice + gap / 2; + const end = (i + 1) * slice - gap / 2; + const color = item.on + ? SCHED_DISC_COLORS[i % SCHED_DISC_COLORS.length] + : "#475569"; + return ( + { + e.stopPropagation(); + onSelect(item); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + onSelect(item); + } + }} + > + {`${item.label} · ${item.on ? "ON" : "OFF"} · ${item.age}`} + + ); + })} + +
+
{center}
+ {unit ?
{unit}
: null} +
+
+ ); +} + +function topLevelRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] { + if (!root) return []; + return (root.children || []).filter((c) => { + if (String(c.kind) !== "region") return false; + if (isUmeWorldContainer(c) || isWorldDrillFolder(c)) return false; + return true; + }); +} + function clampPct(n: number): number { if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, Math.round(n))); @@ -20,25 +259,20 @@ function ratioPct(used: number, limit: number): number { return clampPct((used / limit) * 100); } -function loadHint(pct: number, t: (k: string) => string): string { - if (pct >= 75) return t("workbench.gauge.high"); - if (pct >= 40) return t("workbench.gauge.normal"); - return t("workbench.gauge.low"); +function toneClass(pct: number, muted?: boolean): string { + if (muted) return "is-muted"; + if (pct >= 75) return "is-high"; + if (pct >= 40) return "is-mid"; + return "is-ok"; } -function gaugeTone(pct: number): string { - if (pct >= 75) return "#f59e0b"; - if (pct >= 40) return "#3b82f6"; - return "#22c55e"; -} - -function formatBytes(n: number, locale: string): string { +function formatBytes(n: number): string { const v = Math.max(0, Number(n) || 0); const gib = 1024 ** 3; const mib = 1024 ** 2; if (v >= gib) { const g = v / gib; - return locale.startsWith("zh") ? `${g.toFixed(1)} GiB` : `${g.toFixed(1)} GiB`; + return `${g.toFixed(g >= 10 ? 0 : 1)} GiB`; } return `${(v / mib).toFixed(0)} MiB`; } @@ -52,39 +286,105 @@ function formatBytesCompact(n: number): string { return `${Math.max(1, Math.round(v / 1024))}K`; } -function Gauge({ +function isActiveStatus(status: string): boolean { + const s = status.toLowerCase(); + return ( + s === "collecting" || + s === "running" || + s === "connecting" || + s === "testing" || + s === "paused" || + s === "pending" || + s === "detached" + ); +} + +function kindLabel(kind: string, t: (k: string) => string): string { + const key = `audit.tasks.kind.${kind}`; + const tr = t(key); + return tr === key ? kind : tr; +} + +function statusLabel(status: string, t: (k: string) => string): string { + const key = `audit.tasks.status.${status}`; + const tr = t(key); + return tr === key ? status : tr; +} + +function statusTone(status: string): "running" | "ok" | "paused" | "stopped" | "other" { + const s = status.toLowerCase(); + if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running"; + if (s === "ready" || s === "idle" || s === "succeeded" || s === "success") return "ok"; + if (s === "paused" || s === "pending" || s === "detached") return "paused"; + if (s === "failed" || s === "error" || s === "stopped") return "stopped"; + return "other"; +} + +function taskTitle(row: OpsTaskItem, t: (k: string) => string): string { + const kind = kindLabel(row.kind, t); + const subject = String(row.title || row.id || "").trim(); + if (!subject) return kind; + if (subject === kind || subject.startsWith(`${kind} · `) || subject.startsWith(`${kind}·`)) { + return subject; + } + return `${kind} · ${subject}`; +} + +function openTaskHref(href: string) { + const path = String(href || "").trim() || "/audit/tasks"; + if (path.startsWith("/ume")) { + void openOrFocusModule({ moduleId: "ume", path }); + return; + } + if ( + path.startsWith("/network") || + path.startsWith("/lldp") || + path.startsWith("/config-sync") || + path.startsWith("/port-traffic") + ) { + void openOrFocusModule({ moduleId: "network", path }); + return; + } + if (path.startsWith("/ne")) { + void openOrFocusModule({ moduleId: "ne", path }); + return; + } + if (path.startsWith("/webcrt")) { + void openOrFocusModule({ moduleId: "webcrt", path }); + return; + } + void openOrFocusModule({ + moduleId: "audit", + path: path.startsWith("/audit") ? path : "/audit/tasks", + }); +} + +function StatLine({ label, - hint, + value, pct, - valueText, + muted, }: { label: string; - hint: string; - pct: number; - valueText?: string; + value: string; + pct?: number; + muted?: boolean; }) { - const tone = gaugeTone(pct); - const safe = clampPct(pct); return ( -
-
-
- {valueText ?? `${safe}%`} -
-
-
{label}
-
{hint}
+
+ {label} + {value} + {pct == null ? null : ( +
); } export function WorkbenchPage() { - const { t, locale } = useI18n(); + const { t } = useI18n(); const { isAdmin, hasScope } = useAuth(); const metricsQuery = useQuery({ @@ -99,11 +399,37 @@ export function WorkbenchPage() { queryKey: ["opsTasks"], queryFn: fetchOpsTasks, staleTime: 10_000, - // Share AppLayout cache; speed up only while something is active. refetchInterval: (q) => ((q.state.data?.active ?? 0) > 0 ? 4000 : 20_000), refetchIntervalInBackground: false, }); + const neStatsQuery = useQuery({ + queryKey: ["managedNeStats"], + queryFn: fetchManagedNeStats, + staleTime: 30_000, + refetchInterval: 45_000, + refetchIntervalInBackground: false, + enabled: isAdmin || hasScope("ne:read"), + }); + + const fabricQuery = useQuery({ + queryKey: ["fabricSummary"], + queryFn: fetchFabricSummary, + staleTime: 30_000, + refetchInterval: 60_000, + refetchIntervalInBackground: false, + enabled: isAdmin || hasScope("ne:read"), + }); + + const topoTreeQuery = useQuery({ + queryKey: ["topologyTree"], + queryFn: fetchTopologyTree, + staleTime: 45_000, + refetchInterval: 60_000, + refetchIntervalInBackground: false, + enabled: isAdmin || hasScope("ne:read"), + }); + const visibleBySection = useMemo(() => { const canSee = (mod: ModuleDefinition) => !mod.workbenchHidden && @@ -115,10 +441,25 @@ export function WorkbenchPage() { ) as Record; }, [isAdmin, hasScope]); + const displaySections = useMemo((): DisplaySection[] => { + const ops = [...visibleBySection.monitoring, ...visibleBySection.operations]; + const system = visibleBySection.system; + const sections: DisplaySection[] = [ + { key: "operations", mods: ops }, + { key: "system", mods: system }, + ]; + return sections.filter((s) => s.mods.length > 0); + }, [visibleBySection]); + + const railTasks = useMemo(() => { + const items = opsQuery.data?.items ?? []; + const active = items.filter((row) => isActiveStatus(row.status)); + return (active.length ? active : items).slice(0, 8); + }, [opsQuery.data]); + const metrics = metricsQuery.data; const host = metrics?.host; const activeTasks = opsQuery.data?.active ?? 0; - const totalTasks = opsQuery.data?.total ?? 0; const cpuPct = clampPct(Number(host?.cpu_percent ?? 0)); const memPct = clampPct(Number(host?.mem_percent ?? 0)); @@ -129,10 +470,6 @@ export function WorkbenchPage() { const storageUsed = Number(storage?.used_bytes || 0); const storageOk = storageUsed > 0 || (storage?.source === "pg_database_size" && !storage?.error); - const storageHint = storageOk - ? `${t("workbench.gauge.storageUsed")} · ${formatBytes(storageUsed, locale)}` - : t("workbench.gauge.storageUnavailable"); - const storageValue = storageOk ? formatBytesCompact(storageUsed) : "—"; const cliUsed = Number(metrics?.cli_budget?.in_use || 0); const cliLimit = Math.max(1, Number(metrics?.cli_budget?.limit || 0)); @@ -146,126 +483,470 @@ export function WorkbenchPage() { const webMax = Math.max(1, Number(metrics?.webcrt?.max_sessions || 20)); const webPct = ratioPct(webUsed, webMax); - const taskCap = Math.max(totalTasks, activeTasks, 8); - const taskPct = ratioPct(activeTasks, taskCap); + const hostBusy = cpuPct >= 75 || memPct >= 75; + const platformBusy = cliPct >= 75 || dbPct >= 75 || webPct >= 75; + const schedulers = metrics?.device_schedulers; + const schedConfigOn = Boolean(schedulers?.config_sync?.running ?? metrics?.config_sync?.running); + const schedLldpOn = Boolean(schedulers?.lldp_collect?.running ?? metrics?.lldp_collect?.running); + const schedPortOn = Boolean(schedulers?.port_traffic?.running ?? metrics?.port_traffic?.running); + const schedNeOn = Boolean(schedulers?.ne_collect?.running); + const schedStale = Boolean(schedulers?.stale); + const schedMode = String(schedulers?.mode || ""); + const schedAge = typeof schedulers?.age_sec === "number" ? schedulers.age_sec : null; + const healthBusy = hostBusy || platformBusy || schedStale; + const canReadFleet = isAdmin || hasScope("ne:read"); - const [now, setNow] = useState(() => new Date()); - useEffect(() => { - const id = window.setInterval(() => setNow(new Date()), 1000); - return () => window.clearInterval(id); - }, []); + const neTotal = neStatsQuery.data?.total ?? 0; + const neByStatus = neStatsQuery.data?.by_status ?? {}; + const nePass = Number(neByStatus.pass || 0); + const neFail = Number(neByStatus.fail || 0); + const neTesting = Number(neByStatus.testing || 0); + const neUnknown = Number(neByStatus.unknown || 0); + const neNoTag = Number(neStatsQuery.data?.no_tag_count || 0); + const neKnownPct = neTotal > 0 ? clampPct(((neTotal - neUnknown) / neTotal) * 100) : 0; + const neTopTags = Object.entries(neStatsQuery.data?.tag_counts ?? {}) + .filter(([tag, n]) => tag && tag !== "__no_tag__" && Number(n) > 0) + .sort((a, b) => Number(b[1]) - Number(a[1])) + .slice(0, 2) + .map(([tag, n]) => ({ tag, n: Number(n) })); + const neValue = !canReadFleet + ? "—" + : neStatsQuery.isLoading + ? "…" + : neStatsQuery.isError + ? "—" + : neTotal; - const clockLocale = locale === "en" ? "en-US" : "zh-CN"; - const clockDate = useMemo(() => { - try { - return new Intl.DateTimeFormat(clockLocale, { - year: "numeric", - month: "2-digit", - day: "2-digit", - weekday: "short", - }).format(now); - } catch { - return now.toLocaleDateString(); + const fabricNodes = Number(fabricQuery.data?.node_count || 0); + const fabricEdges = Number(fabricQuery.data?.edge_count || 0); + const fabricDiscover = formatCompactTime(fabricQuery.data?.last_discover_at ?? null); + const fabricUpdated = formatCompactTime(fabricQuery.data?.updated_at ?? null); + + const regionSlices = useMemo((): FleetSlice[] => { + const regions = topLevelRegions(topoTreeQuery.data?.root) + .map((r) => ({ + id: r.id, + name: String(r.name || "").trim() || r.id, + n: Number(r.ne_count || 0), + })) + .filter((r) => r.n > 0) + .sort((a, b) => b.n - a.n); + + // Cap at 4 legend rows to match 网元 / 调度 denseness. + const TOP_N = 3; + const head = regions.slice(0, TOP_N); + const rest = regions.slice(TOP_N); + const restSum = rest.reduce((s, r) => s + r.n, 0); + const assigned = regions.reduce((s, r) => s + r.n, 0); + const unassigned = Math.max(0, fabricNodes - assigned); + const other = restSum + unassigned; + + const slices: FleetSlice[] = head.map((r, i) => ({ + key: r.id, + n: r.n, + label: r.name, + color: REGION_PALETTE[i % REGION_PALETTE.length], + })); + if (other > 0) { + slices.push({ + key: unassigned > 0 && restSum === 0 ? "__none__" : "__other__", + n: other, + label: "", + color: unassigned > 0 && restSum === 0 ? FLEET_TONE_COLOR.muted : "#94a3b8", + }); } - }, [now, clockLocale]); - const hh = String(now.getHours()).padStart(2, "0"); - const mm = String(now.getMinutes()).padStart(2, "0"); - const ss = String(now.getSeconds()).padStart(2, "0"); - const colonOn = now.getSeconds() % 2 === 0; + return slices; + }, [topoTreeQuery.data, fabricNodes]); + + const memValue = + memTotal > 0 ? `${memPct}% · ${formatBytes(memUsed)}` : `${memPct}%`; + const storageValue = storageOk ? formatBytesCompact(storageUsed) : "—"; + + const dash = (v: number | string) => (canReadFleet ? v : "—"); + const tickAge = (sec?: number | null) => formatAgeSec(sec ?? null); + + const schedRows: SchedDiscItem[] = [ + { + key: "config", + on: schedConfigOn, + label: t("workbench.fleetSchedConfig"), + age: tickAge(schedulers?.config_sync?.last_tick_age_sec), + moduleId: "network", + path: "/network/tasks/config-sync", + }, + { + key: "lldp", + on: schedLldpOn, + label: t("workbench.fleetSchedLldp"), + age: tickAge(schedulers?.lldp_collect?.last_tick_age_sec), + moduleId: "network", + path: "/network/topology/lldp", + }, + { + key: "port", + on: schedPortOn, + label: t("workbench.fleetSchedPort"), + age: tickAge(schedulers?.port_traffic?.last_tick_age_sec), + moduleId: "network", + path: "/network/tasks/port-traffic", + }, + { + key: "ne", + on: schedNeOn, + label: t("workbench.fleetSchedNeCollect"), + age: tickAge(schedulers?.ne_collect?.last_tick_age_sec), + moduleId: "network", + path: "/network/tasks/collect", + }, + ]; + const schedOnCount = schedRows.filter((r) => r.on).length; return ( -
-
-
- +
+
+
+

{t("workbench.title")}

+ +
-
-
- - 0 - ? `${formatBytes(memUsed, locale)} / ${formatBytes(memTotal, locale)}` - : loadHint(memPct, t) - } - pct={memPct} - /> - - - - - -
+
+ +
+
+ {t("workbench.hostHealth")} + + + +
- {SECTIONS.map((section) => { - const mods = visibleBySection[section]; - if (mods.length === 0) return null; - return ( -
-
-

{t(`workbench.${section}`)}

- {mods.length} +
+
+
+ {displaySections.map((section) => ( +
+

{t(`workbench.${section.key}`)}

+
+ {section.mods.map((mod) => ( + + ))} +
+
+ ))} +
+ +
+
+

{t("workbench.fleet")}

-
- {mods.map((mod) => ( - - ))} +
+