) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(payload));
+ }
+ };
+
+ const sendResize = () => {
+ fit.fit();
+ sendJson({ type: "resize", cols: term.cols, rows: term.rows });
+ };
+
+ ws.onopen = () => {
+ onStatusRef.current?.("open");
+ sendResize();
+ onReadyRef.current?.();
+ };
+
+ ws.onmessage = (ev) => {
+ try {
+ const msg = JSON.parse(String(ev.data || "{}")) as {
+ type?: string;
+ data?: string;
+ state?: string;
+ message?: string;
+ };
+ if (msg.type === "stdout" && typeof msg.data === "string") {
+ term.write(msg.data);
+ return;
+ }
+ if (msg.type === "status") {
+ onStatusRef.current?.(String(msg.state || ""), msg.message);
+ if (msg.state === "closed" || msg.state === "error") {
+ const detail = msg.message ? `: ${msg.message}` : "";
+ term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`);
+ }
+ return;
+ }
+ if (msg.type === "pong") return;
+ } catch {
+ term.write(String(ev.data || ""));
+ }
+ };
+
+ ws.onerror = () => {
+ onStatusRef.current?.("error", "websocket_error");
+ };
+
+ ws.onclose = () => {
+ onStatusRef.current?.("closed", "websocket_closed");
+ };
+
+ const dataDisposable = term.onData((data) => {
+ sendJson({ type: "stdin", data });
+ });
+
+ const pingTimer = window.setInterval(() => {
+ sendJson({ type: "ping" });
+ }, 25000);
+
+ const onWinResize = () => sendResize();
+ window.addEventListener("resize", onWinResize);
+
+ const ro =
+ typeof ResizeObserver !== "undefined"
+ ? new ResizeObserver(() => {
+ sendResize();
+ })
+ : null;
+ ro?.observe(host);
+
+ return () => {
+ window.clearInterval(pingTimer);
+ window.removeEventListener("resize", onWinResize);
+ ro?.disconnect();
+ dataDisposable.dispose();
+ try {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "close" }));
+ }
+ ws.close();
+ } catch {
+ /* ignore */
+ }
+ wsRef.current = null;
+ term.dispose();
+ termRef.current = null;
+ fitRef.current = null;
+ };
+ }, [wsUrl]);
+
+ return ;
+}
diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts
index 2e8ff16..d127bcb 100644
--- a/web/src/config/modules.ts
+++ b/web/src/config/modules.ts
@@ -2,7 +2,7 @@
* Single registry for workbench modules. Add new modules here only.
*/
-export type ModuleIconTone = "blue" | "green" | "amber";
+export type ModuleIconTone = "blue" | "green" | "amber" | "slate";
export type WorkbenchSection = "monitoring" | "operations";
export type ModuleDefinition = {
@@ -43,6 +43,15 @@ export const MODULES: readonly ModuleDefinition[] = [
iconTone: "amber",
titleKey: "layout.titleCollect",
},
+ {
+ moduleId: "webcrt",
+ path: "/webcrt",
+ section: "operations",
+ labelKey: "workbench.cards.webcrt",
+ descKey: "workbench.cards.webcrtDesc",
+ iconTone: "slate",
+ titleKey: "layout.titleWebcrt",
+ },
] as const;
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index 44e33be..5a74de5 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -26,6 +26,8 @@ const en = {
managedNeDesc: "Multi-vendor SSH/Telnet NEs & connectivity test",
collect: "Batch Collect",
collectDesc: "Run custom CLI commands on connected NEs and save logs",
+ webcrt: "WebCRT",
+ webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
},
},
layout: {
@@ -33,6 +35,7 @@ const en = {
titleUme: "UME Sync",
titleManagedNe: "NE Management",
titleCollect: "Batch Collect",
+ titleWebcrt: "WebCRT",
navUme: "UME",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@@ -135,6 +138,7 @@ const en = {
importing: "Importing…",
edit: "Edit",
delete: "Delete",
+ openTerminal: "Terminal",
confirmDelete: "Delete this network element?",
batchDelete: "Batch delete",
batchDeleting: "Deleting…",
@@ -535,6 +539,42 @@ const en = {
noHostName: "No host_name (sync inventory first)",
},
},
+ webcrt: {
+ title: "WebCRT",
+ sessionManager: "Sessions",
+ connect: "Connect",
+ hint: "Pick a connected NE and open an interactive terminal in the browser. Credentials stay on the server.",
+ hintCrt: "Select an NE on the left, then double-click or press ⚡. Includes managed and UME inventory; search + paging for large fleets.",
+ filterKeyword: "Filter",
+ filterKeywordPh: "Filter by name / IP / tag",
+ empty: "No matching NEs. Switch source or refine the search.",
+ open: "Open terminal",
+ opening: "Connecting…",
+ opened: "Opened {{name}}",
+ disconnect: "Disconnect",
+ closed: "Session closed",
+ termPlaceholder: "No terminal session open",
+ sessionMeta: "{{name}} · {{ip}}",
+ source: {
+ all: "All",
+ managed: "Managed",
+ ume: "UME",
+ },
+ status: {
+ idle: "Idle",
+ connecting: "Connecting",
+ connected: "Connected",
+ error: "Error",
+ closed: "Closed",
+ },
+ err: {
+ sessionLimit: "Session limit reached; close other terminals first",
+ neNotFound: "Network element not found",
+ credsIncomplete: "Incomplete username/password on this NE",
+ cliProfile: "UME NEs require a CLI connect profile (default account / hop)",
+ connectFailed: "Device login failed: {{detail}}",
+ },
+ },
};
export default en;
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index b4212df..22e36e3 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -26,6 +26,8 @@ const zh = {
managedNeDesc: "多厂商 SSH/Telnet 网元与连通性测试",
collect: "批量采集",
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
+ webcrt: "WebCRT",
+ webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
},
},
layout: {
@@ -33,6 +35,7 @@ const zh = {
titleUme: "UME同步",
titleManagedNe: "网元管理",
titleCollect: "批量采集",
+ titleWebcrt: "WebCRT",
navUme: "UME 对接",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@@ -134,6 +137,7 @@ const zh = {
importing: "导入中…",
edit: "编辑",
delete: "删除",
+ openTerminal: "终端",
confirmDelete: "确定删除该网元?",
batchDelete: "批量删除",
batchDeleting: "删除中…",
@@ -533,6 +537,42 @@ const zh = {
noHostName: "无 host_name(需先同步网元)",
},
},
+ webcrt: {
+ title: "WebCRT",
+ sessionManager: "会话管理",
+ connect: "连接",
+ hint: "选择已连通网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
+ hintCrt: "在左侧选择网元后双击或点 ⚡ 连接;支持托管网元与 UME 网元,可用搜索与分页浏览上万台设备。",
+ filterKeyword: "筛选",
+ filterKeywordPh: "按名称 / IP / 标签筛选",
+ empty: "没有匹配的网元。可切换来源或调整搜索关键字。",
+ open: "打开终端",
+ opening: "连接中…",
+ opened: "已打开 {{name}}",
+ disconnect: "断开",
+ closed: "会话已断开",
+ termPlaceholder: "尚未打开终端会话",
+ sessionMeta: "{{name}} · {{ip}}",
+ source: {
+ all: "全部",
+ managed: "托管",
+ ume: "UME",
+ },
+ status: {
+ idle: "空闲",
+ connecting: "连接中",
+ connected: "已连接",
+ error: "错误",
+ closed: "已关闭",
+ },
+ err: {
+ sessionLimit: "并发会话已达上限,请先关闭其他终端",
+ neNotFound: "网元不存在",
+ credsIncomplete: "网元账号或密码不完整",
+ cliProfile: "UME 网元需要先配置 CLI 连接模板(默认账号/跳板)",
+ connectFailed: "登录设备失败:{{detail}}",
+ },
+ },
};
export default zh;
diff --git a/web/src/index.css b/web/src/index.css
index 0e9c0e5..91e0109 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -168,6 +168,11 @@ body {
color: #f57f17;
}
+.wb-card__icon--slate {
+ background: #eceff1;
+ color: #455a64;
+}
+
.wb-card__text {
display: flex;
flex-direction: column;
@@ -1396,6 +1401,356 @@ pre {
border-radius: 6px;
}
+.webcrt-page {
+ min-height: calc(100vh - 120px);
+}
+
+.webcrt-session-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+ font-size: 13px;
+ color: #334155;
+}
+
+.webcrt-term-panel {
+ padding: 0;
+ overflow: hidden;
+ min-height: 420px;
+ flex: 1;
+}
+
+.webcrt-term {
+ width: 100%;
+ height: min(70vh, 720px);
+ min-height: 360px;
+ background: #0f172a;
+ padding: 8px;
+ box-sizing: border-box;
+}
+
+.webcrt-term .xterm {
+ height: 100%;
+}
+
+.webcrt-term .xterm-viewport {
+ overflow-y: auto !important;
+}
+
+.webcrt-shell {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ height: calc(100vh - 56px);
+ min-height: 480px;
+ margin: -20px -28px -32px;
+ background: #c0c0c0;
+ border: 1px solid #808080;
+ overflow: hidden;
+}
+
+.webcrt-sidebar {
+ display: flex;
+ flex-direction: column;
+ background: #f0f0f0;
+ border-right: 1px solid #808080;
+ min-width: 0;
+}
+
+.webcrt-sidebar__title {
+ padding: 6px 10px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #1e293b;
+ background: linear-gradient(#f8f8f8, #e4e4e4);
+ border-bottom: 1px solid #b0b0b0;
+}
+
+.webcrt-sidebar__toolbar {
+ display: flex;
+ gap: 4px;
+ padding: 4px 6px;
+ border-bottom: 1px solid #d0d0d0;
+ background: #ececec;
+}
+
+.webcrt-icon-btn {
+ width: 28px;
+ height: 24px;
+ padding: 0;
+ border: 1px solid #a0a0a0;
+ background: #f7f7f7;
+ border-radius: 2px;
+ cursor: pointer;
+ line-height: 1;
+}
+
+.webcrt-icon-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.webcrt-sidebar__search {
+ padding: 6px;
+ border-bottom: 1px solid #d0d0d0;
+}
+
+.webcrt-sidebar__search input {
+ width: 100%;
+ box-sizing: border-box;
+ height: 28px;
+ padding: 0 8px;
+ border: 1px solid #a8a8a8;
+ border-radius: 2px;
+ font-size: 12px;
+}
+
+.webcrt-source-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0;
+ border-bottom: 1px solid #c8c8c8;
+}
+
+.webcrt-source-tabs__btn {
+ height: 26px;
+ border: 0;
+ border-right: 1px solid #c8c8c8;
+ background: #e8e8e8;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.webcrt-source-tabs__btn:last-child {
+ border-right: 0;
+}
+
+.webcrt-source-tabs__btn.is-active {
+ background: #fff;
+ font-weight: 600;
+}
+
+.webcrt-tree {
+ flex: 1;
+ overflow: auto;
+ background: #fff;
+ min-height: 0;
+}
+
+.webcrt-tree__folder {
+ padding: 6px 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #334155;
+}
+
+.webcrt-tree__empty {
+ padding: 10px 12px;
+ font-size: 12px;
+ color: #64748b;
+}
+
+.webcrt-tree__list {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 8px;
+}
+
+.webcrt-tree__item {
+ width: 100%;
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ padding: 4px 8px 4px 18px;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.webcrt-tree__item:hover {
+ background: #f1f5f9;
+}
+
+.webcrt-tree__item.is-selected,
+.webcrt-tree__item.is-active {
+ background: #cde8ff;
+}
+
+.webcrt-tree__icon {
+ color: #2563eb;
+ line-height: 1.2;
+}
+
+.webcrt-tree__label {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.webcrt-tree__name {
+ color: #0f172a;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.webcrt-tree__meta {
+ color: #64748b;
+ font-size: 11px;
+}
+
+.webcrt-sidebar__pager {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+ padding: 4px 8px;
+ border-top: 1px solid #c8c8c8;
+ background: #ececec;
+ font-size: 11px;
+ color: #475569;
+}
+
+.webcrt-sidebar__pager-btns {
+ display: flex;
+ gap: 2px;
+}
+
+.webcrt-sidebar__pager-btns button {
+ width: 22px;
+ height: 20px;
+ border: 1px solid #a0a0a0;
+ background: #f7f7f7;
+ cursor: pointer;
+}
+
+.webcrt-sidebar__pager-btns button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.webcrt-sidebar__footer {
+ display: flex;
+ border-top: 1px solid #a8a8a8;
+ background: #ddd;
+}
+
+.webcrt-sidebar__footer button {
+ flex: 1;
+ height: 28px;
+ border: 0;
+ border-right: 1px solid #b8b8b8;
+ background: transparent;
+ font-size: 12px;
+}
+
+.webcrt-sidebar__footer button.is-active {
+ background: #fff;
+ font-weight: 600;
+}
+
+.webcrt-main {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ background: #808080;
+}
+
+.webcrt-tabs {
+ display: flex;
+ gap: 1px;
+ overflow-x: auto;
+ background: #6e6e6e;
+ border-bottom: 1px solid #505050;
+ min-height: 28px;
+}
+
+.webcrt-tabs__item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 8px;
+ background: #cfcfcf;
+ color: #1e293b;
+ font-size: 12px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.webcrt-tabs__item.is-active {
+ background: #f3f3f3;
+ font-weight: 600;
+}
+
+.webcrt-tabs__close {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ color: #475569;
+}
+
+.webcrt-main__body {
+ flex: 1;
+ min-height: 0;
+ background: #0f172a;
+}
+
+.webcrt-main__pane {
+ height: 100%;
+}
+
+.webcrt-main__pane .webcrt-term {
+ height: 100%;
+ min-height: 0;
+}
+
+.webcrt-main__empty,
+.webcrt-main__placeholder {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: #e2e8f0;
+ background: #6b7280;
+ text-align: center;
+ padding: 24px;
+}
+
+.webcrt-main__placeholder--error {
+ color: #fecaca;
+}
+
+.webcrt-statusline {
+ padding: 3px 8px;
+ background: #ececec;
+ border-top: 1px solid #a8a8a8;
+ font-size: 11px;
+ color: #334155;
+}
+
+@media (max-width: 900px) {
+ .webcrt-shell {
+ grid-template-columns: 1fr;
+ height: auto;
+ min-height: calc(100vh - 64px);
+ }
+
+ .webcrt-sidebar {
+ max-height: 42vh;
+ }
+
+ .webcrt-main {
+ min-height: 50vh;
+ }
+}
+
@media (max-width: 1200px) {
.cards {
grid-template-columns: 1fr;
diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx
index 428c149..84ba8b4 100644
--- a/web/src/pages/NePage.tsx
+++ b/web/src/pages/NePage.tsx
@@ -26,6 +26,7 @@ import { useToast } from "../hooks/useToast";
import type { ManagedNeItem } from "../types";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
+import { openOrFocusModule } from "../utils/moduleWindows";
import {
defaultHopTemplate,
isAutoHopTemplate,
@@ -772,6 +773,19 @@ export function NePage() {
: t("common.empty")}
|
+
|