diff --git a/.superset/lib/setup/main.sh b/.superset/lib/setup/main.sh index 3ad4e6cfa0..c2ea6bfab9 100644 --- a/.superset/lib/setup/main.sh +++ b/.superset/lib/setup/main.sh @@ -56,9 +56,9 @@ setup_main() { step_failed "Allocate port base" fi - # Step 8: Start Electric SQL - if ! step_start_electric; then - step_failed "Start Electric SQL" + # Step 8: Prepare Electric SQL env + if ! step_prepare_electric; then + step_failed "Prepare Electric SQL" fi # Step 9: Write .env file @@ -66,7 +66,12 @@ setup_main() { step_failed "Write .env file" fi - # Step 10: Setup local MCP in .mcp.json (opt-in) + # Step 10: Start Electric SQL + if ! step_start_electric; then + step_failed "Start Electric SQL" + fi + + # Step 11: Setup local MCP in .mcp.json (opt-in) if [ "$SETUP_LOCAL_MCP" = "1" ]; then if ! step_setup_local_mcp; then step_failed "Setup local MCP" diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 01dc39a483..57b1c22a77 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -216,6 +216,28 @@ SQL return 0 } +step_prepare_electric() { + WORKSPACE_NAME="${WORKSPACE_NAME:-$(basename "$PWD")}" + + # Sanitize workspace name for Docker (valid chars only, max 64 chars) + local container_suffix + container_suffix=$(echo "$WORKSPACE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//') + ELECTRIC_CONTAINER=$(echo "superset-electric-$container_suffix" | cut -c1-64) + ELECTRIC_SECRET="${ELECTRIC_SECRET:-local_electric_dev_secret}" + + # Step 7 allocates SUPERSET_PORT_BASE; Electric must use that reserved port. + if [ -z "${SUPERSET_PORT_BASE:-}" ]; then + error "SUPERSET_PORT_BASE not set before preparing Electric" + return 1 + fi + + ELECTRIC_PORT=$((SUPERSET_PORT_BASE + 9)) + ELECTRIC_URL="http://localhost:$ELECTRIC_PORT/v1/shape" + + export ELECTRIC_CONTAINER ELECTRIC_PORT ELECTRIC_URL ELECTRIC_SECRET + return 0 +} + step_start_electric() { echo "⚡ Starting Electric SQL container..." @@ -229,13 +251,11 @@ step_start_electric() { return 1 fi - WORKSPACE_NAME="${WORKSPACE_NAME:-$(basename "$PWD")}" - - # Sanitize workspace name for Docker (valid chars only, max 64 chars) - local container_suffix - container_suffix=$(echo "$WORKSPACE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//') - ELECTRIC_CONTAINER=$(echo "superset-electric-$container_suffix" | cut -c1-64) - ELECTRIC_SECRET="${ELECTRIC_SECRET:-local_electric_dev_secret}" + if [ -z "${ELECTRIC_CONTAINER:-}" ] || [ -z "${ELECTRIC_PORT:-}" ] || [ -z "${ELECTRIC_URL:-}" ]; then + if ! step_prepare_electric; then + return 1 + fi + fi # Stop and remove existing container if it exists if docker ps -a --format '{{.Names}}' | grep -q "^${ELECTRIC_CONTAINER}$"; then @@ -244,14 +264,12 @@ step_start_electric() { docker rm "$ELECTRIC_CONTAINER" &> /dev/null || true fi - # Step 6 allocates SUPERSET_PORT_BASE; Electric must use that reserved port. - if [ -z "${SUPERSET_PORT_BASE:-}" ]; then - error "SUPERSET_PORT_BASE not set before starting Electric" + if lsof -nP -iTCP:"$ELECTRIC_PORT" -sTCP:LISTEN &> /dev/null; then + error "Electric port $ELECTRIC_PORT is already in use" return 1 fi local port_flag - ELECTRIC_PORT=$((SUPERSET_PORT_BASE + 9)) port_flag="-p $ELECTRIC_PORT:3000" echo " Clearing stale Electric replication sessions..." @@ -295,14 +313,11 @@ step_start_electric() { if [ "$ready" = false ]; then error "Electric failed to become active within 60s (last status: $health_status). Check logs: docker logs $ELECTRIC_CONTAINER" + echo " Stopping inactive Electric container to free port $ELECTRIC_PORT..." + docker stop "$ELECTRIC_CONTAINER" &> /dev/null || true return 1 fi - ELECTRIC_URL="http://localhost:$ELECTRIC_PORT/v1/shape" - - # Export for use in other steps - export ELECTRIC_CONTAINER ELECTRIC_PORT ELECTRIC_URL ELECTRIC_SECRET - success "Electric SQL running at $ELECTRIC_URL" return 0 } diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 19e1075598..222e8c4de3 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -74,6 +74,13 @@ PLATFORM.IS_WINDOWS && app.commandLine.appendSwitch("force-color-profile", "srgb"); +if (env.NODE_ENV === "development" && process.env.RENDERER_REMOTE_DEBUG_PORT) { + app.commandLine.appendSwitch( + "remote-debugging-port", + process.env.RENDERER_REMOTE_DEBUG_PORT, + ); +} + // Each xterm pane holds one WebGL context. v2 parking keeps panes alive // across workspace switches, so cumulative contexts can reach the low // hundreds — past Chromium's default cap of 16, Blink force-evicts the diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 4867b91d22..9027985824 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -3,6 +3,7 @@ import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; import { app, Notification, nativeTheme } from "electron"; +import log from "electron-log/main"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; @@ -135,6 +136,40 @@ export async function MainWindow() { window.webContents.setBackgroundThrottling(false); } + if (isDev) { + window.webContents.on( + "console-message", + (_event, level, message, line, sourceId) => { + const shouldForward = + level >= 2 || + message.includes("[stress]") || + message.includes("[main]"); + if (!shouldForward) return; + + const details = sourceId ? ` (${sourceId}:${line})` : ""; + const formatted = `[renderer-console] ${message}${details}`; + if (level >= 3) { + log.error(formatted); + } else if (level >= 2) { + log.warn(formatted); + } else { + log.info(formatted); + } + }, + ); + + window.on("unresponsive", () => { + log.warn("[main-window] Renderer became unresponsive", { + url: window.webContents.getURL(), + }); + }); + window.on("responsive", () => { + log.info("[main-window] Renderer became responsive", { + url: window.webContents.getURL(), + }); + }); + } + if (ipcHandler) { ipcHandler.attachWindow(window); } else { @@ -289,6 +324,7 @@ export async function MainWindow() { window.webContents.on("render-process-gone", (_event, details) => { console.error("[main-window] Renderer process gone:", details); + log.error("[main-window] Renderer process gone", details); }); window.webContents.on("preload-error", (_event, preloadPath, error) => { diff --git a/apps/desktop/src/renderer/lib/performance/stress-instrumentation.ts b/apps/desktop/src/renderer/lib/performance/stress-instrumentation.ts new file mode 100644 index 0000000000..ddc4923218 --- /dev/null +++ b/apps/desktop/src/renderer/lib/performance/stress-instrumentation.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; +import { env } from "renderer/env.renderer"; + +interface RenderStressOptions { + windowMs?: number; + warnAt?: number; + getDetails?: () => Record; +} + +const DEFAULT_WINDOW_MS = 5_000; +const DEFAULT_WARN_AT = 40; + +export function useRenderStressInstrumentation( + name: string, + options: RenderStressOptions = {}, +): void { + const stateRef = useRef({ count: 0, windowStartedAt: 0, warned: false }); + + useEffect(() => { + if (env.NODE_ENV !== "development") return; + + const now = performance.now(); + const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS; + const warnAt = options.warnAt ?? DEFAULT_WARN_AT; + const state = stateRef.current; + + if (state.windowStartedAt === 0 || now - state.windowStartedAt > windowMs) { + state.count = 0; + state.windowStartedAt = now; + state.warned = false; + } + + state.count += 1; + + if (!state.warned && state.count >= warnAt) { + state.warned = true; + console.warn( + "[stress] high renderer commit rate", + JSON.stringify({ + name, + count: state.count, + windowMs, + ...(options.getDetails?.() ?? {}), + }), + ); + } + }); +} + +export function logStressEvent( + name: string, + details?: Record, +): void { + if (env.NODE_ENV !== "development") return; + console.debug("[stress]", name, JSON.stringify(details ?? {})); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts index acc7662c1e..c91cc7a528 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts @@ -1,9 +1,64 @@ const backgroundTerminalIds = new Set(); +const backgroundTerminalMarkersByWorkspace = new Map>(); +const markerListeners = new Set<() => void>(); -export function markTerminalForBackground(terminalId: string): void { +function emitMarkerChange(): void { + for (const listener of markerListeners) { + listener(); + } +} + +function getWorkspaceMarkers(workspaceId: string): Set { + const existing = backgroundTerminalMarkersByWorkspace.get(workspaceId); + if (existing) return existing; + + const markers = new Set(); + backgroundTerminalMarkersByWorkspace.set(workspaceId, markers); + return markers; +} + +export function markTerminalForBackground( + terminalId: string, + workspaceId?: string, +): void { backgroundTerminalIds.add(terminalId); + + if (!workspaceId) return; + + const markers = getWorkspaceMarkers(workspaceId); + if (markers.has(terminalId)) return; + + markers.add(terminalId); + emitMarkerChange(); } export function consumeTerminalBackgroundIntent(terminalId: string): boolean { return backgroundTerminalIds.delete(terminalId); } + +export function clearTerminalBackgroundMarker( + workspaceId: string, + terminalId: string, +): void { + const markers = backgroundTerminalMarkersByWorkspace.get(workspaceId); + if (!markers?.delete(terminalId)) return; + + if (markers.size === 0) { + backgroundTerminalMarkersByWorkspace.delete(workspaceId); + } + emitMarkerChange(); +} + +export function getTerminalBackgroundMarkerIdsKey(workspaceId: string): string { + const markers = backgroundTerminalMarkersByWorkspace.get(workspaceId); + return JSON.stringify(markers ? [...markers].sort() : []); +} + +export function subscribeTerminalBackgroundMarkers( + listener: () => void, +): () => void { + markerListeners.add(listener); + return () => { + markerListeners.delete(listener); + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 11c29c8bc3..cfa7f03fbf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -169,6 +169,10 @@ export function useDashboardSidebarData() { })), [collections], ); + const hostsByMachineId = useMemo( + () => new Map(hosts.map((host) => [host.machineId, host])), + [hosts], + ); const { data: rawSidebarProjects = [] } = useLiveQuery( (q) => @@ -235,19 +239,15 @@ export function useDashboardSidebarData() { ({ sidebarWorkspaces, workspaces }) => eq(sidebarWorkspaces.workspaceId, workspaces.id), ) - .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => - eq(workspaces.hostId, hosts.machineId), - ) .orderBy( ({ sidebarWorkspaces }) => sidebarWorkspaces.sidebarState.tabOrder, "asc", ) - .select(({ sidebarWorkspaces, workspaces, hosts }) => ({ + .select(({ sidebarWorkspaces, workspaces }) => ({ id: workspaces.id, projectId: sidebarWorkspaces.sidebarState.projectId, hostId: workspaces.hostId, type: workspaces.type, - hostIsOnline: hosts.isOnline, name: workspaces.name, branch: workspaces.branch, taskId: workspaces.taskId, @@ -259,10 +259,18 @@ export function useDashboardSidebarData() { })), [collections], ); + const rawSidebarWorkspacesWithHostStatus = useMemo( + () => + rawSidebarWorkspaces.map((workspace) => ({ + ...workspace, + hostIsOnline: hostsByMachineId.get(workspace.hostId)?.isOnline ?? false, + })), + [hostsByMachineId, rawSidebarWorkspaces], + ); const sidebarWorkspaces = useMemo( - () => getVisibleSidebarWorkspaces(rawSidebarWorkspaces), - [rawSidebarWorkspaces], + () => getVisibleSidebarWorkspaces(rawSidebarWorkspacesWithHostStatus), + [rawSidebarWorkspacesWithHostStatus], ); const localStateWorkspaceIds = useMemo( @@ -270,20 +278,16 @@ export function useDashboardSidebarData() { [rawSidebarWorkspaces], ); - const { data: localMainWorkspaces = [] } = useLiveQuery( + const { data: rawLocalMainWorkspaces = [] } = useLiveQuery( (q) => q .from({ workspaces: collections.v2Workspaces }) - .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => - eq(workspaces.hostId, hosts.machineId), - ) .where(({ workspaces }) => eq(workspaces.type, "main")) - .select(({ workspaces, hosts }) => ({ + .select(({ workspaces }) => ({ id: workspaces.id, projectId: workspaces.projectId, hostId: workspaces.hostId, type: workspaces.type, - hostIsOnline: hosts.isOnline, name: workspaces.name, branch: workspaces.branch, taskId: workspaces.taskId, @@ -294,6 +298,14 @@ export function useDashboardSidebarData() { })), [collections], ); + const localMainWorkspaces = useMemo( + () => + rawLocalMainWorkspaces.map((workspace) => ({ + ...workspace, + hostIsOnline: hostsByMachineId.get(workspace.hostId)?.isOnline ?? false, + })), + [hostsByMachineId, rawLocalMainWorkspaces], + ); // Cloud-row fallback: when workspaces.create has resolved on the host // service but Electric hasn't yet delivered the v2Workspaces row, surface @@ -302,16 +314,13 @@ export function useDashboardSidebarData() { // catches up, at which point the live query takes over seamlessly. const cloudRowFallbackWorkspaces = useMemo(() => { if (inFlightEntries.length === 0) return []; - const hostByMachineId = new Map( - hosts.map((host) => [host.machineId, host]), - ); const rows = inFlightEntries.flatMap((entry) => { const cloudRow = entry.cloudRow; if (!cloudRow) return []; // Electric already delivered; let the live query own this row. if (localStateWorkspaceIds.has(cloudRow.id)) return []; const localState = collections.v2WorkspaceLocalState.get(cloudRow.id); - const host = hostByMachineId.get(cloudRow.hostId); + const host = hostsByMachineId.get(cloudRow.hostId); return [ { id: cloudRow.id, @@ -332,7 +341,7 @@ export function useDashboardSidebarData() { ]; }); return getVisibleSidebarWorkspaces(rows); - }, [collections, hosts, inFlightEntries, localStateWorkspaceIds]); + }, [collections, hostsByMachineId, inFlightEntries, localStateWorkspaceIds]); const visibleSidebarWorkspaces = useMemo(() => { const sidebarProjectIds = new Set( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx index 2b8923dd7e..b3eeb5ace7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx @@ -12,7 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useLiveQuery } from "@tanstack/react-db"; import { useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { HiOutlineArrowPath, HiOutlineBarsArrowDown, @@ -20,6 +20,10 @@ import { } from "react-icons/hi2"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + logStressEvent, + useRenderStressInstrumentation, +} from "renderer/lib/performance/stress-instrumentation"; import { navigateToWorkspace as navigateToV1Workspace, navigateToV2Workspace, @@ -30,6 +34,10 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { AppResourceSection } from "./components/AppResourceSection"; import { MetricBadge } from "./components/MetricBadge"; import { WorkspaceResourceSection } from "./components/WorkspaceResourceSection"; +import { + getResourceMonitorRefetchInterval, + shouldQueryResourceMonitor, +} from "./resourceConsumptionPolicy"; import type { SessionMetrics, SortOption, UsageValues } from "./types"; import { formatCpu, formatMemory, formatPercent } from "./utils/formatters"; import { normalizeResourceMetricsSnapshot } from "./utils/normalizeSnapshot"; @@ -99,6 +107,58 @@ export function ResourceConsumption({ className, }: ResourceConsumptionProps) { const [open, setOpen] = useState(false); + const { data: enabled } = + electronTrpc.settings.getShowResourceMonitor.useQuery(); + + useRenderStressInstrumentation("ResourceConsumptionTrigger", { + warnAt: 25, + getDetails: () => ({ open, surface }), + }); + + if (!enabled) return null; + + return ( + + + + + + + + + Resources + + + + {open && ( + setOpen(false)} + /> + )} + + ); +} + +interface ResourceConsumptionContentProps { + surface: "v1" | "v2"; + onClose: () => void; +} + +function ResourceConsumptionContent({ + surface, + onClose, +}: ResourceConsumptionContentProps) { const [sortOption, setSortOption] = useState("memory"); const [collapsedProjects, setCollapsedProjects] = useState>( new Set(), @@ -116,8 +176,10 @@ export function ResourceConsumption({ const { data: session } = authClient.useSession(); const organizationId = session?.session?.activeOrganizationId ?? undefined; - const { data: enabled } = - electronTrpc.settings.getShowResourceMonitor.useQuery(); + useRenderStressInstrumentation("ResourceConsumptionContent", { + warnAt: 25, + getDetails: () => ({ surface }), + }); const { data: rawSidebarProjects = [], isReady: sidebarProjectsReady } = useLiveQuery( @@ -190,7 +252,11 @@ export function ResourceConsumption({ sidebarWorkspacesReady && v2ProjectsReady && v2WorkspacesReady); - const shouldQueryMetrics = enabled === true && v2MetadataReady; + const shouldQueryMetrics = shouldQueryResourceMonitor({ + enabled: true, + open: true, + metadataReady: v2MetadataReady, + }); const { data: snapshot, @@ -198,16 +264,21 @@ export function ResourceConsumption({ isFetching, } = electronTrpc.resourceMetrics.getSnapshot.useQuery( { - mode: open ? "interactive" : "idle", + mode: "interactive", surface, organizationId, }, { enabled: shouldQueryMetrics, - refetchInterval: open ? 2000 : 15000, + refetchInterval: getResourceMonitorRefetchInterval(true), }, ); + useEffect(() => { + if (!isFetching) return; + logStressEvent("resource-monitor.fetch", { surface }); + }, [isFetching, surface]); + const normalizedSnapshot = useMemo(() => { const normalized = normalizeResourceMetricsSnapshot(snapshot); if (!normalized || !isV2) return normalized; @@ -250,8 +321,6 @@ export function ResourceConsumption({ terminalTitleOverrides, ]); - if (!enabled) return null; - const getPaneName = (session: SessionMetrics): string => { if (isV2) { return session.title ?? `Terminal ${session.sessionId.slice(0, 8)}`; @@ -266,7 +335,7 @@ export function ResourceConsumption({ } else { void navigateToV1Workspace(workspaceId, navigate); } - setOpen(false); + onClose(); }; const navigateToPane = (workspaceId: string, paneId: string) => { @@ -277,7 +346,7 @@ export function ResourceConsumption({ focusRequestId: crypto.randomUUID(), }, }); - setOpen(false); + onClose(); return; } @@ -287,7 +356,7 @@ export function ResourceConsumption({ setFocusedPane(pane.tabId, paneId); } void navigateToV1Workspace(workspaceId, navigate); - setOpen(false); + onClose(); }; const toggleWorkspace = (workspaceId: string) => { @@ -335,185 +404,141 @@ export function ResourceConsumption({ : hostShareSeverity === "elevated" ? "bg-amber-500/80" : "bg-foreground/40"; - const triggerDotColorClass = - hostShareSeverity === "high" - ? "bg-red-500" - : hostShareSeverity === "elevated" - ? "bg-amber-500" - : null; - return ( - - - - - + + + setSortOption(value as SortOption)} + > + + Memory + + CPU + + Name + + + Sidebar order + + + + + - - - - {normalizedSnapshot - ? `Resources · ${formatMemory(normalizedSnapshot.totalMemory)}` - : "Resources"} - - - - -
-
-

- Resources -

-
- - - - - - - setSortOption(value as SortOption) - } - > - - Memory - - - CPU - - - Name - - - Sidebar order - - - - - -
+ +
- - {normalizedSnapshot && ( - <> -
- - - -
- - -
-
-
- - - Superset uses {formatPercent(trackedMemorySharePercent)} of - system RAM - - - - )}
-
- {normalizedSnapshot && ( - - )} - - {normalizedSnapshot && ( - - )} - - {normalizedSnapshot && normalizedSnapshot.workspaces.length === 0 && ( -
- No active terminal sessions + {normalizedSnapshot && ( + <> +
+ + +
- )} + + +
+
+
+ + + Superset uses {formatPercent(trackedMemorySharePercent)} of + system RAM + + + + )} +
+ +
+ {normalizedSnapshot && ( + + )} + + {normalizedSnapshot && ( + + )} + + {normalizedSnapshot && normalizedSnapshot.workspaces.length === 0 && ( +
+ No active terminal sessions +
+ )} - {!normalizedSnapshot && ( -
- Loading… -
- )} -
- - + {!normalizedSnapshot && ( +
+ Loading… +
+ )} +
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.test.ts new file mode 100644 index 0000000000..b6a860773f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { + getResourceMonitorRefetchInterval, + shouldQueryResourceMonitor, +} from "./resourceConsumptionPolicy"; + +describe("resourceConsumptionPolicy", () => { + test("keeps resource metrics cold while the popover is closed", () => { + for (let i = 0; i < 10_000; i += 1) { + expect( + shouldQueryResourceMonitor({ + enabled: true, + open: false, + metadataReady: true, + }), + ).toBe(false); + expect(getResourceMonitorRefetchInterval(false)).toBe(false); + } + }); + + test("enables polling only after the popover is open and metadata is ready", () => { + expect( + shouldQueryResourceMonitor({ + enabled: true, + open: true, + metadataReady: true, + }), + ).toBe(true); + expect( + shouldQueryResourceMonitor({ + enabled: true, + open: true, + metadataReady: false, + }), + ).toBe(false); + expect(getResourceMonitorRefetchInterval(true)).toBe(2_000); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.ts new file mode 100644 index 0000000000..f8bd41b807 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.ts @@ -0,0 +1,19 @@ +export const RESOURCE_MONITOR_REFETCH_INTERVAL_MS = 2_000; + +export function shouldQueryResourceMonitor({ + enabled, + open, + metadataReady, +}: { + enabled: boolean | undefined; + open: boolean; + metadataReady: boolean; +}): boolean { + return enabled === true && open && metadataReady; +} + +export function getResourceMonitorRefetchInterval( + open: boolean, +): number | false { + return open ? RESOURCE_MONITOR_REFETCH_INTERVAL_MS : false; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx index c17c0aa6cf..2e4e980798 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx @@ -11,11 +11,38 @@ import { import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { Archive, ChevronDown, Trash2 } from "lucide-react"; -import { useMemo, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { + logStressEvent, + useRenderStressInstrumentation, +} from "renderer/lib/performance/stress-instrumentation"; +import { + clearTerminalBackgroundMarker, + getTerminalBackgroundMarkerIdsKey, + subscribeTerminalBackgroundMarkers, +} from "renderer/lib/terminal/terminal-background-intents"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { useStore } from "zustand"; import type { StoreApi } from "zustand/vanilla"; import type { PaneViewerData, TerminalPaneData } from "../../types"; +import { + BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS, + getAttachedTerminalIdsKey, + getBackgroundTerminalCountRefetchInterval, + getBackgroundTerminalListRefetchInterval, + getBackgroundTerminalSessions, + getUnattachedTerminalIds, + parseAttachedTerminalIdsKey, +} from "./BackgroundTerminalsButton.utils"; interface BackgroundTerminalsButtonProps { workspaceId: string; @@ -28,127 +55,257 @@ interface BackgroundTerminalsButtonProps { * terminal pane header). Renders nothing when there are none; otherwise a * single button with a dropdown to re-open or kill each background session. */ -export function BackgroundTerminalsButton({ - workspaceId, - store, -}: BackgroundTerminalsButtonProps) { - const [isOpen, setIsOpen] = useState(false); - const tabs = useStore(store, (s) => s.tabs); - const utils = workspaceTrpc.useUtils(); - const killSession = workspaceTrpc.terminal.killSession.useMutation(); - const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( - { workspaceId }, - { refetchInterval: isOpen ? 2_000 : 5_000, refetchOnWindowFocus: true }, - ); - - const attachedTerminalIds = useMemo(() => { - const ids = new Set(); - for (const tab of tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind !== "terminal") continue; - const data = pane.data as Partial; - if (data.terminalId) ids.add(data.terminalId); - } - } - return ids; - }, [tabs]); - - const backgroundSessions = useMemo(() => { - const sessions = sessionsQuery.data?.sessions ?? []; - return sessions - .filter((session) => !attachedTerminalIds.has(session.terminalId)) - .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); - }, [sessionsQuery.data?.sessions, attachedTerminalIds]); - - if (backgroundSessions.length === 0) return null; - - const label = `${backgroundSessions.length} background terminal session${ - backgroundSessions.length === 1 ? "" : "s" - }`; - - const handleAdopt = (terminalId: string) => { - store.getState().addTab({ - panes: [ +export const BackgroundTerminalsButton = memo( + function BackgroundTerminalsButton({ + workspaceId, + store, + }: BackgroundTerminalsButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const attachedTerminalIdsKey = useStore(store, (s) => + getAttachedTerminalIdsKey(s.tabs), + ); + const debouncedAttachedTerminalIdsKey = useDebouncedValue( + attachedTerminalIdsKey, + BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS, + ); + const attachedTerminalIds = useMemo( + () => parseAttachedTerminalIdsKey(attachedTerminalIdsKey), + [attachedTerminalIdsKey], + ); + const getBackgroundMarkerSnapshot = useCallback( + () => getTerminalBackgroundMarkerIdsKey(workspaceId), + [workspaceId], + ); + const backgroundMarkerIdsKey = useSyncExternalStore( + subscribeTerminalBackgroundMarkers, + getBackgroundMarkerSnapshot, + () => "[]", + ); + const backgroundMarkerIds = useMemo( + () => parseAttachedTerminalIdsKey(backgroundMarkerIdsKey), + [backgroundMarkerIdsKey], + ); + const debouncedAttachedTerminalIds = useMemo( + () => parseAttachedTerminalIdsKey(debouncedAttachedTerminalIdsKey), + [debouncedAttachedTerminalIdsKey], + ); + const optimisticBackgroundTerminalIds = useMemo( + () => getUnattachedTerminalIds(backgroundMarkerIds, attachedTerminalIds), + [backgroundMarkerIds, attachedTerminalIds], + ); + const optimisticBackgroundCount = optimisticBackgroundTerminalIds.length; + const backgroundCountInput = useMemo( + () => ({ + workspaceId, + attachedTerminalIds: debouncedAttachedTerminalIds, + }), + [workspaceId, debouncedAttachedTerminalIds], + ); + const sessionsInput = useMemo(() => ({ workspaceId }), [workspaceId]); + const utils = workspaceTrpc.useUtils(); + const killSession = workspaceTrpc.terminal.killSession.useMutation(); + const backgroundCountQuery = + workspaceTrpc.terminal.countBackgroundSessions.useQuery( + backgroundCountInput, { - kind: "terminal", - data: { terminalId } as TerminalPaneData, + enabled: !isOpen, + notifyOnChangeProps: ["data", "dataUpdatedAt"], + refetchInterval: getBackgroundTerminalCountRefetchInterval(isOpen), + refetchOnWindowFocus: false, + staleTime: 5_000, }, - ], + ); + const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( + sessionsInput, + { + enabled: isOpen, + notifyOnChangeProps: ["data", "isLoading"], + refetchInterval: getBackgroundTerminalListRefetchInterval(isOpen), + refetchOnWindowFocus: isOpen, + staleTime: 1_000, + }, + ); + + useRenderStressInstrumentation("BackgroundTerminalsButton", { + warnAt: 35, + getDetails: () => ({ + isOpen, + attachedTerminalCount: attachedTerminalIds.length, + optimisticBackgroundCount, + closedCount: backgroundCountQuery.data?.count ?? null, + }), }); - void utils.terminal.listSessions.invalidate({ workspaceId }); - setIsOpen(false); - }; - - const handleKill = async (terminalId: string) => { - try { - await killSession.mutateAsync({ terminalId, workspaceId }); - } catch (error) { - console.error( - "[BackgroundTerminalsButton] Failed to kill session:", - error, + + const backgroundSessions = useMemo(() => { + const sessions = sessionsQuery.data?.sessions ?? []; + return getBackgroundTerminalSessions(sessions, attachedTerminalIds); + }, [sessionsQuery.data?.sessions, attachedTerminalIds]); + + const markerObservedAtRef = useRef(0); + useEffect(() => { + markerObservedAtRef.current = + backgroundMarkerIdsKey === "[]" ? 0 : Date.now(); + }, [backgroundMarkerIdsKey]); + + useEffect(() => { + if (!sessionsQuery.data) return; + + const actualBackgroundTerminalIds = new Set( + backgroundSessions.map((session) => session.terminalId), ); - toast.error("Failed to close terminal session"); - } finally { + for (const terminalId of backgroundMarkerIds) { + if (actualBackgroundTerminalIds.has(terminalId)) continue; + clearTerminalBackgroundMarker(workspaceId, terminalId); + } + }, [ + backgroundMarkerIds, + backgroundSessions, + sessionsQuery.data, + workspaceId, + ]); + + useEffect(() => { + if (isOpen || optimisticBackgroundTerminalIds.length === 0) return; + if (debouncedAttachedTerminalIdsKey !== attachedTerminalIdsKey) return; + if (backgroundCountQuery.data?.count !== 0) return; + if (backgroundCountQuery.dataUpdatedAt <= markerObservedAtRef.current) { + return; + } + + for (const terminalId of optimisticBackgroundTerminalIds) { + clearTerminalBackgroundMarker(workspaceId, terminalId); + } + }, [ + attachedTerminalIdsKey, + backgroundCountQuery.data?.count, + backgroundCountQuery.dataUpdatedAt, + debouncedAttachedTerminalIdsKey, + isOpen, + optimisticBackgroundTerminalIds, + workspaceId, + ]); + + const backgroundCount = + isOpen && sessionsQuery.data + ? backgroundSessions.length + : Math.max( + backgroundCountQuery.data?.count ?? 0, + optimisticBackgroundCount, + ); + + if (!isOpen && backgroundCount === 0) return null; + + const label = `${backgroundCount} background terminal session${ + backgroundCount === 1 ? "" : "s" + }`; + + const handleAdopt = (terminalId: string) => { + clearTerminalBackgroundMarker(workspaceId, terminalId); + store.getState().addTab({ + panes: [ + { + kind: "terminal", + data: { terminalId } as TerminalPaneData, + }, + ], + }); void utils.terminal.listSessions.invalidate({ workspaceId }); - } - }; - - return ( - - - - - - - Background terminal sessions - - -
- {backgroundSessions.map((session) => ( - handleAdopt(session.terminalId)} - > - - - {session.title ?? "Terminal"} - - {session.createdAt > 0 && ( - - {getRelativeTime(session.createdAt, { format: "compact" })} - - )} - + + + + Background terminal sessions + + +
+ {sessionsQuery.isLoading && ( +
+ Loading sessions… +
+ )} + {!sessionsQuery.isLoading && backgroundSessions.length === 0 && ( +
+ No background terminal sessions +
+ )} + {backgroundSessions.map((session) => ( + handleAdopt(session.terminalId)} > - - - - ))} -
-
- - ); + + + {session.title ?? "Terminal"} + + {session.createdAt > 0 && ( + + {getRelativeTime(session.createdAt, { format: "compact" })} + + )} + +
+ ))} +
+
+
+ ); + }, + areBackgroundTerminalsButtonPropsEqual, +); + +function areBackgroundTerminalsButtonPropsEqual( + prev: BackgroundTerminalsButtonProps, + next: BackgroundTerminalsButtonProps, +) { + return prev.workspaceId === next.workspaceId && prev.store === next.store; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts new file mode 100644 index 0000000000..1f54c36980 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { + getAttachedTerminalIdsKey, + getBackgroundTerminalCountRefetchInterval, + getBackgroundTerminalListRefetchInterval, + getBackgroundTerminalSessions, + getUnattachedTerminalIds, + parseAttachedTerminalIdsKey, +} from "./BackgroundTerminalsButton.utils"; + +describe("BackgroundTerminalsButton utils", () => { + test("keeps the attached terminal key stable across tab object churn", () => { + type WorkspaceTabs = Parameters[0]; + const makeTabs = (): WorkspaceTabs => [ + { + panes: { + a: { kind: "terminal", data: { terminalId: "term-b" } }, + b: { kind: "browser", data: { terminalId: "ignored" } }, + }, + }, + { + panes: { + c: { kind: "terminal", data: { terminalId: "term-a" } }, + }, + }, + ]; + const firstKey = getAttachedTerminalIdsKey(makeTabs()); + + for (let i = 0; i < 10_000; i += 1) { + expect(getAttachedTerminalIdsKey(makeTabs())).toBe(firstKey); + } + expect(parseAttachedTerminalIdsKey(firstKey)).toEqual(["term-a", "term-b"]); + }); + + test("filters attached sessions and sorts background sessions newest first", () => { + expect( + getBackgroundTerminalSessions( + [ + { terminalId: "old", createdAt: 1 }, + { terminalId: "attached", createdAt: 3 }, + { terminalId: "new", createdAt: 5 }, + ], + ["attached"], + ).map((session) => session.terminalId), + ).toEqual(["new", "old"]); + }); + + test("deduplicates optimistic background terminal markers and ignores attached terminals", () => { + expect( + getUnattachedTerminalIds(["term-b", "term-a", "term-b"], ["term-a"]), + ).toEqual(["term-b"]); + }); + + test("uses shallow count polling only while closed", () => { + expect(getBackgroundTerminalCountRefetchInterval(false)).toBe(10_000); + expect(getBackgroundTerminalCountRefetchInterval(true)).toBe(false); + expect(getBackgroundTerminalListRefetchInterval(false)).toBe(false); + expect(getBackgroundTerminalListRefetchInterval(true)).toBe(2_000); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts new file mode 100644 index 0000000000..89de8aba18 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts @@ -0,0 +1,81 @@ +export const BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS = 250; +export const BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS = 10_000; +export const BACKGROUND_TERMINAL_LIST_REFETCH_INTERVAL_MS = 2_000; + +interface TerminalPaneLike { + kind: string; + data: unknown; +} + +interface WorkspaceTabLike { + panes: Record; +} + +export interface BackgroundTerminalSessionLike { + terminalId: string; + createdAt?: number; +} + +function getTerminalIdFromPaneData(data: unknown): string | null { + if (!data || typeof data !== "object") return null; + const terminalId = (data as { terminalId?: unknown }).terminalId; + return typeof terminalId === "string" && terminalId.length > 0 + ? terminalId + : null; +} + +export function getAttachedTerminalIdsKey( + tabs: readonly WorkspaceTabLike[], +): string { + const ids = new Set(); + for (const tab of tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "terminal") continue; + const terminalId = getTerminalIdFromPaneData(pane.data); + if (terminalId) ids.add(terminalId); + } + } + return JSON.stringify([...ids].sort()); +} + +export function parseAttachedTerminalIdsKey(key: string): string[] { + try { + const parsed = JSON.parse(key); + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === "string") + : []; + } catch { + return []; + } +} + +export function getBackgroundTerminalSessions< + T extends BackgroundTerminalSessionLike, +>(sessions: readonly T[], attachedTerminalIds: Iterable): T[] { + const attached = new Set(attachedTerminalIds); + return sessions + .filter((session) => !attached.has(session.terminalId)) + .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); +} + +export function getUnattachedTerminalIds( + terminalIds: Iterable, + attachedTerminalIds: Iterable, +): string[] { + const attached = new Set(attachedTerminalIds); + return [...new Set(terminalIds)] + .filter((terminalId) => !attached.has(terminalId)) + .sort(); +} + +export function getBackgroundTerminalCountRefetchInterval( + isOpen: boolean, +): number | false { + return isOpen ? false : BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS; +} + +export function getBackgroundTerminalListRefetchInterval( + isOpen: boolean, +): number | false { + return isOpen ? BACKGROUND_TERMINAL_LIST_REFETCH_INTERVAL_MS : false; +} 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 414ceddd2b..4b962c1c6e 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 @@ -23,7 +23,7 @@ export function TerminalHeaderExtras({ const data = context.pane.data as TerminalPaneData; const handleMoveToBackground = () => { - markTerminalForBackground(data.terminalId); + markTerminalForBackground(data.terminalId, workspaceId); void context.actions.close(); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index c3bd6a43b9..440e94f402 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -13,6 +13,7 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { Check, ChevronDown, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { useRenderStressInstrumentation } from "renderer/lib/performance/stress-instrumentation"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import type { TerminalLauncher } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher"; @@ -23,6 +24,11 @@ import type { import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { TerminalPaneIcon } from "../TerminalPaneIcon"; +import { + getTerminalSessionListRefetchInterval, + shouldQueryTerminalSessionList, + TERMINAL_SESSION_LIST_STALE_MS, +} from "./TerminalSessionDropdown.utils"; interface TerminalSessionDropdownProps { context: RendererContext; @@ -88,13 +94,26 @@ export function TerminalSessionDropdown({ const terminalInstanceId = context.pane.id; const utils = workspaceTrpc.useUtils(); const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); + const sessionsInput = useMemo(() => ({ workspaceId }), [workspaceId]); const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( - { workspaceId }, + sessionsInput, { - refetchInterval: isOpen ? 2_000 : false, - refetchOnWindowFocus: true, + enabled: shouldQueryTerminalSessionList(isOpen), + notifyOnChangeProps: ["data", "isFetching"], + refetchInterval: getTerminalSessionListRefetchInterval(isOpen), + refetchOnWindowFocus: false, + staleTime: TERMINAL_SESSION_LIST_STALE_MS, }, ); + useRenderStressInstrumentation("TerminalSessionDropdown", { + warnAt: 30, + getDetails: () => ({ + workspaceId, + terminalId, + isOpen, + hasSessionData: Boolean(sessionsQuery.data), + }), + }); const { data: localWorkspaceRows = [] } = useLiveQuery( (query) => query @@ -172,7 +191,7 @@ export function TerminalSessionDropdown({ } if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { - markTerminalForBackground(terminalId); + markTerminalForBackground(terminalId, workspaceId); } state.setPaneData({ @@ -231,7 +250,7 @@ export function TerminalSessionDropdown({ const state = context.store.getState(); const terminalPaneLocations = getTerminalPaneLocations(context); if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { - markTerminalForBackground(terminalId); + markTerminalForBackground(terminalId, workspaceId); } state.setPaneData({ paneId: context.pane.id, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.test.ts new file mode 100644 index 0000000000..9d64809a40 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { + getTerminalSessionListRefetchInterval, + shouldQueryTerminalSessionList, + TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS, +} from "./TerminalSessionDropdown.utils"; + +describe("TerminalSessionDropdown query policy", () => { + it("does not query or poll while closed", () => { + expect(shouldQueryTerminalSessionList(false)).toBe(false); + expect(getTerminalSessionListRefetchInterval(false)).toBe(false); + }); + + it("queries and polls while open", () => { + expect(shouldQueryTerminalSessionList(true)).toBe(true); + expect(getTerminalSessionListRefetchInterval(true)).toBe( + TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS, + ); + }); + + it("keeps closed dropdowns cold under tab churn", () => { + for (let i = 0; i < 10_000; i++) { + expect(shouldQueryTerminalSessionList(false)).toBe(false); + expect(getTerminalSessionListRefetchInterval(false)).toBe(false); + } + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.ts new file mode 100644 index 0000000000..39e2b657dc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.ts @@ -0,0 +1,12 @@ +export const TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS = 2_000; +export const TERMINAL_SESSION_LIST_STALE_MS = 5_000; + +export function shouldQueryTerminalSessionList(isOpen: boolean): boolean { + return isOpen; +} + +export function getTerminalSessionListRefetchInterval( + isOpen: boolean, +): false | number { + return isOpen ? TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS : false; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index f9be670e24..8b4148b3cd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -25,6 +25,7 @@ import type { SelectWorkspace, } from "@superset/db/schema"; import type { AppRouter } from "@superset/trpc"; +import { BasicIndex } from "@tanstack/db"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import { createElectronSQLitePersistence, @@ -35,7 +36,6 @@ import type { LocalStorageCollectionUtils, } from "@tanstack/react-db"; import { - BasicIndex, createCollection, localStorageCollectionOptions, } from "@tanstack/react-db"; @@ -72,6 +72,7 @@ const indexDefaults = { autoIndex: "eager", defaultIndexType: BasicIndex, } as const; +const basicIndexConfig = { indexType: BasicIndex } as const; const createIndexedCollection = (( config: Parameters[0], @@ -303,6 +304,10 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2Projects.createIndex( + (project) => project.githubRepositoryId, + basicIndexConfig, + ); const v2Hosts = createPersistedElectricCollection( electricCollectionOptions({ @@ -332,6 +337,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2Hosts.createIndex((host) => host.machineId, basicIndexConfig); const v2Clients = createPersistedElectricCollection( electricCollectionOptions({ @@ -395,6 +401,8 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2UsersHosts.createIndex((userHost) => userHost.hostId, basicIndexConfig); + v2UsersHosts.createIndex((userHost) => userHost.userId, basicIndexConfig); const v2Workspaces = createPersistedElectricCollection( electricCollectionOptions({ @@ -423,6 +431,12 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2Workspaces.createIndex((workspace) => workspace.hostId, basicIndexConfig); + v2Workspaces.createIndex( + (workspace) => workspace.projectId, + basicIndexConfig, + ); + v2Workspaces.createIndex((workspace) => workspace.type, basicIndexConfig); const workspaces = createPersistedElectricCollection( electricCollectionOptions({ @@ -690,6 +704,10 @@ function createOrgCollections(organizationId: string): OrgCollections { getKey: (item) => item.projectId, }), ); + v2SidebarProjects.createIndex( + (sidebarProject) => sidebarProject.tabOrder, + basicIndexConfig, + ); const v2WorkspaceLocalState = createIndexedCollection( localStorageCollectionOptions( @@ -706,6 +724,18 @@ function createOrgCollections(organizationId: string): OrgCollections { ), ), ); + v2WorkspaceLocalState.createIndex( + (localState) => localState.sidebarState.projectId, + basicIndexConfig, + ); + v2WorkspaceLocalState.createIndex( + (localState) => localState.sidebarState.sectionId, + basicIndexConfig, + ); + v2WorkspaceLocalState.createIndex( + (localState) => localState.sidebarState.tabOrder, + basicIndexConfig, + ); const v2SidebarSections = createIndexedCollection( localStorageCollectionOptions({ @@ -715,6 +745,14 @@ function createOrgCollections(organizationId: string): OrgCollections { getKey: (item) => item.sectionId, }), ); + v2SidebarSections.createIndex( + (section) => section.projectId, + basicIndexConfig, + ); + v2SidebarSections.createIndex( + (section) => section.tabOrder, + basicIndexConfig, + ); const v2TerminalPresets = createIndexedCollection( localStorageCollectionOptions({ diff --git a/packages/host-service/src/terminal/terminal-mode-tracker.ts b/packages/host-service/src/terminal/terminal-mode-tracker.ts index 7534da741e..05787e1ded 100644 --- a/packages/host-service/src/terminal/terminal-mode-tracker.ts +++ b/packages/host-service/src/terminal/terminal-mode-tracker.ts @@ -13,7 +13,11 @@ // Pattern adapted from VSCode's XtermSerializer // (src/vs/platform/terminal/node/ptyService.ts). -import { Terminal as HeadlessTerminal } from "@xterm/headless"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { Terminal: HeadlessTerminal } = + require("@xterm/headless") as typeof import("@xterm/headless"); export interface ModeTracker { feed(bytes: Uint8Array): void; diff --git a/packages/host-service/src/terminal/terminal.adoption.node-test.ts b/packages/host-service/src/terminal/terminal.adoption.node-test.ts index 83d0638956..55bdb7d3a9 100644 --- a/packages/host-service/src/terminal/terminal.adoption.node-test.ts +++ b/packages/host-service/src/terminal/terminal.adoption.node-test.ts @@ -32,10 +32,10 @@ import { initTerminalBaseEnv } from "./env.ts"; import { __resetSessionsForTesting, createTerminalSessionInternal, - disposeSession, disposeSessionAndWait, listTerminalSessions, } from "./terminal.ts"; +import { __setAccountShellForTesting } from "./user-shell.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TEST_HOME = path.join(os.tmpdir(), `host-svc-adopt-${process.pid}`); @@ -68,6 +68,7 @@ before(async () => { process.env.HOST_SERVICE_VERSION = "0.0.0-adoption-e2e"; process.env.NODE_ENV = "development"; + __setAccountShellForTesting("/bin/sh"); initTerminalBaseEnv({ PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? TEST_HOME, @@ -100,6 +101,7 @@ before(async () => { after(async () => { __resetSessionsForTesting(); + __setAccountShellForTesting(undefined); await disposeDaemonClient(); await server.close(); try { @@ -134,7 +136,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = assert.equal(daemonSession.cols, 101); assert.equal(daemonSession.rows, 27); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("existing session accepts a not-yet-queued initialCommand", async () => { @@ -163,7 +165,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = assert.equal(second.initialCommandQueued, true); await waitFor(() => fs.existsSync(sentinelFile), 5000); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("rejects reusing a live terminal id from another workspace", async () => { @@ -200,7 +202,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = false, ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adoptOnly refuses to spawn when daemon does not own the session", async () => { @@ -242,11 +244,9 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = db, listed: true, }); - assert.ok( - !("error" in result), - `expected session, got error: ${JSON.stringify(result)}`, - ); - if ("error" in result) return; + if ("error" in result) { + assert.fail(`expected session, got error: ${result.error}`); + } assert.equal(result.terminalId, terminalId); assert.ok(result.pty.pid > 0, "pty pid should be populated"); @@ -257,7 +257,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "new session should be in listTerminalSessions", ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adopts existing daemon session after host-service restart simulation", async () => { @@ -304,7 +304,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = await waitFor(() => buf.includes("after-host-restart"), 3000); disposer.dispose(); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adopted session keeps listed/exited bookkeeping", async () => { @@ -337,7 +337,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = ), ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("rejects adopting a daemon session from another workspace after host-service restart simulation", async () => { @@ -428,7 +428,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "initialCommand re-fired on adopted session — would re-run setup.sh on every host-service restart", ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adoption when the original workspace row is gone returns a clear error", async () => { @@ -482,13 +482,13 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "adoption with missing workspace must return error, not throw or loop", ); if ("error" in second) { - assert.match(second.error, /Workspace worktree not found/); + assert.match(second.error, /Workspace (not found|worktree)/); } // Daemon still has the orphan session — clean it up directly so the // test suite leaves nothing behind. Production needs a periodic // "orphan session sweep" but that's a separate cleanup concern. - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("replayOnAdoption: false suppresses ring-buffer replay on reconnect", async () => { @@ -535,15 +535,18 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "adopted session should have same shell pid", ); - // session.bufferBytes is the direct signal: the primary subscription - // writes incoming chunks here for WS broadcast. Non-zero after a - // replay-suppressed adopt = bug. + // The shell may still produce live prompt bytes after reconnect, but + // the daemon ring-buffer sentinel from the previous host lifetime must + // not be replayed when replayOnAdoption=false. await new Promise((r) => setTimeout(r, 500)); + const bufferedAfterAdoption = Buffer.concat( + second.buffer.map((b) => Buffer.from(b)), + ).toString("utf8"); assert.equal( - second.bufferBytes, - 0, - `adopted session.bufferBytes must remain 0 when replayOnAdoption=false; got ${second.bufferBytes} bytes (first chunk: ${second.buffer[0] ? JSON.stringify(Buffer.from(second.buffer[0]).toString("utf8").slice(0, 100)) : ""})`, + bufferedAfterAdoption.includes(SENTINEL), + false, + `adopted session replayed prior output despite replayOnAdoption=false: ${JSON.stringify(bufferedAfterAdoption.slice(0, 200))}`, ); // Sanity check: live output still flows post-reattach. @@ -556,7 +559,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = return text.includes(LIVE_SENTINEL); }, 3000); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("dispose then re-create with the same id works (no zombie state)", async () => { @@ -575,7 +578,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = assert.ok(!("error" in first)); const firstPid = "error" in first ? -1 : first.pty.pid; - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); // Wait for the daemon's onExit handler to mark the session exited // (SIGTERM → shell exits → wireSession.onExit fires → session.exited @@ -588,11 +591,9 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = db, listed: true, }); - assert.ok( - !("error" in second), - `re-create after dispose failed: ${JSON.stringify(second)}`, - ); - if ("error" in second) return; + if ("error" in second) { + assert.fail(`re-create after dispose failed: ${second.error}`); + } // Different shell pid (real fresh spawn) — not adoption. assert.notEqual( @@ -601,7 +602,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "re-create after dispose should be a fresh spawn, not adoption of the dead session", ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); }); diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 8774605a41..3e9559843f 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -408,6 +408,35 @@ export function listTerminalSessions( })); } +export function countTerminalSessions( + options: { + workspaceId?: string; + includeExited?: boolean; + excludeTerminalIds?: Iterable; + } = {}, +): number { + const includeExited = options.includeExited ?? true; + const excludedTerminalIds = options.excludeTerminalIds + ? new Set(options.excludeTerminalIds) + : null; + let count = 0; + + for (const session of sessions.values()) { + if (!session.listed) continue; + if ( + options.workspaceId !== undefined && + session.workspaceId !== options.workspaceId + ) { + continue; + } + if (!includeExited && session.exited) continue; + if (excludedTerminalIds?.has(session.terminalId)) continue; + count += 1; + } + + return count; +} + export function writeInputToSession({ terminalId, workspaceId, diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index afe9552620..45eccd3175 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { getSupervisor, waitForDaemonReady } from "../../../daemon"; import { terminalSessions, workspaces } from "../../../db/schema"; import { + countTerminalSessions, createTerminalSessionInternal, disposeSessionAndWait, listTerminalSessions, @@ -119,6 +120,21 @@ export const terminalRouter = router({ }), })), + countBackgroundSessions: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + attachedTerminalIds: z.array(z.string()).default([]), + }), + ) + .query(({ input }) => ({ + count: countTerminalSessions({ + workspaceId: input.workspaceId, + includeExited: false, + excludeTerminalIds: input.attachedTerminalIds, + }), + })), + writeInput: protectedProcedure .input( z.object({ diff --git a/packages/host-service/test/integration/terminal.integration.test.ts b/packages/host-service/test/integration/terminal.integration.test.ts index ac0ed1a481..81d1b0f383 100644 --- a/packages/host-service/test/integration/terminal.integration.test.ts +++ b/packages/host-service/test/integration/terminal.integration.test.ts @@ -124,8 +124,20 @@ describe("terminal router integration", () => { workspaceId: scenario.workspaceId, terminalId, }); + const detachedCount = + await scenario.host.trpc.terminal.countBackgroundSessions.query({ + workspaceId: scenario.workspaceId, + attachedTerminalIds: [], + }); + const attachedCount = + await scenario.host.trpc.terminal.countBackgroundSessions.query({ + workspaceId: scenario.workspaceId, + attachedTerminalIds: [terminalId], + }); expect(spawned).toHaveLength(1); + expect(detachedCount.count).toBe(1); + expect(attachedCount.count).toBe(0); const [{ meta }] = spawned; expect(meta.shell).toBe(fakeFishPath); expect(meta.argv[0]).toBe("-l"); diff --git a/turbo.jsonc b/turbo.jsonc index 3084336005..125a76a58b 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -25,7 +25,8 @@ "VERCEL", "VERCEL_ENV", "VERCEL_URL", - "npm_lifecycle_event" + "npm_lifecycle_event", + "RENDERER_REMOTE_DEBUG_PORT" ], "tasks": { "build": {