netx/web/src/pages/WebcrtPage.tsx
oliver fb3da4dfc7 Fix WebCRT terminal not mounting when only sessionId is set.
Reconnect and tab restore clear wsUrl; the pane still gated on wsUrl so Telnet stayed on authenticating with an empty terminal. Mount/render from sessionId instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 19:14:30 +08:00

3407 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<SftpColKey, number>;
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<Exclude<ColorSchemeId, "custom">, { 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<Exclude<ColorSchemeId, "custom">>;
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 | number>) => 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<SessionOptions> & { 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<ColorSchemeId, "custom">];
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<ColorSchemeId, "custom">];
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 (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
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 ComputerIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
<path d="M4 3h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1h-5v2h2v2H7v-2h2v-2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm1 2v9h14V5H5z" />
</svg>
);
}
function FolderIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
<path d="M3 6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
</svg>
);
}
function FileIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
<path d="M6 2h8l4 4v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm7 1.5V8h4.5L13 3.5z" />
</svg>
);
}
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<CliTargetItem, "source" | "protocol">): 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<TermTab, "target" | "cliHop" | "sftpReady" | "status">,
):
| "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<string, unknown>;
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<TermTab, "target">): { 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<FsEntryLike["createReader"]>,
): Promise<FsEntryLike[]> {
const all: FsEntryLike[] = [];
for (;;) {
const batch = await new Promise<FsEntryLike[]>((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<void> {
if (entry.isFile) {
const file = await new Promise<File>((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<SftpUploadItem[]> {
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 | number>) => string): string {
const raw = String(err);
if (raw.includes("webcrt_session_limit")) return t("webcrt.err.sessionLimit");
if (raw.includes("managed_ne_not_found") || raw.includes("ume_ne_not_found")) return t("webcrt.err.neNotFound");
if (raw.includes("credentials_incomplete") || raw.includes("cli_username_required") || 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>): 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<TermTab[]>(() => {
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<string[]>(() => (activeTabKey ? [activeTabKey] : []));
const [sftpOpen, setSftpOpen] = useState(false);
const [sftpPath, setSftpPath] = useState(".");
const [sftpBusy, setSftpBusy] = useState(false);
const [sftpItems, setSftpItems] = useState<WebcrtSftpItem[]>([]);
const [sftpSelected, setSftpSelected] = useState<string[]>([]);
const [sftpSortKey, setSftpSortKey] = useState<SftpSortKey>("name");
const [sftpSortDir, setSftpSortDir] = useState<"asc" | "desc">("asc");
const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth());
const [sftpResizing, setSftpResizing] = useState(false);
const [sftpColWidths, setSftpColWidths] = useState<SftpColWidths>(() => 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<Record<string, string>>({});
const sftpActiveTabRef = useRef("");
const sftpSelectAnchorRef = useRef("");
const sftpAbortRef = useRef<AbortController | null>(null);
const sftpBodyRef = useRef<HTMLDivElement | null>(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<SessionOptions>(() => loadSessionOptions());
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
const [sessionOptsModalOpen, setSessionOptsModalOpen] = useState(false);
const [sessionOptsDraft, setSessionOptsDraft] = useState<SessionOptions>(() => loadSessionOptions());
const [keywordHl, setKeywordHl] = useState<KeywordHighlightConfig>(() => loadKeywordHighlightConfig());
const [keywordHlModalOpen, setKeywordHlModalOpen] = useState(false);
const [keywordHlDraft, setKeywordHlDraft] = useState<KeywordHighlightConfig>(() =>
loadKeywordHighlightConfig(),
);
const [keywordDraftText, setKeywordDraftText] = useState("");
const [keywordDraftRegex, setKeywordDraftRegex] = useState(false);
const [keywordSelectedId, setKeywordSelectedId] = useState("");
const [tabMenu, setTabMenu] = useState<TabMenuState | null>(null);
const [treeMenu, setTreeMenu] = useState<TreeMenuState | null>(null);
const [renameDialog, setRenameDialog] = useState<{ target: CliTargetItem; name: string } | null>(null);
const [hostDialogOpen, setHostDialogOpen] = useState(false);
const [hostForm, setHostForm] = useState<HostForm>(() => emptyHostForm());
const [authDialog, setAuthDialog] = useState<AuthDialogState | null>(null);
const [authForm, setAuthForm] = useState<AuthForm>(() => emptyAuthForm());
const [sessionBusy, setSessionBusy] = useState(false);
const connectingKeysRef = useRef<Set<string>>(new Set());
const tabsRef = useRef<TermTab[]>([]);
tabsRef.current = tabs;
const termRefs = useRef<Map<string, WebTerminalHandle>>(new Map());
/** Chunk lists avoid O(n²) string append while recording. */
const logBuffersRef = useRef<Map<string, string[]>>(new Map());
const lastQueueDropToastAtRef = useRef(0);
/** Limit silent WS remount retries per tab to avoid storms. */
const softWsRetryRef = useRef<Map<string, number>>(new Map());
const optionsMenuRef = useRef<HTMLDivElement | null>(null);
const tabMenuRef = useRef<HTMLDivElement | null>(null);
const treeMenuRef = useRef<HTMLDivElement | null>(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<TermTab>) => {
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<HTMLElement>) => {
if (![...e.dataTransfer.types].includes("Files")) return;
e.preventDefault();
e.stopPropagation();
sftpDragDepthRef.current += 1;
setSftpDragOver(true);
}, []);
const onSftpDragOver = useCallback((e: ReactDragEvent<HTMLElement>) => {
if (![...e.dataTransfer.types].includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const onSftpDragLeave = useCallback((e: ReactDragEvent<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLSpanElement>) => {
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 (
<li key={key}>
<button
type="button"
className={`webcrt-tree__item${activeTabKey === key ? " is-active" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void openTarget(row)}
onContextMenu={(e) => {
if (!isWebcrtSession) return;
e.preventDefault();
e.stopPropagation();
setTabMenu(null);
setTreeMenu({ target: row, x: e.clientX, y: e.clientY });
}}
title={`${deviceLabel(row)}\n${row.ip_address}\n${row.source}`}
>
<span className="webcrt-tree__icon" aria-hidden>
<ComputerIcon />
</span>
<span className="webcrt-tree__label">
<span className="webcrt-tree__name">{deviceLabel(row)}</span>
<span className="webcrt-tree__meta">
{row.ip_address || row.source}
{isConnecting ? ` · ${t("webcrt.status.connecting")}` : ""}
{tab?.status === "connected" ? ` · ${t("webcrt.status.connected")}` : ""}
</span>
</span>
</button>
</li>
);
};
return (
<div className="webcrt-shell">
<aside className="webcrt-sidebar">
<div className="webcrt-sidebar__chrome">
<div className="webcrt-sidebar__actions">
<button
type="button"
className="webcrt-sidebar__new-btn"
onClick={() => {
setHostForm(emptyHostForm());
setHostDialogOpen(true);
}}
>
{t("webcrt.newSession.title")}
</button>
<div className="webcrt-menubar__item" ref={optionsMenuRef}>
<button
type="button"
className={`webcrt-menubar__btn${optionsMenuOpen ? " is-open" : ""}`}
aria-haspopup="menu"
aria-expanded={optionsMenuOpen}
onClick={() => setOptionsMenuOpen((v) => !v)}
>
{t("webcrt.options")}
</button>
{optionsMenuOpen ? (
<div className="webcrt-menubar__menu webcrt-menubar__menu--end" role="menu">
<button
type="button"
role="menuitem"
className="webcrt-menubar__menu-item"
onClick={() => {
setOptionsMenuOpen(false);
setSessionOptsDraft(sessionOpts);
setSessionOptsModalOpen(true);
}}
>
{t("webcrt.globalSessionOptions")}…
</button>
<button
type="button"
role="menuitem"
className="webcrt-menubar__menu-item"
onClick={() => {
setOptionsMenuOpen(false);
// If keywords exist but Enable was left off, open as enabled so Save works.
setKeywordHlDraft({
...keywordHl,
enabled: keywordHl.keywords.length > 0 ? true : keywordHl.enabled,
});
setKeywordDraftText("");
setKeywordDraftRegex(false);
setKeywordSelectedId(keywordHl.keywords[0]?.id || "");
setKeywordHlModalOpen(true);
}}
>
{t("webcrt.keywordHl.title")}…
</button>
</div>
) : null}
</div>
</div>
<div className="webcrt-sidebar__search">
<input
type="search"
value={keywordInput}
placeholder={t("webcrt.filterKeywordPh")}
onChange={(e) => setKeywordInput(e.target.value)}
/>
</div>
<div className="webcrt-source-tabs webcrt-source-tabs--4">
{(["all", "managed", "webcrt", "ume"] as const).map((s) => (
<button
key={s}
type="button"
className={`webcrt-source-tabs__btn${source === s ? " is-active" : ""}`}
onClick={() => {
setSource(s);
setPage(1);
}}
>
{t(`webcrt.source.${s}`)}
</button>
))}
</div>
</div>
<div className="webcrt-tree">
{targetsQuery.isLoading ? <div className="webcrt-tree__empty">{t("common.refreshing")}</div> : null}
{!targetsQuery.isLoading && items.length === 0 ? (
<div className="webcrt-tree__empty">{t("webcrt.empty")}</div>
) : null}
<ul className="webcrt-tree__list">{items.map((row) => renderDeviceRow(row))}</ul>
</div>
<div className="webcrt-sidebar__pager">
<span>{t("common.pagerMeta", { total, page, pages })}</span>
<div className="webcrt-sidebar__pager-btns">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} aria-label="prev">
‹
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)} aria-label="next">
›
</button>
</div>
</div>
</aside>
<main className="webcrt-main">
{tabs.length > 0 ? (
<>
<div className="webcrt-tabs">
{tabs.map((tab) => {
const dead = tab.status === "closed" || tab.status === "error";
return (
<div
key={tab.key}
className={`webcrt-tabs__item${activeTabKey === tab.key ? " is-active" : ""}${
dead ? " is-dead" : ""
}${tab.status === "connecting" ? " is-connecting" : ""}`}
onMouseDown={(e) => {
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 });
}}
>
<span>
{deviceLabel(tab.target)}
{tab.status === "connecting"
? ` (${connectPhaseLabel(tab.connectPhase, t)})`
: ""}
{tab.status === "closed" ? ` (${t("webcrt.status.closed")})` : ""}
{tab.status === "error" ? ` (${t("webcrt.status.error")})` : ""}
<span
role="button"
tabIndex={0}
className="webcrt-tabs__close"
aria-label={t("webcrt.disconnect")}
onClick={(e) => {
e.stopPropagation();
void closeTab(tab.key);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
void closeTab(tab.key);
}
}}
>
×
</span>
</span>
</div>
);
})}
</div>
{activeTab ? (
<div className="webcrt-actions">
<button
type="button"
className={`webcrt-action-btn${
activeTab.status === "closed" || activeTab.status === "error" ? " is-warn" : ""
}`}
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>
<button
type="button"
className={`webcrt-action-btn${sftpOpen ? " is-active" : ""}`}
disabled={!sftpAllowed}
title={sftpAllowed ? t("webcrt.sftp.title") : t(sftpBlockedKey || "webcrt.err.sftpSsh")}
onClick={() => {
if (!sftpAllowed) {
showError(t(sftpBlockedKey || "webcrt.err.sftpSsh"));
return;
}
setSftpOpen((v) => {
const next = !v;
if (next) {
const saved =
sftpPathByTabRef.current[activeTabKey] || sftpPathRef.current || ".";
sftpPathRef.current = saved;
setSftpPath(saved);
void refreshSftp(saved);
}
return next;
});
}}
>
{t("webcrt.sftp.title")}
</button>
</div>
) : null}
<div
ref={sftpBodyRef}
className={`webcrt-main__body${sftpOpen && activeTab && sftpAllowed ? " has-sftp" : ""}`}
>
<div className="webcrt-main__terms">
{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 (
<div
key={tab.key}
className="webcrt-main__pane"
hidden={!isActive}
>
{tab.status === "connecting" && !tab.sessionId ? (
<div className="webcrt-main__placeholder">
<div>{connectPhaseLabel(tab.connectPhase, t)}…</div>
<p className="panel__hint">{t("webcrt.phase.hint")}</p>
</div>
) : null}
{tab.status === "error" && !tab.sessionId ? (
<div className="webcrt-main__placeholder webcrt-main__placeholder--error">
<div>{t("webcrt.status.error")}</div>
{tab.errorMessage ? <pre className="webcrt-error-detail">{tab.errorMessage}</pre> : null}
<button type="button" className="webcrt-action-btn is-warn" onClick={() => void reconnectTab(tab)}>
{t("webcrt.actions.reconnect")}
</button>
</div>
) : null}
{tab.sessionId ? (
<>
{tab.status === "connecting" ? (
<div className="webcrt-connect-banner" role="status">
<div className="webcrt-connect-banner__text">
<strong>{connectPhaseLabel(tab.connectPhase, t)}…</strong>
<span>{t("webcrt.phase.hint")}</span>
</div>
</div>
) : null}
{tab.status === "closed" || tab.status === "error" ? (
<div className="webcrt-disconnect-banner" role="status">
<div className="webcrt-disconnect-banner__text">
<strong>
{tab.status === "error"
? t("webcrt.disconnectBannerError")
: t("webcrt.disconnectBanner")}
</strong>
{tab.errorMessage ? <span>{tab.errorMessage}</span> : null}
</div>
<button
type="button"
className="webcrt-action-btn is-warn"
onClick={() => void reconnectTab(tab)}
>
{t("webcrt.actions.reconnect")}
</button>
</div>
) : null}
{mountTerminal ? (
<WebTerminal
key={`${tab.key}:${tab.termEpoch}`}
ref={(handle) => {
if (handle) termRefs.current.set(tab.key, handle);
else termRefs.current.delete(tab.key);
}}
sessionId={tab.sessionId || undefined}
wsUrl={tab.wsUrl || undefined}
title={deviceLabel(tab.target)}
recording={tab.recording}
encoding={tab.encoding}
fontSize={sessionOpts.fontSize}
termColors={{
background: sessionOpts.background,
foreground: sessionOpts.foreground,
}}
copyOnSelect={sessionOpts.copyOnSelect}
pasteDelayMs={sessionOpts.pasteDelayMs}
keywordHighlight={keywordHl}
autoFocus={tab.status === "connected"}
onStdout={(chunk) => {
if (!chunk) return;
let parts = logBuffersRef.current.get(tab.key);
if (!parts) {
parts = [];
logBuffersRef.current.set(tab.key, parts);
}
parts.push(chunk);
if (parts.length >= LOG_COMPACT_CHUNKS) {
const joined = parts.join("");
logBuffersRef.current.set(
tab.key,
[joined.length > LOG_MAX_CHARS ? joined.slice(-LOG_MAX_CHARS) : joined],
);
}
}}
onReady={() => {
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
}}
onStatus={(state, message, phase, meta) => {
if (state === "connected") {
softWsRetryRef.current.delete(tab.key);
updateTab(tab.key, {
status: "connected",
connectPhase: undefined,
errorMessage: undefined,
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
});
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
} else if (state === "open" || state === "connecting") {
const nextPhase: ConnectPhase =
phase === "waiting_prompt" || message === "waiting_prompt"
? "waiting_prompt"
: "authenticating";
updateTab(tab.key, {
status: "connecting",
connectPhase: nextPhase,
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
});
} else if (state === "warning") {
const m = String(message || "");
const dropMatch = /^queue_dropped:(\d+)/i.exec(m);
if (dropMatch) {
const now = Date.now();
if (now - lastQueueDropToastAtRef.current > 3000) {
lastQueueDropToastAtRef.current = now;
showError(
t("webcrt.err.queueDropped", {
count: Number(dropMatch[1]) || dropMatch[1],
}),
);
}
}
} else if (state === "error") {
const errMsg = message || t("webcrt.disconnectBannerError");
const sid = tab.sessionId;
// Re-attach missed the grace window → create a fresh session.
if (sid && isSessionGoneError(errMsg)) {
updateTab(tab.key, {
status: "connecting",
sessionId: "",
wsUrl: "",
connectPhase: "creating",
errorMessage: undefined,
});
void openTarget(tab.target, { force: true });
return;
}
// Transient browser WS failures (tab switch / page leave / ticket race):
// keep session_id and silently remount with a fresh ticket (once).
const transientWs =
/^(websocket_error|websocket_closed)/i.test(String(message || "")) ||
String(message || "").toLowerCase().includes("websocket");
if (transientWs && sid) {
const retries = softWsRetryRef.current.get(tab.key) || 0;
if (retries < 1) {
softWsRetryRef.current.set(tab.key, retries + 1);
updateTab(tab.key, {
status: "connecting",
connectPhase: "authenticating",
termEpoch: tab.termEpoch + 1,
errorMessage: undefined,
});
return;
}
softWsRetryRef.current.set(tab.key, 0);
updateTab(tab.key, {
status: "closed",
connectPhase: undefined,
errorMessage: t("webcrt.disconnectBanner"),
});
return;
}
updateTab(tab.key, {
status: "error",
sessionId: "",
wsUrl: "",
connectPhase: undefined,
errorMessage: webcrtErrorMessage(errMsg, t),
});
if (sid) {
void closeWebcrtSession(sid).catch(() => undefined);
}
if (isInventorySsh(tab.target) && isSshAuthFailure(errMsg)) {
openAuthForTarget(tab.target, webcrtErrorMessage(errMsg, t));
}
} else if (state === "closed") {
const msg = String(message || "");
// Local WS drop (unmount / navigate away): PTY stays in detach grace.
// Remount with a fresh ticket instead of showing a hard disconnect.
if (msg.startsWith("websocket_closed:") && tab.sessionId) {
const retries = softWsRetryRef.current.get(tab.key) || 0;
if (retries < 1) {
softWsRetryRef.current.set(tab.key, retries + 1);
updateTab(tab.key, {
status: "connecting",
connectPhase: "authenticating",
termEpoch: tab.termEpoch + 1,
errorMessage: undefined,
});
return;
}
softWsRetryRef.current.set(tab.key, 0);
updateTab(tab.key, {
status: "closed",
connectPhase: undefined,
errorMessage: t("webcrt.disconnectBanner"),
});
return;
}
const deviceGone = isDeviceClosedMessage(msg);
updateTab(tab.key, {
status: "closed",
sessionId: deviceGone ? "" : tab.sessionId,
wsUrl: deviceGone ? "" : tab.wsUrl,
connectPhase: undefined,
errorMessage: msg || t("webcrt.disconnectBanner"),
});
}
}}
/>
) : null}
</>
) : null}
</div>
);
})}
</div>
{sftpOpen && activeTab && sftpAllowed ? (
<>
<div
className={`webcrt-sftp-split${sftpResizing ? " is-dragging" : ""}`}
role="separator"
aria-orientation="vertical"
aria-label={t("webcrt.sftp.resizeHint")}
title={t("webcrt.sftp.resizeHint")}
onMouseDown={onSftpSplitDown}
/>
<aside
className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}${sftpDragOver ? " is-drop-target" : ""}`}
style={{ width: sftpWidth }}
onDragEnter={onSftpDragEnter}
onDragOver={onSftpDragOver}
onDragLeave={onSftpDragLeave}
onDrop={onSftpDrop}
>
<div className="webcrt-sftp__bar">
<button
type="button"
title={t("webcrt.sftp.up")}
disabled={sftpBusy}
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
>
{t("webcrt.sftp.up")}
</button>
<input
value={sftpPath}
disabled={sftpBusy}
onChange={(e) => {
const v = e.target.value;
sftpPathRef.current = v;
setSftpPath(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void refreshSftp(sftpPathRef.current);
}
}}
/>
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
{t("webcrt.sftp.refresh")}
</button>
<button type="button" disabled={sftpBusy} onClick={() => void mkdirSftp()}>
{t("webcrt.sftp.mkdir")}
</button>
<button
type="button"
disabled={sftpBusy || sftpSelected.length !== 1}
onClick={() => void renameSftpSelected()}
>
{t("webcrt.sftp.rename")}
</button>
<button
type="button"
disabled={sftpBusy || sftpSelectedFiles.length === 0}
onClick={() => void downloadSftpItems(sftpSelectedFiles)}
>
{t("webcrt.sftp.download")}
</button>
<button
type="button"
disabled={sftpBusy || sftpSelected.length === 0}
onClick={() => void chmodSftpSelected()}
>
{t("webcrt.sftp.chmod")}
</button>
<button
type="button"
disabled={sftpBusy || sftpSelected.length === 0}
onClick={() => void removeSftpSelected()}
>
{t("webcrt.sftp.delete")}
</button>
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
{t("webcrt.sftp.upload")}
<input
type="file"
hidden
multiple
disabled={sftpBusy}
onChange={(e) => {
const files = Array.from(e.target.files || []);
e.target.value = "";
if (!files.length || sftpBusy) return;
void uploadSftpItems(files.map((file) => ({ file, relativePath: file.name })));
}}
/>
</label>
</div>
{sftpBusy || sftpStatus ? (
<div className="webcrt-sftp__status" role="status">
<span>{sftpStatus || t("webcrt.sftp.loading")}</span>
{sftpTransferring ? (
<button
type="button"
className="webcrt-sftp__cancel"
onClick={cancelSftpTransfer}
>
{t("webcrt.sftp.cancel")}
</button>
) : null}
</div>
) : null}
{!sftpBusy && sftpListTruncated ? (
<div className="webcrt-sftp__status webcrt-sftp__status--warn" role="status">
{t("webcrt.sftp.listTruncated", {
count: sftpItems.length,
max: sftpListMaxEntries || sftpItems.length,
})}
</div>
) : null}
{sftpDragOver ? (
<div className="webcrt-sftp__drop-overlay" aria-hidden>
{t("webcrt.sftp.dropHint")}
</div>
) : null}
<div className="webcrt-sftp__table-wrap">
<table
className="webcrt-sftp__table"
style={{
width: sumSftpColWidths(sftpColWidths),
minWidth: sumSftpColWidths(sftpColWidths),
}}
>
<colgroup>
{SFTP_COL_KEYS.map((key) => (
<col key={key} style={{ width: sftpColWidths[key] }} />
))}
</colgroup>
<thead>
<tr>
{(
[
["name", "webcrt.sftp.colName"],
["size", "webcrt.sftp.colSize"],
["mtime", "webcrt.sftp.colMtime"],
["owner", "webcrt.sftp.colOwner"],
["group", "webcrt.sftp.colGroup"],
["mode", "webcrt.sftp.colMode"],
] as const
).map(([key, labelKey]) => (
<th
key={key}
className={`webcrt-sftp__col-${key}${sftpSortKey === key ? " is-sorted" : ""}`}
style={{ width: sftpColWidths[key] }}
>
<button
type="button"
className="webcrt-sftp__th-label"
onClick={() => toggleSftpSort(key)}
>
{t(labelKey)}
{sftpSortKey === key ? (sftpSortDir === "asc" ? " ▲" : " ▼") : ""}
</button>
<span
className="webcrt-sftp__col-resizer"
title={t("webcrt.sftp.colResizeHint")}
onMouseDown={(e) => onSftpColResizeDown(key, e)}
/>
</th>
))}
</tr>
</thead>
<tbody>
{!sftpBusy && sftpSortedItems.length === 0 ? (
<tr className="webcrt-sftp__empty-row">
<td colSpan={6}>{t("webcrt.sftp.empty")}</td>
</tr>
) : null}
{sftpSortedItems.map((it) => (
<tr
key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}
className={`${sftpBusy ? "is-busy" : ""}${
sftpSelected.includes(it.name) ? " is-selected" : ""
}`}
title={it.is_dir ? t("webcrt.sftp.enterHint") : t("webcrt.sftp.downloadHint")}
onClick={(e) => {
if (sftpBusy) return;
selectSftpRow(it.name, e, sftpOrderedNames);
}}
onDoubleClick={() => {
if (sftpBusy) return;
if (it.is_dir) {
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
return;
}
void downloadSftpItem(it);
}}
>
<td className="webcrt-sftp__col-name" style={{ width: sftpColWidths.name }}>
<span className="webcrt-sftp__name-cell">
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
{it.is_dir ? <FolderIcon /> : <FileIcon />}
</span>
<span className="webcrt-sftp__name">{it.name}</span>
</span>
</td>
<td className="webcrt-sftp__col-size" style={{ width: sftpColWidths.size }}>
{formatSftpSizeKb(it.size, it.is_dir)}
</td>
<td className="webcrt-sftp__col-mtime" style={{ width: sftpColWidths.mtime }}>
{formatSftpMtime(it.mtime)}
</td>
<td className="webcrt-sftp__col-owner" style={{ width: sftpColWidths.owner }}>
{it.owner || ""}
</td>
<td className="webcrt-sftp__col-group" style={{ width: sftpColWidths.group }}>
{it.group || ""}
</td>
<td className="webcrt-sftp__col-mode" style={{ width: sftpColWidths.mode }}>
{it.mode || ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
</aside>
</>
) : null}
</div>
</>
) : (
<div className="webcrt-main__empty">
<p>{t("webcrt.termPlaceholder")}</p>
<p className="panel__hint">{t("webcrt.hintCrt")}</p>
</div>
)}
</main>
{treeMenu ? (
<div
ref={treeMenuRef}
className="webcrt-ctx webcrt-tab-ctx"
style={{ left: treeMenu.x, top: treeMenu.y }}
role="menu"
>
<button
type="button"
role="menuitem"
onClick={() => {
const target = treeMenu.target;
setTreeMenu(null);
setRenameDialog({ target, name: target.name || target.ip_address || "" });
}}
>
{t("webcrt.treeMenu.rename")}
</button>
<button
type="button"
role="menuitem"
onClick={() => {
const target = treeMenu.target;
setTreeMenu(null);
void deleteWebcrtSession(target);
}}
>
{t("webcrt.treeMenu.delete")}
</button>
</div>
) : null}
{renameDialog ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setRenameDialog(null)}
>
<div
className="modal webcrt-auth-modal"
role="dialog"
aria-labelledby="webcrt-rename-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-rename-title">{t("webcrt.treeMenu.rename")}</h3>
<p className="form-hint">
{renameDialog.target.ip_address}
{renameDialog.target.protocol ? ` · ${String(renameDialog.target.protocol).toUpperCase()}` : ""}
</p>
<div className="form-grid">
<label>
<FormLabel required>{t("webcrt.treeMenu.renameLabel")}</FormLabel>
<input
autoFocus
value={renameDialog.name}
onChange={(e) => setRenameDialog({ ...renameDialog, name: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void renameWebcrtSession(renameDialog.target, renameDialog.name);
}}
/>
</label>
</div>
<div className="modal__actions">
<button type="button" onClick={() => setRenameDialog(null)}>
{t("webcrt.sessionOptionsCancel")}
</button>
<button
type="button"
onClick={() => void renameWebcrtSession(renameDialog.target, renameDialog.name)}
>
{t("webcrt.treeMenu.renameSave")}
</button>
</div>
</div>
</div>
) : null}
{tabMenu ? (
<div
ref={tabMenuRef}
className="webcrt-ctx webcrt-tab-ctx"
style={{ left: tabMenu.x, top: tabMenu.y }}
role="menu"
>
<button
type="button"
role="menuitem"
onClick={() => {
const key = tabMenu.key;
setTabMenu(null);
void closeTab(key);
}}
>
{t("webcrt.tabMenu.close")}
</button>
<button
type="button"
role="menuitem"
disabled={tabs.length <= 1}
onClick={() => {
const key = tabMenu.key;
setTabMenu(null);
void closeOtherTabs(key);
}}
>
{t("webcrt.tabMenu.closeOthers")}
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setTabMenu(null);
void closeAllTabs();
}}
>
{t("webcrt.tabMenu.closeAll")}
</button>
<button
type="button"
role="menuitem"
onClick={() => {
const key = tabMenu.key;
setTabMenu(null);
void copyTabIp(key);
}}
>
{t("webcrt.tabMenu.copyIp")}
</button>
</div>
) : null}
{keywordHlModalOpen ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setKeywordHlModalOpen(false)}
>
<div
className="modal webcrt-keyword-modal"
role="dialog"
aria-modal="true"
aria-labelledby="webcrt-keyword-hl-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-keyword-hl-title">{t("webcrt.keywordHl.title")}</h3>
<label className="webcrt-session-opts__field webcrt-session-opts__field--check">
<span>{t("webcrt.keywordHl.enabled")}</span>
<input
type="checkbox"
checked={keywordHlDraft.enabled}
onChange={(e) =>
setKeywordHlDraft((prev) => ({ ...prev, enabled: e.target.checked }))
}
/>
</label>
<div className="webcrt-keyword-add">
<label className="webcrt-session-opts__field">
<span>{t("webcrt.keywordHl.newKeyword")}</span>
<input
type="text"
value={keywordDraftText}
placeholder={t("webcrt.keywordHl.newKeywordPh")}
onChange={(e) => setKeywordDraftText(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
const pattern = keywordDraftText.trim();
if (!pattern) return;
const row: KeywordRule = {
id: newKeywordId(),
pattern,
regex: keywordDraftRegex,
};
setKeywordHlDraft((prev) => ({
...prev,
enabled: true,
keywords: [...prev.keywords, row],
}));
setKeywordSelectedId(row.id);
setKeywordDraftText("");
}}
/>
</label>
<label className="webcrt-keyword-add__regex">
<input
type="checkbox"
checked={keywordDraftRegex}
onChange={(e) => setKeywordDraftRegex(e.target.checked)}
/>
<span>{t("webcrt.keywordHl.regex")}</span>
</label>
<button
type="button"
onClick={() => {
const pattern = keywordDraftText.trim();
if (!pattern) return;
const row: KeywordRule = {
id: newKeywordId(),
pattern,
regex: keywordDraftRegex,
};
setKeywordHlDraft((prev) => ({
...prev,
enabled: true,
keywords: [...prev.keywords, row],
}));
setKeywordSelectedId(row.id);
setKeywordDraftText("");
}}
>
{t("webcrt.keywordHl.add")}
</button>
</div>
<div className="webcrt-keyword-list-wrap">
<div className="webcrt-keyword-list-head">
<span>{t("webcrt.keywordHl.colKeyword")}</span>
<span>{t("webcrt.keywordHl.colRegex")}</span>
</div>
<ul className="webcrt-keyword-list">
{keywordHlDraft.keywords.length === 0 ? (
<li className="webcrt-keyword-list__empty">{t("webcrt.keywordHl.empty")}</li>
) : (
keywordHlDraft.keywords.map((kw) => (
<li key={kw.id}>
<button
type="button"
className={`webcrt-keyword-list__row${
keywordSelectedId === kw.id ? " is-selected" : ""
}`}
onClick={() => setKeywordSelectedId(kw.id)}
>
<span className="webcrt-keyword-list__pattern" title={kw.pattern}>
{kw.pattern}
</span>
<span>{kw.regex ? "✓" : ""}</span>
</button>
</li>
))
)}
</ul>
<div className="webcrt-keyword-list-actions">
<button
type="button"
disabled={!keywordSelectedId}
title={t("webcrt.keywordHl.moveUp")}
onClick={() => {
setKeywordHlDraft((prev) => {
const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
if (idx <= 0) return prev;
const next = [...prev.keywords];
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
return { ...prev, keywords: next };
});
}}
>
↑
</button>
<button
type="button"
disabled={!keywordSelectedId}
title={t("webcrt.keywordHl.moveDown")}
onClick={() => {
setKeywordHlDraft((prev) => {
const idx = prev.keywords.findIndex((k) => k.id === keywordSelectedId);
if (idx < 0 || idx >= prev.keywords.length - 1) return prev;
const next = [...prev.keywords];
[next[idx + 1], next[idx]] = [next[idx], next[idx + 1]];
return { ...prev, keywords: next };
});
}}
>
↓
</button>
<button
type="button"
disabled={!keywordSelectedId}
title={t("webcrt.keywordHl.remove")}
onClick={() => {
setKeywordHlDraft((prev) => {
const next = prev.keywords.filter((k) => k.id !== keywordSelectedId);
setKeywordSelectedId(next[0]?.id || "");
return { ...prev, keywords: next };
});
}}
>
×
</button>
</div>
</div>
<div className="webcrt-keyword-footer">
<label className="webcrt-session-opts__field webcrt-session-opts__field--check">
<span>{t("webcrt.keywordHl.caseSensitive")}</span>
<input
type="checkbox"
checked={keywordHlDraft.caseSensitive}
onChange={(e) =>
setKeywordHlDraft((prev) => ({ ...prev, caseSensitive: e.target.checked }))
}
/>
</label>
<label className="webcrt-session-opts__field webcrt-keyword-color">
<span>{t("webcrt.keywordHl.color")}</span>
<input
type="color"
value={keywordHlDraft.color || "#ffff00"}
onChange={(e) =>
setKeywordHlDraft((prev) => ({ ...prev, color: e.target.value }))
}
/>
</label>
</div>
<div className="modal__actions">
<button type="button" onClick={() => setKeywordHlModalOpen(false)}>
{t("webcrt.sessionOptionsCancel")}
</button>
<button
type="button"
onClick={() => {
const keywords = keywordHlDraft.keywords
.map((k) => ({
id: k.id || newKeywordId(),
pattern: String(k.pattern || "").trim(),
regex: Boolean(k.regex),
}))
.filter((k) => k.pattern);
const next: KeywordHighlightConfig = {
...defaultKeywordHighlightConfig(),
enabled: keywords.length > 0,
caseSensitive: Boolean(keywordHlDraft.caseSensitive),
color: keywordHlDraft.color || "#ffff00",
keywords,
};
saveKeywordHighlightConfig(next);
setKeywordHl(next);
setKeywordHlDraft(next);
setKeywordHlModalOpen(false);
showOk(t("webcrt.keywordHl.saved"));
}}
>
{t("webcrt.sessionOptionsSave")}
</button>
</div>
</div>
</div>
) : null}
{sessionOptsModalOpen ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setSessionOptsModalOpen(false)}
>
<div
className="modal webcrt-session-opts-modal"
role="dialog"
aria-modal="true"
aria-labelledby="webcrt-session-opts-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-session-opts-title">{t("webcrt.globalSessionOptions")}</h3>
<div className="webcrt-session-opts">
<label className="webcrt-session-opts__field">
<span>{t("webcrt.encoding")}</span>
<select
value={sessionOptsDraft.encoding}
onChange={(e) =>
setSessionOptsDraft((prev) => ({ ...prev, encoding: e.target.value }))
}
>
{ENCODING_OPTIONS.map((enc) => (
<option key={enc} value={enc}>
{enc}
</option>
))}
</select>
</label>
<label className="webcrt-session-opts__field">
<span>{t("webcrt.fontSize")}</span>
<select
value={sessionOptsDraft.fontSize}
onChange={(e) =>
setSessionOptsDraft((prev) => ({
...prev,
fontSize: Number(e.target.value) || 13,
}))
}
>
{FONT_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</label>
<label className="webcrt-session-opts__field">
<span>{t("webcrt.colorScheme")}</span>
<select
value={sessionOptsDraft.colorScheme}
onChange={(e) => {
const id = e.target.value as ColorSchemeId;
if (id !== "custom" && id in COLOR_SCHEMES) {
const preset = COLOR_SCHEMES[id as Exclude<ColorSchemeId, "custom">];
setSessionOptsDraft((prev) => ({
...prev,
colorScheme: id,
background: preset.background,
foreground: preset.foreground,
}));
return;
}
setSessionOptsDraft((prev) => ({ ...prev, colorScheme: "custom" }));
}}
>
<option value="dark">{t("webcrt.scheme.dark")}</option>
<option value="blackWhite">{t("webcrt.scheme.blackWhite")}</option>
<option value="whiteBlack">{t("webcrt.scheme.whiteBlack")}</option>
<option value="greenBlack">{t("webcrt.scheme.greenBlack")}</option>
<option value="amberBlack">{t("webcrt.scheme.amberBlack")}</option>
<option value="custom">{t("webcrt.scheme.custom")}</option>
</select>
</label>
<div className="webcrt-session-opts__colors">
<label className="webcrt-session-opts__field webcrt-session-opts__color">
<span>{t("webcrt.backgroundColor")}</span>
<input
type="color"
value={sessionOptsDraft.background || "#0b1220"}
onChange={(e) =>
setSessionOptsDraft((prev) => ({
...prev,
colorScheme: "custom",
background: e.target.value,
}))
}
/>
</label>
<label className="webcrt-session-opts__field webcrt-session-opts__color">
<span>{t("webcrt.foregroundColor")}</span>
<input
type="color"
value={sessionOptsDraft.foreground || "#e2e8f0"}
onChange={(e) =>
setSessionOptsDraft((prev) => ({
...prev,
colorScheme: "custom",
foreground: e.target.value,
}))
}
/>
</label>
<div
className="webcrt-session-opts__preview"
style={{
background: sessionOptsDraft.background,
color: sessionOptsDraft.foreground,
}}
aria-hidden
>
Aa 192.168.0.1
</div>
</div>
<label className="webcrt-session-opts__field webcrt-session-opts__field--check">
<span>{t("webcrt.copyOnSelect")}</span>
<input
type="checkbox"
checked={sessionOptsDraft.copyOnSelect}
onChange={(e) =>
setSessionOptsDraft((prev) => ({ ...prev, copyOnSelect: e.target.checked }))
}
/>
</label>
<label className="webcrt-session-opts__field">
<span>{t("webcrt.pasteDelay")}</span>
<select
value={sessionOptsDraft.pasteDelayMs}
onChange={(e) =>
setSessionOptsDraft((prev) => ({
...prev,
pasteDelayMs: Number(e.target.value) || 0,
}))
}
>
{PASTE_DELAY_OPTIONS.map((ms) => (
<option key={ms} value={ms}>
{ms === 0 ? t("webcrt.pasteDelayOff") : `${ms} ms`}
</option>
))}
</select>
</label>
<label className="webcrt-session-opts__field">
<span>{t("webcrt.keepalive")}</span>
<select
value={sessionOptsDraft.keepaliveSec}
onChange={(e) =>
setSessionOptsDraft((prev) => ({
...prev,
keepaliveSec: Number(e.target.value) || 0,
}))
}
>
{KEEPALIVE_OPTIONS.map((sec) => (
<option key={sec} value={sec}>
{sec === 0 ? t("webcrt.keepaliveOff") : t("webcrt.keepaliveSec", { n: sec })}
</option>
))}
</select>
</label>
</div>
<div className="modal__actions">
<button type="button" onClick={() => setSessionOptsModalOpen(false)}>
{t("webcrt.sessionOptionsCancel")}
</button>
<button
type="button"
onClick={() => {
const next = normalizeSessionOptions(sessionOptsDraft);
saveSessionOptions(next);
setSessionOpts(next);
setSessionOptsModalOpen(false);
showOk(t("webcrt.sessionOptionsSaved"));
}}
>
{t("webcrt.sessionOptionsSave")}
</button>
</div>
</div>
</div>
) : null}
{authDialog ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
if (!sessionBusy) setAuthDialog(null);
}}
>
<div
className="modal webcrt-auth-modal"
role="dialog"
aria-labelledby="webcrt-auth-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-auth-title">{t("webcrt.newSession.authTitle")}</h3>
<p className="form-hint">
{authDialog.host.ip_address}
{authDialog.host.port ? `:${authDialog.host.port}` : ""}
{" · SSH"}
</p>
{authDialog.errorHint ? (
<p className="form-hint webcrt-auth-error">{authDialog.errorHint}</p>
) : null}
<div className="form-grid">
<label>
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
<input
required
autoFocus
value={authForm.username}
onChange={(e) => setAuthForm({ ...authForm, username: e.target.value })}
/>
</label>
<label>
<FormLabel required>{t("managedNe.col.password")}</FormLabel>
<input
type="password"
required
value={authForm.password}
onChange={(e) => setAuthForm({ ...authForm, password: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void submitAuthDialog();
}}
/>
</label>
<label className="form-check form-grid__full">
<input
type="checkbox"
checked={authForm.savePassword}
onChange={(e) => setAuthForm({ ...authForm, savePassword: e.target.checked })}
/>
<span className="form-check__text">{t("webcrt.newSession.savePassword")}</span>
</label>
</div>
<div className="modal__actions">
<button type="button" disabled={sessionBusy} onClick={() => setAuthDialog(null)}>
{t("webcrt.sessionOptionsCancel")}
</button>
<button type="button" disabled={sessionBusy} onClick={() => void submitAuthDialog()}>
{sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
</button>
</div>
</div>
</div>
) : null}
{hostDialogOpen ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
if (!sessionBusy) setHostDialogOpen(false);
}}
>
<div
className="modal webcrt-new-session-modal"
role="dialog"
aria-labelledby="webcrt-new-session-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="webcrt-new-session-title">{t("webcrt.newSession.title")}</h3>
<div className="form-grid">
<label>
<FormLabel required>{t("webcrt.newSession.protocol")}</FormLabel>
<select
value={hostForm.protocol}
onChange={(e) => {
const protocol = e.target.value === "telnet" ? "telnet" : "ssh";
setHostForm((prev) => ({
...prev,
protocol,
port: protocol === "telnet" ? 23 : 22,
}));
}}
>
<option value="ssh">SSH</option>
<option value="telnet">Telnet</option>
</select>
</label>
<label>
<FormLabel required>{t("webcrt.newSession.host")}</FormLabel>
<input
required
autoFocus
value={hostForm.ip_address}
placeholder="192.168.1.1"
onChange={(e) => setHostForm({ ...hostForm, ip_address: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("webcrt.newSession.port")}</FormLabel>
<input
type="number"
value={hostForm.port}
onChange={(e) =>
setHostForm({
...hostForm,
port: Number(e.target.value) || (hostForm.protocol === "telnet" ? 23 : 22),
})
}
/>
</label>
<label>
<FormLabel>{t("webcrt.newSession.sessionName")}</FormLabel>
<input
value={hostForm.name}
placeholder={t("webcrt.newSession.sessionNamePh")}
onChange={(e) => setHostForm({ ...hostForm, name: e.target.value })}
/>
</label>
</div>
<div className="modal__actions">
<button type="button" disabled={sessionBusy} onClick={() => setHostDialogOpen(false)}>
{t("webcrt.sessionOptionsCancel")}
</button>
<button type="button" disabled={sessionBusy} onClick={() => submitHostDialog()}>
{sessionBusy ? t("webcrt.newSession.connecting") : t("webcrt.newSession.connect")}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}