fix(stacks): reflect apply progress and clear the anatomy update banner (#1279)

* fix(stacks): reflect apply progress and clear the anatomy update banner

The "Update available" banner in the stack Anatomy panel ran its apply
in the background but gave no feedback: the apply button stayed idle and
the banner never went away once the update finished.

Pass an in-flight flag to the panel so the apply button disables and
shows progress while the update runs, and re-check the update preview
when the apply finishes so the banner clears on success and stays in
place when the update did not take effect.

* fix(stacks): scope the update re-check to its stack and keep the banner on refresh failure

The post-apply re-check of the Anatomy update banner tracked only the
in-flight flag, so switching stacks while the first was still updating
looked like a completion for the newly selected stack and triggered a
stray preview request that could clear that stack's valid banner. Track
the stack name alongside the flag and only re-check when the completion
belongs to the stack on screen.

Also keep the banner when the re-check itself fails (non-OK response or
network error) instead of clearing it, so a transient read failure can
no longer hide an update that may still be pending. Both failure paths
now log for diagnosis.
This commit is contained in:
Anso
2026-06-02 09:51:15 -04:00
committed by GitHub
parent f6c6ffea15
commit 2dd0660491
3 changed files with 296 additions and 4 deletions
@@ -876,6 +876,7 @@ export function EditorView({
onOpenFiles={canRead ? () => { setEditingCompose(true); setActiveTab('files'); } : undefined}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
applying={loadingAction === 'update'}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
/>
@@ -0,0 +1,256 @@
/**
* Covers the "Update available" banner lifecycle: the apply button must reflect
* the in-flight update (disabled + progress label), and the banner must clear
* itself once the update lands (re-checking the preview) while staying put if
* the update did not take effect.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('./stack/StackActivityTimeline', () => ({
StackActivityTimeline: () => <div data-testid="activity-timeline" />,
}));
import { apiFetch } from '@/lib/api';
import StackAnatomyPanel from './StackAnatomyPanel';
const COMPOSE = 'services:\n web:\n image: nginx:1.25\n';
function previewBody(hasUpdate: boolean) {
return {
summary: {
has_update: hasUpdate,
primary_image: 'nginx',
current_tag: '1.25',
next_tag: '1.26',
semver_bump: 'minor',
blocked: false,
blocked_reason: null,
},
changelog: null,
};
}
function jsonRes(body: unknown, ok = true) {
return { ok, status: ok ? 200 : 404, json: async () => body, text: async () => '' } as unknown as Response;
}
let hasUpdate = true;
const updatePreviewCalls = () =>
vi.mocked(apiFetch).mock.calls.filter(([input]) => String(input).includes('/update-preview')).length;
beforeEach(() => {
hasUpdate = true;
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(hasUpdate));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false); // git-source and anything else: nothing to show
});
});
function panel(applying: boolean, onApplyUpdate: () => void = vi.fn(), stackName = 'web') {
return (
<StackAnatomyPanel
stackName={stackName}
content={COMPOSE}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={onApplyUpdate}
canEdit
applying={applying}
/>
);
}
describe('StackAnatomyPanel update banner', () => {
it('shows the apply button and fires onApplyUpdate when clicked', async () => {
const onApply = vi.fn();
render(panel(false, onApply));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'apply' }));
expect(onApply).toHaveBeenCalledTimes(1);
});
it('disables the apply button and shows progress while applying', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply));
const btn = screen.getByRole('button', { name: /applying/i });
expect(btn).toBeDisabled();
});
it('clears the banner after the update lands', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply)); // update in flight
hasUpdate = false; // backend now reports the stack is current
rerender(panel(false, onApply)); // apply finished
await waitFor(() =>
expect(screen.queryByTestId('update-available-banner')).not.toBeInTheDocument(),
);
});
it('keeps the banner if the update did not take effect', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(true, onApply));
rerender(panel(false, onApply)); // hasUpdate stays true: still an update pending
await waitFor(() => expect(updatePreviewCalls()).toBeGreaterThan(before));
expect(screen.getByTestId('update-available-banner')).toBeInTheDocument();
});
it('does not re-check the preview while applying stays false', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(false, onApply));
rerender(panel(false, onApply));
await Promise.resolve();
expect(updatePreviewCalls()).toBe(before); // re-check fires only on the true -> false edge
});
it('does not fire onApplyUpdate while the apply button is disabled', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply));
fireEvent.click(screen.getByRole('button', { name: /applying/i }));
expect(onApply).not.toHaveBeenCalled();
});
it('keeps the banner when the post-apply re-check returns a non-OK response', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
let updateCalls = 0;
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
updateCalls += 1;
return updateCalls >= 2 ? jsonRes(null, false) : jsonRes(previewBody(true));
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(true, onApply));
rerender(panel(false, onApply)); // re-check returns not-ok: keep the known banner
await waitFor(() => expect(updatePreviewCalls()).toBeGreaterThan(before));
expect(screen.getByTestId('update-available-banner')).toBeInTheDocument();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('keeps the banner when the post-apply re-check throws', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
let updateCalls = 0;
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
updateCalls += 1;
if (updateCalls >= 2) throw new Error('network down');
return jsonRes(previewBody(true));
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
await screen.findByTestId('update-available-banner');
const before = updatePreviewCalls();
rerender(panel(true, onApply));
rerender(panel(false, onApply)); // re-check throws: keep the known banner
await waitFor(() => expect(updatePreviewCalls()).toBeGreaterThan(before));
expect(screen.getByTestId('update-available-banner')).toBeInTheDocument();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('does not re-check after switching stacks while the first is still applying', async () => {
const calls: string[] = [];
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) {
calls.push(url);
return jsonRes(previewBody(!url.includes('/stacks/other/'))); // web has an update, other does not
}
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
const otherCalls = () => calls.filter((u) => u.includes('/stacks/other/update-preview')).length;
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply, 'web'));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply, 'web')); // web applying
rerender(panel(false, onApply, 'other')); // switch stacks before web's apply finishes
await waitFor(() => expect(otherCalls()).toBe(1)); // only the stack-change mount fetch
await Promise.resolve();
expect(otherCalls()).toBe(1); // the apply-completion re-check must not fire for "other"
});
it('ignores a stale re-check that resolves after the stack changed', async () => {
let resolveStale!: (r: Response) => void;
const stale = new Promise<Response>((r) => { resolveStale = r; });
const webResponses: Response[] = [jsonRes(previewBody(true))]; // mount: web has an update
vi.mocked(apiFetch).mockImplementation((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/stacks/web/update-preview')) {
const next = webResponses.shift();
return next ? Promise.resolve(next) : stale; // post-apply re-check for web hangs
}
if (url.includes('/stacks/other/update-preview')) return Promise.resolve(jsonRes(previewBody(false)));
if (url.includes('/scan-status')) return Promise.resolve(jsonRes({ status: 'ok' }));
return Promise.resolve(jsonRes(null, false));
});
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply, 'web'));
await screen.findByTestId('update-available-banner');
rerender(panel(true, onApply, 'web'));
rerender(panel(false, onApply, 'web')); // re-check fires and hangs
rerender(panel(false, onApply, 'other')); // switch stacks: cleanup cancels the hung re-check
await waitFor(() =>
expect(screen.queryByTestId('update-available-banner')).not.toBeInTheDocument(),
);
resolveStale(jsonRes(previewBody(true))); // late stale result for web must be dropped
await Promise.resolve();
expect(screen.queryByTestId('update-available-banner')).not.toBeInTheDocument();
});
});
+39 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { parse as parseYaml } from 'yaml';
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
import { Button } from './ui/button';
@@ -19,6 +19,7 @@ interface StackAnatomyPanelProps {
onApplyUpdate: () => void;
onOpenFiles?: () => void;
canEdit: boolean;
applying?: boolean;
notifications?: NotificationItem[];
}
@@ -236,6 +237,7 @@ export default function StackAnatomyPanel({
onApplyUpdate,
onOpenFiles,
canEdit,
applying = false,
notifications,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
@@ -295,6 +297,38 @@ export default function StackAnatomyPanel({
return () => { cancelled = true; };
}, [stackName]);
// When an apply for the current stack finishes (applying true -> false on the same
// stackName), re-check the preview: a landed update clears has_update so the banner
// unmounts; if it did not land, or the re-check itself fails, the banner stays.
// Tracking stackName alongside applying avoids treating a stack switch made while the
// first stack is still applying as a completion for the newly selected stack.
const prevApplyRef = useRef({ applying, stackName });
useEffect(() => {
const prev = prevApplyRef.current;
const finishedApplying = prev.applying && !applying && prev.stackName === stackName;
prevApplyRef.current = { applying, stackName };
if (!finishedApplying) return;
let cancelled = false;
const run = async () => {
try {
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
if (cancelled) return;
if (!res.ok) {
// Re-check failed: keep the banner already shown rather than hiding a
// possibly-still-pending update. The apply action reports its own outcome.
console.error(`[StackAnatomyPanel] update-preview re-check returned ${res.status}; keeping the existing banner`);
return;
}
const data = await res.json();
if (!cancelled) setUpdatePreview(data);
} catch (err) {
console.error('[StackAnatomyPanel] update-preview re-check failed:', err);
}
};
void run();
return () => { cancelled = true; };
}, [applying, stackName]);
useEffect(() => {
let cancelled = false;
const run = async () => {
@@ -494,7 +528,7 @@ export default function StackAnatomyPanel({
</>
)}
{hasUpdate && updatePreview && (
<div className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-[11px] uppercase tracking-wide">
@@ -524,11 +558,12 @@ export default function StackAnatomyPanel({
type="button"
size="sm"
variant="outline"
disabled={applying}
className={cn('shrink-0 h-7 gap-1', applyBtnTone)}
onClick={onApplyUpdate}
>
<Rocket className="h-3 w-3" strokeWidth={1.5} />
apply
<Rocket className={cn('h-3 w-3', applying && 'animate-pulse')} strokeWidth={1.5} />
{applying ? 'applying...' : 'apply'}
</Button>
)}
</div>