;
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); };
}