From d5393a6027d13623ab63bcd0cdbd2a2cd65209bf Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 3 May 2026 18:34:32 -0400 Subject: [PATCH] refactor(frontend): extract useStackListState hook from EditorLayout (#901) Moves the stack-list state cluster (14 useState calls, plus refs, memos, effects, and 7 absorbed sub-hooks) into a new hook at EditorLayout/hooks/useStackListState.ts, following the same pattern as useEditorViewState (B4-5). EditorLayout useState count: 42 -> 28. useEffect count unchanged. Also fixes a stale-closure bug in isStackBusy: previously read from stackActions state, making isBusy stale inside buildMenuCtx whenever a stack action fired between dep-array updates. Now reads from stackActionsRef.current so it is always current without needing to be listed as a dep. Wrapping it in useCallback makes the function reference stable. --- frontend/src/components/EditorLayout.tsx | 351 ++--------------- .../EditorLayout/hooks/useStackListState.ts | 357 ++++++++++++++++++ 2 files changed, 394 insertions(+), 314 deletions(-) create mode 100644 frontend/src/components/EditorLayout/hooks/useStackListState.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7d01909f..0e5b5ff5 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspense } from 'react'; type Theme = 'light' | 'dark' | 'auto'; -import { useImageUpdates } from '@/hooks/useImageUpdates'; import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import LazyBoundary from './LazyBoundary'; @@ -20,8 +19,9 @@ import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog'; import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog'; -import { EditorView, type ContainerInfo, type StackAction } from './EditorLayout/EditorView'; +import { EditorView, type StackAction } from './EditorLayout/EditorView'; import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState'; +import { useStackListState } from './EditorLayout/hooks/useStackListState'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; @@ -43,8 +43,7 @@ import { GlobalCommandPaletteProvider, GlobalCommandPaletteTrigger, } from './GlobalCommandPalette'; -import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch'; -import { SENCHO_OPEN_LOGS_EVENT, SENCHO_LABELS_CHANGED } from '@/lib/events'; +import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; @@ -54,24 +53,11 @@ import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { StackSidebar } from '@/components/sidebar/StackSidebar'; -import { usePinnedStacks } from '@/hooks/usePinnedStacks'; -import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; import type { StackRowStatus } from '@/components/sidebar/stack-status-utils'; -import type { FilterChip, StackMenuCtx } from '@/components/sidebar/sidebar-types'; -import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions'; -import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards'; +import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog'; -interface StackStatus { - [key: string]: 'running' | 'exited' | 'unknown'; -} - -interface StackStatusInfo { - status: 'running' | 'exited' | 'unknown'; - mainPort?: number; -} - const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -107,6 +93,37 @@ export default function EditorLayout() { isEditing, setIsEditing, editingCompose, setEditingCompose, } = useEditorViewState(); + const { + files, + selectedFile, setSelectedFile, + isLoading, + stackActions, + isScanning, + searchQuery, setSearchQuery, + stackStatuses, + stackPorts, + labels, + stackLabelMap, + autoUpdateSettings, setAutoUpdateSettings, + filterChip, setFilterChip, + bulkMode, + selectedFiles, + filterCounts, + chipFilteredFiles, + remoteResults, + setStackAction, clearStackAction, isStackBusy, + setOptimisticStatus, + refreshLabels, + refreshStacks, + fetchAutoUpdateSettings, + handleScanStacks, + scheduleStateInvalidateRefresh, + toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, + stackUpdates, fetchImageUpdates, + pinned, pin, unpin, isPinned, + isCollapsed, toggleCollapse, + remoteSearchLoading, + } = useStackListState(); const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null); const [policyBypassing, setPolicyBypassing] = useState(false); @@ -117,8 +134,6 @@ export default function EditorLayout() { nodesRef.current = nodes; // Tracks cleanup functions for per-remote-node notification WebSocket connections. const remoteNotifWsRef = useRef void>>(new Map()); - const [files, setFiles] = useState([]); - const [selectedFile, setSelectedFile] = useState(null); // Incoming WebSocket stats are written here first (no re-render), then flushed // to React state in one batched update every 1.5 s. const pendingStatsRef = useRef(null); const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); const [pendingUnsavedNode, setPendingUnsavedNode] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [stackActions, setStackActions] = useState>({}); - const stackActionsRef = useRef>({}); - stackActionsRef.current = stackActions; - - const setStackAction = (stackFile: string, action: StackAction) => { - setStackActions(prev => ({ ...prev, [stackFile]: action })); - }; - const clearStackAction = (stackFile: string) => { - setStackActions(prev => { - const next = { ...prev }; - delete next[stackFile]; - return next; - }); - }; - const isStackBusy = (stackFile: string) => stackFile in stackActions; - const getStackMenuVisibility = (file: string) => { const status = stackStatuses[file]; return { @@ -182,7 +180,6 @@ export default function EditorLayout() { const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null; - const [isScanning, setIsScanning] = useState(false); const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('sencho-theme') as Theme | null; if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved; @@ -207,12 +204,6 @@ export default function EditorLayout() { fileName: string; } | null>(null); const [diffPreviewConfirming, setDiffPreviewConfirming] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [stackStatuses, setStackStatuses] = useState({}); - const [stackPorts, setStackPorts] = useState>({}); - const [labels, setLabels] = useState([]); - const [stackLabelMap, setStackLabelMap] = useState>({}); - // Bash exec modal state const [bashModalOpen, setBashModalOpen] = useState(false); const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null); @@ -222,9 +213,6 @@ export default function EditorLayout() { const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null); - // Image update checker state - const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id); - const [autoUpdateSettings, setAutoUpdateSettings] = useState>({}); const isAdmiral = license?.variant === 'admiral'; const handleOpenSettings = useCallback((section?: SectionId) => { @@ -326,20 +314,6 @@ export default function EditorLayout() { return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler); }, []); - // Fan out stack search across other online nodes so the sidebar can surface matches from the whole fleet. - const { hits: remoteSearchHits, loading: remoteSearchLoading } = useCrossNodeStackSearch({ - query: searchQuery, - enabled: true, - excludeNodeId: activeNode?.id, - }); - const remoteStackResults = useMemo(() => { - const out: Record> = {}; - for (const hit of remoteSearchHits) { - (out[hit.nodeId] ??= []).push({ file: hit.file, status: hit.status }); - } - return out; - }, [remoteSearchHits]); - // Force Monaco to re-measure its container after the tab switch DOM settles. // Monaco's internal child is position:static with an explicit pixel height that // creates a circular CSS dependency (Monaco drives card height → grid height → Monaco). @@ -355,102 +329,6 @@ export default function EditorLayout() { return () => cancelAnimationFrame(id); }, [activeTab]); - const refreshStacks = async (background = false): Promise => { - if (!background) setIsLoading(true); - try { - const res = await apiFetch('/stacks'); - if (!res.ok) { - setFiles([]); - return []; - } - const data = await res.json(); - const fileList: string[] = Array.isArray(data) ? data : []; - setFiles(fileList); - - // Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes) - const statusRes = await apiFetch('/stacks/statuses'); - let bulkStatuses: Record | null = null; - const bulkPorts: Record = {}; - if (statusRes.ok) { - const raw = await statusRes.json(); - bulkStatuses = {}; - // Handle both old format (plain string) and new format ({ status, mainPort }) - for (const [key, val] of Object.entries(raw)) { - if (typeof val === 'string') { - bulkStatuses[key] = val as 'running' | 'exited' | 'unknown'; - } else if (val && typeof val === 'object' && 'status' in val) { - const info = val as StackStatusInfo; - bulkStatuses[key] = info.status; - if (info.mainPort) bulkPorts[key] = info.mainPort; - } - } - } else { - // Fallback: query each stack individually (remote node may not have bulk endpoint) - const statusResults = await Promise.allSettled( - fileList.map(async (file) => { - const containersRes = await apiFetch(`/stacks/${file}/containers`); - if (!containersRes.ok) return { file, status: 'unknown' as const }; - const containers = await containersRes.json(); - const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running'); - return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) }; - }) - ); - bulkStatuses = {}; - for (const result of statusResults) { - if (result.status === 'fulfilled') { - bulkStatuses[result.value.file] = result.value.status; - } - } - } - setStackStatuses(prev => { - const next: StackStatus = {}; - for (const file of fileList) { - const status = bulkStatuses?.[file] ?? 'unknown'; - next[file] = (file in stackActionsRef.current) ? (prev[file] ?? status) : status; - } - return next; - }); - setStackPorts(prev => { - const keys = Object.keys(bulkPorts); - if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev; - return bulkPorts; - }); - refreshLabels(); - return fileList; - } catch (error) { - console.error('Failed to refresh stacks:', error); - setFiles([]); - return []; - } finally { - setIsLoading(false); - } - }; - - const setOptimisticStatus = (stackFile: string, status: 'running' | 'exited') => { - setStackStatuses(prev => ({ ...prev, [stackFile]: status })); - }; - - // Stable identity required: captured by buildMenuCtx's memoization and passed as a prop; unstable refs cause descendant re-render churn. - const refreshLabels = useCallback(async () => { - if (!isPaid) return; - try { - const [labelsRes, assignmentsRes] = await Promise.all([ - apiFetch('/labels'), - apiFetch('/labels/assignments'), - ]); - if (labelsRes.ok) setLabels(await labelsRes.json()); - if (assignmentsRes.ok) setStackLabelMap(await assignmentsRes.json()); - } catch { - // Labels are non-critical; fail silently - } - }, [isPaid]); - - useEffect(() => { - const handler = () => refreshLabels(); - window.addEventListener(SENCHO_LABELS_CHANGED, handler); - return () => window.removeEventListener(SENCHO_LABELS_CHANGED, handler); - }, [refreshLabels]); - /** * Populate the per-stack "pending git source update" map. Runs on mount and * whenever a git-source change is signalled by the panel. Backend failure @@ -471,49 +349,6 @@ export default function EditorLayout() { } }; - const handleScanStacks = async () => { - if (isScanning) return; - setIsScanning(true); - const previousStacks = [...files]; - try { - const currentStacks = await refreshStacks(); - const added = currentStacks.filter(s => !previousStacks.includes(s)); - const removed = previousStacks.filter(s => !currentStacks.includes(s)); - - if (added.length > 0) { - toast.success(`Found ${added.length} new stack${added.length !== 1 ? 's' : ''}: ${added.join(', ')}`); - } - if (removed.length > 0) { - toast.info(`${removed.length} stack${removed.length !== 1 ? 's' : ''} no longer detected: ${removed.join(', ')}`); - } - if (added.length === 0 && removed.length === 0) { - toast.info('No new stacks found.'); - } - } catch (error: unknown) { - const err = error as Record; - const data = err?.data as Record | undefined; - toast.error((err?.message as string) || (err?.error as string) || (data?.error as string) || 'Something went wrong.'); - } finally { - setIsScanning(false); - } - }; - - // Coalesce a burst of state-invalidate signals (e.g. compose recreating - // 10 services produces ~30 docker events in <500ms) into one stack refetch - // and one downstream window event. The 250ms debounce balances "feels - // instant" against not thrashing the API. The function is held in a ref - // so the long-lived WS effect never closes over a stale refreshStacks. - const stateInvalidateTimerRef = useRef | null>(null); - const refreshStacksRef = useRef(refreshStacks); - useEffect(() => { refreshStacksRef.current = refreshStacks; }, [refreshStacks]); - const scheduleStateInvalidateRefresh = useCallback(() => { - if (stateInvalidateTimerRef.current) clearTimeout(stateInvalidateTimerRef.current); - stateInvalidateTimerRef.current = setTimeout(() => { - stateInvalidateTimerRef.current = null; - refreshStacksRef.current(true); - }, 250); - }, []); - // Notification WS push - subscribe to local real-time alerts. // Initial history load is handled by the [nodes] effect below. useEffect(() => { @@ -715,7 +550,7 @@ export default function EditorLayout() { const localNode = currentNodes.find(n => n.type === 'local'); const remoteNodes = currentNodes.filter(n => n.type === 'remote'); - const [localResult, ...remoteResults] = await Promise.allSettled([ + const [localResult, ...remoteNodeResults] = await Promise.allSettled([ apiFetch('/notifications', { localOnly: true }), ...remoteNodes.map(n => fetchForNode('/notifications', n.id)), ]); @@ -728,7 +563,7 @@ export default function EditorLayout() { } for (let i = 0; i < remoteNodes.length; i++) { - const result = remoteResults[i]; + const result = remoteNodeResults[i]; if (result?.status === 'fulfilled' && result.value.ok) { const data = await result.value.json() as Omit[]; const rn = remoteNodes[i]; @@ -754,20 +589,6 @@ export default function EditorLayout() { return () => clearInterval(id); }, []); - const fetchAutoUpdateSettings = async () => { - try { - const res = await apiFetch('/stacks/auto-update-settings'); - if (res.ok) { - const data = await res.json(); - setAutoUpdateSettings(data as Record); - } else { - console.error('[AutoUpdateSettings] fetch returned', res.status); - } - } catch (e: unknown) { - console.error('[AutoUpdateSettings] fetch failed:', e); - } - }; - const markAllRead = async () => { try { const localNode = nodesRef.current.find(n => n.type === 'local'); @@ -1621,109 +1442,11 @@ export default function EditorLayout() { // Stack name is now the same as selectedFile (no extension to strip) const stackName = selectedFile || ''; - const filteredFiles = useMemo( - () => files.filter(file => file.toLowerCase().includes(searchQuery.toLowerCase())), - [files, searchQuery], - ); - // Get display name for stack (now just returns the name as-is since no extension) const getDisplayName = (stackName: string) => { return stackName; }; - const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id); - - useEffect(() => { - if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).'); - }, [evictedOldest]); - - const [filterChip, setFilterChip] = useState('all'); - - const filterCounts = useMemo(() => ({ - all: filteredFiles.length, - up: filteredFiles.filter(f => stackStatuses[f] === 'running').length, - down: filteredFiles.filter(f => stackStatuses[f] === 'exited').length, - updates: filteredFiles.filter(f => !!stackUpdates[f]).length, - }), [filteredFiles, stackStatuses, stackUpdates]); - - const chipFilteredFiles = useMemo(() => { - if (filterChip === 'all') return filteredFiles; - if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running'); - if (filterChip === 'down') return filteredFiles.filter(f => stackStatuses[f] === 'exited'); - if (filterChip === 'updates') return filteredFiles.filter(f => !!stackUpdates[f]); - return filteredFiles; - }, [filteredFiles, filterChip, stackStatuses, stackUpdates]); - - const [bulkMode, setBulkMode] = useState(false); - const [selectedFiles, setSelectedFiles] = useState>(new Set()); - - const toggleBulkMode = useCallback(() => { - setBulkMode(prev => { - if (prev) setSelectedFiles(new Set()); - return !prev; - }); - }, []); - - const toggleSelect = useCallback((file: string) => { - setSelectedFiles(prev => { - const next = new Set(prev); - if (next.has(file)) next.delete(file); - else next.add(file); - return next; - }); - }, []); - - const clearSelection = useCallback(() => { - setSelectedFiles(new Set()); - }, []); - - const { runBulk } = useBulkStackActions(); - - const handleBulkAction = useCallback((action: BulkAction) => { - const files = Array.from(selectedFiles); - runBulk(action, files, { - onAfter: () => { refreshStacks(true); clearSelection(); }, - }); - }, [selectedFiles, runBulk, clearSelection]); - - const chipFilteredFilesRef = useRef(chipFilteredFiles); - useEffect(() => { chipFilteredFilesRef.current = chipFilteredFiles; }, [chipFilteredFiles]); - - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (isInputFocused()) return; - if (isPaletteOpen()) return; - - if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - toggleBulkMode(); - } else if (e.key === 'Escape' && bulkMode) { - e.preventDefault(); - setBulkMode(false); - setSelectedFiles(new Set()); - } else if ((e.metaKey || e.ctrlKey) && e.key === 'a' && bulkMode) { - e.preventDefault(); - setSelectedFiles(new Set(chipFilteredFilesRef.current)); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [bulkMode, toggleBulkMode]); - - const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id); - - const remoteResults = useMemo(() => { - return Object.entries(remoteStackResults).flatMap(([nodeIdStr, remoteFiles]) => { - const node = nodes.find(n => n.id === Number(nodeIdStr)); - if (!node || remoteFiles.length === 0) return []; - return [{ - nodeId: node.id, - nodeName: node.name, - files: remoteFiles.map(({ file, status }) => ({ file, status: status as StackRowStatus })), - }]; - }); - }, [remoteStackResults, nodes]); - const buildMenuCtx = useCallback((file: string): StackMenuCtx => { const stackName = file.replace(/\.(yml|yaml)$/, ''); return { diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts new file mode 100644 index 00000000..f5285e8a --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -0,0 +1,357 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { useNodes } from '@/context/NodeContext'; +import { useLicense } from '@/context/LicenseContext'; +import { useImageUpdates } from '@/hooks/useImageUpdates'; +import { usePinnedStacks } from '@/hooks/usePinnedStacks'; +import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; +import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions'; +import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch'; +import { SENCHO_LABELS_CHANGED } from '@/lib/events'; +import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards'; +import type { StackAction, ContainerInfo } from '../EditorView'; +import type { Label as StackLabel } from '../../label-types'; +import type { FilterChip } from '../../sidebar/sidebar-types'; +import type { StackRowStatus } from '../../sidebar/stack-status-utils'; + +interface StackStatus { + [key: string]: 'running' | 'exited' | 'unknown'; +} + +interface StackStatusInfo { + status: 'running' | 'exited' | 'unknown'; + mainPort?: number; +} + +export interface RemoteResult { + nodeId: number; + nodeName: string; + files: Array<{ file: string; status: StackRowStatus }>; +} + +export function useStackListState() { + const { nodes, activeNode } = useNodes(); + const { isPaid } = useLicense(); + + const [files, setFiles] = useState([]); + const [selectedFile, setSelectedFile] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [stackActions, setStackActions] = useState>({}); + const stackActionsRef = useRef>({}); + stackActionsRef.current = stackActions; + + const [isScanning, setIsScanning] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [stackStatuses, setStackStatuses] = useState({}); + const [stackPorts, setStackPorts] = useState>({}); + const [labels, setLabels] = useState([]); + const [stackLabelMap, setStackLabelMap] = useState>({}); + const [autoUpdateSettings, setAutoUpdateSettings] = useState>({}); + const [filterChip, setFilterChip] = useState('all'); + const [bulkMode, setBulkMode] = useState(false); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + + const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id); + const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id); + const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id); + const { runBulk } = useBulkStackActions(); + + const { hits: remoteSearchHits, loading: remoteSearchLoading } = useCrossNodeStackSearch({ + query: searchQuery, + enabled: true, + excludeNodeId: activeNode?.id, + }); + + useEffect(() => { + if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).'); + }, [evictedOldest]); + + const setStackAction = (stackFile: string, action: StackAction) => { + setStackActions(prev => ({ ...prev, [stackFile]: action })); + }; + const clearStackAction = (stackFile: string) => { + setStackActions(prev => { + const next = { ...prev }; + delete next[stackFile]; + return next; + }); + }; + const isStackBusy = useCallback((stackFile: string) => stackFile in stackActionsRef.current, []); + + const setOptimisticStatus = (stackFile: string, status: 'running' | 'exited') => { + setStackStatuses(prev => ({ ...prev, [stackFile]: status })); + }; + + const refreshLabels = useCallback(async () => { + if (!isPaid) return; + try { + const [labelsRes, assignmentsRes] = await Promise.all([ + apiFetch('/labels'), + apiFetch('/labels/assignments'), + ]); + if (labelsRes.ok) setLabels(await labelsRes.json()); + if (assignmentsRes.ok) setStackLabelMap(await assignmentsRes.json()); + } catch { + // Labels are non-critical; fail silently + } + }, [isPaid]); + + useEffect(() => { + const handler = () => refreshLabels(); + window.addEventListener(SENCHO_LABELS_CHANGED, handler); + return () => window.removeEventListener(SENCHO_LABELS_CHANGED, handler); + }, [refreshLabels]); + + const refreshStacks = async (background = false): Promise => { + if (!background) setIsLoading(true); + try { + const res = await apiFetch('/stacks'); + if (!res.ok) { + setFiles([]); + return []; + } + const data = await res.json(); + const fileList: string[] = Array.isArray(data) ? data : []; + setFiles(fileList); + + // Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes) + const statusRes = await apiFetch('/stacks/statuses'); + let bulkStatuses: Record | null = null; + const bulkPorts: Record = {}; + if (statusRes.ok) { + const raw = await statusRes.json(); + bulkStatuses = {}; + // Handle both old format (plain string) and new format ({ status, mainPort }) + for (const [key, val] of Object.entries(raw)) { + if (typeof val === 'string') { + bulkStatuses[key] = val as 'running' | 'exited' | 'unknown'; + } else if (val && typeof val === 'object' && 'status' in val) { + const info = val as StackStatusInfo; + bulkStatuses[key] = info.status; + if (info.mainPort) bulkPorts[key] = info.mainPort; + } + } + } else { + // Fallback: query each stack individually (remote node may not have bulk endpoint) + const statusResults = await Promise.allSettled( + fileList.map(async (file) => { + const containersRes = await apiFetch(`/stacks/${file}/containers`); + if (!containersRes.ok) return { file, status: 'unknown' as const }; + const containers = await containersRes.json(); + const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running'); + return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) }; + }) + ); + bulkStatuses = {}; + for (const result of statusResults) { + if (result.status === 'fulfilled') { + bulkStatuses[result.value.file] = result.value.status; + } + } + } + setStackStatuses(prev => { + const next: StackStatus = {}; + for (const file of fileList) { + const status = bulkStatuses?.[file] ?? 'unknown'; + next[file] = (file in stackActionsRef.current) ? (prev[file] ?? status) : status; + } + return next; + }); + setStackPorts(prev => { + const keys = Object.keys(bulkPorts); + if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev; + return bulkPorts; + }); + refreshLabels(); + return fileList; + } catch (error) { + console.error('Failed to refresh stacks:', error); + setFiles([]); + return []; + } finally { + setIsLoading(false); + } + }; + + // Held in a ref so the long-lived WS effect and scheduleStateInvalidateRefresh + // never close over a stale refreshStacks. + const refreshStacksRef = useRef(refreshStacks); + useEffect(() => { refreshStacksRef.current = refreshStacks; }); + + const fetchAutoUpdateSettings = async () => { + try { + const res = await apiFetch('/stacks/auto-update-settings'); + if (res.ok) { + const data = await res.json(); + setAutoUpdateSettings(data as Record); + } else { + console.error('[AutoUpdateSettings] fetch returned', res.status); + } + } catch (e: unknown) { + console.error('[AutoUpdateSettings] fetch failed:', e); + } + }; + + const handleScanStacks = async () => { + if (isScanning) return; + setIsScanning(true); + const previousStacks = [...files]; + try { + const currentStacks = await refreshStacksRef.current(); + const added = currentStacks.filter(s => !previousStacks.includes(s)); + const removed = previousStacks.filter(s => !currentStacks.includes(s)); + + if (added.length > 0) { + toast.success(`Found ${added.length} new stack${added.length !== 1 ? 's' : ''}: ${added.join(', ')}`); + } + if (removed.length > 0) { + toast.info(`${removed.length} stack${removed.length !== 1 ? 's' : ''} no longer detected: ${removed.join(', ')}`); + } + if (added.length === 0 && removed.length === 0) { + toast.info('No new stacks found.'); + } + } catch (error: unknown) { + const err = error as Record; + const data = err?.data as Record | undefined; + toast.error((err?.message as string) || (err?.error as string) || (data?.error as string) || 'Something went wrong.'); + } finally { + setIsScanning(false); + } + }; + + // Coalesce a burst of state-invalidate signals into one stack refetch. + // The 250ms debounce balances responsiveness against API thrashing. + const stateInvalidateTimerRef = useRef | null>(null); + const scheduleStateInvalidateRefresh = useCallback(() => { + if (stateInvalidateTimerRef.current) clearTimeout(stateInvalidateTimerRef.current); + stateInvalidateTimerRef.current = setTimeout(() => { + stateInvalidateTimerRef.current = null; + refreshStacksRef.current(true); + }, 250); + }, []); + + const filteredFiles = useMemo( + () => files.filter(file => file.toLowerCase().includes(searchQuery.toLowerCase())), + [files, searchQuery], + ); + + const filterCounts = useMemo(() => ({ + all: filteredFiles.length, + up: filteredFiles.filter(f => stackStatuses[f] === 'running').length, + down: filteredFiles.filter(f => stackStatuses[f] === 'exited').length, + updates: filteredFiles.filter(f => !!stackUpdates[f]).length, + }), [filteredFiles, stackStatuses, stackUpdates]); + + const chipFilteredFiles = useMemo(() => { + if (filterChip === 'all') return filteredFiles; + if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running'); + if (filterChip === 'down') return filteredFiles.filter(f => stackStatuses[f] === 'exited'); + if (filterChip === 'updates') return filteredFiles.filter(f => !!stackUpdates[f]); + return filteredFiles; + }, [filteredFiles, filterChip, stackStatuses, stackUpdates]); + + const toggleBulkMode = useCallback(() => { + setBulkMode(prev => { + if (prev) setSelectedFiles(new Set()); + return !prev; + }); + }, []); + + const toggleSelect = useCallback((file: string) => { + setSelectedFiles(prev => { + const next = new Set(prev); + if (next.has(file)) next.delete(file); + else next.add(file); + return next; + }); + }, []); + + const clearSelection = useCallback(() => { + setSelectedFiles(new Set()); + }, []); + + const handleBulkAction = useCallback((action: BulkAction) => { + const filesToAction = Array.from(selectedFiles); + runBulk(action, filesToAction, { + onAfter: () => { refreshStacksRef.current(true); clearSelection(); }, + }); + }, [selectedFiles, runBulk, clearSelection]); + + const chipFilteredFilesRef = useRef(chipFilteredFiles); + useEffect(() => { chipFilteredFilesRef.current = chipFilteredFiles; }, [chipFilteredFiles]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (isInputFocused()) return; + if (isPaletteOpen()) return; + + if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + toggleBulkMode(); + } else if (e.key === 'Escape' && bulkMode) { + e.preventDefault(); + setBulkMode(false); + setSelectedFiles(new Set()); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'a' && bulkMode) { + e.preventDefault(); + setSelectedFiles(new Set(chipFilteredFilesRef.current)); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [bulkMode, toggleBulkMode]); + + const remoteStackResults = useMemo(() => { + const out: Record> = {}; + for (const hit of remoteSearchHits) { + (out[hit.nodeId] ??= []).push({ file: hit.file, status: hit.status }); + } + return out; + }, [remoteSearchHits]); + + const remoteResults = useMemo((): RemoteResult[] => { + return Object.entries(remoteStackResults).flatMap(([nodeIdStr, remoteFiles]) => { + const node = nodes.find(n => n.id === Number(nodeIdStr)); + if (!node || remoteFiles.length === 0) return []; + return [{ + nodeId: node.id, + nodeName: node.name, + files: remoteFiles.map(({ file, status }) => ({ file, status: status as StackRowStatus })), + }]; + }); + }, [remoteStackResults, nodes]); + + return { + files, setFiles, + selectedFile, setSelectedFile, + isLoading, setIsLoading, + stackActions, stackActionsRef, + isScanning, + searchQuery, setSearchQuery, + stackStatuses, setStackStatuses, + stackPorts, setStackPorts, + labels, + stackLabelMap, + autoUpdateSettings, setAutoUpdateSettings, + filterChip, setFilterChip, + bulkMode, setBulkMode, + selectedFiles, setSelectedFiles, + filteredFiles, + filterCounts, + chipFilteredFiles, + remoteResults, + setStackAction, clearStackAction, isStackBusy, + setOptimisticStatus, + refreshLabels, + refreshStacks, + fetchAutoUpdateSettings, + handleScanStacks, + scheduleStateInvalidateRefresh, + toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, + stackUpdates, fetchImageUpdates, + pinned, pin, unpin, isPinned, + isCollapsed, toggleCollapse, + remoteSearchLoading, + } as const; +}