Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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,
};
68 changes: 64 additions & 4 deletions apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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<typeof setTimeout> | null;
/** Internal: reconnect attempt count for backoff. */
Expand All @@ -28,6 +44,9 @@ export interface TerminalTransport {
_exited: boolean;
}

const MAX_LOG_ENTRIES = 200;
let logIdCounter = 0;

function setConnectionState(
transport: TerminalTransport,
state: ConnectionState,
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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.`,
);
});

Expand Down Expand Up @@ -272,4 +330,6 @@ export function disposeTransport(transport: TerminalTransport) {
transport.onDataDisposable = null;
transport.stateListeners.clear();
transport.titleListeners.clear();
transport.logs = [];
transport.logListeners.clear();
}
Original file line number Diff line number Diff line change
@@ -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<PaneViewerData>;
Expand All @@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Move terminal to background"
onClick={(event) => {
event.stopPropagation();
handleMoveToBackground();
}}
className="rounded p-1 text-muted-foreground/60 transition-colors hover:text-muted-foreground"
>
<Archive className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" showArrow={false}>
Move terminal to background
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-0.5">
<TerminalLogsButton
terminalId={data.terminalId}
terminalInstanceId={context.pane.id}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`View terminal connection log (${logs.length} ${logs.length === 1 ? "event" : "events"})`}
onClick={(event) => event.stopPropagation()}
className={cn(
"rounded p-1 transition-colors",
hasError
? "text-destructive/70 hover:text-destructive"
: "text-amber-500/70 hover:text-amber-500",
)}
>
<AlertTriangle className="size-3.5" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" showArrow={false}>
{logs.length} connection {logs.length === 1 ? "event" : "events"}
</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-96 p-0"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="text-xs font-medium text-foreground">
Connection log
</div>
<button
type="button"
onClick={handleClear}
className="rounded px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Clear
</button>
</div>
<div className="max-h-72 overflow-y-auto">
<ul className="divide-y divide-border">
{[...logs].reverse().map((entry) => (
<LogRow key={entry.id} entry={entry} />
))}
</ul>
</div>
</PopoverContent>
</Popover>
);
}

function LogRow({ entry }: { entry: TerminalLogEntry }) {
return (
<li className="min-w-0 px-3 py-2 text-xs">
<div className="flex items-baseline gap-2">
<span
className={cn(
"shrink-0 font-mono uppercase tracking-wider",
entry.level === "error" && "text-destructive",
entry.level === "warn" && "text-amber-500",
entry.level === "info" && "text-muted-foreground",
)}
>
{entry.level}
</span>
<time className="shrink-0 font-mono text-muted-foreground">
{formatTime(entry.timestamp)}
</time>
</div>
<p className="mt-1 wrap-anywhere text-foreground">{entry.message}</p>
</li>
);
}

function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TerminalLogsButton } from "./TerminalLogsButton";
Loading