feat(web): NetX workbench, UME-only UI, i18n, and header status

- Replace sidebar app with workbench home and module tabs (openOrFocusModule)

- Remove legacy pages; add module registry, shared tab channel, WEB.md

- Add zh/en i18n, global ToastProvider, restore oclaw bridge pill in header

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-27 11:18:30 +08:00
parent 664775927b
commit 282dbe75af
33 changed files with 1631 additions and 1677 deletions

78
web/src/i18n/context.tsx Normal file
View file

@ -0,0 +1,78 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import en from "./en";
import zh from "./zh";
export type Locale = "zh" | "en";
const STORAGE_KEY = "netx-locale";
const MESSAGES: Record<Locale, typeof zh> = { zh, en };
function resolveMessage(obj: unknown, path: string): string {
const keys = path.split(".");
let cur: unknown = obj;
for (const k of keys) {
if (cur == null || typeof cur !== "object") return path;
cur = (cur as Record<string, unknown>)[k];
}
return typeof cur === "string" ? cur : path;
}
function interpolate(text: string, vars?: Record<string, string | number>): string {
if (!vars) return text;
let out = text;
for (const [k, v] of Object.entries(vars)) {
out = out.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), String(v));
}
return out;
}
type I18nContextValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved === "en" ? "en" : "zh";
} catch {
return "zh";
}
});
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
/* ignore */
}
}, []);
const t = useCallback(
(key: string, vars?: Record<string, string | number>) => {
const raw = resolveMessage(MESSAGES[locale], key);
return interpolate(raw, vars);
},
[locale],
);
useEffect(() => {
document.documentElement.lang = locale === "en" ? "en" : "zh-CN";
}, [locale]);
const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
return ctx;
}

138
web/src/i18n/en.ts Normal file
View file

@ -0,0 +1,138 @@
const en = {
common: {
help: "Help",
refresh: "Refresh",
refreshing: "Refreshing…",
query: "Search",
clearFilters: "Clear filters",
prevPage: "Previous",
nextPage: "Next",
perPage: "{{n}}/page",
pagerMeta: "{{total}} items · page {{page}}/{{pages}}",
opFailed: "Operation failed",
empty: "-",
},
workbench: {
title: "Workbench",
backToWorkbench: "Back to Workbench",
openInNewTab: "Open in new tab",
openModule: "Open or switch to this module tab",
monitoring: "Monitoring",
cards: {
umeSync: "UME Sync",
umeSyncDesc: "UME alarms, subscription & inventory",
},
},
layout: {
title: "UME Integration",
titleUme: "UME Sync",
navUme: "UME",
netxApi: "netx api",
oclawBridge: "oclaw bridge",
language: "Language",
moreMenu: "More",
langZh: "中文",
langEn: "English",
},
ume: {
token: {
title: "UME Token",
disconnected: "disconnected",
connected: "connected",
connectedRenew: "connected (renew required)",
expiresIn: "expires_in",
needsRenew: "renew / 0s",
preview: "preview",
refreshStatus: "Refresh status",
renewLogin: "Renew / login",
renewing: "Renewing…",
disconnect: "Disconnect token",
disconnecting: "Disconnecting…",
renewOkChanged: "Token renewed and updated",
renewOkSame: "Renew completed (unchanged)",
disconnectOk: "UME token disconnected",
renewHint: "Token missing expiry or expired. Use Renew / login or wait for background keepalive.",
},
subscription: {
title: "UME alarm subscription (WebSocket)",
help:
"Subscriptions must be created/cancelled manually. After establish (or on restart if a valid subscription exists in DB), the backend auto-connects WSS for live alarms. Pause/resume on runtime task alarms_current_ws_consumer stops/resumes WSS only (does not cancel the UME subscription).",
sub: "subscription",
subActive: "active",
subInactive: "inactive",
wssPaused: "paused",
wssModeBanner:
"Current alarms are maintained via WSS (mode: {{mode}}); scheduled REST full sync is paused. Use Sync current alarms below for full reconcile.",
serverLostBanner:
"UME server subscription is missing or expired; local state may still show active. Clear local record and re-subscribe.",
establish: "Establish subscription",
establishing: "Establishing…",
reestablish: "Clear local & re-subscribe",
reestablishing: "Re-subscribing…",
cancel: "Cancel subscription",
cancelling: "Cancelling…",
refreshStatus: "Refresh subscription",
clearLocalOnly: "Clear local only",
clearing: "Clearing…",
opFailed: "Subscription operation failed",
confirmClearDefault:
"UME alarm subscription no longer exists or has expired.\n\nClear local subscription record? You can establish again afterwards.",
confirmReestablish: "Clear local record and re-establish subscription on UME. Continue?",
titleLoginFirst: "Log in to UME token first",
titleReestablish: "Clear stale local record and re-establish on UME",
establishInvalid: "Establish did not return a valid id/uri",
establishExists: "Subscription already exists (WSS will stay/connect)",
establishOk: "Subscription established; WSS will connect",
clearLocalOk: "Local subscription cleared; you can establish again",
serverLostMsg: "UME subscription lost. Clear local record and re-subscribe?",
cancelFailed: "Cancel failed: subscription may still exist on UME",
cancelOkMissing: "Local cleared (UME subscription was already missing)",
cancelOk: "Subscription cancelled",
wsLogs: "WSS runtime log",
wsActivity: "Last activity {{time}}",
wsAutoRefresh: "Auto refresh",
wsLogsEmpty: "No logs yet (connect WSS after establishing subscription)",
},
sync: {
title: "UME sync",
inventory: "Sync inventory",
alarmsCurrent: "Sync current alarms",
full: "Full sync",
failed: "Sync failed",
},
tasks: {
currentTitle: "Running jobs",
running: "running",
noRunning: "No running jobs",
runtimeTitle: "Background tasks",
runtimeOpFailed: "Background task operation failed",
noRuntime: "No background task status",
intervalTitle: "Configured sleep between loop iterations (clamped at process start)",
lastRunTitle: "Last finished sync for scheduled tasks; refreshed at each loop tick",
deletedTitle: "Reconcile deletes: inventory = NE rows; alarms_current = current alarm rows",
},
syncStatus: {
title: "Sync history",
empty: "No sync records",
},
ne: {
title: "Network elements",
keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)",
clearTitle: "Clear keyword and reset to page 1",
expand: "Expand details",
collapse: "Collapse details",
},
alarms: {
title: "Current alarms",
keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)",
hostNamePh: "host_name (contains)",
allSeverity: "All severities",
clearedAll: "is_cleared: all",
clearTitle: "Clear filters and reset to page 1",
filterByHost: "Filter by this host",
noHostName: "No host_name (sync inventory first)",
},
},
};
export default en;

1
web/src/i18n/index.ts Normal file
View file

@ -0,0 +1 @@
export { I18nProvider, useI18n, type Locale } from "./context";

138
web/src/i18n/zh.ts Normal file
View file

@ -0,0 +1,138 @@
const zh = {
common: {
help: "帮助说明",
refresh: "刷新",
refreshing: "刷新中…",
query: "查询",
clearFilters: "清除筛选",
prevPage: "上一页",
nextPage: "下一页",
perPage: "{{n}}/页",
pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页",
opFailed: "操作失败",
empty: "-",
},
workbench: {
title: "工作台",
backToWorkbench: "返回工作台",
openInNewTab: "在新标签页中打开",
openModule: "打开或切换到该模块页签",
monitoring: "监控",
cards: {
umeSync: "UME同步",
umeSyncDesc: "UME 告警同步、订阅与清单",
},
},
layout: {
title: "UME 对接",
titleUme: "UME同步",
navUme: "UME 对接",
netxApi: "netx api",
oclawBridge: "oclaw bridge",
language: "语言",
moreMenu: "更多",
langZh: "中文",
langEn: "English",
},
ume: {
token: {
title: "UME Token 状态",
disconnected: "disconnected",
connected: "connected",
connectedRenew: "connected (需续期)",
expiresIn: "expires_in",
needsRenew: "需续期 / 0s",
preview: "preview",
refreshStatus: "刷新状态",
renewLogin: "手动续期/登录",
renewing: "续期中…",
disconnect: "断开 token",
disconnecting: "断开中…",
renewOkChanged: "续期成功,token 已更新",
renewOkSame: "续期完成(与此前一致)",
disconnectOk: "已断开 UME token",
renewHint: "库中 token 缺少有效过期时间或已过期,请点「手动续期/登录」或等待后台自动续期",
},
subscription: {
title: "UME 告警订阅(WebSocket)",
help:
"订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。后台任务 alarms_current_ws_consumer 的 pause/resume 会停止或恢复 WSS 连接(不会取消 UME 订阅)。",
sub: "订阅",
subActive: "已建立",
subInactive: "未建立",
wssPaused: "已暂停",
wssModeBanner:
"当前告警由 WSS 实时维护(模式: {{mode}}),定时 REST 全量同步已暂停。需要全量对账时请使用下方「同步当前告警」。",
serverLostBanner:
"UME 服务器侧告警订阅已丢失或已过期,本地记录可能仍显示「已建立」。请确认清除本地记录后重新订阅。",
establish: "建立告警订阅",
establishing: "建立订阅…",
reestablish: "清除本地并重新订阅",
reestablishing: "重新订阅…",
cancel: "取消告警订阅",
cancelling: "取消订阅…",
refreshStatus: "刷新订阅状态",
clearLocalOnly: "仅清除本地记录",
clearing: "清除中…",
opFailed: "订阅操作失败",
confirmClearDefault:
"UME 侧告警订阅已不存在或已过期(服务器订阅已丢失)。\n\n是否清除本地订阅记录?清除后可点击「建立告警订阅」重新订阅。",
confirmReestablish: "将清除本地订阅记录并在 UME 上重新建立告警订阅,是否继续?",
titleLoginFirst: "请先登录 UME token",
titleReestablish: "清除本地失效记录并重新向 UME 建立订阅",
establishInvalid: "建立订阅未返回有效 id/uri",
establishExists: "订阅已存在,未重复建立(WSS 将保持/恢复连接)",
establishOk: "告警订阅已建立,WSS 将自动连接",
clearLocalOk: "已清除本地订阅记录,可重新建立订阅",
serverLostMsg: "UME 侧告警订阅已丢失。请确认是否清除本地订阅记录后重新订阅。",
cancelFailed: "取消订阅失败:UME 侧订阅可能仍存在,请查看错误详情后重试",
cancelOkMissing: "本地已清除(UME 侧订阅此前已不存在)",
cancelOk: "告警订阅已取消",
wsLogs: "WSS 运行日志",
wsActivity: "最近活动 {{time}}",
wsAutoRefresh: "自动刷新",
wsLogsEmpty: "暂无日志(建立订阅并连接 WSS 后会出现连接、收包、重连等记录)",
},
sync: {
title: "UME 同步",
inventory: "同步 Inventory",
alarmsCurrent: "同步当前告警",
full: "全量同步",
failed: "同步失败",
},
tasks: {
currentTitle: "当前任务",
running: "running",
noRunning: "当前无运行中的任务",
runtimeTitle: "后台任务",
runtimeOpFailed: "后台任务操作失败",
noRuntime: "暂无后台任务状态",
intervalTitle: "Configured sleep between loop iterations (clamped at process start)",
lastRunTitle: "Last finished sync for scheduled tasks; refreshed at each loop tick",
deletedTitle: "全量对账删除条数:inventory 为网元行,alarms_current 为当前告警行,其余域多为 0",
},
syncStatus: {
title: "同步状态",
empty: "暂无同步记录",
},
ne: {
title: "网元清单",
keywordPh: "keyword(ne_id/ne_name/user_label/ip/host_name)",
clearTitle: "清空 keyword,回到第 1 页",
expand: "展开详情",
collapse: "收起详情",
},
alarms: {
title: "当前告警",
keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)",
hostNamePh: "host_name(含匹配)",
allSeverity: "全部级别",
clearedAll: "is_cleared: all",
clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页",
filterByHost: "按该主机名筛选",
noHostName: "无 host_name(需先同步网元)",
},
},
};
export default zh;