diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index b99aafec..b2340d3b 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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} /> diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx new file mode 100644 index 00000000..e93d57c7 --- /dev/null +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -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: () =>
, +})); + +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 ( + + ); +} + +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((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(); + }); +}); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index eb27ac17..4d62797b 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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 && ( -
+
@@ -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} > - - apply + + {applying ? 'applying...' : 'apply'} )}