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.
This commit is contained in:
Anso
2026-05-03 18:34:32 -04:00
committed by GitHub
parent a85fbd5265
commit d5393a6027
2 changed files with 394 additions and 314 deletions
+37 -314
View File
@@ -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<number | null>(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<Map<number, () => void>>(new Map());
const [files, setFiles] = useState<string[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(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<Record<string, {
@@ -144,23 +159,6 @@ export default function EditorLayout() {
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
const stackActionsRef = useRef<Record<string, StackAction>>({});
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<Theme>(() => {
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<StackStatus>({});
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
// 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<Record<string, boolean>>({});
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<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>> = {};
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<string[]> => {
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<string, 'running' | 'exited' | 'unknown'> | null = null;
const bulkPorts: Record<string, number | undefined> = {};
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<string, unknown>;
const data = err?.data as Record<string, unknown> | 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<ReturnType<typeof setTimeout> | 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<NotificationItem, 'nodeId' | 'nodeName'>[];
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<string, boolean>);
} 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<FilterChip>('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<Set<string>>(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 {
@@ -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<string[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
const stackActionsRef = useRef<Record<string, StackAction>>({});
stackActionsRef.current = stackActions;
const [isScanning, setIsScanning] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
const [filterChip, setFilterChip] = useState<FilterChip>('all');
const [bulkMode, setBulkMode] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(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<string[]> => {
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<string, 'running' | 'exited' | 'unknown'> | null = null;
const bulkPorts: Record<string, number | undefined> = {};
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<string, boolean>);
} 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<string, unknown>;
const data = err?.data as Record<string, unknown> | 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<ReturnType<typeof setTimeout> | 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<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>> = {};
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;
}