mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
feat: live-refresh stack detail container and health state (#1712)
* feat: live-refresh stack detail container and health state Keep the open stack's container cards in sync with Docker via state-invalidate events and a visibility-aware poll, without reloading compose, env, or logs. * fix: remove unused _ms parameter from visibilityInterval mock Fixes the @typescript-eslint/no-unused-vars ESLint error in CI lint job. * fix: stop stack detail live-refresh when leaving the editor Gate poll and invalidate handling on editor visibility, refresh the current selection after a mid-flight stack switch, and skip starting visibilityInterval when the tab is already hidden. * fix: avoid return in finally for stack detail live-refresh Satisfy no-unsafe-finally by gating the trailing refresh with a positive condition instead of early returns inside the finally block.
This commit is contained in:
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-view/containers.png" alt="Container card for the plex service showing health badge, uptime, port mapping, open link, action icons, and CPU, memory, and network stat tiles" />
|
||||
</Frame>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
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}`}
|
||||
/>
|
||||
</ScrollArea>
|
||||
@@ -523,6 +530,8 @@ export function EditorView(props: EditorViewProps) {
|
||||
containersLoadStatus={containersLoadStatus}
|
||||
containersLoadError={containersLoadError}
|
||||
onRetryContainersLoad={onRetryContainersLoad}
|
||||
syncStale={containersSyncStale}
|
||||
onRetrySync={onRetrySync}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<ContainersHealth
|
||||
safeContainers={[{
|
||||
Id: 'a',
|
||||
Names: ['/web'],
|
||||
State: 'running',
|
||||
Status: 'Up 1 hour',
|
||||
healthStatus: 'healthy',
|
||||
}]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
containersLoadStatus="success"
|
||||
syncStale
|
||||
onRetrySync={onRetrySync}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ContainersHealth
|
||||
safeContainers={[]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
containersLoadStatus="error"
|
||||
containersLoadError="Could not load containers."
|
||||
onRetryContainersLoad={vi.fn()}
|
||||
syncStale
|
||||
onRetrySync={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/Container state may be stale/i)).toBeNull();
|
||||
expect(screen.getByText(/Could not load containers/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
{syncStale && onRetrySync && (
|
||||
<div className="mb-3 flex items-center justify-between gap-2 rounded-md border border-warning/30 bg-warning/10 px-2 py-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground">
|
||||
Container state may be stale
|
||||
</span>
|
||||
<Button type="button" variant="outline" size="sm" className="h-7" onClick={onRetrySync}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{densityToolbar}
|
||||
{isMultiService ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
@@ -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<typeof import('@/lib/utils')>('@/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<string, unknown>) {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail }));
|
||||
}
|
||||
|
||||
function container(id: string, overrides: Partial<ContainerInfo> = {}): 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<string>(),
|
||||
containerIds: new Set<string>(),
|
||||
};
|
||||
|
||||
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<typeof vi.fn>;
|
||||
|
||||
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<SoftRefreshOutcome>,
|
||||
}),
|
||||
{
|
||||
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<SoftRefreshOutcome>((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<SoftRefreshOutcome>((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<SoftRefreshOutcome>((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<SoftRefreshOutcome>((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);
|
||||
});
|
||||
});
|
||||
@@ -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<SoftRefreshOutcome>;
|
||||
};
|
||||
|
||||
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<string, unknown>).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<string>, 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<string>;
|
||||
containerIds: ReadonlySet<string>;
|
||||
},
|
||||
): 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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const learnedAliasesRef = useRef<Set<string>>(new Set());
|
||||
const composeProjectNameRef = useRef<string | null>(null);
|
||||
const containerIdsRef = useRef<Set<string>>(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<StateInvalidateDetail>).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 };
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
let newerPromise!: Promise<boolean>;
|
||||
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<boolean>;
|
||||
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<boolean>;
|
||||
let refreshPromise!: Promise<'ok' | 'skipped' | 'failed'>;
|
||||
await act(async () => {
|
||||
refreshPromise = result.current.refreshSelectedContainers('web', 'web.yml');
|
||||
});
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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 () => {
|
||||
|
||||
@@ -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); };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user