import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { ApiRequestError, closeWebcrtSession, createWebcrtSession, deleteManagedNe, fetchCliTargets, fetchManagedNeById, quickConnectWebcrtSession, updateManagedNe, webcrtSftpDownload, webcrtSftpList, webcrtSftpChmod, webcrtSftpMkdir, webcrtSftpRemove, webcrtSftpRename, webcrtSftpUpload, webcrtWsUrl, type WebcrtSftpItem, } from "../services/api"; import { pageCount } from "../utils/display"; import type { CliTargetItem, ManagedNeItem } from "../types"; import { defaultKeywordHighlightConfig, loadKeywordHighlightConfig, newKeywordId, saveKeywordHighlightConfig, type KeywordHighlightConfig, type KeywordRule, } from "../utils/webcrtKeywordHighlight"; const PAGE_SIZE = 50; const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions"; const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs"; const SFTP_WIDTH_KEY = "netx.webcrt.sftpWidth"; const SFTP_WIDTH_DEFAULT = 480; const SFTP_WIDTH_MIN = 280; const SFTP_COL_WIDTHS_KEY = "netx.webcrt.sftpColWidths"; type SftpColKey = "name" | "size" | "mtime" | "owner" | "group" | "mode"; type SftpColWidths = Record; const SFTP_COL_KEYS: SftpColKey[] = ["name", "size", "mtime", "owner", "group", "mode"]; const SFTP_COL_WIDTH_DEFAULTS: SftpColWidths = { name: 220, size: 72, mtime: 140, owner: 72, group: 72, mode: 88, }; const SFTP_COL_WIDTH_MIN: SftpColWidths = { name: 120, size: 48, mtime: 96, owner: 48, group: 48, mode: 64, }; const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const; const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] as const; const PASTE_DELAY_OPTIONS = [0, 20, 40, 60, 100, 150] as const; const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const; /** Compact recording chunks before they grow unbounded (join + trim). */ const LOG_COMPACT_CHUNKS = 1500; const LOG_MAX_CHARS = 8 * 1024 * 1024; /** Keep active + recent tabs mounted; colder tabs detach WS until focused (fresh ticket on remount). */ const WARM_TAB_LIMIT = 8; const OPEN_TABS_STORAGE_KEY = "netx.webcrt.openTabs.v1"; type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom"; const COLOR_SCHEMES: Record, { background: string; foreground: string }> = { dark: { background: "#0b1220", foreground: "#e2e8f0" }, blackWhite: { background: "#ffffff", foreground: "#000000" }, whiteBlack: { background: "#000000", foreground: "#ffffff" }, greenBlack: { background: "#000000", foreground: "#33ff33" }, amberBlack: { background: "#1a1200", foreground: "#ffb000" }, }; const COLOR_SCHEME_IDS = Object.keys(COLOR_SCHEMES) as Array>; type SessionOptions = { encoding: string; fontSize: number; colorScheme: ColorSchemeId; background: string; foreground: string; copyOnSelect: boolean; pasteDelayMs: number; keepaliveSec: number; }; function normalizeHexColor(hex: string, fallback: string): string { const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex || "").trim()); return m ? `#${m[1].toLowerCase()}` : fallback; } function matchColorScheme(background: string, foreground: string): ColorSchemeId { const bg = normalizeHexColor(background, ""); const fg = normalizeHexColor(foreground, ""); for (const id of COLOR_SCHEME_IDS) { const preset = COLOR_SCHEMES[id]; if (preset.background === bg && preset.foreground === fg) return id; } return "custom"; } type ConnectPhase = "creating" | "authenticating" | "waiting_prompt"; type HostForm = { name: string; ip_address: string; port: number; protocol: "ssh" | "telnet"; }; type AuthForm = { username: string; password: string; savePassword: boolean; }; type AuthDialogState = { mode: "quick" | "retry"; host: HostForm; /** When retrying an existing tree target (ne_id known). */ target?: CliTargetItem; errorHint?: string; }; type TermTab = { key: string; sessionId: string; wsUrl: string; termEpoch: number; target: CliTargetItem; status: "connecting" | "connected" | "error" | "closed"; connectPhase?: ConnectPhase; errorMessage?: string; recording: boolean; encoding: string; /** From session create — nested CLI hop cannot use SFTP. */ cliHop?: boolean; /** SFTP channel attached on the live SSH transport after connect. */ sftpReady?: boolean; }; type TabMenuState = { key: string; x: number; y: number }; type TreeMenuState = { target: CliTargetItem; x: number; y: number }; function connectPhaseLabel( phase: ConnectPhase | undefined, t: (key: string, vars?: Record) => string, ): string { if (phase === "waiting_prompt") return t("webcrt.phase.waitingPrompt"); if (phase === "authenticating") return t("webcrt.phase.authenticating"); if (phase === "creating") return t("webcrt.phase.creating"); return t("webcrt.status.connecting"); } function defaultSessionOptions(): SessionOptions { return { encoding: "utf-8", fontSize: 13, colorScheme: "dark", background: COLOR_SCHEMES.dark.background, foreground: COLOR_SCHEMES.dark.foreground, copyOnSelect: true, pasteDelayMs: 40, keepaliveSec: 0, }; } function loadSessionOptions(): SessionOptions { const base = defaultSessionOptions(); try { const legacyRaw = localStorage.getItem(LEGACY_TERM_PREFS_KEY); if (legacyRaw) { const legacy = JSON.parse(legacyRaw) as Partial<{ copyOnSelect: boolean; pasteDelayMs: number }>; if (legacy.copyOnSelect === false) base.copyOnSelect = false; if (legacy.pasteDelayMs != null) { base.pasteDelayMs = Math.max(0, Math.min(200, Number(legacy.pasteDelayMs) || 40)); } } const raw = localStorage.getItem(SESSION_OPTS_KEY); if (!raw) return base; const j = JSON.parse(raw) as Partial & { theme?: string }; // Migrate legacy theme dark/light. let background = normalizeHexColor(String(j.background || ""), ""); let foreground = normalizeHexColor(String(j.foreground || ""), ""); let colorScheme = String(j.colorScheme || "") as ColorSchemeId; if (!background || !foreground) { if (j.theme === "light") { background = COLOR_SCHEMES.blackWhite.background; foreground = COLOR_SCHEMES.blackWhite.foreground; colorScheme = "blackWhite"; } else { background = COLOR_SCHEMES.dark.background; foreground = COLOR_SCHEMES.dark.foreground; colorScheme = "dark"; } } if (colorScheme !== "custom" && colorScheme in COLOR_SCHEMES) { const preset = COLOR_SCHEMES[colorScheme as Exclude]; background = preset.background; foreground = preset.foreground; } else { colorScheme = matchColorScheme(background, foreground); } return { encoding: String(j.encoding || base.encoding).trim().toLowerCase() || "utf-8", fontSize: Math.max(10, Math.min(28, Number(j.fontSize) || base.fontSize)), colorScheme, background, foreground, copyOnSelect: j.copyOnSelect !== false, pasteDelayMs: Math.max(0, Math.min(200, Number(j.pasteDelayMs ?? base.pasteDelayMs) || 40)), keepaliveSec: Math.max(0, Math.min(600, Number(j.keepaliveSec ?? base.keepaliveSec) || 0)), }; } catch { return base; } } function saveSessionOptions(opts: SessionOptions) { localStorage.setItem(SESSION_OPTS_KEY, JSON.stringify(opts)); localStorage.setItem( LEGACY_TERM_PREFS_KEY, JSON.stringify({ copyOnSelect: opts.copyOnSelect, pasteDelayMs: opts.pasteDelayMs }), ); } function normalizeSessionOptions(draft: SessionOptions): SessionOptions { const background = normalizeHexColor(draft.background, COLOR_SCHEMES.dark.background); const foreground = normalizeHexColor(draft.foreground, COLOR_SCHEMES.dark.foreground); let colorScheme = draft.colorScheme; if (colorScheme !== "custom" && colorScheme in COLOR_SCHEMES) { const preset = COLOR_SCHEMES[colorScheme as Exclude]; return { encoding: draft.encoding || "utf-8", fontSize: Math.max(10, Math.min(28, Number(draft.fontSize) || 13)), colorScheme, background: preset.background, foreground: preset.foreground, copyOnSelect: draft.copyOnSelect !== false, pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)), keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)), }; } return { encoding: draft.encoding || "utf-8", fontSize: Math.max(10, Math.min(28, Number(draft.fontSize) || 13)), colorScheme: matchColorScheme(background, foreground), background, foreground, copyOnSelect: draft.copyOnSelect !== false, pasteDelayMs: Math.max(0, Math.min(200, Number(draft.pasteDelayMs) || 0)), keepaliveSec: Math.max(0, Math.min(600, Number(draft.keepaliveSec) || 0)), }; } const emptyHostForm = (): HostForm => ({ name: "", ip_address: "", port: 22, protocol: "ssh", }); const emptyAuthForm = (username = ""): AuthForm => ({ username, password: "", savePassword: true, }); function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { return ( {children} {required ? ( ) : null} ); } function targetKey(t: Pick): string { return `${t.source}:${t.id}`; } function deviceLabel(t: Pick): string { return String(t.name || t.ip_address || "").trim() || "-"; } function ComputerIcon() { return ( ); } function FolderIcon() { return ( ); } function FileIcon() { return ( ); } function isSshAuthFailure(err: unknown): boolean { const raw = String(err).toLowerCase(); return ( raw.includes("auth_rejected") || raw.includes("authentication") || raw.includes("auth_failed") || raw.includes("permission denied") || raw.includes("credentials_incomplete") || raw.includes("password rejected") || raw.includes("incorrect password") ); } /** Inventory SSH (managed / Quick Connect). Telnet authenticates in-terminal; UME uses shared profile. */ function isInventorySsh(target: Pick): boolean { const src = String(target.source || "").toLowerCase(); if (src !== "webcrt" && src !== "managed") return false; return String(target.protocol || "ssh").toLowerCase() !== "telnet"; } function isSessionGoneError(err: unknown): boolean { const raw = String(err).toLowerCase(); return ( raw.includes("webcrt_session_not_found") || raw.includes("session_not_found") || raw.includes("4404") ); } function isDeviceClosedMessage(err: unknown): boolean { const raw = String(err).toLowerCase(); return ( raw.includes("device_closed") || raw.includes("client_close") || raw.includes("client_delete") || raw.includes("cli_hop_return") || raw.includes("idle_timeout") || raw.includes("detach_timeout") || raw.includes("attach_timeout") || raw.includes("session closed") ); } /** SFTP is direct SSH only; returns i18n key when unavailable. */ function sftpUnavailableReason( tab: Pick, ): | "webcrt.err.sftpSsh" | "webcrt.err.sftpHop" | "webcrt.err.sftpNeedPassword" | "webcrt.err.sftpUnsupported" | null { const proto = String(tab.target.protocol || "ssh").toLowerCase(); if (proto === "telnet") return "webcrt.err.sftpSsh"; if (tab.cliHop || tab.target.hop_enabled) return "webcrt.err.sftpHop"; // Prefer the live SSH session channel; inventory still needs saved password for pool fallback. if (tab.target.source !== "ume" && !tab.target.has_password && tab.sftpReady !== true) { return "webcrt.err.sftpNeedPassword"; } if (tab.status === "connected" && tab.sftpReady === false) return "webcrt.err.sftpUnsupported"; return null; } function sftpParentPath(path: string): string { const cur = String(path || ".").replace(/\\/g, "/").replace(/\/+$/, "") || "."; if (cur === "." || cur === "/") return cur === "/" ? "/" : "."; const parts = cur.split("/").filter((p, i) => (i === 0 && cur.startsWith("/")) || Boolean(p)); if (parts.length <= 1) return cur.startsWith("/") ? "/" : "."; parts.pop(); if (cur.startsWith("/")) { return parts.length <= 1 ? "/" : parts.join("/"); } const parent = parts.join("/"); return parent || "."; } function joinSftpPath(base: string, name: string): string { const n = String(name || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); if (!n || n === ".") return String(base || ".") || "."; if (n === "..") return sftpParentPath(base); const b = String(base || ".").replace(/\\/g, "/").replace(/\/+$/, "") || "."; if (b === "." || b === "") return n; if (b === "/") return `/${n}`; return `${b}/${n}`; } function loadSftpWidth(): number { try { const n = Number(localStorage.getItem(SFTP_WIDTH_KEY)); if (Number.isFinite(n) && n >= SFTP_WIDTH_MIN) return Math.min(Math.round(n), 1200); } catch { /* ignore */ } return SFTP_WIDTH_DEFAULT; } function saveSftpWidth(width: number): void { try { localStorage.setItem(SFTP_WIDTH_KEY, String(Math.round(width))); } catch { /* ignore */ } } function clampSftpColWidth(key: SftpColKey, width: number): number { const min = SFTP_COL_WIDTH_MIN[key]; const n = Math.round(Number(width) || 0); if (!Number.isFinite(n)) return SFTP_COL_WIDTH_DEFAULTS[key]; return Math.max(min, Math.min(n, 640)); } function normalizeSftpColWidths(raw: unknown): SftpColWidths { const base = { ...SFTP_COL_WIDTH_DEFAULTS }; if (!raw || typeof raw !== "object") return base; const obj = raw as Record; for (const key of SFTP_COL_KEYS) { const v = obj[key]; if (typeof v === "number" || typeof v === "string") { base[key] = clampSftpColWidth(key, Number(v)); } } return base; } function loadSftpColWidths(): SftpColWidths { try { const raw = localStorage.getItem(SFTP_COL_WIDTHS_KEY); if (!raw) return { ...SFTP_COL_WIDTH_DEFAULTS }; return normalizeSftpColWidths(JSON.parse(raw)); } catch { return { ...SFTP_COL_WIDTH_DEFAULTS }; } } function saveSftpColWidths(widths: SftpColWidths): void { try { localStorage.setItem(SFTP_COL_WIDTHS_KEY, JSON.stringify(widths)); } catch { /* ignore */ } } function sumSftpColWidths(widths: SftpColWidths): number { return SFTP_COL_KEYS.reduce((acc, key) => acc + widths[key], 0); } function formatSftpSizeKb(bytes: number, isDir: boolean): string { if (isDir) return ""; const kb = Number(bytes || 0) / 1024; if (!Number.isFinite(kb) || kb <= 0) return "0"; if (kb < 10) return kb.toFixed(1); return String(Math.round(kb)); } function formatSftpMtime(sec: number): string { const n = Number(sec || 0); if (!Number.isFinite(n) || n <= 0) return ""; const d = new Date(n * 1000); if (Number.isNaN(d.getTime())) return ""; const pad = (x: number) => String(x).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function formatSftpTransferPct(loaded: number, total: number): string { const t = Number(total || 0); if (!Number.isFinite(t) || t <= 0) return ""; const pct = Math.min(100, Math.max(0, Math.round((Number(loaded || 0) / t) * 100))); return `${pct}%`; } type SftpSortKey = SftpColKey; function sortSftpItems(items: WebcrtSftpItem[], key: SftpSortKey, dir: "asc" | "desc"): WebcrtSftpItem[] { const mul = dir === "asc" ? 1 : -1; return [...items].sort((a, b) => { if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; let cmp = 0; if (key === "size" || key === "mtime") { cmp = Number(a[key] || 0) - Number(b[key] || 0); } else if (key === "name") { cmp = String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" }); } else { cmp = String(a[key] || "").localeCompare(String(b[key] || ""), undefined, { sensitivity: "base" }); } if (cmp !== 0) return cmp * mul; return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" }); }); } function sftpTargetIds(tab: Pick): { ne_id?: string; ume_ne_id?: string } { return tab.target.source === "ume" ? { ume_ne_id: tab.target.ume_ne_id || tab.target.id } : { ne_id: tab.target.id }; } type SftpUploadItem = { file: File; relativePath: string }; type FsEntryLike = { isFile: boolean; isDirectory: boolean; name: string; file: (ok: (f: File) => void, err?: (e: DOMException) => void) => void; createReader: () => { readEntries: (ok: (entries: FsEntryLike[]) => void, err?: (e: DOMException) => void) => void; }; }; async function readAllDirectoryEntries( reader: ReturnType, ): Promise { const all: FsEntryLike[] = []; for (;;) { const batch = await new Promise((resolve, reject) => { reader.readEntries(resolve, reject); }); if (!batch.length) break; all.push(...batch); } return all; } async function walkFsEntry(entry: FsEntryLike, prefix: string, out: SftpUploadItem[]): Promise { if (entry.isFile) { const file = await new Promise((resolve, reject) => { entry.file(resolve, reject); }); const relativePath = (prefix ? `${prefix}/${entry.name}` : entry.name).replace(/\\/g, "/"); out.push({ file, relativePath }); return; } if (!entry.isDirectory) return; const nextPrefix = prefix ? `${prefix}/${entry.name}` : entry.name; const children = await readAllDirectoryEntries(entry.createReader()); for (const child of children) { await walkFsEntry(child, nextPrefix, out); } } async function collectDroppedSftpUploads(dt: DataTransfer): Promise { const out: SftpUploadItem[] = []; const items = Array.from(dt.items || []); const entries = items .map((it) => { const getter = (it as DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry; return typeof getter === "function" ? (getter.call(it) as FsEntryLike | null) : null; }) .filter((e): e is FsEntryLike => Boolean(e)); if (entries.length) { for (const entry of entries) { await walkFsEntry(entry, "", out); } return out; } for (const file of Array.from(dt.files || [])) { const rel = String((file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name) .replace(/\\/g, "/") .replace(/^\/+/, ""); out.push({ file, relativePath: rel || file.name }); } return out; } function connectFailedNe(err: unknown): ManagedNeItem | null { if (!(err instanceof ApiRequestError)) return null; const detail = err.detail; if (!detail || typeof detail !== "object" || Array.isArray(detail)) return null; const ne = (detail as { ne?: unknown }).ne; if (!ne || typeof ne !== "object") return null; const id = String((ne as ManagedNeItem).id || "").trim(); if (!id) return null; return ne as ManagedNeItem; } function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record) => 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") || raw.includes("password_required")) return t("webcrt.err.credsIncomplete"); if (isSshAuthFailure(raw)) return t("webcrt.err.authRejected"); if (raw.includes("unsupported_device_type")) return t("webcrt.err.deviceType"); if (raw.includes("ip_address_conflict_restart_required")) return t("webcrt.err.ipConflictRestart"); if (raw.includes("ip_address_required")) return t("webcrt.newSession.ipRequired"); if (raw.includes("cli_connect_profile_not_configured")) return t("webcrt.err.cliProfile"); if (raw.includes("connect_failed")) { const detail = raw.replace(/^.*connect_failed:/, "").split("\n---")[0].trim(); return t("webcrt.err.connectFailed", { detail: detail.slice(0, 180) }); } if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop"); if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh"); if (raw.includes("sftp_file_too_large")) return t("webcrt.err.sftpTooLarge"); if (raw.includes("sftp_chmod_invalid_mode")) return t("webcrt.err.sftpChmodInvalid"); if (raw.includes("aborted")) return t("webcrt.err.sftpAborted"); if (raw.includes("websocket_error")) return t("webcrt.err.websocket"); return raw; } function targetFromManagedNe(ne: ManagedNeItem, fallback?: Partial): CliTargetItem { return { source: "webcrt", id: ne.id, name: ne.name || fallback?.name || ne.ip_address || "", ip_address: ne.ip_address || fallback?.ip_address || "", vendor: ne.vendor || fallback?.vendor, device_type: ne.device_type || fallback?.device_type, protocol: ne.protocol || fallback?.protocol || "ssh", username: ne.username || fallback?.username || "", has_password: Boolean(fallback?.has_password), connect_status: ne.connect_status || "unknown", cli_profile_ready: true, }; } 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(); const queryClient = useQueryClient(); 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" | "webcrt" | "ume">("all"); const [keywordInput, setKeywordInput] = useState(""); const [keyword, setKeyword] = useState(""); const [page, setPage] = useState(1); const [tabs, setTabs] = useState(() => { try { const raw = sessionStorage.getItem(OPEN_TABS_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as { tabs?: TermTab[] }; const items = Array.isArray(parsed.tabs) ? parsed.tabs : []; return items .filter((t) => t && t.key && t.target && t.sessionId) .map((t) => ({ ...t, // Force remount + fresh ticket after returning to the page. status: "connecting" as const, connectPhase: "authenticating" as const, termEpoch: Number(t.termEpoch || 0) + 1, wsUrl: "", errorMessage: undefined, })); } catch { return []; } }); const [activeTabKey, setActiveTabKey] = useState(() => { try { const raw = sessionStorage.getItem(OPEN_TABS_STORAGE_KEY); if (!raw) return ""; const parsed = JSON.parse(raw) as { activeTabKey?: string; tabs?: TermTab[] }; const key = String(parsed.activeTabKey || ""); if (key && (parsed.tabs || []).some((t) => t.key === key && t.sessionId)) return key; const first = (parsed.tabs || []).find((t) => t.sessionId); return first?.key || ""; } catch { return ""; } }); /** MRU tab keys for warm-mount (active always mounts via isActive even before this updates). */ const [warmOrder, setWarmOrder] = useState(() => (activeTabKey ? [activeTabKey] : [])); const [sftpOpen, setSftpOpen] = useState(false); const [sftpPath, setSftpPath] = useState("."); const [sftpBusy, setSftpBusy] = useState(false); const [sftpItems, setSftpItems] = useState([]); const [sftpSelected, setSftpSelected] = useState([]); const [sftpSortKey, setSftpSortKey] = useState("name"); const [sftpSortDir, setSftpSortDir] = useState<"asc" | "desc">("asc"); const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth()); const [sftpResizing, setSftpResizing] = useState(false); const [sftpColWidths, setSftpColWidths] = useState(() => loadSftpColWidths()); const [sftpDragOver, setSftpDragOver] = useState(false); const [sftpStatus, setSftpStatus] = useState(""); const [sftpTransferring, setSftpTransferring] = useState(false); const [sftpListTruncated, setSftpListTruncated] = useState(false); const [sftpListMaxEntries, setSftpListMaxEntries] = useState(0); const sftpPathRef = useRef("."); const sftpPathByTabRef = useRef>({}); const sftpActiveTabRef = useRef(""); const sftpSelectAnchorRef = useRef(""); const sftpAbortRef = useRef(null); const sftpBodyRef = useRef(null); const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null); const sftpColDragRef = useRef<{ key: SftpColKey; startX: number; startW: number } | null>(null); const sftpDragDepthRef = useRef(0); sftpPathRef.current = sftpPath; const [sessionOpts, setSessionOpts] = useState(() => loadSessionOptions()); const [optionsMenuOpen, setOptionsMenuOpen] = useState(false); const [sessionOptsModalOpen, setSessionOptsModalOpen] = useState(false); const [sessionOptsDraft, setSessionOptsDraft] = useState(() => loadSessionOptions()); const [keywordHl, setKeywordHl] = useState(() => loadKeywordHighlightConfig()); const [keywordHlModalOpen, setKeywordHlModalOpen] = useState(false); const [keywordHlDraft, setKeywordHlDraft] = useState(() => loadKeywordHighlightConfig(), ); const [keywordDraftText, setKeywordDraftText] = useState(""); const [keywordDraftRegex, setKeywordDraftRegex] = useState(false); const [keywordSelectedId, setKeywordSelectedId] = useState(""); const [tabMenu, setTabMenu] = useState(null); const [treeMenu, setTreeMenu] = useState(null); const [renameDialog, setRenameDialog] = useState<{ target: CliTargetItem; name: string } | null>(null); const [hostDialogOpen, setHostDialogOpen] = useState(false); const [hostForm, setHostForm] = useState(() => emptyHostForm()); const [authDialog, setAuthDialog] = useState(null); const [authForm, setAuthForm] = useState(() => emptyAuthForm()); const [sessionBusy, setSessionBusy] = useState(false); const connectingKeysRef = useRef>(new Set()); const tabsRef = useRef([]); tabsRef.current = tabs; const termRefs = useRef>(new Map()); /** Chunk lists avoid O(n²) string append while recording. */ const logBuffersRef = useRef>(new Map()); const lastQueueDropToastAtRef = useRef(0); /** Limit silent WS remount retries per tab to avoid storms. */ const softWsRetryRef = useRef>(new Map()); const optionsMenuRef = useRef(null); const tabMenuRef = useRef(null); const treeMenuRef = useRef(null); const sessionOptsRef = useRef(sessionOpts); sessionOptsRef.current = sessionOpts; useEffect(() => { if (!activeTabKey) return; setWarmOrder((prev) => [activeTabKey, ...prev.filter((k) => k !== activeTabKey)].slice(0, 16)); }, [activeTabKey]); const warmTabKeys = useMemo(() => new Set(warmOrder.slice(0, WARM_TAB_LIMIT)), [warmOrder]); // Persist open tabs so leaving /webcrt and coming back can re-attach within detach grace. useEffect(() => { const slim = tabs .filter((t) => t.sessionId) .map((t) => ({ key: t.key, sessionId: t.sessionId, wsUrl: "", termEpoch: t.termEpoch, target: t.target, status: t.status, recording: t.recording, encoding: t.encoding, cliHop: t.cliHop, sftpReady: t.sftpReady, })); try { sessionStorage.setItem( OPEN_TABS_STORAGE_KEY, JSON.stringify({ tabs: slim, activeTabKey }), ); } catch { /* ignore quota */ } }, [tabs, activeTabKey]); useEffect(() => { if (!optionsMenuOpen) return; const onDoc = (e: MouseEvent) => { const el = optionsMenuRef.current; if (el && e.target instanceof Node && !el.contains(e.target)) { setOptionsMenuOpen(false); } }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOptionsMenuOpen(false); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [optionsMenuOpen]); useEffect(() => { if (!tabMenu) return; const onDoc = (e: MouseEvent) => { const el = tabMenuRef.current; if (el && e.target instanceof Node && !el.contains(e.target)) { setTabMenu(null); } }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setTabMenu(null); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [tabMenu]); useEffect(() => { if (!treeMenu) return; const onDoc = (e: MouseEvent) => { const el = treeMenuRef.current; if (el && e.target instanceof Node && !el.contains(e.target)) { setTreeMenu(null); } }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setTreeMenu(null); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [treeMenu]); 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 updateTab = useCallback((key: string, patch: Partial) => { setTabs((prev) => prev.map((tab) => (tab.key === key ? { ...tab, ...patch } : tab))); }, []); const attachSessionResult = useCallback( ( target: CliTargetItem, sessionId: string, encoding: string, cliHop?: boolean, sftpReady?: boolean, ) => { const key = targetKey(target); const existing = tabsRef.current.find((tab) => tab.key === key); const pending: TermTab = { key, sessionId, wsUrl: "", termEpoch: (existing?.termEpoch || 0) + 1, target, status: "connecting", connectPhase: "authenticating", recording: existing?.recording || false, encoding, errorMessage: undefined, cliHop: Boolean(cliHop), sftpReady: sftpReady === true ? true : sftpReady === false ? false : existing?.sftpReady, }; setTabs((prev) => { const without = prev.filter((x) => x.key !== key); return [...without, pending]; }); setActiveTabKey(key); void webcrtWsUrl(sessionId).then((wsUrl) => { updateTab(key, { wsUrl }); }); showOk(t("webcrt.opened", { name: deviceLabel(target) })); }, [showOk, t, updateTab], ); const openAuthForTarget = useCallback((target: CliTargetItem, errorHint?: string) => { const proto = String(target.protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh"; setAuthForm(emptyAuthForm(String(target.username || ""))); setAuthDialog({ mode: "retry", host: { name: target.name || "", ip_address: target.ip_address || "", port: proto === "telnet" ? 23 : 22, protocol: proto === "telnet" ? "telnet" : "ssh", }, target, errorHint, }); }, []); const openTarget = useCallback( async (target: CliTargetItem, opts?: { force?: boolean }) => { const key = targetKey(target); const existing = tabsRef.current.find( (tab) => tab.key === key && tab.status !== "closed" && tab.status !== "error", ); if (existing && !opts?.force) { setActiveTabKey(existing.key); return; } if (connectingKeysRef.current.has(key)) return; // Managed / WebCRT SSH without saved password → credential popup. // Telnet stays interactive in the terminal (SecureCRT-style); UME uses shared profile. if (isInventorySsh(target) && !target.has_password && !opts?.force) { openAuthForTarget(target); return; } connectingKeysRef.current.add(key); // force = brand-new device session; drop any prior PTY first. const prior = tabsRef.current.find((tab) => tab.key === key); if (opts?.force && prior?.sessionId) { try { await closeWebcrtSession(prior.sessionId); } catch { /* ignore */ } } const optsNow = sessionOptsRef.current; const encoding = optsNow.encoding || "utf-8"; const keepaliveSec = Math.max(0, Math.min(600, Number(optsNow.keepaliveSec) || 0)); const pending: TermTab = { key, sessionId: "", wsUrl: "", termEpoch: (prior?.termEpoch || existing?.termEpoch || 0) + (opts?.force ? 1 : 0), target, status: "connecting", connectPhase: "creating", recording: prior?.recording || existing?.recording || false, encoding, errorMessage: undefined, }; 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, encoding, keepalive_sec: keepaliveSec, async_connect: true, } : { ne_id: target.id, cols, rows, encoding, keepalive_sec: keepaliveSec, async_connect: true, }; const sess = await createWebcrtSession(body); const wsUrl = await webcrtWsUrl(sess.session_id); updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting", connectPhase: "authenticating", termEpoch: pending.termEpoch + 1, cliHop: Boolean(sess.cli_hop), sftpReady: typeof sess.sftp_ready === "boolean" ? sess.sftp_ready : undefined, }); showOk(t("webcrt.opened", { name: deviceLabel(target) })); } catch (err) { const message = webcrtErrorMessage(err, t); const needAuth = isInventorySsh(target) && (String(err).includes("credentials_incomplete") || String(err).includes("connect_failed") || isSshAuthFailure(err)); updateTab(key, { status: "error", connectPhase: undefined, errorMessage: message }); if (needAuth) { openAuthForTarget(target, message); } else { showError(message); } } finally { connectingKeysRef.current.delete(key); } }, [openAuthForTarget, showOk, showError, t, updateTab], ); /** Re-open WS to an existing backend session (within detach grace). */ const reattachTab = useCallback( (tab: TermTab) => { if (!tab.sessionId) return false; // WebTerminal mints a fresh ticket from sessionId on each mount. updateTab(tab.key, { status: "connecting", connectPhase: "authenticating", termEpoch: tab.termEpoch + 1, errorMessage: undefined, }); setActiveTabKey(tab.key); return true; }, [updateTab], ); const openTargetRef = useRef(openTarget); openTargetRef.current = openTarget; const sessionDims = useCallback(() => { const optsNow = sessionOptsRef.current; return { encoding: optsNow.encoding || "utf-8", keepaliveSec: Math.max(0, Math.min(600, Number(optsNow.keepaliveSec) || 0)), cols: Math.max(80, Math.floor((window.innerWidth - 360) / 9)), rows: Math.max(24, Math.floor((window.innerHeight - 180) / 18)), }; }, []); const submitHostDialog = useCallback(() => { const ip = hostForm.ip_address.trim(); if (!ip) { showError(t("webcrt.newSession.ipRequired")); return; } const host: HostForm = { ...hostForm, ip_address: ip, name: hostForm.name.trim() || ip, port: hostForm.port || (hostForm.protocol === "telnet" ? 23 : 22), }; if (host.protocol === "telnet") { void (async () => { setSessionBusy(true); try { const dims = sessionDims(); const result = await quickConnectWebcrtSession({ name: host.name, ip_address: host.ip_address, port: host.port, protocol: "telnet", save_password: false, cols: dims.cols, rows: dims.rows, encoding: dims.encoding, keepalive_sec: dims.keepaliveSec, async_connect: true, }); const listSource = result.list_source === "managed" ? "managed" : "webcrt"; const target: CliTargetItem = { source: listSource, id: result.ne.id, name: result.ne.name || result.ne_name || host.ip_address, ip_address: result.ne.ip_address || result.ne_ip || host.ip_address, vendor: result.ne.vendor, device_type: result.ne.device_type, protocol: result.ne.protocol || "telnet", username: result.ne.username || "", has_password: false, connect_status: result.ne.connect_status || "unknown", cli_profile_ready: true, }; attachSessionResult( target, result.session_id, dims.encoding, Boolean(result.cli_hop), result.sftp_ready, ); setHostDialogOpen(false); setHostForm(emptyHostForm()); setSource(listSource === "managed" ? "managed" : "webcrt"); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); } catch (err) { showError(webcrtErrorMessage(err, t)); } finally { setSessionBusy(false); } })(); return; } setHostDialogOpen(false); setAuthForm(emptyAuthForm()); setAuthDialog({ mode: "quick", host }); }, [attachSessionResult, hostForm, queryClient, sessionDims, showError, t]); const submitAuthDialog = useCallback(async () => { if (!authDialog) return; const username = authForm.username.trim(); if (!username) { showError(t("webcrt.newSession.userRequired")); return; } if (!authForm.password) { showError(t("webcrt.newSession.passwordRequired")); return; } setSessionBusy(true); const dims = sessionDims(); const host = authDialog.host; const ip = (authDialog.target?.ip_address || host.ip_address).trim(); try { if (authDialog.mode === "retry" && authDialog.target) { const existing = authDialog.target; // Persist to ManagedNE for inventory + Quick Connect; UME never uses this dialog. if (authForm.savePassword && existing.source !== "ume") { try { await updateManagedNe(existing.id, { username, password: authForm.password }); } catch { /* connect still proceeds with one-shot creds */ } } const sess = await createWebcrtSession({ ne_id: existing.id, cols: dims.cols, rows: dims.rows, encoding: dims.encoding, keepalive_sec: dims.keepaliveSec, async_connect: false, username, password: authForm.password, }); attachSessionResult( { ...existing, username, has_password: authForm.savePassword || Boolean(existing.has_password), }, sess.session_id, dims.encoding, Boolean(sess.cli_hop), sess.sftp_ready, ); setAuthDialog(null); setAuthForm(emptyAuthForm()); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); return; } const result = await quickConnectWebcrtSession({ name: host.name || ip, ip_address: ip, port: host.port || 22, protocol: "ssh", username, password: authForm.password, save_password: authForm.savePassword, cols: dims.cols, rows: dims.rows, encoding: dims.encoding, keepalive_sec: dims.keepaliveSec, async_connect: false, }); const target: CliTargetItem = { source: "webcrt", id: result.ne.id, name: result.ne.name || result.ne_name || ip, ip_address: result.ne.ip_address || result.ne_ip || ip, vendor: result.ne.vendor, device_type: result.ne.device_type, protocol: result.ne.protocol || "ssh", username: result.ne.username || username, has_password: authForm.savePassword, connect_status: result.ne.connect_status || "unknown", cli_profile_ready: true, }; attachSessionResult( target, result.session_id, dims.encoding, Boolean(result.cli_hop), result.sftp_ready, ); setAuthDialog(null); setAuthForm(emptyAuthForm()); setSource("webcrt"); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); } catch (err) { const message = webcrtErrorMessage(err, t); // Keep dialog open; allow fixing username and/or password. setAuthForm((prev) => ({ ...prev, password: "" })); const failedNe = connectFailedNe(err); if (failedNe) { const target = targetFromManagedNe(failedNe, { name: host.name || ip, ip_address: ip, username, has_password: authForm.savePassword, protocol: "ssh", }); setAuthDialog({ mode: "retry", host: { name: target.name, ip_address: target.ip_address, port: host.port || 22, protocol: "ssh", }, target: { ...target, username }, errorHint: message, }); setAuthForm((prev) => ({ ...prev, username, password: "" })); setSource("webcrt"); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); } else { setAuthDialog((prev) => (prev ? { ...prev, errorHint: message } : prev)); } } finally { setSessionBusy(false); } }, [attachSessionResult, authDialog, authForm, queryClient, sessionDims, showError, t]); const closeTab = useCallback( async (key: string) => { const tab = tabsRef.current.find((x) => x.key === key); if (tab?.sessionId) { try { await closeWebcrtSession(tab.sessionId); } catch { /* ignore */ } } logBuffersRef.current.delete(key); termRefs.current.delete(key); setTabs((prev) => { const next = prev.filter((x) => x.key !== key); if (activeTabKey === key) { setActiveTabKey(next.length ? next[next.length - 1].key : ""); } return next; }); }, [activeTabKey], ); const closeOtherTabs = useCallback(async (keepKey: string) => { const others = tabsRef.current.filter((x) => x.key !== keepKey); await Promise.all( others.map(async (tab) => { if (tab.sessionId) { try { await closeWebcrtSession(tab.sessionId); } catch { /* ignore */ } } logBuffersRef.current.delete(tab.key); termRefs.current.delete(tab.key); }), ); setTabs((prev) => prev.filter((x) => x.key === keepKey)); setActiveTabKey(keepKey); }, []); const closeAllTabs = useCallback(async () => { const all = [...tabsRef.current]; await Promise.all( all.map(async (tab) => { if (tab.sessionId) { try { await closeWebcrtSession(tab.sessionId); } catch { /* ignore */ } } logBuffersRef.current.delete(tab.key); termRefs.current.delete(tab.key); }), ); setTabs([]); setActiveTabKey(""); }, []); const copyTabIp = useCallback( async (key: string) => { const tab = tabsRef.current.find((x) => x.key === key); const ip = String(tab?.target.ip_address || "").trim(); if (!ip) { showError(t("webcrt.tabMenu.copyIpEmpty")); return; } try { await navigator.clipboard.writeText(ip); showOk(t("webcrt.tabMenu.copyIpOk", { ip })); } catch { showError(t("webcrt.actions.copyFailed")); } }, [showError, showOk, t], ); const reconnectTab = useCallback( async (tab: TermTab) => { // Prefer re-attach while the device PTY may still be in detach grace. const canReattach = Boolean(tab.sessionId) && !isDeviceClosedMessage(tab.errorMessage) && !isSshAuthFailure(tab.errorMessage); if (canReattach && reattachTab(tab)) return; await openTarget(tab.target, { force: true }); }, [openTarget, reattachTab], ); const reconnectActive = useCallback(async () => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab) return; await reconnectTab(tab); }, [activeTabKey, reconnectTab]); 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 chunks = logBuffersRef.current.get(tab.key); const body = chunks?.length ? chunks.join("") : 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 handle = termRefs.current.get(tab.key); const sel = await handle?.copySelection(); const text = sel || (await handle?.copyAll()); if (text) showOk(t("webcrt.actions.copied")); else showError(t("webcrt.actions.copyEmpty")); } catch { showError(t("webcrt.actions.copyFailed")); } }, [activeTabKey, showOk, showError, t]); const refreshSftp = useCallback( async (pathOverride?: string) => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab) return; const path = String(pathOverride ?? sftpPathRef.current ?? ".").trim() || "."; setSftpBusy(true); try { const res = await webcrtSftpList({ ...sftpTargetIds(tab), path }); setSftpItems(res.items || []); setSftpListTruncated(Boolean(res.truncated)); setSftpListMaxEntries(Number(res.max_entries || 0)); const nextPath = String(res.path || path).trim() || path; sftpPathRef.current = nextPath; setSftpPath(nextPath); if (activeTabKey) sftpPathByTabRef.current[activeTabKey] = nextPath; const names = new Set((res.items || []).map((it) => it.name)); setSftpSelected((cur) => cur.filter((n) => names.has(n))); } catch (err) { showError(webcrtErrorMessage(err, t)); } finally { setSftpBusy(false); } }, [activeTabKey, showError, t], ); const navigateSftp = useCallback( (nextPath: string) => { const path = String(nextPath || ".").trim() || "."; sftpPathRef.current = path; setSftpPath(path); if (activeTabKey) sftpPathByTabRef.current[activeTabKey] = path; setSftpSelected([]); sftpSelectAnchorRef.current = ""; void refreshSftp(path); }, [activeTabKey, refreshSftp], ); const beginSftpTransfer = useCallback(() => { sftpAbortRef.current?.abort(); const ac = new AbortController(); sftpAbortRef.current = ac; setSftpTransferring(true); setSftpBusy(true); return ac; }, []); const endSftpTransfer = useCallback(() => { sftpAbortRef.current = null; setSftpTransferring(false); setSftpBusy(false); setSftpStatus(""); }, []); const cancelSftpTransfer = useCallback(() => { sftpAbortRef.current?.abort(); }, []); const selectSftpRow = useCallback( (name: string, e: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean }, orderedNames: string[]) => { const multi = Boolean(e.ctrlKey || e.metaKey); const range = Boolean(e.shiftKey); if (range && sftpSelectAnchorRef.current) { const a = orderedNames.indexOf(sftpSelectAnchorRef.current); const b = orderedNames.indexOf(name); if (a >= 0 && b >= 0) { const [lo, hi] = a < b ? [a, b] : [b, a]; setSftpSelected(orderedNames.slice(lo, hi + 1)); return; } } if (multi) { setSftpSelected((prev) => prev.includes(name) ? prev.filter((x) => x !== name) : [...prev, name], ); sftpSelectAnchorRef.current = name; return; } setSftpSelected([name]); sftpSelectAnchorRef.current = name; }, [], ); const downloadSftpItems = useCallback( async (items: WebcrtSftpItem[]) => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); const files = items.filter((it) => !it.is_dir); if (!tab || !files.length || sftpBusy) return; const ac = beginSftpTransfer(); let done = 0; let failed = 0; try { for (const it of files) { if (ac.signal.aborted) throw new Error("aborted"); done += 1; setSftpStatus( t("webcrt.sftp.downloading", { name: it.name, pct: "0%", done, total: files.length, }), ); try { const remote = joinSftpPath(sftpPathRef.current, it.name); const blob = await webcrtSftpDownload( { ...sftpTargetIds(tab), path: remote }, { signal: ac.signal, retries: 2, onRetry: (attempt) => { setSftpStatus( t("webcrt.sftp.retrying", { name: it.name, attempt, max: 3 }), ); }, onProgress: (p) => { const pct = formatSftpTransferPct(p.loaded, p.total || it.size); setSftpStatus( t("webcrt.sftp.downloading", { name: it.name, pct: pct || "…", done, total: files.length, }), ); }, }, ); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = it.name; a.click(); URL.revokeObjectURL(url); } catch (err) { if (String(err).includes("aborted")) throw err; failed += 1; if (files.length === 1) throw err; } } if (failed > 0) showError(t("webcrt.sftp.downloadPartial", { ok: files.length - failed, failed })); else if (files.length > 1) showOk(t("webcrt.sftp.downloaded", { count: files.length })); } catch (err) { if (!String(err).includes("aborted")) showError(webcrtErrorMessage(err, t)); else showError(t("webcrt.err.sftpAborted")); } finally { endSftpTransfer(); } }, [activeTabKey, beginSftpTransfer, endSftpTransfer, showError, showOk, sftpBusy, t], ); const downloadSftpItem = useCallback( async (it: WebcrtSftpItem) => { await downloadSftpItems([it]); }, [downloadSftpItems], ); const mkdirSftp = useCallback(async () => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab || sftpBusy) return; const name = window.prompt(t("webcrt.sftp.mkdirPrompt"), ""); if (name == null) return; const trimmed = String(name).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); if (!trimmed || trimmed.includes("..") || trimmed.includes("/")) { showError(t("webcrt.sftp.mkdirInvalid")); return; } setSftpBusy(true); try { const remote = joinSftpPath(sftpPathRef.current, trimmed); await webcrtSftpMkdir({ ...sftpTargetIds(tab), path: remote }); showOk(t("webcrt.sftp.mkdirOk")); await refreshSftp(sftpPathRef.current); setSftpSelected([trimmed]); sftpSelectAnchorRef.current = trimmed; } catch (err) { showError(webcrtErrorMessage(err, t)); } finally { setSftpBusy(false); } }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, t]); const removeSftpSelected = useCallback(async () => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab || !sftpSelected.length || sftpBusy) return; const items = sftpItems.filter((x) => sftpSelected.includes(x.name)); if (!items.length) return; const label = items.length === 1 ? items[0].name : t("webcrt.sftp.selectedCount", { count: items.length }); if (!window.confirm(t("webcrt.sftp.deleteConfirm", { name: label }))) return; const hasDir = items.some((x) => x.is_dir); let recursive = false; if (hasDir) { recursive = window.confirm(t("webcrt.sftp.deleteRecursiveConfirm", { name: label })); } setSftpBusy(true); let failed = 0; try { for (const item of items) { const remote = joinSftpPath(sftpPathRef.current, item.name); try { try { await webcrtSftpRemove({ ...sftpTargetIds(tab), path: remote, recursive: false, }); } catch (err) { const raw = String(err); if (item.is_dir && raw.includes("sftp_dir_not_empty")) { if (!recursive) throw err; await webcrtSftpRemove({ ...sftpTargetIds(tab), path: remote, recursive: true }); } else { throw err; } } } catch { failed += 1; } } setSftpSelected([]); await refreshSftp(sftpPathRef.current); if (failed > 0) showError(t("webcrt.sftp.deletePartial", { ok: items.length - failed, failed })); else showOk(t("webcrt.sftp.deleted")); } catch (err) { showError(webcrtErrorMessage(err, t)); } finally { setSftpBusy(false); } }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpItems, sftpSelected, t]); const renameSftpSelected = useCallback(async () => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab || sftpSelected.length !== 1 || sftpBusy) return; const name = sftpSelected[0]; const next = window.prompt(t("webcrt.sftp.renamePrompt"), name); if (next == null) return; const trimmed = String(next).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); if (!trimmed || trimmed.includes("..") || trimmed.includes("/")) { showError(t("webcrt.sftp.renameInvalid")); return; } if (trimmed === name) return; const oldPath = joinSftpPath(sftpPathRef.current, name); const newPath = joinSftpPath(sftpPathRef.current, trimmed); setSftpBusy(true); try { await webcrtSftpRename({ ...sftpTargetIds(tab), old_path: oldPath, new_path: newPath }); showOk(t("webcrt.sftp.renamed")); setSftpSelected([trimmed]); sftpSelectAnchorRef.current = trimmed; await refreshSftp(sftpPathRef.current); } catch (err) { showError(webcrtErrorMessage(err, t)); } finally { setSftpBusy(false); } }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpSelected, t]); const chmodSftpSelected = useCallback(async () => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab || !sftpSelected.length || sftpBusy) return; const sample = sftpItems.find((x) => x.name === sftpSelected[0]); const preset = sample?.mode ? sample.mode.replace(/^./, "").replace(/[^rwx-]/g, "") : "755"; const mode = window.prompt(t("webcrt.sftp.chmodPrompt"), preset.length === 9 ? preset : "755"); if (mode == null) return; const trimmed = String(mode).trim(); if (!trimmed) return; setSftpBusy(true); let failed = 0; try { for (const name of sftpSelected) { const remote = joinSftpPath(sftpPathRef.current, name); try { await webcrtSftpChmod({ ...sftpTargetIds(tab), path: remote, mode: trimmed }); } catch { failed += 1; } } await refreshSftp(sftpPathRef.current); if (failed > 0) showError(t("webcrt.sftp.chmodPartial", { ok: sftpSelected.length - failed, failed })); else showOk(t("webcrt.sftp.chmodOk")); } catch (err) { showError(webcrtErrorMessage(err, t)); } finally { setSftpBusy(false); } }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpItems, sftpSelected, t]); const toggleSftpSort = useCallback((key: SftpSortKey) => { setSftpSortKey((prev) => { if (prev === key) { setSftpSortDir((d) => (d === "asc" ? "desc" : "asc")); return prev; } setSftpSortDir(key === "mtime" || key === "size" ? "desc" : "asc"); return key; }); }, []); const uploadSftpItems = useCallback( async (items: SftpUploadItem[]) => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); if (!tab || !items.length || sftpBusy) return; const ac = beginSftpTransfer(); setSftpDragOver(false); sftpDragDepthRef.current = 0; let done = 0; let failed = 0; try { for (const item of items) { if (ac.signal.aborted) throw new Error("aborted"); done += 1; const shortName = String(item.relativePath || item.file.name).split("/").pop() || item.file.name; setSftpStatus( t("webcrt.sftp.uploading", { done, total: items.length, name: shortName, pct: "0%" }), ); const rel = String(item.relativePath || item.file.name) .replace(/\\/g, "/") .replace(/^\/+/, "") .replace(/\/+/g, "/"); if (!rel || rel.includes("..")) { failed += 1; continue; } const remote = joinSftpPath(sftpPathRef.current, rel); try { await webcrtSftpUpload( { ...sftpTargetIds(tab), remote_path: remote, file: item.file }, { signal: ac.signal, retries: 2, onRetry: (attempt) => { setSftpStatus(t("webcrt.sftp.retrying", { name: shortName, attempt, max: 3 })); }, onProgress: (p) => { const pct = formatSftpTransferPct(p.loaded, p.total || item.file.size); setSftpStatus( t("webcrt.sftp.uploading", { done, total: items.length, name: shortName, pct: pct || "…", }), ); }, }, ); } catch (err) { if (String(err).includes("aborted")) throw err; failed += 1; if (items.length === 1) throw err; } } await refreshSftp(sftpPathRef.current); if (failed > 0) { showError(t("webcrt.sftp.uploadPartial", { ok: items.length - failed, failed })); } else { showOk(t("webcrt.sftp.uploaded")); } } catch (err) { if (String(err).includes("aborted")) showError(t("webcrt.err.sftpAborted")); else showError(webcrtErrorMessage(err, t)); } finally { endSftpTransfer(); } }, [activeTabKey, beginSftpTransfer, endSftpTransfer, refreshSftp, showError, showOk, sftpBusy, t], ); const onSftpDragEnter = useCallback((e: ReactDragEvent) => { if (![...e.dataTransfer.types].includes("Files")) return; e.preventDefault(); e.stopPropagation(); sftpDragDepthRef.current += 1; setSftpDragOver(true); }, []); const onSftpDragOver = useCallback((e: ReactDragEvent) => { if (![...e.dataTransfer.types].includes("Files")) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = "copy"; }, []); const onSftpDragLeave = useCallback((e: ReactDragEvent) => { if (![...e.dataTransfer.types].includes("Files")) return; e.preventDefault(); e.stopPropagation(); sftpDragDepthRef.current = Math.max(0, sftpDragDepthRef.current - 1); if (sftpDragDepthRef.current === 0) setSftpDragOver(false); }, []); const onSftpDrop = useCallback( (e: ReactDragEvent) => { e.preventDefault(); e.stopPropagation(); sftpDragDepthRef.current = 0; setSftpDragOver(false); if (sftpBusy) return; void (async () => { try { const items = await collectDroppedSftpUploads(e.dataTransfer); if (!items.length) { showError(t("webcrt.sftp.dropEmpty")); return; } await uploadSftpItems(items); } catch (err) { showError(webcrtErrorMessage(err, t)); } })(); }, [showError, sftpBusy, t, uploadSftpItems], ); const onSftpSplitDown = useCallback((e: ReactMouseEvent) => { e.preventDefault(); sftpDragRef.current = { startX: e.clientX, startW: sftpWidth }; setSftpResizing(true); const onMove = (ev: MouseEvent) => { const drag = sftpDragRef.current; if (!drag) return; const bodyW = sftpBodyRef.current?.clientWidth || window.innerWidth; const maxW = Math.max(SFTP_WIDTH_MIN, Math.floor(bodyW * 0.75)); const next = Math.max(SFTP_WIDTH_MIN, Math.min(maxW, drag.startW + (drag.startX - ev.clientX))); setSftpWidth(next); }; const onUp = () => { sftpDragRef.current = null; setSftpResizing(false); document.body.style.cursor = ""; document.body.style.userSelect = ""; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); setSftpWidth((w) => { saveSftpWidth(w); return w; }); }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [sftpWidth]); const onSftpColResizeDown = useCallback((key: SftpColKey, e: ReactMouseEvent) => { e.preventDefault(); e.stopPropagation(); sftpColDragRef.current = { key, startX: e.clientX, startW: sftpColWidths[key] }; const onMove = (ev: MouseEvent) => { const drag = sftpColDragRef.current; if (!drag) return; const next = clampSftpColWidth(drag.key, drag.startW + (ev.clientX - drag.startX)); setSftpColWidths((prev) => (prev[drag.key] === next ? prev : { ...prev, [drag.key]: next })); }; const onUp = () => { sftpColDragRef.current = null; document.body.style.cursor = ""; document.body.style.userSelect = ""; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); setSftpColWidths((prev) => { saveSftpColWidths(prev); return prev; }); }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [sftpColWidths]); // Auto-connect from /webcrt?ne_id=... useEffect(() => { if (!presetNeId) return; const neId = presetNeId; const sourceHint = presetSource === "ume" ? "ume" : "managed"; let alive = true; (async () => { try { if (sourceHint === "ume") { await openTargetRef.current({ source: "ume", id: neId, ume_ne_id: neId, name: neId, ip_address: "", connect_status: "unknown", }); } else { 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 (alive) showError(webcrtErrorMessage(err, t)); } finally { 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 () => { alive = false; }; }, [presetNeId, presetSource, showError, t, setSearchParams]); const activeTab = tabs.find((x) => x.key === activeTabKey) || null; const sftpBlockedKey = activeTab ? sftpUnavailableReason(activeTab) : "webcrt.err.sftpSsh"; const sftpAllowed = Boolean(activeTab && !sftpBlockedKey); const sftpSortedItems = sortSftpItems(sftpItems, sftpSortKey, sftpSortDir); const sftpOrderedNames = sftpSortedItems.map((it) => it.name); const sftpSelectedItems = sftpSortedItems.filter((it) => sftpSelected.includes(it.name)); const sftpSelectedFiles = sftpSelectedItems.filter((it) => !it.is_dir); useEffect(() => { if (sftpOpen && !sftpAllowed) setSftpOpen(false); }, [sftpOpen, sftpAllowed]); useEffect(() => { const prev = sftpActiveTabRef.current; if (prev === activeTabKey) return; if (prev) sftpPathByTabRef.current[prev] = sftpPathRef.current; sftpActiveTabRef.current = activeTabKey; if (!sftpOpen || !activeTabKey || !sftpAllowed) return; const saved = sftpPathByTabRef.current[activeTabKey] || "."; sftpPathRef.current = saved; setSftpPath(saved); setSftpSelected([]); sftpSelectAnchorRef.current = ""; void refreshSftp(saved); }, [activeTabKey, refreshSftp, sftpAllowed, sftpOpen]); const renameWebcrtSession = useCallback( async (target: CliTargetItem, nextName: string) => { const name = nextName.trim(); if (!name) { showError(t("webcrt.treeMenu.renameRequired")); return; } if (target.source !== "webcrt") return; try { await updateManagedNe(target.id, { name }); const key = targetKey(target); setTabs((prev) => prev.map((tab) => (tab.key === key ? { ...tab, target: { ...tab.target, name } } : tab)), ); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); showOk(t("webcrt.treeMenu.renamed")); setRenameDialog(null); } catch (err) { showError(webcrtErrorMessage(err, t)); } }, [queryClient, showError, showOk, t], ); const deleteWebcrtSession = useCallback( async (target: CliTargetItem) => { if (target.source !== "webcrt") return; const label = deviceLabel(target); if (!window.confirm(t("webcrt.treeMenu.deleteConfirm", { name: label }))) return; try { const key = targetKey(target); await closeTab(key); await deleteManagedNe(target.id); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); showOk(t("webcrt.treeMenu.deleted")); } catch (err) { showError(webcrtErrorMessage(err, t)); } }, [closeTab, queryClient, showError, showOk, t], ); const renderDeviceRow = (row: CliTargetItem) => { const key = targetKey(row); const tab = tabs.find((x) => x.key === key); const isConnecting = tab?.status === "connecting" || connectingKeysRef.current.has(key); const isWebcrtSession = row.source === "webcrt"; return (
  • ); }; return (
    {tabs.length > 0 ? ( <>
    {tabs.map((tab) => { const dead = tab.status === "closed" || tab.status === "error"; return (
    { if (e.button === 0) e.preventDefault(); }} onClick={() => { setActiveTabKey(tab.key); setTabMenu(null); const cur = tabsRef.current.find((x) => x.key === tab.key); // Soft-closed tabs still hold a live backend session — re-attach immediately. if ( cur?.sessionId && (cur.status === "closed" || cur.status === "error") && !isDeviceClosedMessage(cur.errorMessage) && !isSshAuthFailure(cur.errorMessage) && !isSessionGoneError(cur.errorMessage) ) { reattachTab(cur); return; } window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 0); }} onContextMenu={(e) => { e.preventDefault(); setActiveTabKey(tab.key); setTabMenu({ key: tab.key, x: e.clientX, y: e.clientY }); }} > {deviceLabel(tab.target)} {tab.status === "connecting" ? ` (${connectPhaseLabel(tab.connectPhase, t)})` : ""} {tab.status === "closed" ? ` (${t("webcrt.status.closed")})` : ""} {tab.status === "error" ? ` (${t("webcrt.status.error")})` : ""} { e.stopPropagation(); void closeTab(tab.key); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); void closeTab(tab.key); } }} > ×
    ); })}
    {activeTab ? (
    ) : null}
    {tabs.map((tab) => { const isActive = activeTabKey === tab.key; // Cap concurrent xterm+WS: active always mounts; keep recent tabs warm. // Colder tabs detach (server grace); remount mints a fresh ws_ticket via sessionId. const mountTerminal = Boolean( tab.sessionId && (isActive || warmTabKeys.has(tab.key)), ); return ( ); })}
    {sftpOpen && activeTab && sftpAllowed ? ( <>
    ) : null}
    ) : (

    {t("webcrt.termPlaceholder")}

    {t("webcrt.hintCrt")}

    )}
    {treeMenu ? (
    ) : null} {renameDialog ? (
    setRenameDialog(null)} >
    e.stopPropagation()} >

    {t("webcrt.treeMenu.rename")}

    {renameDialog.target.ip_address} {renameDialog.target.protocol ? ` · ${String(renameDialog.target.protocol).toUpperCase()}` : ""}

    ) : null} {tabMenu ? (
    ) : null} {keywordHlModalOpen ? (
    setKeywordHlModalOpen(false)} >
    e.stopPropagation()} >

    {t("webcrt.keywordHl.title")}

    {t("webcrt.keywordHl.colKeyword")} {t("webcrt.keywordHl.colRegex")}
      {keywordHlDraft.keywords.length === 0 ? (
    • {t("webcrt.keywordHl.empty")}
    • ) : ( keywordHlDraft.keywords.map((kw) => (
    • )) )}
    ) : null} {sessionOptsModalOpen ? (
    setSessionOptsModalOpen(false)} >
    e.stopPropagation()} >

    {t("webcrt.globalSessionOptions")}

    Aa 192.168.0.1
    ) : null} {authDialog ? (
    { if (!sessionBusy) setAuthDialog(null); }} >
    e.stopPropagation()} >

    {t("webcrt.newSession.authTitle")}

    {authDialog.host.ip_address} {authDialog.host.port ? `:${authDialog.host.port}` : ""} {" · SSH"}

    {authDialog.errorHint ? (

    {authDialog.errorHint}

    ) : null}
    ) : null} {hostDialogOpen ? (
    { if (!sessionBusy) setHostDialogOpen(false); }} >
    e.stopPropagation()} >

    {t("webcrt.newSession.title")}

    ) : null}
    ); }