diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 5683a83c..31ed3e2c 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -210,6 +210,8 @@ The same links button appears on update cards in [Auto-Update Policies](/feature Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" When the stack has multiple containers, a summary strip appears above the list showing total, running, paused, and unhealthy counts, along with a **Compact / Detailed** toggle. Compact mode shows status, name, uptime, port, and action buttons; detailed mode (the default) adds CPU, memory, and network I/O sparklines. +While the stack detail stays open, Sencho keeps those rows aligned with Docker: health transitions and container lifecycle changes update the cards without reloading the compose file, environment files, or logs. If automatic refresh cannot reach the node for a stretch, a small stale notice appears with a Retry action so you can pull state manually. + Container card for the plex service showing health badge, uptime, port mapping, open link, action icons, and CPU, memory, and network stat tiles diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index be3611eb..24aa9a43 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -17,6 +17,7 @@ import { useUrlSync } from './EditorLayout/hooks/useUrlSync'; import { shouldClearPendingDetailStack } from './EditorLayout/mobile-pending-detail'; import { useOverlayState } from './EditorLayout/hooks/useOverlayState'; import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions'; +import { useSelectedStackLiveRefresh } from './EditorLayout/hooks/useSelectedStackLiveRefresh'; import { useTheme } from '@/hooks/use-theme'; import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch'; import { useNotifications } from './EditorLayout/hooks/useNotifications'; @@ -296,6 +297,16 @@ export default function EditorLayout() { // Wire the ref now that stackActions is available resetEditorStateRef.current = stackActions.resetEditorState; + const { syncStale: containersSyncStale, retrySync: retryContainersSync } = useSelectedStackLiveRefresh({ + selectedFile, + activeNodeId: activeNode?.id, + isDetailVisible: activeView === 'editor', + containers, + composeContent: content, + containersLoadStatus, + refreshSelectedContainers: stackActions.refreshSelectedContainers, + }); + // A failed health gate routes into the existing recovery affordance: record // a failure for the stack so RecoveryChip/RecoveryPanel offer the same // explicit, user-confirmed rollback as any failed operation. Keyed by gate @@ -623,6 +634,8 @@ export default function EditorLayout() { containersLoadStatus={containersLoadStatus} containersLoadError={containersLoadError} onRetryContainersLoad={() => { void stackActions.retryContainersLoad(); }} + containersSyncStale={containersSyncStale} + onRetrySync={retryContainersSync} containerStats={containerStats} containerStatsError={containerStatsError} content={content} @@ -684,10 +697,10 @@ export default function EditorLayout() { onRefreshState={async () => { if (!selectedFile) return; const name = selectedFile.replace(/\.(yml|yaml)$/, ''); - const ok = await stackActions.refreshSelectedContainers(name, selectedFile); + const outcome = await stackActions.refreshSelectedContainers(name, selectedFile); await refreshStacks(true); - if (ok) toast.success('Refreshed container state.'); - else toast.error('Could not refresh container state.'); + if (outcome === 'ok') toast.success('Refreshed container state.'); + else if (outcome === 'failed') toast.error('Could not refresh container state.'); }} onDismissRecovery={() => { if (selectedFile) dismissActionResult(selectedFile); }} panelStartedAt={panelStartedAt} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index d2ad501b..36dc7f61 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -138,6 +138,9 @@ export interface EditorViewProps { containersLoadStatus?: 'idle' | 'loading' | 'success' | 'error'; containersLoadError?: string | null; onRetryContainersLoad?: () => void; + /** Live-refresh soft failures exhausted; advisory when container cards are shown. */ + containersSyncStale?: boolean; + onRetrySync?: () => void; backupInfo: { exists: boolean; timestamp: number | null }; gitSourcePendingMap: Record; notifications: NotificationItem[]; @@ -252,6 +255,8 @@ export function EditorView(props: EditorViewProps) { containersLoadStatus = 'success', containersLoadError = null, onRetryContainersLoad, + containersSyncStale = false, + onRetrySync, backupInfo, gitSourcePendingMap, notifications, @@ -500,6 +505,8 @@ export function EditorView(props: EditorViewProps) { containersLoadStatus={containersLoadStatus} containersLoadError={containersLoadError} onRetryContainersLoad={onRetryContainersLoad} + syncStale={containersSyncStale} + onRetrySync={onRetrySync} key={`${activeNode?.id ?? 'local'}:${stackName}`} /> @@ -523,6 +530,8 @@ export function EditorView(props: EditorViewProps) { containersLoadStatus={containersLoadStatus} containersLoadError={containersLoadError} onRetryContainersLoad={onRetryContainersLoad} + syncStale={containersSyncStale} + onRetrySync={onRetrySync} key={`${activeNode?.id ?? 'local'}:${stackName}`} /> diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index c2d409dd..2a53b8d8 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -241,6 +241,8 @@ export function MobileStackDetail(props: EditorViewProps) { containersLoadStatus={props.containersLoadStatus} containersLoadError={props.containersLoadError} onRetryContainersLoad={props.onRetryContainersLoad} + syncStale={props.containersSyncStale} + onRetrySync={props.onRetrySync} key={`${activeNode?.id ?? 'local'}:${stackName}`} /> diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx index 071de52d..06db972c 100644 --- a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -625,3 +625,55 @@ describe('ContainersHealth Docker health status labels', () => { expect(screen.getByRole('link', { name: /8080/ })).toBeInTheDocument(); }); }); + +describe('live-refresh stale chip', () => { + it('shows stale chip with Retry when syncStale and cards are visible', () => { + const onRetrySync = vi.fn(); + render( + , + ); + expect(screen.getByText(/Container state may be stale/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Retry/i })); + expect(onRetrySync).toHaveBeenCalledTimes(1); + }); + + it('suppresses stale chip when containersLoadStatus is error', () => { + render( + , + ); + expect(screen.queryByText(/Container state may be stale/i)).toBeNull(); + expect(screen.getByText(/Could not load containers/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index 84883190..d82fa62c 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -334,6 +334,13 @@ export interface ContainersHealthProps { containersLoadStatus?: 'idle' | 'loading' | 'success' | 'error'; containersLoadError?: string | null; onRetryContainersLoad?: () => void; + /** + * Soft live-refresh failures exhausted. Shown only when container cards are + * visible (containersLoadStatus === 'success'). When status is 'error', the + * existing error card Retry is sufficient and this chip is suppressed. + */ + syncStale?: boolean; + onRetrySync?: () => void; } // Per-container health strip: status badge, uptime, ports, and CPU/Mem/Net @@ -357,6 +364,8 @@ export function ContainersHealth({ containersLoadStatus = 'success', containersLoadError = null, onRetryContainersLoad, + syncStale = false, + onRetrySync, }: ContainersHealthProps) { // Multi-service only: a single-service stack keeps the existing flat layout // untouched, including its per-container Start/Stop/Restart kebab. @@ -723,6 +732,17 @@ export function ContainersHealth({ )} + {syncStale && onRetrySync && ( +
+ + Container state may be stale + + +
+ )} {densityToolbar} {isMultiService ? (
diff --git a/frontend/src/components/EditorLayout/hooks/useSelectedStackLiveRefresh.test.tsx b/frontend/src/components/EditorLayout/hooks/useSelectedStackLiveRefresh.test.tsx new file mode 100644 index 00000000..b47d6e3e --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useSelectedStackLiveRefresh.test.tsx @@ -0,0 +1,520 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +const visibilityCleanups: Array<() => void> = []; +const visibilityFns: Array<() => void> = []; + +vi.mock('@/lib/utils', async () => { + const actual = await vi.importActual('@/lib/utils'); + return { + ...actual, + visibilityInterval: (fn: () => void) => { + visibilityFns.push(fn); + const cleanup = () => { + const idx = visibilityFns.indexOf(fn); + if (idx >= 0) visibilityFns.splice(idx, 1); + }; + visibilityCleanups.push(cleanup); + return cleanup; + }, + }; +}); + +import { + useSelectedStackLiveRefresh, + shouldRefreshForInvalidate, + parseComposeProjectName, + containerIdMatches, + INVALIDATE_DEBOUNCE_MS, + STALE_FAILURE_THRESHOLD, + type SoftRefreshOutcome, +} from './useSelectedStackLiveRefresh'; +import type { ContainerInfo } from '../EditorView'; + +function fireInvalidate(detail: Record) { + window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail })); +} + +function container(id: string, overrides: Partial = {}): ContainerInfo { + return { + Id: id, + Names: [`/${id}`], + State: 'running', + healthStatus: 'healthy', + ...overrides, + }; +} + +describe('parseComposeProjectName', () => { + it('reads top-level name', () => { + expect(parseComposeProjectName('name: custom-proj\nservices:\n web:\n image: nginx\n')).toBe('custom-proj'); + }); + + it('returns null when name is absent', () => { + expect(parseComposeProjectName('services:\n web:\n image: nginx\n')).toBeNull(); + }); +}); + +describe('containerIdMatches', () => { + it('matches short list id against full event id', () => { + const short = 'abcdef012345'; + const full = `${short}${'f'.repeat(52)}`; + expect(containerIdMatches(new Set([short]), full)).toBe(true); + expect(containerIdMatches(new Set([full]), short)).toBe(true); + }); + + it('rejects unrelated ids', () => { + expect(containerIdMatches(new Set(['abcdef012345']), 'ffffffffffff')).toBe(false); + }); +}); + +describe('shouldRefreshForInvalidate', () => { + const base = { + activeNodeId: 1, + selectedBasename: 'web', + composeProjectName: null as string | null, + learnedAliases: new Set(), + containerIds: new Set(), + }; + + it('requires scope stack and matching nodeId', () => { + expect(shouldRefreshForInvalidate({ scope: 'image-updates', nodeId: 1 }, base)).toBe(false); + expect(shouldRefreshForInvalidate({ scope: 'stack', nodeId: 2 }, base)).toBe(false); + }); + + it('matches basename case-sensitively', () => { + expect(shouldRefreshForInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }, base)).toBe(true); + expect(shouldRefreshForInvalidate({ scope: 'stack', nodeId: 1, stackName: 'Web' }, base)).toBe(false); + }); + + it('matches compose project name and container ids (including prefix)', () => { + expect(shouldRefreshForInvalidate( + { scope: 'stack', nodeId: 1, stackName: 'custom' }, + { ...base, composeProjectName: 'custom' }, + )).toBe(true); + const short = 'abcdef012345'; + const full = `${short}${'0'.repeat(52)}`; + expect(shouldRefreshForInvalidate( + { scope: 'stack', nodeId: 1, stackName: 'other', containerId: full }, + { ...base, containerIds: new Set([short]) }, + )).toBe(true); + }); + + it('falls back when identity is unproven', () => { + expect(shouldRefreshForInvalidate({ scope: 'stack', nodeId: 1, stackName: null }, base)).toBe(true); + expect(shouldRefreshForInvalidate({ scope: 'stack', nodeId: 1 }, base)).toBe(true); + }); + + it('ignores other project names', () => { + expect(shouldRefreshForInvalidate({ scope: 'stack', nodeId: 1, stackName: 'other' }, base)).toBe(false); + }); +}); + +describe('useSelectedStackLiveRefresh', () => { + let refreshMock: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + refreshMock = vi.fn().mockResolvedValue('ok' satisfies SoftRefreshOutcome); + visibilityFns.length = 0; + visibilityCleanups.length = 0; + }); + + afterEach(() => { + for (const c of [...visibilityCleanups]) c(); + visibilityCleanups.length = 0; + visibilityFns.length = 0; + vi.useRealTimers(); + }); + + function renderLive(overrides: Partial<{ + selectedFile: string | null; + activeNodeId: number | undefined; + isDetailVisible: boolean; + containers: ContainerInfo[]; + composeContent: string; + containersLoadStatus: 'idle' | 'loading' | 'success' | 'error'; + }> = {}) { + return renderHook( + (props) => useSelectedStackLiveRefresh({ + selectedFile: props.selectedFile, + activeNodeId: props.activeNodeId, + isDetailVisible: props.isDetailVisible, + containers: props.containers, + composeContent: props.composeContent, + containersLoadStatus: props.containersLoadStatus, + refreshSelectedContainers: refreshMock as ( + n: string, + f: string, + ) => Promise, + }), + { + initialProps: { + selectedFile: 'web.yml' as string | null, + activeNodeId: 1 as number | undefined, + isDetailVisible: true, + containers: [container('c1')] as ContainerInfo[], + composeContent: 'services:\n web:\n image: nginx\n', + containersLoadStatus: 'success' as const, + ...overrides, + }, + }, + ); + } + + it('debounces a burst of matching invalidates into one soft refresh', async () => { + renderLive(); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web', action: 'health_status' }); + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web', action: 'start' }); + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web', action: 'die' }); + }); + + expect(refreshMock).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(refreshMock).toHaveBeenCalledWith('web', 'web.yml'); + }); + + it('ignores event stackName that differs from basename only in case', async () => { + renderLive(); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'Web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).not.toHaveBeenCalled(); + }); + + it('queues at most one trailing refresh while in flight', async () => { + let resolveRefresh: (v: SoftRefreshOutcome) => void = () => {}; + refreshMock.mockImplementation(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + + renderLive(); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveRefresh('ok'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(2); + }); + + it('poll on visible while debounced invalidate pending collapses via serialization', async () => { + let resolveRefresh: (v: SoftRefreshOutcome) => void = () => {}; + refreshMock.mockImplementation(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + + renderLive(); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + expect(visibilityFns.length).toBeGreaterThan(0); + act(() => { + visibilityFns[0](); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveRefresh('ok'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(2); + }); + + it('does not trailing-refresh after stack switch mid-flight when no trailing was queued', async () => { + let resolveRefresh: (v: SoftRefreshOutcome) => void = () => {}; + refreshMock.mockImplementation(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + + const { rerender } = renderLive(); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + + rerender({ + selectedFile: 'other.yml', + activeNodeId: 1, + isDetailVisible: true, + containers: [], + composeContent: '', + containersLoadStatus: 'success', + }); + + await act(async () => { + resolveRefresh('ok'); + await Promise.resolve(); + await Promise.resolve(); + }); + // No trailing was queued, so selection change alone does not fire another refresh. + expect(refreshMock).toHaveBeenCalledTimes(1); + }); + + it('refreshes the new selection when a trailing event was queued during stack switch', async () => { + let resolveRefresh: (v: SoftRefreshOutcome) => void = () => {}; + refreshMock.mockImplementation(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + + const { rerender } = renderLive(); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + + // Switch stacks while the first soft refresh is still in flight. + rerender({ + selectedFile: 'other.yml', + activeNodeId: 1, + isDetailVisible: true, + containers: [], + composeContent: '', + containersLoadStatus: 'success', + }); + + // Event for the new stack arrives before the old request completes. + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'other' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveRefresh('ok'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(2); + expect(refreshMock).toHaveBeenLastCalledWith('other', 'other.yml'); + }); + + it('stops polling and ignores invalidates when stack detail is not visible', async () => { + const { rerender } = renderLive(); + await act(async () => { await Promise.resolve(); }); + expect(visibilityFns.length).toBe(1); + refreshMock.mockClear(); + + rerender({ + selectedFile: 'web.yml', + activeNodeId: 1, + isDetailVisible: false, + containers: [container('c1')], + composeContent: 'services:\n web:\n image: nginx\n', + containersLoadStatus: 'success', + }); + expect(visibilityFns.length).toBe(0); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web', action: 'health_status' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).not.toHaveBeenCalled(); + }); + + it('does not register a poll interval when detail starts hidden', async () => { + renderLive({ isDetailVisible: false }); + await act(async () => { await Promise.resolve(); }); + expect(visibilityFns.length).toBe(0); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).not.toHaveBeenCalled(); + }); + + it('sets syncStale after consecutive soft failures including confirmed-empty', async () => { + refreshMock.mockResolvedValue('failed'); + const { result, rerender } = renderLive({ containers: [] }); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + for (let i = 0; i < STALE_FAILURE_THRESHOLD; i += 1) { + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + expect(refreshMock).toHaveBeenCalledTimes(STALE_FAILURE_THRESHOLD); + expect(result.current.syncStale).toBe(true); + + rerender({ + selectedFile: 'web.yml', + activeNodeId: 1, + isDetailVisible: true, + containers: [], + composeContent: '', + containersLoadStatus: 'error', + }); + expect(result.current.syncStale).toBe(true); + }); + + it('does not count skipped arbitration outcomes toward syncStale', async () => { + refreshMock.mockResolvedValue('skipped'); + const { result } = renderLive(); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + for (let i = 0; i < STALE_FAILURE_THRESHOLD + 2; i += 1) { + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + await Promise.resolve(); + }); + } + expect(result.current.syncStale).toBe(false); + }); + + it('clears syncStale when same-id health fingerprint changes', async () => { + refreshMock.mockResolvedValue('failed'); + const { result, rerender } = renderLive({ + containers: [container('c1', { healthStatus: 'starting' })], + }); + await act(async () => { await Promise.resolve(); }); + + for (let i = 0; i < STALE_FAILURE_THRESHOLD; i += 1) { + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + await Promise.resolve(); + }); + } + expect(result.current.syncStale).toBe(true); + + rerender({ + selectedFile: 'web.yml', + activeNodeId: 1, + isDetailVisible: true, + containers: [container('c1', { healthStatus: 'healthy' })], + composeContent: 'services:\n web:\n image: nginx\n', + containersLoadStatus: 'success', + }); + expect(result.current.syncStale).toBe(false); + }); + + it('matches custom compose name: alias', async () => { + renderLive({ + composeContent: 'name: custom-proj\nservices:\n web:\n image: nginx\n', + }); + await act(async () => { await Promise.resolve(); }); + refreshMock.mockClear(); + + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'custom-proj', action: 'health_status' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + }); + + it('retrySync clears stale and requests a refresh', async () => { + refreshMock.mockResolvedValue('failed'); + const { result } = renderLive(); + await act(async () => { await Promise.resolve(); }); + + for (let i = 0; i < STALE_FAILURE_THRESHOLD; i += 1) { + act(() => { + fireInvalidate({ scope: 'stack', nodeId: 1, stackName: 'web' }); + }); + await act(async () => { + vi.advanceTimersByTime(INVALIDATE_DEBOUNCE_MS); + await Promise.resolve(); + await Promise.resolve(); + }); + } + expect(result.current.syncStale).toBe(true); + refreshMock.mockClear(); + refreshMock.mockResolvedValue('ok'); + + await act(async () => { + result.current.retrySync(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(result.current.syncStale).toBe(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useSelectedStackLiveRefresh.ts b/frontend/src/components/EditorLayout/hooks/useSelectedStackLiveRefresh.ts new file mode 100644 index 00000000..21899b58 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useSelectedStackLiveRefresh.ts @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { parse as parseYaml } from 'yaml'; +import { visibilityInterval } from '@/lib/utils'; +import type { ContainerInfo } from '../EditorView'; + +/** Trailing-edge debounce for state-invalidate, matches useDashboardData. */ +export const INVALIDATE_DEBOUNCE_MS = 250; +export const POLL_INTERVAL_MS = 10_000; +export const STALE_FAILURE_THRESHOLD = 3; + +export type SoftRefreshOutcome = 'ok' | 'skipped' | 'failed'; + +export type StateInvalidateDetail = { + type?: string; + scope?: string; + nodeId?: number | null; + stackName?: string | null; + containerId?: string | null; + action?: string; + ts?: number; +}; + +export type UseSelectedStackLiveRefreshArgs = { + selectedFile: string | null; + activeNodeId: number | undefined; + /** False when activeView is not the stack editor (e.g. Security, Fleet). */ + isDetailVisible: boolean; + containers: ContainerInfo[]; + composeContent: string; + containersLoadStatus: 'idle' | 'loading' | 'success' | 'error'; + refreshSelectedContainers: (stackName: string, stackFile: string) => Promise; +}; + +export type UseSelectedStackLiveRefreshResult = { + syncStale: boolean; + retrySync: () => void; +}; + +function stackBasename(stackFile: string): string { + return stackFile.replace(/\.(yml|yaml)$/i, ''); +} + +/** Parse top-level Compose `name:` once per content snapshot. */ +export function parseComposeProjectName(content: string): string | null { + if (!content.trim()) return null; + try { + const parsed = parseYaml(content) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const name = (parsed as Record).name; + if (typeof name !== 'string') return null; + const trimmed = name.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + // Invalid YAML: no project alias from content. + } + return null; +} + +/** Match Docker event IDs (often full 64-char) against list IDs (often short). */ +export function containerIdMatches(ids: ReadonlySet, eventId: string): boolean { + if (ids.has(eventId)) return true; + if (eventId.length < 12) return false; + for (const id of ids) { + if (id.length < 12) continue; + if (id.startsWith(eventId) || eventId.startsWith(id)) return true; + } + return false; +} + +function containersFingerprint(containers: ContainerInfo[]): string { + return containers + .map((c) => `${c.Id}:${c.State}:${c.healthStatus ?? ''}`) + .join('\0'); +} + +/** + * Decide whether a stack-scoped invalidate should soft-refresh the open detail. + * Basename and project-alias comparisons are case-sensitive (Docker project labels are). + */ +export function shouldRefreshForInvalidate( + detail: StateInvalidateDetail, + opts: { + activeNodeId: number | undefined; + selectedBasename: string; + composeProjectName: string | null; + learnedAliases: ReadonlySet; + containerIds: ReadonlySet; + }, +): boolean { + if (detail.scope !== 'stack') return false; + if (opts.activeNodeId === undefined || detail.nodeId !== opts.activeNodeId) return false; + + const containerId = detail.containerId ?? null; + if (containerId && containerIdMatches(opts.containerIds, containerId)) return true; + + const project = detail.stackName ?? null; + // Identity unproven: soft-refresh the selected stack (node-scoped fallback). + if (!project) return true; + + if (project === opts.selectedBasename) return true; + if (opts.composeProjectName !== null && project === opts.composeProjectName) return true; + if (opts.learnedAliases.has(project)) return true; + + return false; +} + +/** + * Keep the open stack's container cards and health state synchronized with Docker + * via sencho:state-invalidate plus a visibility-aware poll. Soft-refreshes only; + * does not reload compose, env, or logs. + */ +export function useSelectedStackLiveRefresh({ + selectedFile, + activeNodeId, + isDetailVisible, + containers, + composeContent, + containersLoadStatus, + refreshSelectedContainers, +}: UseSelectedStackLiveRefreshArgs): UseSelectedStackLiveRefreshResult { + const [syncStale, setSyncStale] = useState(false); + + const selectedFileRef = useRef(selectedFile); + const activeNodeIdRef = useRef(activeNodeId); + const isDetailVisibleRef = useRef(isDetailVisible); + const refreshRef = useRef(refreshSelectedContainers); + const failureCountRef = useRef(0); + const inFlightRef = useRef(false); + const trailingNeededRef = useRef(false); + const invalidateTimerRef = useRef | null>(null); + const learnedAliasesRef = useRef>(new Set()); + const composeProjectNameRef = useRef(null); + const containerIdsRef = useRef>(new Set()); + const prevFingerprintRef = useRef(containersFingerprint(containers)); + const prevLoadStatusRef = useRef(containersLoadStatus); + + selectedFileRef.current = selectedFile; + activeNodeIdRef.current = activeNodeId; + isDetailVisibleRef.current = isDetailVisible; + refreshRef.current = refreshSelectedContainers; + containerIdsRef.current = new Set(containers.map((c) => c.Id).filter(Boolean)); + + function clearInvalidateTimer(): void { + if (!invalidateTimerRef.current) return; + clearTimeout(invalidateTimerRef.current); + invalidateTimerRef.current = null; + } + + // Cache compose project alias when content changes (not per event). + useEffect(() => { + composeProjectNameRef.current = parseComposeProjectName(composeContent); + }, [composeContent]); + + // Reset learned aliases and stale state when the selection changes. + useEffect(() => { + learnedAliasesRef.current = new Set(); + failureCountRef.current = 0; + // Keep trailingNeeded while a soft refresh is in flight so the finally + // block can refresh the *current* selection instead of dropping the event. + if (!inFlightRef.current) { + trailingNeededRef.current = false; + } + clearInvalidateTimer(); + setSyncStale(false); // eslint-disable-line react-hooks/set-state-in-effect -- reset on selection identity change + }, [selectedFile, activeNodeId]); + + // Drop pending debounce when leaving stack detail (Security / Fleet / etc.). + // Keep trailingNeeded while in flight so finally can refresh if the user + // returns before the request finishes (gated on isDetailVisibleRef there). + useEffect(() => { + if (isDetailVisible) return; + if (!inFlightRef.current) { + trailingNeededRef.current = false; + } + clearInvalidateTimer(); + }, [isDetailVisible]); + + // Successful container list from any path clears the failure counter. + // Fingerprint includes State + healthStatus so same-ID health transitions clear stale. + const fingerprint = containersFingerprint(containers); + if (fingerprint !== prevFingerprintRef.current) { + prevFingerprintRef.current = fingerprint; + failureCountRef.current = 0; + if (syncStale) setSyncStale(false); + } + + // Confirmed-empty success (fingerprint stays '') must also clear stale after a Retry. + if ( + containersLoadStatus === 'success' + && prevLoadStatusRef.current !== 'success' + ) { + failureCountRef.current = 0; + if (syncStale) setSyncStale(false); + } + prevLoadStatusRef.current = containersLoadStatus; + + const runRefresh = useCallback(async () => { + if (!isDetailVisibleRef.current) return; + const file = selectedFileRef.current; + const nodeId = activeNodeIdRef.current; + if (!file || nodeId === undefined) return; + + if (inFlightRef.current) { + trailingNeededRef.current = true; + return; + } + + inFlightRef.current = true; + const basename = stackBasename(file); + try { + const outcome = await refreshRef.current(basename, file); + if (selectedFileRef.current !== file || activeNodeIdRef.current !== nodeId) { + return; + } + if (outcome === 'ok') { + failureCountRef.current = 0; + setSyncStale(false); + } else if (outcome === 'failed') { + // Count real soft failures only. 'skipped' (stale/aborted arbitration) + // must not advance the stale chip. + failureCountRef.current += 1; + if (failureCountRef.current >= STALE_FAILURE_THRESHOLD) { + setSyncStale(true); + } + } + } finally { + inFlightRef.current = false; + const hadTrailing = trailingNeededRef.current; + trailingNeededRef.current = false; + // Trailing refresh targets the current selection (may have changed mid-flight). + if ( + hadTrailing + && isDetailVisibleRef.current + && selectedFileRef.current + && activeNodeIdRef.current !== undefined + ) { + void runRefresh(); + } + } + }, []); + + const scheduleDebouncedRefresh = useCallback(() => { + if (!isDetailVisibleRef.current) return; + clearInvalidateTimer(); + invalidateTimerRef.current = setTimeout(() => { + invalidateTimerRef.current = null; + void runRefresh(); + }, INVALIDATE_DEBOUNCE_MS); + }, [runRefresh]); + + useEffect(() => { + if (!isDetailVisible) return; + + const onInvalidate = (e: Event) => { + // Ref guard covers the gap between isDetailVisible flipping and effect cleanup. + if (!isDetailVisibleRef.current) return; + const detail = (e as CustomEvent).detail ?? {}; + const file = selectedFileRef.current; + if (!file) return; + + const basename = stackBasename(file); + const should = shouldRefreshForInvalidate(detail, { + activeNodeId: activeNodeIdRef.current, + selectedBasename: basename, + composeProjectName: composeProjectNameRef.current, + learnedAliases: learnedAliasesRef.current, + containerIds: containerIdsRef.current, + }); + if (!should) return; + + const project = detail.stackName; + if ( + project + && detail.containerId + && containerIdMatches(containerIdsRef.current, detail.containerId) + ) { + learnedAliasesRef.current.add(project); + } + + scheduleDebouncedRefresh(); + }; + + window.addEventListener('sencho:state-invalidate', onInvalidate); + return () => { + window.removeEventListener('sencho:state-invalidate', onInvalidate); + clearInvalidateTimer(); + }; + }, [isDetailVisible, scheduleDebouncedRefresh]); + + useEffect(() => { + if (!isDetailVisible || !selectedFile || activeNodeId === undefined) return; + return visibilityInterval(() => { + void runRefresh(); + }, POLL_INTERVAL_MS); + }, [isDetailVisible, selectedFile, activeNodeId, runRefresh]); + + const retrySync = useCallback(() => { + failureCountRef.current = 0; + setSyncStale(false); + void runRefresh(); + }, [runRefresh]); + + return { syncStale, retrySync }; +} diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 50441939..8eefa8ba 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -1246,11 +1246,11 @@ describe('container fetch contract', () => { setContainersLoadError, } as never, }); - let ok = true; + let ok: 'ok' | 'skipped' | 'failed' = 'ok'; await act(async () => { ok = await result.current.refreshSelectedContainers('web', 'web.yml'); }); - expect(ok).toBe(false); + expect(ok).toBe('failed'); expect(setContainersLoadStatus).toHaveBeenCalledWith('error'); expect(setContainersLoadError).toHaveBeenCalled(); }); @@ -1320,8 +1320,8 @@ describe('container fetch contract', () => { } as never, }); - let olderPromise!: Promise; - let newerPromise!: Promise; + let olderPromise!: Promise<'ok' | 'skipped' | 'failed'>; + let newerPromise!: Promise<'ok' | 'skipped' | 'failed'>; await act(async () => { olderPromise = result.current.refreshSelectedContainers('web', 'web.yml'); }); @@ -1394,7 +1394,7 @@ describe('container fetch contract', () => { { initialProps: { selectedFile: 'web.yml' as string | null } }, ); - let refreshPromise!: Promise; + let refreshPromise!: Promise<'ok' | 'skipped' | 'failed'>; await act(async () => { refreshPromise = result.current.refreshSelectedContainers('web', 'web.yml'); }); @@ -1459,7 +1459,7 @@ describe('container fetch contract', () => { }, ); - let refreshPromise!: Promise; + let refreshPromise!: Promise<'ok' | 'skipped' | 'failed'>; await act(async () => { refreshPromise = result.current.refreshSelectedContainers('web', 'web.yml'); }); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 10023e33..1d6922b7 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -671,17 +671,23 @@ export function useStackActions(options: UseStackActionsOptions) { // Re-sync the open stack's container list. Used after both successful and // failed/stalled operations so the detail never shows containers that no - // longer reflect reality. Returns true only when the live list was fetched; - // false on a non-applicable stack, a non-ok response, or a network error, so - // callers (e.g. the recovery panel's Refresh) can report the real outcome. + // longer reflect reality. Returns 'ok' when the live list was applied, + // 'skipped' when ownership arbitration dropped the result (stale/aborted or + // wrong selection), and 'failed' on a real soft fetch error. Callers that + // only care about a successful apply should check for 'ok'. // stackName is kept for call-site clarity; the fetch derives the name from stackFile. - const refreshSelectedContainers = async (_stackName: string, stackFile: string): Promise => { - if (selectedFileRef.current !== stackFile) return false; + const refreshSelectedContainers = async ( + _stackName: string, + stackFile: string, + ): Promise<'ok' | 'skipped' | 'failed'> => { + if (selectedFileRef.current !== stackFile) return 'skipped'; const result = await fetchStackContainers(stackFile, 'soft', { expectedFile: stackFile, expectedNodeId: activeNodeIdRef.current, }); - return result.ok; + if (result.ok) return 'ok'; + if (result.reason === 'stale' || result.reason === 'aborted') return 'skipped'; + return 'failed'; }; const retryContainersLoad = async () => { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index aaed44f4..7d266d79 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -36,6 +36,8 @@ export function visibilityInterval(fn: () => void, ms: number): () => void { const stop = () => { if (interval) { clearInterval(interval); interval = null; } }; const onVisChange = () => { if (document.hidden) { stop(); } else { fn(); start(); } }; document.addEventListener('visibilitychange', onVisChange); - start(); + // Do not start a timer when the tab is already hidden (e.g. deep-link opened + // in a background tab). Resume via visibilitychange when the tab is shown. + if (!document.hidden) start(); return () => { stop(); document.removeEventListener('visibilitychange', onVisChange); }; }