Refactor web UI: feature-first structure, tokenized styles, modern blue theme.

Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
  333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
  types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
  (2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.

Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
  pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
  off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
  unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
  without tree indent, soft selected state instead of left accent bar.

Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 03:17:51 +08:00
parent 3b45d6abe3
commit 0d894efe5e
196 changed files with 26947 additions and 28091 deletions

View file

@ -1,165 +0,0 @@
/** Jump-host (hop) templates per vendor. */
import { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate } from "./zteHop";
export type HopVendor = "zte" | "huawei" | "cisco" | "linux" | "bastion";
export type HopTargetAuthMode = "bastion_managed" | "manual";
export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux", "bastion"];
export function bastionHopTemplate(): string {
return "{hop_user}@{target_user}@{target_ip}";
}
export const LEGACY_BASTION_HOP_TEMPLATE = "{hop_user}@{target_user}@{target_ip}@{hop_host}";
export function isBastionHopVendor(vendor: string): boolean {
return String(vendor || "").toLowerCase() === "bastion";
}
export function isLinuxHopVendor(vendor: string): boolean {
return String(vendor || "").toLowerCase() === "linux";
}
export function isCliHopVendor(vendor: string): boolean {
const v = String(vendor || "").toLowerCase();
return v === "zte" || v === "huawei" || v === "cisco";
}
/** Strip ssh:// / trailing slash / :port from bastion host (IP or FQDN). */
export function normalizeHopHost(value: string): string {
let host = String(value || "").trim();
if (!host) return "";
host = host.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
if (host.includes("://")) host = host.split("://", 2)[1] || host;
host = host.trim().replace(/\/+$/, "");
if (host.startsWith("[") && host.includes("]")) {
const inside = host.slice(1, host.indexOf("]"));
const rest = host.slice(host.indexOf("]") + 1);
if (!rest || rest.startsWith(":")) return inside.trim();
return host;
}
if ((host.match(/:/g) || []).length === 1) {
const [left, right] = host.split(":");
if (/^\d+$/.test(right || "") && left && !left.includes("@")) return left.trim();
}
return host;
}
/**
* Parse OpenSSH-style bastion destination.
* Example: bastion-user@target-user@198.51.100.20@ssh-bastion.example.com
*/
export function parseBastionSshDestination(value: string): {
hop_host: string;
hop_username: string;
target_user: string;
target_ip: string;
ssh_username: string;
} {
let raw = String(value || "").trim().replace(/^["']|["']$/g, "");
raw = raw.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
if (!raw) {
return { hop_host: "", hop_username: "", target_user: "", target_ip: "", ssh_username: "" };
}
if (!raw.includes("@")) {
return {
hop_host: normalizeHopHost(raw),
hop_username: "",
target_user: "",
target_ip: "",
ssh_username: "",
};
}
const at = raw.lastIndexOf("@");
const userPart = raw.slice(0, at).trim();
const hopHost = normalizeHopHost(raw.slice(at + 1));
const parts = userPart.split("@").filter(Boolean);
return {
hop_host: hopHost,
hop_username: parts[0] || "",
target_user: parts[1] || "",
target_ip: parts[2] || "",
ssh_username: userPart,
};
}
/** If hop_host is a pasted user@…@bastion string, split into host + hop username. */
export function expandBastionHopFields(
hopHost: string,
hopUsername = "",
): {
hop_host: string;
hop_username: string;
target_user: string;
target_ip: string;
} {
const curUser = String(hopUsername || "").trim();
const raw = String(hopHost || "").trim();
if (!raw.includes("@")) {
return { hop_host: normalizeHopHost(raw), hop_username: curUser, target_user: "", target_ip: "" };
}
const parsed = parseBastionSshDestination(raw);
return {
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || curUser,
target_user: parsed.target_user,
target_ip: parsed.target_ip,
};
}
export function defaultHopTemplate(vendor: string, protocol: string, vrf: string): string {
const v = String(vendor || "zte").toLowerCase();
if (v === "huawei") return huaweiHopTemplate(protocol, vrf);
if (v === "cisco") return ciscoHopTemplate(protocol, vrf);
if (v === "linux") return "";
if (v === "bastion") return bastionHopTemplate();
return zteHopTemplate(protocol, vrf);
}
export function patchHopVendorChange(
vendor: HopVendor,
prev: { hop_protocol: string; hop_vrf: string; hop_command_template: string; hop_vendor?: string },
): {
hop_vendor: HopVendor;
hop_protocol: string;
hop_vrf: string;
hop_command_template: string;
hop_port?: number;
hop_target_auth_mode?: HopTargetAuthMode;
hop_enter_system_view?: boolean;
} {
if (vendor === "linux") {
return {
hop_vendor: "linux",
hop_protocol: "ssh",
hop_vrf: "",
hop_command_template: "",
hop_enter_system_view: false,
};
}
if (vendor === "bastion") {
return {
hop_vendor: "bastion",
hop_protocol: "ssh",
hop_port: 22,
hop_vrf: "",
hop_command_template: bastionHopTemplate(),
hop_target_auth_mode: "bastion_managed" as HopTargetAuthMode,
hop_enter_system_view: false,
};
}
const protocol = prev.hop_protocol || "ssh";
const vrf = prev.hop_vrf || "";
return {
hop_vendor: vendor,
hop_protocol: protocol,
hop_vrf: vrf,
hop_command_template: defaultHopTemplate(vendor, protocol, vrf),
// Explicit Huawei option only; reset when leaving/entering other CLI hop vendors.
hop_enter_system_view: false,
};
}
export { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate };

View file

@ -1,17 +0,0 @@
import { openNewModuleWindow } from "./moduleWindows";
export const PORT_TRAFFIC_WALL_MODULE_ID = "port-traffic-wall";
export function portTrafficBoardPath(boardId: string): string {
return `/port-traffic/wall/${encodeURIComponent(boardId)}`;
}
/** Open a dedicated browser tab for one board (outside Network management). */
export function openPortTrafficBoardWindow(boardId: string): void {
const id = String(boardId || "").trim();
if (!id) return;
openNewModuleWindow({
moduleId: PORT_TRAFFIC_WALL_MODULE_ID,
path: portTrafficBoardPath(id),
});
}

View file

@ -1,74 +0,0 @@
/** Map backend runtime status codes (rt:* / ws:*) and legacy Chinese to i18n keys. */
type TFunc = (key: string) => string;
const LEGACY_ZH_RUNTIME_ERROR: Record<string, string> = {
"启动:正在同步当前告警(完成后连接 WSS)…": "ume.tasks.runtimeError.startup_alarm_sync_before_ws",
"启动 REST 全量同步未完成,定时 REST 与 WSS 均待命": "ume.tasks.runtimeError.startup_gate_waiting",
"WSS 实时接收中,已跳过 REST 同步": "ume.tasks.runtimeError.wss_active_skip_rest",
"正在拉取 UME 当前告警…": "ume.tasks.runtimeError.pulling_alarms_current",
"另一条当前告警 REST 同步进行中,已跳过": "ume.tasks.runtimeError.alarms_sync_in_progress_skip",
"正在拉取 UME 网元清单…": "ume.tasks.runtimeError.pulling_inventory",
"正在拉取 UME 拓扑(节点/链路)…": "ume.tasks.runtimeError.pulling_topology",
"未启用或未配置 UME_BASE_URL": "ume.tasks.runtimeError.ume_ws_disabled_no_base_url",
"已恢复:将跳过本轮周期等待并尽快同步": "ume.tasks.runtimeError.resumed_sync_soon",
"已恢复:将尽快重连 WSS": "ume.tasks.runtimeError.resumed_wss_reconnect",
已恢复: "ume.tasks.runtimeError.resumed",
初始化: "ume.tasks.wsState.init",
已连接: "ume.tasks.wsState.connected",
连接中: "ume.tasks.wsState.connecting",
已断开: "ume.tasks.wsState.disconnected",
无订阅: "ume.tasks.wsState.no_subscription",
"等待 token": "ume.tasks.wsState.waiting_token",
已暂停: "ume.tasks.wsState.paused",
连接异常: "ume.tasks.wsState.error",
重连等待: "ume.tasks.wsState.reconnecting",
UME订阅已丢失: "ume.tasks.wsState.subscription_lost",
};
function translatePrefixed(code: string, prefix: string, i18nSection: string, t: TFunc): string | null {
if (!code.startsWith(prefix)) return null;
const slug = code.slice(prefix.length);
if (!slug) return null;
const key = `${i18nSection}.${slug}`;
const tr = t(key);
return tr !== key ? tr : null;
}
export function runtimeLastError(raw: string | null | undefined, t: TFunc): string {
const s = String(raw ?? "").trim();
if (!s) return "";
const rt = translatePrefixed(s, "rt:", "ume.tasks.runtimeError", t);
if (rt) return rt;
const ws = translatePrefixed(s, "ws:", "ume.tasks.wsState", t);
if (ws) return ws;
if (s === "keepalive_failed") {
const tr = t("ume.tasks.runtimeError.keepalive_failed");
if (tr !== "ume.tasks.runtimeError.keepalive_failed") return tr;
}
const legacyKey = LEGACY_ZH_RUNTIME_ERROR[s];
if (legacyKey) {
const tr = t(legacyKey);
if (tr !== legacyKey) return tr;
}
return s;
}
export function wsConnectionLabel(
state: string | null | undefined,
label: string | null | undefined,
t: TFunc,
): string {
const fromLabel = runtimeLastError(label, t);
if (fromLabel && fromLabel !== String(label ?? "").trim()) return fromLabel;
const st = String(state ?? "").trim();
if (st) {
const tr = runtimeLastError(`ws:${st}`, t);
if (tr) return tr;
}
return fromLabel || st || "";
}

View file

@ -1,189 +0,0 @@
/** SecureCRT-like keyword highlighting for WebCRT stdout (ANSI inject). */
export type KeywordRule = {
id: string;
pattern: string;
regex: boolean;
};
export type KeywordHighlightConfig = {
enabled: boolean;
caseSensitive: boolean;
/** Foreground (character) highlight color (hex). */
color: string;
keywords: KeywordRule[];
};
export const KEYWORD_HL_STORAGE_KEY = "netx.webcrt.keywordHighlight";
const DEFAULT_COLOR = "#ffff00";
export function defaultKeywordHighlightConfig(): KeywordHighlightConfig {
return {
enabled: false,
caseSensitive: false,
color: DEFAULT_COLOR,
keywords: [],
};
}
export function newKeywordId(): string {
return `kw_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeHexColor(hex: string): string | null {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim());
return m ? `#${m[1].toLowerCase()}` : null;
}
export function loadKeywordHighlightConfig(): KeywordHighlightConfig {
const base = defaultKeywordHighlightConfig();
try {
const raw = localStorage.getItem(KEYWORD_HL_STORAGE_KEY);
if (!raw) return base;
const j = JSON.parse(raw) as Partial<KeywordHighlightConfig>;
const keywords = Array.isArray(j.keywords)
? j.keywords
.map((k) => ({
id: String((k as KeywordRule).id || newKeywordId()),
pattern: String((k as KeywordRule).pattern || ""),
regex: Boolean((k as KeywordRule).regex),
}))
.filter((k) => k.pattern.trim())
: [];
return {
// Keywords imply active highlighting (avoids stale enabled:false in localStorage).
enabled: keywords.length > 0,
caseSensitive: Boolean(j.caseSensitive),
color: normalizeHexColor(String(j.color || base.color)) || DEFAULT_COLOR,
keywords,
};
} catch {
return base;
}
}
export function saveKeywordHighlightConfig(cfg: KeywordHighlightConfig): void {
const keywords = (cfg.keywords || []).filter((k) => String(k.pattern || "").trim());
localStorage.setItem(
KEYWORD_HL_STORAGE_KEY,
JSON.stringify({
...cfg,
enabled: keywords.length > 0 && cfg.enabled !== false,
keywords,
}),
);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const n = normalizeHexColor(hex) || DEFAULT_COLOR;
const v = parseInt(n.slice(1), 16);
return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
}
/** Map highlight color to bold foreground (character color), not background. */
function sgrForColor(hex: string): { open: string; close: string } {
const { r, g, b } = hexToRgb(hex);
// 16-color bright fg fallback + truecolor fg for xterm.
let brightFg = 93; // bright yellow
if (g >= r && g >= b && g > 80) brightFg = 92; // bright green
else if (r >= g && r >= b && r > 80 && g < 100) brightFg = 91; // bright red
else if (b >= r && b >= g && b > 80) brightFg = 94; // bright blue
else if (r > 180 && g > 180 && b < 120) brightFg = 93; // yellow
const open = `\x1b[1;${brightFg}m\x1b[38;2;${r};${g};${b}m`;
const close = "\x1b[22;39m";
return { open, close };
}
type CompiledRule = {
re: RegExp;
open: string;
close: string;
};
export function compileKeywordRules(config: KeywordHighlightConfig | null | undefined): CompiledRule[] {
if (!config?.keywords?.length) return [];
if (!config.enabled) return [];
const { open, close } = sgrForColor(config.color || DEFAULT_COLOR);
const flags = config.caseSensitive ? "g" : "gi";
const out: CompiledRule[] = [];
for (const kw of config.keywords) {
const pattern = String(kw.pattern || "").trim();
if (!pattern) continue;
try {
const source = kw.regex ? pattern : escapeRegExp(pattern);
out.push({ re: new RegExp(source, flags), open, close });
} catch {
/* skip invalid regex */
}
}
return out;
}
/** CSI / OSC / simple charset sequences — leave untouched. */
const ANSI_RE =
/\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-2AB]|[>=])/g;
function highlightPlain(text: string, rules: CompiledRule[]): string {
if (!text || !rules.length) return text;
type Match = { start: number; end: number; open: string; close: string };
const matches: Match[] = [];
for (const rule of rules) {
rule.re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = rule.re.exec(text)) !== null) {
if (!m[0]) {
rule.re.lastIndex += 1;
continue;
}
matches.push({
start: m.index,
end: m.index + m[0].length,
open: rule.open,
close: rule.close,
});
if (!rule.re.global) break;
}
}
if (!matches.length) return text;
matches.sort((a, b) => a.start - b.start || b.end - a.end);
const picked: Match[] = [];
let cursor = 0;
for (const m of matches) {
if (m.start < cursor) continue;
picked.push(m);
cursor = m.end;
}
let out = "";
let i = 0;
for (const m of picked) {
out += text.slice(i, m.start) + m.open + text.slice(m.start, m.end) + m.close;
i = m.end;
}
out += text.slice(i);
return out;
}
export function applyKeywordHighlight(
text: string,
config: KeywordHighlightConfig | null | undefined,
): string {
if (!text) return text;
const rules = compileKeywordRules(config);
if (!rules.length) return text;
let out = "";
let last = 0;
ANSI_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ANSI_RE.exec(text)) !== null) {
if (m.index > last) out += highlightPlain(text.slice(last, m.index), rules);
out += m[0];
last = m.index + m[0].length;
}
if (last < text.length) out += highlightPlain(text.slice(last), rules);
return out;
}

View file

@ -1,52 +0,0 @@
/** Vendor CLI jump command templates (placeholders). */
const LEGACY_HOP_TEMPLATES = new Set([
"ssh {target_user}@{target_ip}",
"ssh {target_ip}",
"telnet {target_ip}",
]);
export function zteHopTemplate(protocol: string, vrf: string): string {
const cmd = String(protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh";
const v = String(vrf || "").trim();
return v ? `${cmd} {target_ip} vrf {vrf}` : `${cmd} {target_ip}`;
}
/** Cisco: ssh -vrf VRF IP; telnet IP [/vrf VRF]. */
export function ciscoHopTemplate(protocol: string, vrf: string): string {
const v = String(vrf || "").trim();
if (String(protocol || "ssh").toLowerCase() === "telnet") {
return v ? `telnet {target_ip} /vrf {vrf}` : `telnet {target_ip}`;
}
return v ? `ssh -vrf {vrf} {target_ip}` : `ssh {target_ip}`;
}
/** Huawei: telnet [vpn-instance VRF] IP; SSH uses stelnet. */
export function huaweiHopTemplate(protocol: string, vrf: string): string {
const v = String(vrf || "").trim();
if (String(protocol || "ssh").toLowerCase() === "telnet") {
return v ? `telnet vpn-instance {vrf} {target_ip}` : `telnet {target_ip}`;
}
return v ? `stelnet {target_ip} -vpn-instance {vrf}` : `stelnet {target_ip}`;
}
export function isAutoHopTemplate(
template: string,
vendor: string,
protocol: string,
vrf: string,
): boolean {
const t = String(template || "").trim();
if (!t || LEGACY_HOP_TEMPLATES.has(t)) return true;
const v = String(vendor || "zte").toLowerCase();
if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf);
if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf);
if (v === "linux") return t === "";
if (v === "bastion") {
return (
t === "{hop_user}@{target_user}@{target_ip}"
|| t === "{hop_user}@{target_user}@{target_ip}@{hop_host}"
);
}
return t === zteHopTemplate(protocol, vrf);
}