diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index cece4746360..6423555fce4 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -16,12 +16,14 @@ import { } from "./terminal-runtime"; import { type ConnectionState, + clearLogs, connect, createTransport, disposeTransport, sendDispose, sendInput, sendResize, + type TerminalLogEntry, type TerminalTransport, } from "./terminal-ws-transport"; @@ -369,6 +371,19 @@ class TerminalRuntimeRegistryImpl { return this.getEntry(terminalId, instanceId)?.transport.title; } + getLogs( + terminalId: string, + instanceId?: string, + ): readonly TerminalLogEntry[] { + return this.getEntry(terminalId, instanceId)?.transport.logs ?? EMPTY_LOGS; + } + + clearLogs(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); + if (!entry) return; + clearLogs(entry.transport); + } + onStateChange( terminalId: string, listener: () => void, @@ -392,8 +407,26 @@ class TerminalRuntimeRegistryImpl { entry.transport.titleListeners.delete(listener); }; } + + onLogsChange( + terminalId: string, + listener: () => void, + instanceId = terminalId, + ): () => void { + const entry = this.getOrCreateEntry(terminalId, instanceId); + entry.transport.logListeners.add(listener); + return () => { + entry.transport.logListeners.delete(listener); + }; + } } +// Stable empty reference so useSyncExternalStore on a missing entry doesn't +// thrash from getSnapshot returning a fresh array each call. +const EMPTY_LOGS: readonly TerminalLogEntry[] = Object.freeze( + [], +) as readonly []; + // In dev, preserve the singleton across Vite HMR so active WebSocket // connections and xterm instances aren't orphaned on module re-evaluation. // import.meta.hot is undefined in production so this is a plain `new` call. @@ -406,4 +439,9 @@ if (import.meta.hot) { import.meta.hot.data.registry = terminalRuntimeRegistry; } -export type { ConnectionState, LinkHoverInfo, TerminalLinkHandlers }; +export type { + ConnectionState, + LinkHoverInfo, + TerminalLinkHandlers, + TerminalLogEntry, +}; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 1896c07c69e..4a9e34fc5c0 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -2,6 +2,15 @@ import type { Terminal as XTerm } from "@xterm/xterm"; export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; +export type TerminalLogLevel = "info" | "warn" | "error"; + +export interface TerminalLogEntry { + id: number; + timestamp: number; + level: TerminalLogLevel; + message: string; +} + type TerminalServerMessage = | { type: "data"; data: string } | { type: "error"; message: string } @@ -18,6 +27,13 @@ export interface TerminalTransport { onDataDisposable: { dispose(): void } | null; stateListeners: Set<() => void>; titleListeners: Set<() => void>; + /** + * Transport-level status log (WebSocket close/error/reconnect notices). + * Surfaced to the pane UI instead of being written into the xterm buffer, + * so terminal scrollback stays clean. + */ + logs: TerminalLogEntry[]; + logListeners: Set<() => void>; /** Internal: auto-reconnect timer. */ _reconnectTimer: ReturnType | null; /** Internal: reconnect attempt count for backoff. */ @@ -28,6 +44,9 @@ export interface TerminalTransport { _exited: boolean; } +const MAX_LOG_ENTRIES = 200; +let logIdCounter = 0; + function setConnectionState( transport: TerminalTransport, state: ConnectionState, @@ -49,6 +68,39 @@ function setTerminalTitle( } } +function pushLog( + transport: TerminalTransport, + level: TerminalLogLevel, + message: string, +) { + logIdCounter += 1; + const entry: TerminalLogEntry = { + id: logIdCounter, + timestamp: Date.now(), + level, + message, + }; + const next = + transport.logs.length >= MAX_LOG_ENTRIES + ? [ + ...transport.logs.slice(transport.logs.length - MAX_LOG_ENTRIES + 1), + entry, + ] + : [...transport.logs, entry]; + transport.logs = next; + for (const listener of transport.logListeners) { + listener(); + } +} + +export function clearLogs(transport: TerminalTransport) { + if (transport.logs.length === 0) return; + transport.logs = []; + for (const listener of transport.logListeners) { + listener(); + } +} + const MAX_RECONNECT_DELAY = 10_000; const BASE_RECONNECT_DELAY = 500; const MAX_RECONNECT_ATTEMPTS = 10; @@ -62,6 +114,8 @@ export function createTransport(): TerminalTransport { onDataDisposable: null, stateListeners: new Set(), titleListeners: new Set(), + logs: [], + logListeners: new Set(), _reconnectTimer: null, _reconnectAttempt: 0, _terminal: null, @@ -199,8 +253,10 @@ export function connect( !transport._reconnectTimer && Boolean(transport.currentUrl && transport._terminal) && transport._reconnectAttempt < MAX_RECONNECT_ATTEMPTS; - terminal.writeln( - `\r\n[terminal] WebSocket closed while connected to ${formatWsEndpoint(transport.currentUrl)} (${formatCloseDetails(event)}). ${willReconnect ? "Reconnecting..." : "Max reconnect attempts reached."}`, + pushLog( + transport, + willReconnect ? "warn" : "error", + `WebSocket closed while connected to ${formatWsEndpoint(transport.currentUrl)} (${formatCloseDetails(event)}). ${willReconnect ? "Reconnecting..." : "Max reconnect attempts reached."}`, ); } // Auto-reconnect on unexpected close (host-service restart, network blip) @@ -209,8 +265,10 @@ export function connect( socket.addEventListener("error", () => { if (transport.socket !== socket) return; - terminal.writeln( - `\r\n[terminal] WebSocket error while connecting to ${formatWsEndpoint(transport.currentUrl)}. Check host-service or relay connectivity.`, + pushLog( + transport, + "error", + `WebSocket error while connecting to ${formatWsEndpoint(transport.currentUrl)}. Check host-service or relay connectivity.`, ); }); @@ -272,4 +330,6 @@ export function disposeTransport(transport: TerminalTransport) { transport.onDataDisposable = null; transport.stateListeners.clear(); transport.titleListeners.clear(); + transport.logs = []; + transport.logListeners.clear(); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx index fe1da2e097a..0d4f5fa6153 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx @@ -1,11 +1,9 @@ import type { RendererContext } from "@superset/panes"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { Archive } from "lucide-react"; -import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import type { PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { TerminalLogsButton } from "../TerminalLogsButton"; interface TerminalHeaderExtrasProps { context: RendererContext; @@ -16,29 +14,12 @@ export function TerminalHeaderExtras({ context }: TerminalHeaderExtrasProps) { const data = context.pane.data as TerminalPaneData; - const handleMoveToBackground = () => { - markTerminalForBackground(data.terminalId); - void context.actions.close(); - }; - return ( - - - - - - Move terminal to background - - +
+ +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/TerminalLogsButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/TerminalLogsButton.tsx new file mode 100644 index 00000000000..1c9b4769320 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/TerminalLogsButton.tsx @@ -0,0 +1,125 @@ +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { AlertTriangle } from "lucide-react"; +import { useCallback, useState, useSyncExternalStore } from "react"; +import { + type TerminalLogEntry, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; + +interface TerminalLogsButtonProps { + terminalId: string; + terminalInstanceId: string; +} + +export function TerminalLogsButton({ + terminalId, + terminalInstanceId, +}: TerminalLogsButtonProps) { + const subscribe = useCallback( + (cb: () => void) => + terminalRuntimeRegistry.onLogsChange(terminalId, cb, terminalInstanceId), + [terminalId, terminalInstanceId], + ); + const getSnapshot = useCallback( + () => terminalRuntimeRegistry.getLogs(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId], + ); + const logs = useSyncExternalStore(subscribe, getSnapshot); + const [open, setOpen] = useState(false); + + if (logs.length === 0) return null; + + const hasError = logs.some((entry) => entry.level === "error"); + + const handleClear = (event: React.MouseEvent) => { + event.stopPropagation(); + terminalRuntimeRegistry.clearLogs(terminalId, terminalInstanceId); + setOpen(false); + }; + + return ( + + + + + + + + + {logs.length} connection {logs.length === 1 ? "event" : "events"} + + + event.stopPropagation()} + > +
+
+ Connection log +
+ +
+
+
    + {[...logs].reverse().map((entry) => ( + + ))} +
+
+
+
+ ); +} + +function LogRow({ entry }: { entry: TerminalLogEntry }) { + return ( +
  • +
    + + {entry.level} + + +
    +

    {entry.message}

    +
  • + ); +} + +function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/index.ts new file mode 100644 index 00000000000..fb09d565514 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/index.ts @@ -0,0 +1 @@ +export { TerminalLogsButton } from "./TerminalLogsButton";