Switch CLI parsing to TextFSM and polish topology/workbench UX.

Use custom cli_templates plus community ntc-templates for LLDP and port traffic across ZTE/Huawei/Cisco/H3C/Juniper/Nokia, drop regex parsers, and surface Admin/Phy/Prot on port discover. Also batch-add topology NEs and tighten workbench branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 23:06:40 +08:00
parent 60186a12e3
commit 393fd0e0b4
41 changed files with 1771 additions and 1075 deletions

View file

@ -1,67 +1,20 @@
import { useEffect, useRef, useState } from "react";
import { useI18n, type Locale } from "../i18n";
import { useI18n } from "../i18n";
/** Compact locale toggle: shows current language, click switches ZH ↔ EN. */
export function HeaderMenu() {
const { t, locale, setLocale } = useI18n();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocClick = (ev: MouseEvent) => {
if (!rootRef.current?.contains(ev.target as Node)) setOpen(false);
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const pick = (next: Locale) => {
setLocale(next);
setOpen(false);
};
const next = locale === "zh" ? "en" : "zh";
const label = locale === "zh" ? "ZH" : "EN";
return (
<div className="header-menu header-menu--on-brand" ref={rootRef}>
<button
type="button"
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--icon"
aria-label={t("layout.moreMenu")}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
⋯
</button>
{open ? (
<div className="header-menu__panel header-menu__panel--light" role="menu">
<div className="header-menu__label header-menu__label--light">{t("layout.language")}</div>
<button
type="button"
role="menuitemradio"
className={`header-menu__item header-menu__item--light${locale === "zh" ? " header-menu__item--active-light" : ""}`}
aria-checked={locale === "zh"}
onClick={() => pick("zh")}
>
{t("layout.langZh")}
</button>
<button
type="button"
role="menuitemradio"
className={`header-menu__item header-menu__item--light${locale === "en" ? " header-menu__item--active-light" : ""}`}
aria-checked={locale === "en"}
onClick={() => pick("en")}
>
{t("layout.langEn")}
</button>
</div>
) : null}
</div>
<button
type="button"
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--locale"
title={t("layout.switchLanguage")}
aria-label={t("layout.switchLanguage")}
onClick={() => setLocale(next)}
>
{label}
</button>
);
}

View file

@ -1,14 +1,60 @@
import type { ModuleIconTone } from "../config/modules";
import type { ModuleIconKind, ModuleIconTone } from "../config/modules";
type Props = {
tone: ModuleIconTone;
kind: ModuleIconKind;
};
export function WorkbenchCardIcon({ tone }: Props) {
function IconPath({ kind }: { kind: ModuleIconKind }) {
switch (kind) {
case "sync":
return (
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" />
);
case "server":
return (
<path d="M4 4h16a1 1 0 0 1 1 1v5H3V5a1 1 0 0 1 1-1zm-1 8h18v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5zm3 1.25a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm0-8a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM8 6.5h6v1.25H8V6.5zm0 8h6v1.25H8V14.5z" />
);
case "network":
return (
<path d="M12 2a3 3 0 0 1 1 5.83V10h3a3 3 0 1 1 0 2h-3v2.17A3.001 3.001 0 1 1 11 17.83V14H8a3 3 0 1 1 0-2h3v-2.17A3.001 3.001 0 0 1 12 2zm0 2a1 1 0 1 0 .001 2.001A1 1 0 0 0 12 4zM6 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm12 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
);
case "topology":
return (
<path d="M7 3a3 3 0 0 1 2.83 4H11v2h2V7h1.17A3.001 3.001 0 1 1 17 10.83V12h2v2h-2v1.17A3.001 3.001 0 1 1 13.17 18H12v-2h-1.17A3.001 3.001 0 1 1 7 13.17V12H5v-2h2V8.83A3.001 3.001 0 0 1 7 3zm10 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM7 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm0 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
);
case "terminal":
return (
<path d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5zm2 0v14h12V5H6zm2.3 3.2 1.4-1.4L13.1 10l-3.4 3.2-1.4-1.4L10.3 10 8.3 8.2zM12 15h4v1.8h-4V15z" />
);
case "wall":
return (
<path d="M3 5h8v6H3V5zm10 0h8v6h-8V5zM3 13h8v6H3v-6zm10 0h8v6h-8v-6zm2-6v2h4V7h-4zm0 8v2h4v-2h-4zM5 7v2h4V7H5zm0 8v2h4v-2H5z" />
);
case "users":
return (
<path d="M12 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0 2c-3.33 0-6 1.34-6 3v1h12v-1c0-1.66-2.67-3-6-3zm7.5-5.5a2.5 2.5 0 1 0-2.45-3H17a2.5 2.5 0 0 0 2.5 3.5zM19 12c1.66 0 3 .67 3 1.5V14h-2.1c-.3-.7-1-1.2-1.9-1.5v-.5z" />
);
case "audit":
return (
<path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7 1.5V9h4.5L14 4.5zM9 12h6v1.5H9V12zm0 3.5h6V17H9v-1.5zm0-7h3V10H9V8.5z" />
);
case "key":
return (
<path d="M12.65 10A5.99 5.99 0 0 0 7 4a6 6 0 0 0-1 11.92V20l2 2 2-2 1-1v-2.08c.33.05.66.08 1 .08a6 6 0 0 0 5.65-8H12.65zM7 8a2 2 0 1 1 0 4 2 2 0 0 1 0-4z" />
);
default:
return (
<path d="M12 2a2 2 0 0 1 2 2v1.07A7.002 7.002 0 0 1 19.93 11H21a2 2 0 0 1 0 4h-1.07A7.002 7.002 0 0 1 13 20.93V22a2 2 0 0 1-4 0v-1.07A7.002 7.002 0 0 1 4.07 15H3a2 2 0 0 1 0-4h1.07A7.002 7.002 0 0 1 11 5.07V4a2 2 0 0 1 2-2zm0 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
);
}
}
export function WorkbenchCardIcon({ tone, kind }: Props) {
return (
<span className={`wb-card__icon wb-card__icon--${tone}`} aria-hidden>
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M12 2a2 2 0 0 1 2 2v1.07A7.002 7.002 0 0 1 19.93 11H21a2 2 0 0 1 0 4h-1.07A7.002 7.002 0 0 1 13 20.93V22a2 2 0 0 1-4 0v-1.07A7.002 7.002 0 0 1 4.07 15H3a2 2 0 0 1 0-4h1.07A7.002 7.002 0 0 1 11 5.07V4a2 2 0 0 1 2-2zm0 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
<IconPath kind={kind} />
</svg>
</span>
);

View file

@ -3,6 +3,16 @@
*/
export type ModuleIconTone = "blue" | "green" | "amber" | "slate";
export type ModuleIconKind =
| "sync"
| "server"
| "network"
| "topology"
| "terminal"
| "wall"
| "users"
| "audit"
| "key";
export type WorkbenchSection = "monitoring" | "operations" | "system";
export type ModuleDefinition = {
@ -12,6 +22,7 @@ export type ModuleDefinition = {
labelKey: string;
descKey?: string;
iconTone: ModuleIconTone;
iconKind: ModuleIconKind;
titleKey: string;
/** Required capability scope to show in workbench (admin bypasses). */
requiredScope?: string;
@ -28,6 +39,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.umeSync",
descKey: "workbench.cards.umeSyncDesc",
iconTone: "blue",
iconKind: "sync",
titleKey: "layout.titleUme",
requiredScope: "alarms:read",
},
@ -38,6 +50,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.managedNe",
descKey: "workbench.cards.managedNeDesc",
iconTone: "green",
iconKind: "server",
titleKey: "layout.titleManagedNe",
requiredScope: "ne:read",
},
@ -48,6 +61,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.network",
descKey: "workbench.cards.networkDesc",
iconTone: "slate",
iconKind: "network",
titleKey: "layout.titleNetwork",
requiredScope: "ne:read",
},
@ -58,6 +72,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.topology",
descKey: "workbench.cards.topologyDesc",
iconTone: "amber",
iconKind: "topology",
titleKey: "layout.titleTopology",
requiredScope: "ne:read",
},
@ -68,6 +83,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.webcrt",
descKey: "workbench.cards.webcrtDesc",
iconTone: "slate",
iconKind: "terminal",
titleKey: "layout.titleWebcrt",
requiredScope: "webcrt:session",
},
@ -78,6 +94,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.portTrafficWall",
descKey: "workbench.cards.portTrafficWallDesc",
iconTone: "amber",
iconKind: "wall",
titleKey: "layout.titlePortTrafficWall",
workbenchHidden: true,
requiredScope: "ne:read",
@ -89,6 +106,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.users",
descKey: "workbench.cards.usersDesc",
iconTone: "slate",
iconKind: "users",
titleKey: "layout.titleUsers",
adminOnly: true,
},
@ -99,6 +117,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.audit",
descKey: "workbench.cards.auditDesc",
iconTone: "amber",
iconKind: "audit",
titleKey: "layout.titleAudit",
},
{
@ -108,6 +127,7 @@ export const MODULES: readonly ModuleDefinition[] = [
labelKey: "workbench.cards.apiKeys",
descKey: "workbench.cards.apiKeysDesc",
iconTone: "green",
iconKind: "key",
titleKey: "layout.titleApiKeys",
},
] as const satisfies readonly ModuleDefinition[];

View file

@ -14,6 +14,9 @@ const en = {
},
workbench: {
title: "Workbench",
modules: "Modules",
sections: "Sections",
moduleCount: "{{count}} modules",
backToWorkbench: "Back to Workbench",
openInNewTab: "Open in new tab",
openModule: "Open or switch to this module tab",
@ -254,6 +257,13 @@ const en = {
source: "Source",
name: "Device",
vendor: "Vendor",
ifname: "Interface",
bw: "BW",
upDown: "Admin / Phy / Prot",
admin: "Admin",
phy: "Phy",
prot: "Prot",
description: "Description",
},
},
lldpLinks: {
@ -416,13 +426,16 @@ const en = {
navUme: "UME",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
activeTasks: "Active {{count}}",
activeTasksHint: "Open Audit · Task overview",
language: "Language",
switchLanguage: "Switch language / 切换语言",
moreMenu: "More",
langZh: "中文",
langEn: "English",
},
auth: {
loginTitle: "Sign in to NetX",
loginTitle: "Sign in to NETX",
loginHint: "Use a local account to access the ops platform",
username: "Username",
password: "Password",
@ -1278,7 +1291,12 @@ const en = {
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
emptyMaps: "No regions yet. Create a region first.",
palette: "NE library",
paletteHint: "Click or drag onto the canvas",
paletteHint: "Select multiple to add in batch, or drag one onto the canvas",
paletteDragHint: "Drag onto canvas to add",
selectAllVisible: "Select all visible",
addSelected: "Add selected ({{count}})",
addSelectedDone: "Added {{count}} NE(s)",
addingNe: "Adding…",
filterPh: "Type to filter: name / IP / vendor",
save: "Save",
saveDirty: "Save *",

View file

@ -14,6 +14,9 @@ const zh = {
},
workbench: {
title: "工作台",
modules: "可用模块",
sections: "功能分区",
moduleCount: "{{count}} 个模块",
backToWorkbench: "返回工作台",
openInNewTab: "在新标签页中打开",
openModule: "打开或切换到该模块页签",
@ -251,6 +254,13 @@ const zh = {
source: "来源",
name: "设备",
vendor: "厂商",
ifname: "接口",
bw: "带宽",
upDown: "Admin / Phy / Prot",
admin: "Admin",
phy: "Phy",
prot: "Prot",
description: "描述",
},
},
lldpLinks: {
@ -413,13 +423,16 @@ const zh = {
navUme: "UME 对接",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
activeTasks: "活跃任务 {{count}}",
activeTasksHint: "打开操作审计 · 任务概览",
language: "语言",
switchLanguage: "切换语言 / Switch language",
moreMenu: "更多",
langZh: "中文",
langEn: "English",
},
auth: {
loginTitle: "登录 NetX",
loginTitle: "登录 NETX",
loginHint: "使用本地账号访问运维平台",
username: "用户名",
password: "密码",
@ -1272,7 +1285,12 @@ const zh = {
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
emptyMaps: "还没有区域,先点「新建区域」。",
palette: "网元库",
paletteHint: "点击或拖到画布添加",
paletteHint: "勾选后批量添加;也可拖到画布添加单台",
paletteDragHint: "拖到画布添加",
selectAllVisible: "全选当前列表",
addSelected: "添加选中({{count}})",
addSelectedDone: "已添加 {{count}} 台网元",
addingNe: "添加中…",
filterPh: "输入即筛:名称 / IP / 厂商",
save: "保存",
saveDirty: "保存 *",

View file

@ -33,7 +33,7 @@ body {
.app-brand__logo {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.6px;
letter-spacing: 0.12em;
color: #fff;
text-decoration: none;
flex-shrink: 0;
@ -116,59 +116,159 @@ body {
}
.workbench {
max-width: 1280px;
width: 100%;
max-width: none;
}
.app-main:has(> .workbench) {
padding: 16px 20px 28px;
}
.wb-head {
display: flex;
align-items: center;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #d5deea;
}
.wb-head__title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f2744;
letter-spacing: 0.02em;
}
.wb-section {
margin-bottom: 28px;
margin-bottom: 22px;
}
.wb-section__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.wb-section__title {
margin: 0 0 14px;
padding-bottom: 10px;
font-size: 15px;
font-weight: 600;
margin: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 650;
color: #334155;
border-bottom: 1px solid #d8dee8;
}
.wb-section__title::before {
content: "";
width: 3px;
height: 12px;
border-radius: 2px;
background: #64748b;
}
.wb-section--monitoring .wb-section__title::before {
background: #1565c0;
}
.wb-section--operations .wb-section__title::before {
background: #0f766e;
}
.wb-section--system .wb-section__title::before {
background: #b45309;
}
.wb-section__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #e8eef5;
color: #64748b;
font-size: 11px;
font-weight: 650;
line-height: 1;
}
.wb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
}
.wb-card {
display: flex;
.wb-card,
.app-main .wb-card {
display: inline-flex;
align-items: center;
gap: 14px;
min-height: 72px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 4px;
gap: 10px;
width: auto;
min-width: 132px;
max-width: 100%;
min-height: 0;
margin: 0;
padding: 12px 16px 12px 12px;
border: 1px solid #d8e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
box-shadow: none;
cursor: pointer;
text-align: left;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
transition: border-color 0.14s ease, background 0.14s ease, box-shadow 0.14s ease;
}
.wb-card:hover {
.wb-card:hover,
.wb-card:focus-visible {
border-color: #8fb4df;
background: #f7faff;
box-shadow: 0 2px 10px rgba(15, 39, 68, 0.08);
transform: none;
outline: none;
}
.wb-card--blue:hover,
.wb-card--blue:focus-visible {
border-color: #90caf9;
box-shadow: 0 4px 14px rgba(33, 150, 243, 0.15);
transform: translateY(-1px);
}
.wb-card--green:hover,
.wb-card--green:focus-visible {
border-color: #81c784;
}
.wb-card--amber:hover,
.wb-card--amber:focus-visible {
border-color: #ffd54f;
}
.wb-card--slate:hover,
.wb-card--slate:focus-visible {
border-color: #90a4ae;
}
.wb-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 4px;
width: 34px;
height: 38px;
border-radius: 0;
flex-shrink: 0;
/* Flat-top hexagon */
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 {
@ -191,23 +291,14 @@ body {
color: #455a64;
}
.wb-card__text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.wb-card__label {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.wb-card__desc {
font-size: 12px;
color: #64748b;
line-height: 1.35;
min-width: 0;
font-size: 13px;
font-weight: 650;
color: #0f2744;
line-height: 1.25;
white-space: nowrap;
padding-right: 2px;
}
.header-menu__panel--light {
@ -658,6 +749,21 @@ pre {
display: none;
}
.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks {
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 400;
}
.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover,
.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.14);
color: #fff;
outline: none;
}
.app-brand .header-menu__trigger.header-menu__trigger--on-brand {
display: inline-flex;
align-items: center;
@ -683,6 +789,14 @@ pre {
letter-spacing: 0.06em;
}
.app-brand .header-menu__trigger.header-menu__trigger--on-brand.header-menu__trigger--locale {
min-width: 36px;
padding: 0 8px;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
}
.app-brand .header-menu__trigger.header-menu__trigger--on-brand:hover,
.app-brand .header-menu__trigger.header-menu__trigger--on-brand:focus-visible {
background: rgba(255, 255, 255, 0.14);
@ -4424,10 +4538,50 @@ pre {
justify-content: space-between;
gap: 8px;
}
.topo-modal__hint {
margin: 0;
}
.topo-modal__selectbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.topo-modal__selectall {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--nm-muted, #5b6b7c);
cursor: pointer;
user-select: none;
}
.topo-modal__list {
max-height: 360px;
overflow: auto;
}
.topo-modal__foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-top: 6px;
border-top: 1px solid #e2e8f0;
}
.topo-palette__check {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0 8px;
border-right: 1px solid #cdd6e2;
cursor: pointer;
}
.topo-palette__row.is-selected {
border-color: var(--nm-accent, #2563eb);
background: var(--nm-nav-active, #e8f0fb);
box-shadow: inset 2px 0 0 var(--nm-accent, #2563eb);
}
.topo-map-list,
.topo-palette {
list-style: none;
@ -6565,6 +6719,27 @@ pre {
margin-top: 2px;
}
.pt-port-updown {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.pt-port-updown .conn-pill {
min-width: 2.4em;
justify-content: center;
text-transform: lowercase;
font-variant-numeric: tabular-nums;
}
.pt-wizard__confirm-port {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.pt-list-status--ok,
.pt-list-status--up,
.pt-list-status--connected {

View file

@ -1,10 +1,13 @@
import { Link, useLocation } from "react-router-dom";
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { AppsGridIcon } from "../components/AppsGridIcon";
import { HeaderMenu } from "../components/HeaderMenu";
import { getPageTitleKey, isWorkbenchPath } from "../config/modules";
import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration";
import { useI18n } from "../i18n";
import { fetchOpsTasks } from "../services/api";
import { openOrFocusModule } from "../utils/moduleWindows";
import { returnToWorkbench } from "../utils/workbench";
import { useAuth } from "../auth/AuthContext";
@ -30,6 +33,14 @@ export function AppLayout({ connections, children }: Props) {
const { user, logout } = useAuth();
const onWorkbench = isWorkbenchPath(pathname);
const pageTitle = t(getPageTitleKey(pathname));
const opsTasksQuery = useQuery({
queryKey: ["opsTasks"],
queryFn: fetchOpsTasks,
enabled: Boolean(user),
refetchInterval: 4000,
staleTime: 1500,
});
const activeTasks = opsTasksQuery.data?.active ?? 0;
const netxSuffix =
typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : "";
const oclawSuffix =
@ -55,10 +66,10 @@ export function AppLayout({ connections, children }: Props) {
<div className="app-brand__start">
{onWorkbench ? (
<Link to="/" className="app-brand__logo">
NetX
NETX
</Link>
) : (
<span className="app-brand__logo app-brand__logo--static">NetX</span>
<span className="app-brand__logo app-brand__logo--static">NETX</span>
)}
{!onWorkbench ? (
<>
@ -94,6 +105,14 @@ export function AppLayout({ connections, children }: Props) {
<>
<span className="app-brand__actions-sep" aria-hidden />
<div className="app-brand__actions-group">
<button
type="button"
className={`conn-pill conn-pill--on-brand conn-pill--tasks conn-pill--${activeTasks > 0 ? "up" : "unknown"}`}
title={t("layout.activeTasksHint")}
onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
>
{t("layout.activeTasks", { count: activeTasks })}
</button>
<span
className="conn-pill conn-pill--on-brand conn-pill--user"
title={user.role}

View file

@ -44,7 +44,7 @@ export function ForceChangePasswordPage() {
return (
<div className="login-page">
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__brand">NetX</div>
<div className="login-card__brand">NETX</div>
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
<p className="login-card__hint">
{t("auth.forceChangeHint", { user: user?.username || "admin" })}

View file

@ -33,7 +33,7 @@ export function LoginPage() {
return (
<div className="login-page">
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__brand">NetX</div>
<div className="login-card__brand">NETX</div>
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
<p className="login-card__hint">{t("auth.loginHint")}</p>
<label className="login-card__label">

View file

@ -51,7 +51,6 @@ import { useToast } from "../hooks/useToast";
import { openOrFocusModule } from "../utils/moduleWindows";
import type {
FabricNodeSearchHit,
ManagedNeItem,
TopologyDiscoverJob,
TopologyDiscoverNeResult,
TopologyDiscoverOut,
@ -64,7 +63,6 @@ import type {
TopologyViewNodeItem,
TopologyViewKind,
TopologyViewRole,
UmeNeItem,
} from "../types";
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
@ -641,6 +639,8 @@ export function TopologyPage() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [addNeOpen, setAddNeOpen] = useState(false);
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
const [paletteAdding, setPaletteAdding] = useState(false);
const [discoverOpen, setDiscoverOpen] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
@ -1136,7 +1136,19 @@ export function TopologyPage() {
);
const saveMut = useMutation({
mutationFn: () => patchTopologyPositions(mapId, flowToPositions(nodes)),
mutationFn: async () => {
if (!mapId) throw new Error(t("topology.selectMap"));
// Positions patch only updates listed nodes; sync canvas removals first.
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
.filter(Boolean);
const localIds = new Set(nodes.map((n) => n.id));
const toRemove = serverIds.filter((id) => !localIds.has(id));
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
return patchTopologyPositions(mapId, flowToPositions(nodes));
},
onSuccess: async (graph) => {
clearDirty();
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
@ -1336,47 +1348,60 @@ export function TopologyPage() {
[setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError],
);
const addNodeAt = useCallback(
async (item: PaletteItem, position: { x: number; y: number }) => {
const addPaletteItems = useCallback(
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
if (!mapId) {
showError(t("topology.selectMap"));
return;
return false;
}
if (items.length === 0 || paletteAdding) return false;
const onCanvasManaged = new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean));
const onCanvasUme = new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean));
const managedIds = [
...new Set(
items
.filter((item) => item.source === "managed")
.map((item) => item.managed_ne_id)
.filter((id) => id && !onCanvasManaged.has(id)),
),
];
const umeIds = [
...new Set(
items
.filter((item) => item.source === "ume")
.map((item) => item.ume_ne_id)
.filter((id) => id && !onCanvasUme.has(id)),
),
];
if (managedIds.length === 0 && umeIds.length === 0) return false;
setPaletteAdding(true);
try {
if (item.source === "managed") {
if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return;
const graph = await addTopologyViewNodes(mapId, { managed_ne_ids: [item.managed_ne_id] });
const added = graph.nodes.find((n) => n.managed_ne_id === item.managed_ne_id);
if (added) {
await patchTopologyPositions(mapId, [
{
fabric_node_id: added.fabric_node_id,
x: position.x,
y: position.y,
label: added.label || added.name || "",
},
]);
}
const refreshed = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
historyLockRef.current = true;
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
return;
}
if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return;
const graph = await addTopologyViewNodes(mapId, { ume_ne_ids: [item.ume_ne_id] });
const added = graph.nodes.find((n) => n.ume_ne_id === item.ume_ne_id);
if (added) {
await patchTopologyPositions(mapId, [
{
fabric_node_id: added.fabric_node_id,
x: position.x,
y: position.y,
label: added.label || added.name || "",
},
]);
const graph = await addTopologyViewNodes(mapId, {
managed_ne_ids: managedIds,
ume_ne_ids: umeIds,
layout: "grid",
});
const managedSet = new Set(managedIds);
const umeSet = new Set(umeIds);
const added = graph.nodes.filter(
(n) =>
(n.managed_ne_id && managedSet.has(n.managed_ne_id)) ||
(n.ume_ne_id && umeSet.has(n.ume_ne_id)),
);
if (added.length > 0) {
const start = origin || { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
await patchTopologyPositions(
mapId,
added.map((n, i) => ({
fabric_node_id: n.fabric_node_id,
x: start.x + (i % cols) * 180,
y: start.y + Math.floor(i / cols) * 120,
label: n.label || n.name || "",
})),
);
}
const refreshed = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
@ -1384,57 +1409,37 @@ export function TopologyPage() {
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
const count = added.length || managedIds.length + umeIds.length;
showOk(t("topology.addSelectedDone").replace("{{count}}", String(count)));
return count > 0;
} catch (err) {
showError(String(err));
return false;
} finally {
setPaletteAdding(false);
}
},
[mapId, nodes, setNodes, setEdges, showError, t, queryClient, edgeDefaults, clearDirty],
[
mapId,
nodes,
paletteAdding,
setNodes,
setEdges,
showError,
showOk,
t,
queryClient,
edgeDefaults,
clearDirty,
],
);
const addManagedNeToCanvas = (ne: ManagedNeItem) => {
addNodeAt(
{
key: `managed:${ne.id}`,
source: "managed",
managed_ne_id: ne.id,
ume_ne_id: "",
name: ne.name || ne.ip_address,
ip: ne.ip_address,
vendor: ne.vendor,
meta: "",
connect_status: ne.connect_status,
},
{ x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 },
);
};
const addUmeNeToCanvas = (ne: UmeNeItem) => {
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
addNodeAt(
{
key: `ume:${ne.ne_id}`,
source: "ume",
managed_ne_id: "",
ume_ne_id: ne.ne_id,
name,
ip: ne.ip_address || "",
vendor: "ZTE",
meta: "",
connect_status: ne.connection_status || "",
},
{ x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 },
);
};
const addPaletteItem = (item: PaletteItem) => {
if (item.source === "managed") {
const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id);
if (ne) addManagedNeToCanvas(ne);
return;
}
const ne = (umeQuery.data?.items || []).find((x) => x.ne_id === item.ume_ne_id);
if (ne) addUmeNeToCanvas(ne);
};
const addNodeAt = useCallback(
async (item: PaletteItem, position: { x: number; y: number }) => {
await addPaletteItems([item], position);
},
[addPaletteItems],
);
const onPaletteDragStart = (e: React.DragEvent, item: PaletteItem) => {
e.dataTransfer.setData(PALETTE_DND, JSON.stringify(item));
@ -1660,12 +1665,20 @@ export function TopologyPage() {
};
}, [ctxMenu, closeCtxMenu]);
const removeNodeById = (nodeId: string) => {
const removeNodeById = async (nodeId: string) => {
if (!mapId) return;
pushHistory();
markDirty();
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
closeCtxMenu();
try {
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
} catch (err) {
showError(String(err));
}
};
const removeEdgeById = (edgeId: string) => {
@ -2403,6 +2416,7 @@ export function TopologyPage() {
className="btn btn--sm"
onClick={() => {
setKeyword("");
setPaletteSelectedKeys([]);
setAddNeOpen(true);
}}
>
@ -3049,23 +3063,38 @@ export function TopologyPage() {
</main>
{addNeOpen && canvasMode ? (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div className="topo-modal__backdrop" onClick={() => setAddNeOpen(false)} />
<div
className="topo-modal__backdrop"
onClick={() => {
if (paletteAdding) return;
setAddNeOpen(false);
}}
/>
<div className="topo-modal__panel">
<div className="topo-modal__head">
<strong>{t("topology.addNe")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={() => setAddNeOpen(false)}>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={() => setAddNeOpen(false)}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
onClick={() => setPaletteSource("managed")}
disabled={paletteAdding}
onClick={() => {
setPaletteSource("managed");
setPaletteSelectedKeys([]);
}}
>
{t("topology.paletteManaged")}
</button>
@ -3074,7 +3103,11 @@ export function TopologyPage() {
role="tab"
aria-selected={paletteSource === "ume"}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
onClick={() => setPaletteSource("ume")}
disabled={paletteAdding}
onClick={() => {
setPaletteSource("ume");
setPaletteSelectedKeys([]);
}}
>
{t("topology.paletteUme")}
</button>
@ -3082,10 +3115,40 @@ export function TopologyPage() {
<input
className="input"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={(e) => {
setKeyword(e.target.value);
setPaletteSelectedKeys([]);
}}
placeholder={t("topology.filterPh")}
disabled={paletteAdding}
autoFocus
/>
{paletteVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
paletteVisible.length > 0 &&
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
}
disabled={paletteAdding}
onChange={(e) => {
if (e.target.checked) {
setPaletteSelectedKeys(paletteVisible.map((item) => item.key));
return;
}
setPaletteSelectedKeys([]);
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{paletteLoading ? (
<li className="topo-palette__empty">
@ -3096,23 +3159,77 @@ export function TopologyPage() {
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
</li>
) : (
paletteVisible.map((item) => (
<li key={item.key}>
<button
type="button"
className="topo-palette__item"
onClick={() => {
addPaletteItem(item);
setAddNeOpen(false);
}}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</li>
))
paletteVisible.map((item) => {
const checked = paletteSelectedKeys.includes(item.key);
return (
<li key={item.key}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={paletteAdding}
onChange={(e) => {
setPaletteSelectedKeys((prev) =>
e.target.checked
? [...new Set([...prev, item.key])]
: prev.filter((key) => key !== item.key),
);
}}
aria-label={item.name}
/>
</label>
<button
type="button"
className="topo-palette__item"
draggable={!paletteAdding}
disabled={paletteAdding}
onClick={() => {
setPaletteSelectedKeys((prev) =>
prev.includes(item.key)
? prev.filter((key) => key !== item.key)
: [...prev, item.key],
);
}}
onDragStart={(e) => onPaletteDragStart(e, item)}
title={t("topology.paletteDragHint")}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={() => setAddNeOpen(false)}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={async () => {
const selected = paletteVisible.filter((item) => paletteSelectedKeys.includes(item.key));
if (selected.length === 0) return;
const ok = await addPaletteItems(selected);
if (!ok) return;
setPaletteSelectedKeys([]);
setAddNeOpen(false);
}}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</button>
</div>
</div>
</div>
) : null}

View file

@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useI18n } from "../i18n";
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
import { modulesInSection, type WorkbenchSection } from "../config/modules";
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
import { openOrFocusModule } from "../utils/moduleWindows";
import { useAuth } from "../auth/AuthContext";
@ -10,34 +11,49 @@ export function WorkbenchPage() {
const { t } = useI18n();
const { isAdmin, hasScope } = useAuth();
const visibleBySection = useMemo(() => {
const canSee = (mod: ModuleDefinition) =>
!mod.workbenchHidden &&
(!mod.adminOnly || isAdmin) &&
(!mod.requiredScope || hasScope(mod.requiredScope) || isAdmin);
return Object.fromEntries(
SECTIONS.map((section) => [section, modulesInSection(section).filter(canSee)]),
) as Record<WorkbenchSection, ModuleDefinition[]>;
}, [isAdmin, hasScope]);
return (
<div className="workbench">
{SECTIONS.map((section) => (
<section key={section} className="wb-section">
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
<div className="wb-grid">
{modulesInSection(section)
.filter((mod) => !mod.workbenchHidden)
.filter((mod) => !mod.adminOnly || isAdmin)
.filter((mod) => !mod.requiredScope || hasScope(mod.requiredScope) || isAdmin)
.map((mod) => (
<button
key={mod.moduleId}
type="button"
className="wb-card"
title={t("workbench.openModule")}
onClick={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
>
<WorkbenchCardIcon tone={mod.iconTone} />
<span className="wb-card__text">
<header className="wb-head">
<h1 className="wb-head__title">{t("workbench.title")}</h1>
</header>
{SECTIONS.map((section) => {
const mods = visibleBySection[section];
if (mods.length === 0) return null;
return (
<section key={section} className={`wb-section wb-section--${section}`}>
<div className="wb-section__head">
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
<span className="wb-section__count">{mods.length}</span>
</div>
<div className="wb-grid">
{mods.map((mod) => (
<button
key={mod.moduleId}
type="button"
className={`wb-card wb-card--${mod.iconTone}`}
title={t("workbench.openModule")}
onClick={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
>
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
<span className="wb-card__label">{t(mod.labelKey)}</span>
{mod.descKey ? <span className="wb-card__desc">{t(mod.descKey)}</span> : null}
</span>
</button>
))}
</div>
</section>
))}
</button>
))}
</div>
</section>
);
})}
</div>
);
}

View file

@ -41,6 +41,42 @@ function statusTone(status: string): "running" | "paused" | "stopped" | "other"
return "other";
}
function upDownPillClass(value: string): string {
const v = String(value || "").trim().toLowerCase();
if (v === "up") return "conn-pill conn-pill--up";
if (v === "down") return "conn-pill conn-pill--down";
return "conn-pill conn-pill--unknown";
}
function PortUpDownCell({
admin,
phy,
prot,
labels,
}: {
admin?: string;
phy?: string;
prot?: string;
labels: { admin: string; phy: string; prot: string };
}) {
const a = String(admin || "").trim() || "—";
const p = String(phy || "").trim() || "—";
const r = String(prot || "").trim() || "—";
return (
<span className="pt-port-updown" title={`${labels.admin}/${labels.phy}/${labels.prot}`}>
<span className={upDownPillClass(a)} title={labels.admin}>
{a}
</span>
<span className={upDownPillClass(p)} title={labels.phy}>
{p}
</span>
<span className={upDownPillClass(r)} title={labels.prot}>
{r}
</span>
</span>
);
}
function formatPortTrafficLogMessage(
raw: string,
t: (key: string, vars?: Record<string, string | number>) => string,
@ -442,6 +478,15 @@ export function PortTrafficPage() {
};
const pickedList = useMemo(() => Object.values(pickedIfnames), [pickedIfnames]);
const discoveredByIfname = useMemo(
() => Object.fromEntries(ports.map((p) => [p.ifname, p])),
[ports],
);
const upDownLabels = {
admin: t("portTraffic.col.admin"),
phy: t("portTraffic.col.phy"),
prot: t("portTraffic.col.prot"),
};
const submitWizard = () => {
if (!selectedNe) {
@ -801,10 +846,10 @@ export function PortTrafficPage() {
<thead>
<tr>
<th />
<th>Interface</th>
<th>BW</th>
<th>Admin/Phy/Prot</th>
<th>Description</th>
<th>{t("portTraffic.col.ifname")}</th>
<th>{t("portTraffic.col.bw")}</th>
<th>{t("portTraffic.col.upDown")}</th>
<th>{t("portTraffic.col.description")}</th>
</tr>
</thead>
<tbody>
@ -820,7 +865,12 @@ export function PortTrafficPage() {
<td>{p.ifname}</td>
<td>{p.bw_raw || formatBw(p.bw_bps)}</td>
<td>
{p.admin}/{p.phy}/{p.prot}
<PortUpDownCell
admin={p.admin}
phy={p.phy}
prot={p.prot}
labels={upDownLabels}
/>
</td>
<td>{p.description || "—"}</td>
</tr>
@ -887,11 +937,24 @@ export function PortTrafficPage() {
</p>
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
<ul className="pt-wizard__confirm-list">
{pickedList.slice(0, 40).map((p) => (
<li key={p.ifname}>
{p.ifname} ({formatBw(p.bw_bps || 0)})
</li>
))}
{pickedList.slice(0, 40).map((p) => {
const disc = discoveredByIfname[p.ifname];
return (
<li key={p.ifname} className="pt-wizard__confirm-port">
<span>
{p.ifname} ({formatBw(p.bw_bps || 0)})
</span>
{disc ? (
<PortUpDownCell
admin={disc.admin}
phy={disc.phy}
prot={disc.prot}
labels={upDownLabels}
/>
) : null}
</li>
);
})}
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
</ul>
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}>
@ -982,9 +1045,10 @@ export function PortTrafficPage() {
<thead>
<tr>
<th />
<th>Interface</th>
<th>BW</th>
<th>Description</th>
<th>{t("portTraffic.col.ifname")}</th>
<th>{t("portTraffic.col.bw")}</th>
<th>{t("portTraffic.col.upDown")}</th>
<th>{t("portTraffic.col.description")}</th>
</tr>
</thead>
<tbody>
@ -999,6 +1063,14 @@ export function PortTrafficPage() {
</td>
<td>{p.ifname}</td>
<td>{p.bw_raw || formatBw(p.bw_bps)}</td>
<td>
<PortUpDownCell
admin={p.admin}
phy={p.phy}
prot={p.prot}
labels={upDownLabels}
/>
</td>
<td>{p.description || "—"}</td>
</tr>
))}

View file

@ -149,7 +149,7 @@ export function PortTrafficWallPage() {
useEffect(() => {
if (!board?.name) return;
try {
document.title = "NetX";
document.title = "NETX";
} catch {
/* ignore */
}