mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
b06dfd7175
* fix: prevent false empty states during stack hydration Only show confirmed-empty UI after successful stack, status, and container fetches. Distinguish loading and recoverable error states in the sidebar, dashboard, and container health panel. * fix: arbitrate overlapping stack status and container fetches Prevent older dashboard status and same-owner container responses from overwriting newer load state after concurrent poll, invalidation, retry, or lifecycle refresh. * fix: do not let soft status polls starve slow foreground loads Skip soft /stacks/statuses poll and invalidation while a statuses request is already in flight so a deferred foreground hydration can still commit after the ten-second cadence. * fix(stacks): surface recoverable errors for confirmed-empty soft failures Sidebar and dashboard soft (background) refresh failures after a confirmed-empty state silently kept showing the empty/adopt prompt instead of a recoverable error, since only the error message was set without flipping the load status. Also reject malformed non-array /stacks responses instead of coercing them into a confirmed-empty list, and drop malformed per-stack status entries before they reach the dashboard table, which previously crashed the entire app on a null entry. * fix(stacks): close two review-found gaps in the load-failure fix A non-empty stack-statuses map where every entry failed validation was still committed as a confirmed-empty success; it now surfaces as a recoverable error instead, and dropped entries are logged. The sidebar's background-failure helper also checked a stale closure snapshot of the file list, which could wipe a list that had just loaded non-empty in the same attempt if the follow-up statuses fetch then failed; it now tracks the freshest committed list for that decision. Also collapses two refs tracking dashboard status-map emptiness into one.
573 lines
24 KiB
TypeScript
573 lines
24 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
import { apiFetch } from '@/lib/api';
|
|
import {
|
|
newAttemptId,
|
|
abortAttempt,
|
|
beginSpan,
|
|
endSpan,
|
|
flushPendingCommit,
|
|
markMilestone,
|
|
type SpanHandle,
|
|
type PendingCommit,
|
|
} from '@/lib/hydrationTiming';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
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 type { StackUpdateInfo } from '@/types/imageUpdates';
|
|
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
|
import type { StackAction, StackActionResult } from '../EditorView';
|
|
import type { Label as StackLabel } from '../../label-types';
|
|
import type { FilterChip } from '../../sidebar/sidebar-types';
|
|
import { isDownStatus, classifyContainersStatus, isBulkStatusObjectFormat } from '../../sidebar/stack-status-utils';
|
|
import type { StackRowStatus } from '../../sidebar/stack-status-utils';
|
|
|
|
/** Compatibility path for remote nodes whose `/stacks/statuses` is absent or
|
|
* returns the legacy plain-string format: query each stack's containers and
|
|
* classify them so a degraded (partial) stack is not reported as healthy. */
|
|
async function deriveStatusesFromContainers(
|
|
fileList: string[],
|
|
): Promise<Record<string, StackRowStatus>> {
|
|
const results = await Promise.allSettled(
|
|
fileList.map(async (file) => {
|
|
const containersRes = await apiFetch(`/stacks/${file}/containers`);
|
|
if (!containersRes.ok) return { file, status: 'unknown' as StackRowStatus };
|
|
const containers = await containersRes.json();
|
|
return {
|
|
file,
|
|
status: Array.isArray(containers) ? classifyContainersStatus(containers) : 'unknown',
|
|
};
|
|
}),
|
|
);
|
|
const out: Record<string, StackRowStatus> = {};
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') out[result.value.file] = result.value.status;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface StackStatus {
|
|
[key: string]: StackRowStatus;
|
|
}
|
|
|
|
interface StackCounts {
|
|
[key: string]: { running: number; total: number } | undefined;
|
|
}
|
|
|
|
interface StackStatusInfo {
|
|
status: StackRowStatus;
|
|
mainPort?: number;
|
|
running?: number;
|
|
total?: number;
|
|
isSelf?: boolean;
|
|
}
|
|
|
|
export interface RemoteResult {
|
|
nodeId: number;
|
|
nodeName: string;
|
|
files: Array<{ file: string; status: StackRowStatus }>;
|
|
}
|
|
|
|
const EMPTY_UPDATES: Record<string, StackUpdateInfo> = {};
|
|
|
|
export type StacksLoadStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
|
|
export function useStackListState() {
|
|
const { nodes, activeNode } = useNodes();
|
|
|
|
const [files, setFiles] = useState<string[]>([]);
|
|
// Node the current `files` list belongs to (null = local). Stamped together
|
|
// with `files` from the node active when the fetch started, so a consumer can
|
|
// tell whether the list it is reading is the one it expects, even during the
|
|
// async gap right after a node switch when `files` still holds the old node's
|
|
// entries. Filenames repeat across nodes, so a name lookup against the wrong
|
|
// list would resolve to the wrong file.
|
|
const [filesNodeId, setFilesNodeId] = useState<number | null>(null);
|
|
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>>({});
|
|
// Monotonic token per refreshStacks call; lets a superseded fetch skip its
|
|
// state writes so a rapid node switch cannot leave a stale files/filesNodeId.
|
|
const fetchSeqRef = useRef(0);
|
|
|
|
// Hydration-timing: the current foreground list attempt and the commits it is
|
|
// waiting for React to observe. Only foreground loads arm these; background
|
|
// refreshes still record diagnostic spans but do not re-commit the milestones.
|
|
const listAttemptRef = useRef<string | null>(null);
|
|
const listVisiblePendingRef = useRef<PendingCommit | null>(null);
|
|
const listHydratedPendingRef = useRef<PendingCommit | null>(null);
|
|
|
|
// Per-stack terminal failure records driving the in-detail recovery panel.
|
|
// In-memory only. Node scoping is enforced by the caller, which clears these
|
|
// on active-node change (see EditorLayout's node-switch effect) so a repeated
|
|
// stack filename cannot carry a failure across nodes.
|
|
const [lastActionResult, setLastActionResult] = useState<Record<string, StackActionResult>>({});
|
|
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
|
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
|
|
const [stackSelfFlags, setStackSelfFlags] = useState<Record<string, boolean>>({});
|
|
const [stackCounts, setStackCounts] = useState<StackCounts>({});
|
|
const [labels, setLabels] = useState<StackLabel[]>([]);
|
|
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
|
const [filterChip, setFilterChip] = useState<FilterChip>('all');
|
|
const [bulkMode, setBulkMode] = useState(false);
|
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
|
const [stacksLoadStatus, setStacksLoadStatus] = useState<StacksLoadStatus>('idle');
|
|
const [stacksLoadError, setStacksLoadError] = useState<string | null>(null);
|
|
const [stacksLoadNodeId, setStacksLoadNodeId] = useState<number | null>(null);
|
|
const hadSuccessfulListRef = useRef(false);
|
|
|
|
const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id);
|
|
const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES;
|
|
const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id);
|
|
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
|
|
const { runBulk } = useBulkStackActions();
|
|
|
|
const { hits: remoteSearchHits, failedNodes: remoteSearchFailedNodes, loading: remoteSearchLoading } = useCrossNodeStackSearch({
|
|
query: searchQuery,
|
|
enabled: true,
|
|
excludeNodeId: activeNode?.id,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).');
|
|
}, [evictedOldest]);
|
|
|
|
useEffect(() => {
|
|
hadSuccessfulListRef.current = false;
|
|
setStacksLoadStatus('idle');
|
|
setStacksLoadError(null);
|
|
}, [activeNode?.id]);
|
|
|
|
// Ref is updated synchronously alongside the state setter so any code that
|
|
// runs right after (e.g. `refreshStacks(true)` in an action's finally block)
|
|
// observes the cleared map before React commits the next render. Without
|
|
// this, the busy-stack check inside refreshStacks would still flag the
|
|
// stack as in-progress and preserve the optimistic status mask.
|
|
const setStackAction = (stackFile: string, action: StackAction) => {
|
|
const next = { ...stackActionsRef.current, [stackFile]: action };
|
|
stackActionsRef.current = next;
|
|
setStackActions(next);
|
|
};
|
|
const clearStackAction = (stackFile: string) => {
|
|
const next = { ...stackActionsRef.current };
|
|
delete next[stackFile];
|
|
stackActionsRef.current = next;
|
|
setStackActions(next);
|
|
};
|
|
const isStackBusy = useCallback((stackFile: string) => stackFile in stackActionsRef.current, []);
|
|
|
|
const setOptimisticStatus = (stackFile: string, status: 'running' | 'exited') => {
|
|
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
|
|
};
|
|
|
|
// Recovery record lifecycle. recordActionFailure stores a terminal failure;
|
|
// recordActionSuccess / dismissActionResult drop it; clearActionRecords wipes
|
|
// all (node switch). The recovery panel itself renders only when the stack is
|
|
// not mid-operation, so a stale record never shows during a retry.
|
|
const clearStackResult = useCallback((stackFile: string) => {
|
|
setLastActionResult(prev => {
|
|
if (!(stackFile in prev)) return prev;
|
|
const next = { ...prev };
|
|
delete next[stackFile];
|
|
return next;
|
|
});
|
|
}, []);
|
|
const recordActionFailure = useCallback((stackFile: string, result: StackActionResult) => {
|
|
setLastActionResult(prev => ({ ...prev, [stackFile]: result }));
|
|
}, []);
|
|
const recordActionSuccess = clearStackResult;
|
|
const dismissActionResult = clearStackResult;
|
|
const clearActionRecords = useCallback(() => {
|
|
setLastActionResult({});
|
|
}, []);
|
|
|
|
const refreshLabels = useCallback(async () => {
|
|
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
|
|
}
|
|
}, []);
|
|
|
|
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[]> => {
|
|
const fetchNodeId = activeNode?.id ?? null;
|
|
const mySeq = ++fetchSeqRef.current;
|
|
const stale = () => fetchSeqRef.current !== mySeq;
|
|
|
|
// Supersede any in-flight list attempt so a late commit from an interrupted
|
|
// load cannot record list_visible / list_hydrated for a stale fetch.
|
|
if (listAttemptRef.current) abortAttempt(listAttemptRef.current);
|
|
listVisiblePendingRef.current = null;
|
|
listHydratedPendingRef.current = null;
|
|
|
|
const attemptId = newAttemptId();
|
|
listAttemptRef.current = attemptId;
|
|
// True once the list itself is committed, so the shared catch below can tell
|
|
// a list-fetch failure (nothing visible) from a status-path failure (list is
|
|
// visible, hydration errored).
|
|
let listSucceeded = false;
|
|
let proxied = false;
|
|
|
|
if (!background) setIsLoading(true);
|
|
setStacksLoadNodeId(fetchNodeId);
|
|
if (!background || !hadSuccessfulListRef.current) {
|
|
setStacksLoadStatus('loading');
|
|
setStacksLoadError(null);
|
|
}
|
|
|
|
// Tracks the most recently committed list for this attempt: `files` (the
|
|
// render-time closure) is stale once the list itself has just succeeded
|
|
// within this same call, e.g. the list decodes fine but the follow-up
|
|
// /stacks/statuses decode then throws. Seeded from `files` so a failure
|
|
// that happens before the list ever loads still consults the prior state.
|
|
let latestFileList = files;
|
|
|
|
// Soft (background) failure keeps a non-empty list visible, matching the
|
|
// soft-failure handling in applyContainersFetchFailure (useStackActions.ts).
|
|
// A list that was already confirmed empty must not stay masquerading as
|
|
// empty: it becomes a recoverable error instead, since a soft failure is
|
|
// otherwise indistinguishable from "still no stacks".
|
|
const applyStacksFailure = (message: string): string[] => {
|
|
if (background && hadSuccessfulListRef.current && latestFileList.length > 0) {
|
|
setStacksLoadError(message);
|
|
return latestFileList;
|
|
}
|
|
setFiles([]);
|
|
setFilesNodeId(fetchNodeId);
|
|
setStacksLoadStatus('error');
|
|
setStacksLoadError(message);
|
|
return [];
|
|
};
|
|
|
|
const headersSpan = beginSpan('fetch_headers', { attemptId, background });
|
|
let bodySpan: SpanHandle | null = null;
|
|
try {
|
|
const res = await apiFetch('/stacks');
|
|
proxied = res.headers.get('x-sencho-proxy') === '1';
|
|
endSpan(headersSpan, { proxied, detail: { status: res.status } });
|
|
if (stale()) { abortAttempt(attemptId); return []; }
|
|
if (!res.ok) {
|
|
return applyStacksFailure(`Could not load stacks (${res.status})`);
|
|
}
|
|
bodySpan = beginSpan('body_decode', { attemptId, background, proxied });
|
|
const data = await res.json();
|
|
endSpan(bodySpan);
|
|
bodySpan = null;
|
|
if (!Array.isArray(data)) {
|
|
return applyStacksFailure('Stack list response was invalid.');
|
|
}
|
|
const fileList: string[] = data;
|
|
latestFileList = fileList;
|
|
const listDispatch = beginSpan('state_dispatch', { attemptId, background, proxied });
|
|
setFiles(fileList);
|
|
setFilesNodeId(fetchNodeId);
|
|
hadSuccessfulListRef.current = true;
|
|
setStacksLoadStatus('success');
|
|
setStacksLoadError(null);
|
|
endSpan(listDispatch);
|
|
listSucceeded = true;
|
|
// Token folds node + count so an empty->empty commit still fires once per
|
|
// attempt even when the committed `files` is referentially equal.
|
|
const listToken = `${fetchNodeId}:${fileList.length}`;
|
|
if (!background) {
|
|
listVisiblePendingRef.current = { attemptId, token: listToken, proxied };
|
|
}
|
|
|
|
// Fetch all stack statuses in a single bulk call. Only the current object
|
|
// format can express `partial`; a node lacking the endpoint or returning
|
|
// the legacy plain-string format is re-derived from per-stack containers
|
|
// so a crashed container is not hidden behind a healthy sibling.
|
|
const statusHeaders = beginSpan('fetch_headers', { attemptId, background, proxied });
|
|
const statusRes = await apiFetch('/stacks/statuses');
|
|
const statusProxied = statusRes.headers.get('x-sencho-proxy') === '1' || proxied;
|
|
endSpan(statusHeaders, { proxied: statusProxied, detail: { status: statusRes.status } });
|
|
if (stale()) { abortAttempt(attemptId); return fileList; }
|
|
let bulkStatuses: Record<string, StackRowStatus> = {};
|
|
const bulkPorts: Record<string, number | undefined> = {};
|
|
const bulkSelf: Record<string, boolean> = {};
|
|
const bulkCounts: StackCounts = {};
|
|
|
|
let raw: unknown = null;
|
|
if (statusRes.ok) {
|
|
const statusBodySpan = beginSpan('body_decode', { attemptId, background, proxied: statusProxied });
|
|
try {
|
|
raw = await statusRes.json();
|
|
endSpan(statusBodySpan);
|
|
} catch (decodeErr) {
|
|
endSpan(statusBodySpan, { outcome: 'error' });
|
|
throw decodeErr;
|
|
}
|
|
}
|
|
if (isBulkStatusObjectFormat(raw)) {
|
|
for (const [key, val] of Object.entries(raw as Record<string, StackStatusInfo>)) {
|
|
bulkStatuses[key] = val.status;
|
|
if (val.mainPort) bulkPorts[key] = val.mainPort;
|
|
if (val.isSelf) bulkSelf[key] = true;
|
|
if (val.running !== undefined && val.total !== undefined) {
|
|
bulkCounts[key] = { running: val.running, total: val.total };
|
|
}
|
|
}
|
|
} else {
|
|
bulkStatuses = await deriveStatusesFromContainers(fileList);
|
|
}
|
|
const statusDispatch = beginSpan('state_dispatch', { attemptId, background, proxied: statusProxied });
|
|
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;
|
|
});
|
|
setStackSelfFlags(bulkSelf);
|
|
setStackCounts(bulkCounts);
|
|
endSpan(statusDispatch);
|
|
refreshLabels();
|
|
if (!background) {
|
|
listHydratedPendingRef.current = { attemptId, token: listToken, proxied: statusProxied };
|
|
}
|
|
return fileList;
|
|
} catch (error) {
|
|
// endSpan is a no-op when the span was already closed (or never opened).
|
|
endSpan(headersSpan, { outcome: 'error' });
|
|
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
|
|
if (stale()) { abortAttempt(attemptId); return []; }
|
|
console.error('Failed to refresh stacks:', error);
|
|
const message = error instanceof Error ? error.message : 'Failed to load stacks';
|
|
// The list committed but hydrating its statuses threw: record the list
|
|
// path as hydrated-with-error rather than leaving it hanging.
|
|
if (listSucceeded && !background) {
|
|
markMilestone('list_hydrated', { attemptId, outcome: 'error', proxied });
|
|
}
|
|
return applyStacksFailure(message);
|
|
} 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; });
|
|
|
|
// Commit-aligned list milestones: fire once React has actually committed the
|
|
// file list (list_visible) and the statuses (list_hydrated) for the owning
|
|
// attempt. commitMilestone no-ops for a superseded/aborted attempt, so a stale
|
|
// load can never complete a session it no longer owns. Empty lists still fire
|
|
// via the completion token.
|
|
useEffect(() => {
|
|
if (stacksLoadStatus !== 'success') return;
|
|
flushPendingCommit(listVisiblePendingRef, 'list_visible');
|
|
}, [files, filesNodeId, stacksLoadStatus]);
|
|
|
|
useEffect(() => {
|
|
flushPendingCommit(listHydratedPendingRef, 'list_hydrated');
|
|
}, [stackStatuses, filesNodeId]);
|
|
|
|
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 => isDownStatus(stackStatuses[f])).length,
|
|
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
|
|
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
|
|
|
|
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 => isDownStatus(stackStatuses[f]));
|
|
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
|
|
return filteredFiles;
|
|
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
|
|
|
|
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);
|
|
if (action === 'update') void fetchImageUpdates();
|
|
clearSelection();
|
|
},
|
|
});
|
|
}, [selectedFiles, runBulk, clearSelection, fetchImageUpdates]);
|
|
|
|
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: StackRowStatus }>> = {};
|
|
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]);
|
|
|
|
// When the sidebar indicator toggle is turned off, reset an active Updates
|
|
// filter to 'all' so the user is not stuck in a filter that shows nothing.
|
|
useEffect(() => {
|
|
if (!sidebarIndicators && filterChip === 'updates') {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setFilterChip('all');
|
|
}
|
|
}, [sidebarIndicators, filterChip]);
|
|
|
|
return {
|
|
files, setFiles, filesNodeId,
|
|
selectedFile, setSelectedFile,
|
|
isLoading, setIsLoading,
|
|
stackActions, stackActionsRef,
|
|
isScanning,
|
|
searchQuery, setSearchQuery,
|
|
stackStatuses, setStackStatuses,
|
|
stackPorts, setStackPorts,
|
|
stackSelfFlags,
|
|
stackCounts,
|
|
labels,
|
|
stackLabelMap,
|
|
filterChip, setFilterChip,
|
|
bulkMode, setBulkMode,
|
|
selectedFiles, setSelectedFiles,
|
|
filteredFiles,
|
|
filterCounts,
|
|
chipFilteredFiles,
|
|
remoteResults,
|
|
setStackAction, clearStackAction, isStackBusy,
|
|
setOptimisticStatus,
|
|
lastActionResult,
|
|
recordActionFailure, recordActionSuccess, clearActionRecords, dismissActionResult,
|
|
refreshLabels,
|
|
refreshStacks,
|
|
handleScanStacks,
|
|
scheduleStateInvalidateRefresh,
|
|
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
|
stackUpdates, fetchImageUpdates,
|
|
sidebarIndicators, sidebarStackUpdates,
|
|
pinned, pin, unpin, isPinned,
|
|
isCollapsed, toggleCollapse,
|
|
remoteSearchLoading,
|
|
remoteSearchFailedNodes,
|
|
stacksLoadStatus,
|
|
stacksLoadError,
|
|
stacksLoadNodeId,
|
|
} as const;
|
|
}
|