mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 16:43:18 +08:00
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:
parent
30f2db9708
commit
89ff0a7212
11 changed files with 593 additions and 183 deletions
|
|
@ -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)}`,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue