Improve WebCRT UX: real login transcript, session tools, and first-input sync.

Capture Netmiko session_log for login replay, open NE terminals in a fresh tab, tidy the device list UI with reconnect/log/clear/copy, and paint a live prompt after attach so the first keystrokes behave normally.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-29 19:53:41 +08:00
parent 30f2db9708
commit 89ff0a7212
11 changed files with 593 additions and 183 deletions

View file

@ -1,27 +1,79 @@
import { useEffect, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
export type WebTerminalHandle = {
clear: () => void;
copyAll: () => Promise<string>;
getText: () => string;
fit: () => void;
};
type Props = {
wsUrl: string;
title?: string;
recording?: boolean;
onStatus?: (state: string, message?: string) => void;
onReady?: () => void;
onStdout?: (data: string) => void;
};
export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
function serializeTerminal(term: Terminal): string {
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i += 1) {
const line = buf.getLine(i);
if (!line) continue;
lines.push(line.translateToString(true));
}
return lines.join("\n").replace(/\s+$/g, "");
}
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
{ wsUrl, title, recording, onStatus, onReady, onStdout },
ref,
) {
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);
const onStdoutRef = useRef(onStdout);
const recordingRef = useRef(!!recording);
useEffect(() => {
onStatusRef.current = onStatus;
onReadyRef.current = onReady;
}, [onStatus, onReady]);
onStdoutRef.current = onStdout;
}, [onStatus, onReady, onStdout]);
useEffect(() => {
recordingRef.current = !!recording;
}, [recording]);
useImperativeHandle(ref, () => ({
clear: () => {
termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J");
},
copyAll: async () => {
const text = termRef.current ? serializeTerminal(termRef.current) : "";
if (text && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
}
return text;
},
getText: () => (termRef.current ? serializeTerminal(termRef.current) : ""),
fit: () => {
try {
fitRef.current?.fit();
} catch {
/* ignore */
}
},
}));
useEffect(() => {
const host = hostRef.current;
@ -32,9 +84,9 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
fontSize: 13,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: "#0f172a",
background: "#0b1220",
foreground: "#e2e8f0",
cursor: "#93c5fd",
cursor: "#e2e8f0",
},
convertEol: true,
});
@ -51,7 +103,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
/* ignore */
}
};
// Fit after layout; hidden/zero-size parents need a deferred pass.
requestAnimationFrame(() => {
doFit();
window.setTimeout(doFit, 50);
@ -60,7 +111,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
onStatusRef.current?.("connecting");
term.writeln(`\x1b[90mConnecting${title ? ` ${title}` : ""}…\x1b[0m`);
const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) {
@ -86,21 +136,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
data?: string;
state?: string;
message?: string;
ne_name?: string;
ne_ip?: string;
protocol?: string;
};
if (msg.type === "stdout" && typeof msg.data === "string") {
term.write(msg.data);
if (recordingRef.current) onStdoutRef.current?.(msg.data);
return;
}
if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "connected") {
const where = [msg.ne_name || title, msg.ne_ip, msg.protocol].filter(Boolean).join(" · ");
term.writeln(`\x1b[90m--- session ready${where ? `: ${where}` : ""} ---\x1b[0m`);
return;
}
if (msg.state === "connected") return;
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`);
@ -109,14 +153,15 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
}
if (msg.type === "pong") return;
} catch {
term.write(String(ev.data || ""));
const raw = String(ev.data || "");
term.write(raw);
if (recordingRef.current) onStdoutRef.current?.(raw);
}
};
ws.onerror = () => {
onStatusRef.current?.("error", "websocket_error");
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m");
term.writeln("\x1b[33mHint: ensure API has package 'websockets' and is restarted; Vite UI should reach ws://127.0.0.1:8890\x1b[0m");
};
ws.onclose = (ev) => {
@ -150,7 +195,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
window.removeEventListener("resize", onWinResize);
ro?.disconnect();
dataDisposable.dispose();
// Do not send {type:"close"} here — React StrictMode remounts and needs reconnect.
try {
ws.close();
} catch {
@ -164,4 +208,4 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
}, [wsUrl, title]);
return <div className="webcrt-term" ref={hostRef} />;
}
});

View file

@ -541,10 +541,11 @@ const en = {
},
webcrt: {
title: "WebCRT",
sessionManager: "Sessions",
deviceList: "Device list",
sessionManager: "Device list",
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.",
hint: "Pick an NE and open an interactive terminal in the browser. Credentials stay on the server.",
hintCrt: "Click an NE on the left to open a terminal. 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.",
@ -567,6 +568,18 @@ const en = {
error: "Error",
closed: "Closed",
},
actions: {
reconnect: "Reconnect",
recordLog: "Record log",
clear: "Clear",
copy: "Copy",
recording: "Recording",
recordingOn: "Log recording started",
recordingSaved: "Log saved",
copied: "Copied to clipboard",
copyEmpty: "Terminal is empty",
copyFailed: "Copy failed",
},
err: {
sessionLimit: "Session limit reached; close other terminals first",
neNotFound: "Network element not found",

View file

@ -539,10 +539,11 @@ const zh = {
},
webcrt: {
title: "WebCRT",
sessionManager: "会话管理",
deviceList: "设备列表",
sessionManager: "设备列表",
connect: "连接",
hint: "选择已连通网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
hintCrt: "在左侧选择网元后双击或点 ⚡ 连接;支持托管网元与 UME 网元,可用搜索与分页浏览上万台设备。",
hint: "选择网元,在浏览器中打开交互式终端。凭据由服务端解密,不会下发到前端。",
hintCrt: "在左侧点击网元即可打开终端;支持托管与 UME 网元,可用搜索与分页浏览。",
filterKeyword: "筛选",
filterKeywordPh: "按名称 / IP / 标签筛选",
empty: "没有匹配的网元。可切换来源或调整搜索关键字。",
@ -565,6 +566,18 @@ const zh = {
error: "错误",
closed: "已关闭",
},
actions: {
reconnect: "重新连接",
recordLog: "记录日志",
clear: "清空",
copy: "拷贝",
recording: "记录中",
recordingOn: "已开始记录日志",
recordingSaved: "日志已保存",
copied: "已复制到剪贴板",
copyEmpty: "终端内容为空",
copyFailed: "复制失败",
},
err: {
sessionLimit: "并发会话已达上限,请先关闭其他终端",
neNotFound: "网元不存在",

View file

@ -1456,23 +1456,31 @@ pre {
background: #f0f0f0;
border-right: 1px solid #808080;
min-width: 0;
min-height: 0; /* grid/flex: allow tree to shrink and scroll */
overflow: hidden;
height: 100%;
}
.webcrt-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 6px 10px;
background: linear-gradient(#f8f8f8, #e4e4e4);
border-bottom: 1px solid #b0b0b0;
flex-shrink: 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;
line-height: 1.2;
}
.webcrt-sidebar__toolbar {
display: flex;
gap: 4px;
padding: 4px 6px;
border-bottom: 1px solid #d0d0d0;
background: #ececec;
display: none;
}
.webcrt-icon-btn {
@ -1494,6 +1502,7 @@ pre {
.webcrt-sidebar__search {
padding: 6px;
border-bottom: 1px solid #d0d0d0;
flex-shrink: 0;
}
.webcrt-sidebar__search input {
@ -1511,6 +1520,7 @@ pre {
grid-template-columns: repeat(3, 1fr);
gap: 0;
border-bottom: 1px solid #c8c8c8;
flex-shrink: 0;
}
.webcrt-source-tabs__btn {
@ -1532,10 +1542,12 @@ pre {
}
.webcrt-tree {
flex: 1;
overflow: auto;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
background: #fff;
min-height: 0;
overscroll-behavior: contain;
}
.webcrt-tree__folder {
@ -1612,6 +1624,7 @@ pre {
background: #ececec;
font-size: 11px;
color: #475569;
flex-shrink: 0;
}
.webcrt-sidebar__pager-btns {
@ -1636,6 +1649,7 @@ pre {
display: flex;
border-top: 1px solid #a8a8a8;
background: #ddd;
flex-shrink: 0;
}
.webcrt-sidebar__footer button {
@ -1698,6 +1712,40 @@ pre {
color: #475569;
}
.webcrt-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 8px;
background: #f3f4f6;
border-bottom: 1px solid #d1d5db;
flex-shrink: 0;
}
.webcrt-action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border: 1px solid #c0c0c0;
border-radius: 3px;
background: #fff;
color: #1e293b;
font-size: 12px;
cursor: pointer;
}
.webcrt-action-btn:hover {
background: #f8fafc;
}
.webcrt-action-btn.is-active {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.webcrt-main__body {
flex: 1;
min-height: 0;
@ -1752,12 +1800,7 @@ pre {
}
.webcrt-statusline {
padding: 3px 8px;
background: #ececec;
border-top: 1px solid #a8a8a8;
font-size: 11px;
color: #334155;
flex-shrink: 0;
display: none;
}
@media (max-width: 900px) {

View file

@ -26,7 +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 { openNewModuleWindow } from "../utils/moduleWindows";
import {
defaultHopTemplate,
isAutoHopTemplate,
@ -777,7 +777,7 @@ export function NePage() {
type="button"
className="link-btn"
onClick={() =>
openOrFocusModule({
openNewModuleWindow({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
})

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { WebTerminal } from "../components/WebTerminal";
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import {
@ -20,15 +20,21 @@ type TermTab = {
key: string;
sessionId: string;
wsUrl: string;
termEpoch: number;
target: CliTargetItem;
status: "connecting" | "connected" | "error" | "closed";
errorMessage?: string;
recording: boolean;
};
function targetKey(t: Pick<CliTargetItem, "source" | "id">): string {
return `${t.source}:${t.id}`;
}
function deviceLabel(t: Pick<CliTargetItem, "name" | "ip_address">): string {
return String(t.name || t.ip_address || "").trim() || "-";
}
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");
@ -39,6 +45,16 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record<string,
return raw;
}
function downloadText(filename: string, text: string) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function WebcrtPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
@ -50,11 +66,13 @@ export function WebcrtPage() {
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);
const tabsRef = useRef<TermTab[]>([]);
tabsRef.current = tabs;
const termRefs = useRef<Map<string, WebTerminalHandle>>(new Map());
const logBuffersRef = useRef<Map<string, string>>(new Map());
useEffect(() => {
const timer = window.setTimeout(() => {
@ -74,34 +92,39 @@ export function WebcrtPage() {
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) => {
async (target: CliTargetItem, opts?: { force?: boolean }) => {
const key = targetKey(target);
const existing = tabs.find((tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error");
if (existing) {
const existing = tabsRef.current.find(
(tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error",
);
if (existing && !opts?.force) {
setActiveTabKey(existing.key);
setSelectedKey(key);
return;
}
if (connectingKeysRef.current.has(key)) return;
connectingKeysRef.current.add(key);
setSelectedKey(key);
if (opts?.force && existing?.sessionId) {
try {
await closeWebcrtSession(existing.sessionId);
} catch {
/* ignore */
}
}
const pending: TermTab = {
key,
sessionId: "",
wsUrl: "",
termEpoch: (existing?.termEpoch || 0) + (opts?.force ? 1 : 0),
target,
status: "connecting",
recording: existing?.recording || false,
};
setTabs((prev) => {
const without = prev.filter((x) => x.key !== key);
@ -118,8 +141,13 @@ export function WebcrtPage() {
: { 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 }));
updateTab(key, {
sessionId: sess.session_id,
wsUrl,
status: "connecting",
termEpoch: pending.termEpoch + 1,
});
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
} catch (err) {
const message = webcrtErrorMessage(err, t);
updateTab(key, { status: "error", errorMessage: message });
@ -128,12 +156,15 @@ export function WebcrtPage() {
connectingKeysRef.current.delete(key);
}
},
[tabs, showOk, showError, t, updateTab],
[showOk, showError, t, updateTab],
);
const openTargetRef = useRef(openTarget);
openTargetRef.current = openTarget;
const closeTab = useCallback(
async (key: string) => {
const tab = tabs.find((x) => x.key === key);
const tab = tabsRef.current.find((x) => x.key === key);
if (tab?.sessionId) {
try {
await closeWebcrtSession(tab.sessionId);
@ -141,6 +172,8 @@ export function WebcrtPage() {
/* ignore */
}
}
logBuffersRef.current.delete(key);
termRefs.current.delete(key);
setTabs((prev) => {
const next = prev.filter((x) => x.key !== key);
if (activeTabKey === key) {
@ -149,73 +182,113 @@ export function WebcrtPage() {
return next;
});
},
[tabs, activeTabKey],
[activeTabKey],
);
// One-shot open from /webcrt?ne_id=...
const reconnectActive = useCallback(async () => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
await openTarget(tab.target, { force: true });
}, [activeTabKey, openTarget]);
const toggleRecording = useCallback(() => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
const next = !tab.recording;
if (next) {
const seed = termRefs.current.get(tab.key)?.getText() || "";
logBuffersRef.current.set(tab.key, seed ? `${seed}\n` : "");
showOk(t("webcrt.actions.recordingOn"));
} else {
const body = logBuffersRef.current.get(tab.key) || termRefs.current.get(tab.key)?.getText() || "";
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const name = `${deviceLabel(tab.target) || "session"}-${stamp}.log`;
downloadText(name, body);
logBuffersRef.current.delete(tab.key);
showOk(t("webcrt.actions.recordingSaved"));
}
updateTab(tab.key, { recording: next });
}, [activeTabKey, showOk, t, updateTab]);
const clearActive = useCallback(() => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
termRefs.current.get(tab.key)?.clear();
}, [activeTabKey]);
const copyActive = useCallback(async () => {
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
if (!tab) return;
try {
const text = await termRefs.current.get(tab.key)?.copyAll();
if (text) showOk(t("webcrt.actions.copied"));
else showError(t("webcrt.actions.copyEmpty"));
} catch {
showError(t("webcrt.actions.copyFailed"));
}
}, [activeTabKey, showOk, showError, t]);
// Auto-connect from /webcrt?ne_id=...
useEffect(() => {
if (!presetNeId || presetDoneRef.current) return;
let cancelled = false;
if (!presetNeId) return;
const neId = presetNeId;
const sourceHint = presetSource === "ume" ? "ume" : "managed";
let alive = true;
(async () => {
presetDoneRef.current = true;
try {
if (presetSource === "ume") {
await openTarget({
if (sourceHint === "ume") {
await openTargetRef.current({
source: "ume",
id: presetNeId,
ume_ne_id: presetNeId,
name: presetNeId,
id: neId,
ume_ne_id: neId,
name: neId,
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,
});
}
const row = await fetchManagedNeById(neId);
await openTargetRef.current({
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));
if (alive) showError(webcrtErrorMessage(err, t));
} finally {
setSearchParams({}, { replace: true });
if (!alive) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (next.get("ne_id") === neId) {
next.delete("ne_id");
next.delete("source");
}
return next;
},
{ replace: true },
);
}
})();
return () => {
cancelled = true;
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presetNeId]);
}, [presetNeId, presetSource, showError, t, setSearchParams]);
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>
<div className="webcrt-sidebar__header">
<div className="webcrt-sidebar__title">{t("webcrt.deviceList")}</div>
<button
type="button"
className="webcrt-icon-btn"
@ -249,7 +322,6 @@ export function WebcrtPage() {
))}
</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>
@ -263,18 +335,17 @@ export function WebcrtPage() {
<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}`}
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
onClick={() => void openTarget(row)}
title={`${deviceLabel(row)}\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__name">{deviceLabel(row)}</span>
<span className="webcrt-tree__meta">
{row.source === "ume" ? "UME" : "NE"}
{row.ip_address || row.source}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
@ -286,9 +357,7 @@ export function WebcrtPage() {
</ul>
</div>
<div className="webcrt-sidebar__pager">
<span>
{t("common.pagerMeta", { total, page, pages })}
</span>
<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))}>
‹
@ -297,12 +366,6 @@ export function WebcrtPage() {
›
</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>
@ -317,7 +380,7 @@ export function WebcrtPage() {
onClick={() => setActiveTabKey(tab.key)}
>
<span>
{tab.target.ip_address || tab.target.name}
{deviceLabel(tab.target)}
{tab.status === "connecting" ? ` (${t("webcrt.status.connecting")})` : ""}
</span>
<button
@ -334,10 +397,34 @@ export function WebcrtPage() {
</div>
))}
</div>
{activeTab ? (
<div className="webcrt-actions">
<button type="button" className="webcrt-action-btn" onClick={() => void reconnectActive()}>
<span aria-hidden>↻</span>
{t("webcrt.actions.reconnect")}
</button>
<button
type="button"
className={`webcrt-action-btn${activeTab.recording ? " is-active" : ""}`}
onClick={toggleRecording}
>
<span aria-hidden>☐</span>
{t("webcrt.actions.recordLog")}
</button>
<button type="button" className="webcrt-action-btn" onClick={clearActive}>
<span aria-hidden>⌫</span>
{t("webcrt.actions.clear")}
</button>
<button type="button" className="webcrt-action-btn" onClick={() => void copyActive()}>
<span aria-hidden>⧉</span>
{t("webcrt.actions.copy")}
</button>
</div>
) : null}
<div className="webcrt-main__body">
{tabs.map((tab) => (
<div
key={tab.key}
key={`${tab.key}:${tab.termEpoch}`}
className="webcrt-main__pane"
hidden={activeTabKey !== tab.key}
>
@ -352,8 +439,17 @@ export function WebcrtPage() {
) : null}
{tab.wsUrl ? (
<WebTerminal
ref={(handle) => {
if (handle) termRefs.current.set(tab.key, handle);
else termRefs.current.delete(tab.key);
}}
wsUrl={tab.wsUrl}
title={tab.target.ip_address || tab.target.name}
title={deviceLabel(tab.target)}
recording={tab.recording}
onStdout={(chunk) => {
const prev = logBuffersRef.current.get(tab.key) || "";
logBuffersRef.current.set(tab.key, prev + chunk);
}}
onStatus={(state) => {
if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" });
else if (state === "error") updateTab(tab.key, { status: "error" });
@ -371,12 +467,6 @@ export function WebcrtPage() {
<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

@ -30,8 +30,12 @@ function moduleAckKey(moduleId: string): string {
}
export function registerModuleWindow(moduleId: string): () => void {
const sharedName = moduleWindowName(moduleId);
try {
window.name = moduleWindowName(moduleId);
// Keep unique names from openNewModuleWindow; only claim the singleton if unnamed.
if (!window.name) {
window.name = sharedName;
}
} catch {
/* ignore */
}
@ -40,9 +44,29 @@ export function registerModuleWindow(moduleId: string): () => void {
MODULE_CHANNEL,
moduleAckKey(moduleId),
(data) => {
// One-shot session windows (unique name) must not merge into the singleton module tab.
if (window.name && window.name !== sharedName) {
return;
}
const targetPath = String(data.path || "").trim();
if (targetPath && window.location.pathname !== targetPath) {
window.location.assign(targetPath);
if (targetPath) {
// Compare pathname only for hard navigation. Same route + new query
// must soft-navigate so React state (open tabs) survives.
try {
const url = new URL(targetPath, window.location.origin);
const next = `${url.pathname}${url.search}${url.hash}`;
const cur = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (window.location.pathname !== url.pathname) {
window.location.assign(next);
} else if (cur !== next) {
window.history.pushState({}, "", next);
window.dispatchEvent(new PopStateEvent("popstate"));
}
} catch {
if (window.location.pathname + window.location.search !== targetPath) {
window.location.assign(targetPath);
}
}
}
window.focus();
},
@ -74,4 +98,16 @@ export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void {
});
}
/** Always open a fresh browser tab/window; never focus or merge with an existing module tab. */
export function openNewModuleWindow({ moduleId, path }: ModuleWindowSpec): void {
const mod = getModuleById(moduleId);
const base = mod?.path ?? "/";
const targetPath = path && (path === base || path.startsWith(`${base}?`) || path.startsWith(`${base}/`))
? path
: base;
const uniqueName = `${moduleWindowName(moduleId)}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
focusWindowSafe(window.open(targetPath, uniqueName));
}
export { moduleIdFromPath } from "../config/modules";