feat(webcrt): add CRT-style browser terminal for managed and UME NEs

Ship an ops WebCRT module with xterm.js UI, WebSocket session bridge reusing hop/bastion login, searchable paged targets, and session audit limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-29 17:23:45 +08:00
parent 88479245bb
commit 873f2bfe34
21 changed files with 1868 additions and 45 deletions

View file

@ -35,6 +35,7 @@ src/
| `/ume` | UME 同步 | `ume` |
| `/ne` | 网元管理 | `managed-ne` |
| `/collect` | 批量采集 | `collect` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
**新增模块只需改 `config/modules.ts`:**
@ -75,6 +76,15 @@ src/
- 采集日志目录:`NETX_NE_COLLECTION_DATA_DIR`(默认 `data/ne_collections`)
- 命令每行一条,`#` 为注释;输出格式与旧版 NetX 采集 `.txt` 一致
## WebCRT
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
- 目标列表复用 `/v1/cli/targets`(托管 + UME,搜索分页;`source=all|managed|ume`)
- 凭据:托管走网元自身账号;UME 走 CLI 连接模板(`resolve_cli_target`)
- 前端:CRT 风格左右分栏(会话管理 + 多标签终端)
- 审计:`NETX_WEBCRT_DATA_DIR`(默认 `data/webcrt/audit.jsonl`)
- 限流:`NETX_WEBCRT_MAX_SESSIONS`、`NETX_WEBCRT_IDLE_TIMEOUT_SEC`
## Toast
- 使用 `ToastProvider`(`main.tsx`)+ `useToast()`

17
web/package-lock.json generated
View file

@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
@ -1199,6 +1201,21 @@
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",

View file

@ -14,6 +14,8 @@
},
"dependencies": {
"@tanstack/react-query": "^5.100.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"

View file

@ -6,6 +6,7 @@ import { WorkbenchPage } from "./pages/WorkbenchPage";
import { CollectPage } from "./pages/CollectPage";
import { NePage } from "./pages/NePage";
import { UmePage } from "./pages/UmePage";
import { WebcrtPage } from "./pages/WebcrtPage";
import { fetchIntegrationStatus } from "./services/api";
function App() {
@ -49,6 +50,7 @@ function App() {
<Route path="/ume" element={<UmePage />} />
<Route path="/ne" element={<NePage />} />
<Route path="/collect" element={<CollectPage />} />
<Route path="/webcrt" element={<WebcrtPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>

View file

@ -0,0 +1,142 @@
import { useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
type Props = {
wsUrl: string;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
};
export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const onStatusRef = useRef(onStatus);
const onReadyRef = useRef(onReady);
useEffect(() => {
onStatusRef.current = onStatus;
onReadyRef.current = onReady;
}, [onStatus, onReady]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: "#0f172a",
foreground: "#e2e8f0",
cursor: "#93c5fd",
},
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(host);
fit.fit();
termRef.current = term;
fitRef.current = fit;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
onStatusRef.current?.("connecting");
const sendJson = (payload: Record<string, unknown>) => {
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 <div className="webcrt-term" ref={hostRef} />;
}

View file

@ -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 {

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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")}
</td>
<td className="table-actions">
<button
type="button"
className="link-btn"
onClick={() =>
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
})
}
title={t("managedNe.openTerminal")}
>
{t("managedNe.openTerminal")}
</button>
<button
type="button"
className="link-btn"

View file

@ -0,0 +1,379 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { WebTerminal } from "../components/WebTerminal";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import {
closeWebcrtSession,
createWebcrtSession,
fetchCliTargets,
fetchManagedNeById,
webcrtWsUrl,
} from "../services/api";
import { pageCount } from "../utils/display";
import type { CliTargetItem } from "../types";
const PAGE_SIZE = 50;
type TermTab = {
key: string;
sessionId: string;
wsUrl: string;
target: CliTargetItem;
status: "connecting" | "connected" | "error" | "closed";
};
function targetKey(t: Pick<CliTargetItem, "source" | "id">): string {
return `${t.source}:${t.id}`;
}
function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string, string | number>) => string): string {
const raw = String(err);
if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit");
if (raw.includes("managed_ne_not_found") || raw.includes("ume_ne_not_found")) return t("webcrt.err.neNotFound");
if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required")) return t("webcrt.err.credsIncomplete");
if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile");
if (raw.includes("connect_failed")) return t("webcrt.err.connectFailed", { detail: raw.replace(/^.*connect_failed:/, "") });
return raw;
}
export function WebcrtPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const presetNeId = String(searchParams.get("ne_id") || "").trim();
const presetSource = String(searchParams.get("source") || "managed").trim().toLowerCase();
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [keywordInput, setKeywordInput] = useState("");
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [selectedKey, setSelectedKey] = useState("");
const [tabs, setTabs] = useState<TermTab[]>([]);
const [activeTabKey, setActiveTabKey] = useState("");
const connectingKeysRef = useRef<Set<string>>(new Set());
const presetDoneRef = useRef(false);
useEffect(() => {
const timer = window.setTimeout(() => {
setKeyword(keywordInput.trim());
setPage(1);
}, 300);
return () => window.clearTimeout(timer);
}, [keywordInput]);
const targetsQuery = useQuery({
queryKey: ["webcrtTargets", source, keyword, page],
queryFn: () => fetchCliTargets({ source, keyword, page, pageSize: PAGE_SIZE }),
staleTime: 5000,
});
const items = targetsQuery.data?.items ?? [];
const total = targetsQuery.data?.total ?? 0;
const pages = pageCount(total, PAGE_SIZE);
const selected = useMemo(
() => items.find((x) => targetKey(x) === selectedKey) || null,
[items, selectedKey],
);
const updateTab = useCallback((key: string, patch: Partial<TermTab>) => {
setTabs((prev) => prev.map((tab) => (tab.key === key ? { ...tab, ...patch } : tab)));
}, []);
const openTarget = useCallback(
async (target: CliTargetItem) => {
const key = targetKey(target);
const existing = tabs.find((tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error");
if (existing) {
setActiveTabKey(existing.key);
setSelectedKey(key);
return;
}
if (connectingKeysRef.current.has(key)) return;
connectingKeysRef.current.add(key);
setSelectedKey(key);
const pending: TermTab = {
key,
sessionId: "",
wsUrl: "",
target,
status: "connecting",
};
setTabs((prev) => {
const without = prev.filter((x) => x.key !== key);
return [...without, pending];
});
setActiveTabKey(key);
try {
const cols = Math.max(80, Math.floor((window.innerWidth - 360) / 9));
const rows = Math.max(24, Math.floor((window.innerHeight - 180) / 18));
const body =
target.source === "ume"
? { ume_ne_id: target.ume_ne_id || target.id, cols, rows }
: { ne_id: target.id, cols, rows };
const sess = await createWebcrtSession(body);
const wsUrl = webcrtWsUrl(sess.session_id);
updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting" });
showOk(t("webcrt.opened", { name: target.name || target.ip_address }));
} catch (err) {
updateTab(key, { status: "error" });
showError(webcrtErrorMessage(err, t));
} finally {
connectingKeysRef.current.delete(key);
}
},
[tabs, showOk, showError, t, updateTab],
);
const closeTab = useCallback(
async (key: string) => {
const tab = tabs.find((x) => x.key === key);
if (tab?.sessionId) {
try {
await closeWebcrtSession(tab.sessionId);
} catch {
/* ignore */
}
}
setTabs((prev) => {
const next = prev.filter((x) => x.key !== key);
if (activeTabKey === key) {
setActiveTabKey(next.length ? next[next.length - 1].key : "");
}
return next;
});
},
[tabs, activeTabKey],
);
// One-shot open from /webcrt?ne_id=...
useEffect(() => {
if (!presetNeId || presetDoneRef.current) return;
let cancelled = false;
(async () => {
presetDoneRef.current = true;
try {
if (presetSource === "ume") {
await openTarget({
source: "ume",
id: presetNeId,
ume_ne_id: presetNeId,
name: presetNeId,
ip_address: "",
connect_status: "unknown",
});
} else {
const hit = items.find((x) => x.source === "managed" && x.id === presetNeId);
if (hit) {
await openTarget(hit);
} else {
const row = await fetchManagedNeById(presetNeId);
if (cancelled) return;
await openTarget({
source: "managed",
id: row.id,
name: row.name || row.ip_address,
ip_address: row.ip_address,
vendor: row.vendor,
device_type: row.device_type,
connect_status: row.connect_status,
cli_profile_ready: true,
});
}
}
} catch (err) {
if (!cancelled) showError(webcrtErrorMessage(err, t));
} finally {
setSearchParams({}, { replace: true });
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presetNeId]);
const activeTab = tabs.find((x) => x.key === activeTabKey) || null;
const perPage = (n: number) => t("common.perPage", { n });
return (
<div className="webcrt-shell">
<aside className="webcrt-sidebar">
<div className="webcrt-sidebar__title">{t("webcrt.sessionManager")}</div>
<div className="webcrt-sidebar__toolbar">
<button
type="button"
className="webcrt-icon-btn"
title={t("webcrt.connect")}
disabled={!selected}
onClick={() => selected && void openTarget(selected)}
>
⚡
</button>
<button
type="button"
className="webcrt-icon-btn"
title={t("common.refresh")}
onClick={() => void targetsQuery.refetch()}
>
↻
</button>
</div>
<div className="webcrt-sidebar__search">
<input
type="search"
value={keywordInput}
placeholder={t("webcrt.filterKeywordPh")}
onChange={(e) => setKeywordInput(e.target.value)}
/>
</div>
<div className="webcrt-source-tabs">
{(["all", "managed", "ume"] as const).map((s) => (
<button
key={s}
type="button"
className={`webcrt-source-tabs__btn${source === s ? " is-active" : ""}`}
onClick={() => {
setSource(s);
setPage(1);
}}
>
{t(`webcrt.source.${s}`)}
</button>
))}
</div>
<div className="webcrt-tree">
<div className="webcrt-tree__folder">Sessions</div>
{targetsQuery.isLoading ? <div className="webcrt-tree__empty">{t("common.refreshing")}</div> : null}
{!targetsQuery.isLoading && items.length === 0 ? (
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
) : null}
<ul className="webcrt-tree__list">
{items.map((row) => {
const key = targetKey(row);
const tab = tabs.find((x) => x.key === key);
const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key);
return (
<li key={key}>
<button
type="button"
className={`webcrt-tree__item${selectedKey === key ? " is-selected" : ""}${activeTabKey === key ? " is-active" : ""}`}
onClick={() => setSelectedKey(key)}
onDoubleClick={() => void openTarget(row)}
title={`${row.name}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
▣
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{row.ip_address || row.name}</span>
<span className="webcrt-tree__meta">
{row.source === "ume" ? "UME" : "NE"}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
<div className="webcrt-sidebar__pager">
<span>
{t("common.pagerMeta", { total, page, pages })}
</span>
<div className="webcrt-sidebar__pager-btns">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
‹
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
›
</button>
</div>
<span>{perPage(PAGE_SIZE)}</span>
</div>
<div className="webcrt-sidebar__footer">
<button type="button" className="is-active">
{t("webcrt.sessionManager")}
</button>
</div>
</aside>
<main className="webcrt-main">
{tabs.length > 0 ? (
<>
<div className="webcrt-tabs">
{tabs.map((tab) => (
<div
key={tab.key}
className={`webcrt-tabs__item${activeTabKey === tab.key ? " is-active" : ""}`}
onClick={() => setActiveTabKey(tab.key)}
>
<span>
{tab.target.ip_address || tab.target.name}
{tab.status === "connecting" ? ` (${t("webcrt.status.connecting")})` : ""}
</span>
<button
type="button"
className="webcrt-tabs__close"
aria-label={t("webcrt.disconnect")}
onClick={(e) => {
e.stopPropagation();
void closeTab(tab.key);
}}
>
×
</button>
</div>
))}
</div>
<div className="webcrt-main__body">
{tabs.map((tab) => (
<div
key={tab.key}
className="webcrt-main__pane"
hidden={activeTabKey !== tab.key}
>
{tab.status === "connecting" && !tab.wsUrl ? (
<div className="webcrt-main__placeholder">{t("webcrt.status.connecting")}…</div>
) : null}
{tab.status === "error" && !tab.wsUrl ? (
<div className="webcrt-main__placeholder webcrt-main__placeholder--error">
{t("webcrt.status.error")}
</div>
) : null}
{tab.wsUrl ? (
<WebTerminal
wsUrl={tab.wsUrl}
onStatus={(state) => {
if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" });
else if (state === "error") updateTab(tab.key, { status: "error" });
else if (state === "closed") updateTab(tab.key, { status: "closed" });
}}
/>
) : null}
</div>
))}
</div>
</>
) : (
<div className="webcrt-main__empty">
<p>{t("webcrt.termPlaceholder")}</p>
<p className="panel__hint">{t("webcrt.hintCrt")}</p>
</div>
)}
{activeTab ? (
<div className="webcrt-statusline">
{activeTab.target.name} · {activeTab.target.ip_address} · {activeTab.target.source} ·{" "}
{t(`webcrt.status.${activeTab.status}`)}
</div>
) : null}
</main>
</div>
);
}

View file

@ -324,6 +324,37 @@ export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/r
export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`;
export const fetchManagedNeById = (neId: string) =>
apiGet<ManagedNeItem>(`/v1/managed-ne/${encodeURIComponent(neId)}`);
export type WebcrtSessionCreateResult = {
session_id: string;
ne_id: string;
ne_name: string;
ne_ip: string;
protocol: string;
cols: number;
rows: number;
ws_path: string;
};
export const createWebcrtSession = (body: {
ne_id?: string;
ume_ne_id?: string;
cols?: number;
rows?: number;
}) => apiPost<WebcrtSessionCreateResult>("/v1/webcrt/sessions", body);
export const closeWebcrtSession = (sessionId: string) =>
apiDelete<{ ok: boolean; session_id: string; closed: boolean }>(
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
);
export const webcrtWsUrl = (sessionId: string): string => {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
};
export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");
export const fetchCliProfiles = () =>

View file

@ -52,7 +52,11 @@ export function registerModuleWindow(moduleId: string): () => void {
export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void {
const mod = getModuleById(moduleId);
const targetPath = mod?.path ?? path;
const base = mod?.path ?? "/";
// Prefer caller path so query strings like /webcrt?ne_id=... are preserved.
const targetPath = path && (path === base || path.startsWith(`${base}?`) || path.startsWith(`${base}/`))
? path
: base;
const requestId = createFocusRequestId(`mod-${moduleId}`);
const name = moduleWindowName(moduleId);

View file

@ -10,6 +10,7 @@ export default defineConfig({
"/v1": {
target: "http://127.0.0.1:8890",
changeOrigin: true,
ws: true,
},
},
},