From 69c6da49fdc595a65e418d5217deb799ce59eb1c Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:23:25 +0100 Subject: [PATCH 1/2] fix(console): wait for the inventory after a job and keep job polls running in a hidden tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Jobs lane's workflow job poll stopped while the tab was hidden, so a job that finished in the background showed its outcome only after the operator came back. It now keeps polling in the background, refetches on return while the job runs, and stops once the job settles. The Provision stage and the catalog page read the catalog once after a deploy or teardown job and claimed the result while the inventory could still lag. They now say the job finished and wait for the inventory to confirm it, polling every 3 s in a hidden tab too. The catalog page says the result is unconfirmed after 60 s; the Provision wait has no time limit, like the Finalise stage's. useCatalog takes separate teardown and deploy wait lists for this. The catalog page also no longer stays on "Deploying…" after an entry's first action: its in-flight check compared the other action's ids with null, but a first action leaves them undefined. Closes dmfdeploy/dmfdeploy#878 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- .../catalogInventoryConfirm.test.tsx | 185 ++++++++++++++++ .../catalogInventoryTimeout.test.tsx | 110 ++++++++++ .../provisionInventoryConfirm.test.tsx | 202 ++++++++++++++++++ .../workflowJobStatusBackground.test.tsx | 147 +++++++++++++ frontend/src/api/hooks.ts | 43 +++- frontend/src/lib/inventoryWait.ts | 3 + frontend/src/pages/Catalog/index.tsx | 128 +++++++++-- .../MediaWorkloads/stages/FinaliseStage.tsx | 2 +- .../MediaWorkloads/stages/ProvisionStage.tsx | 75 +++++-- 9 files changed, 856 insertions(+), 39 deletions(-) create mode 100644 frontend/src/__tests__/catalogInventoryConfirm.test.tsx create mode 100644 frontend/src/__tests__/catalogInventoryTimeout.test.tsx create mode 100644 frontend/src/__tests__/provisionInventoryConfirm.test.tsx create mode 100644 frontend/src/__tests__/workflowJobStatusBackground.test.tsx create mode 100644 frontend/src/lib/inventoryWait.ts diff --git a/frontend/src/__tests__/catalogInventoryConfirm.test.tsx b/frontend/src/__tests__/catalogInventoryConfirm.test.tsx new file mode 100644 index 0000000..77b1025 --- /dev/null +++ b/frontend/src/__tests__/catalogInventoryConfirm.test.tsx @@ -0,0 +1,185 @@ +/** + * After a deploy or teardown job, the Catalog page must not show the + * entry's lifecycle from the catalog until the inventory confirms the result. + * The automation job can finish before the inventory record changes; the card + * then says the job finished and that it is waiting for the inventory, offers + * no action on the old record, and shows the confirmed state once the record + * catches up. + * + * jsdom has no real visibility API, so the hidden state is simulated by + * redefining `document.visibilityState`. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import Catalog from '../pages/Catalog' +import type { CatalogEntry } from '../api/types' + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +function catalogEntry(lifecycle: CatalogEntry['lifecycle']): CatalogEntry { + return { + key: 'crosspoint', + display_name: 'MXL Crosspoint', + summary: '', + ebu_layer: null, + ebu_vertical: null, + ebu_media_function_type: null, + ebu_lifecycle_owner: null, + lifecycle, + provision_image: null, + provision_netbox_service: null, + configure_awx_job_template: 'deploy-crosspoint', + finalise_awx_job_template: 'teardown-crosspoint', + dependencies: [], + ingress_url: null, + } +} + +interface Server { + catalogLifecycle: CatalogEntry['lifecycle'] + jobStatus: string + catalogReads: number +} + +function stubServer(initial: Partial = {}): Server { + const server: Server = { catalogLifecycle: 'bootstrapped', jobStatus: 'successful', catalogReads: 0, ...initial } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = (typeof input === 'string' ? input : (input as Request).url).toString() + if (url.endsWith('/api/me')) { + return json({ + subject: 'ops', display_name: 'Ops', email: 'o@x', role: 'engineer', real_role: 'engineer', + groups: [], awx_configured: true, + }) + } + if (url.endsWith('/api/catalog')) { + server.catalogReads += 1 + return json({ entries: [catalogEntry(server.catalogLifecycle)] }) + } + if (url.endsWith('/api/media-workloads/grouped')) { + return json({ configured: true, degraded: false, workloads: [], invalid_instances: [] }) + } + if (/\/api\/catalog\/crosspoint\/(deploy|teardown)$/.test(url) && init?.method === 'POST') { + return json({ job_id: 901, status: 'launched', request_id: 'req-1' }) + } + if (url.match(/\/api\/catalog\/crosspoint\/status\/901$/)) { + const done = server.jobStatus !== 'running' + return json({ job_id: 901, status: server.jobStatus, is_done: done, is_running: !done }) + } + return json({}) + }), + ) + return server +} + +function renderCatalog() { + // The same QueryClient defaults as main.tsx, except retry. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }) + return render( + + + + + , + ) +} + +async function arm(buttonName: RegExp, confirmName: RegExp) { + fireEvent.click(await screen.findByRole('button', { name: buttonName })) + fireEvent.change(screen.getByPlaceholderText(/Reason/), { target: { value: 'planned' } }) + fireEvent.click(screen.getByRole('button', { name: confirmName })) +} + +const button = (name: RegExp) => screen.getByRole('button', { name }) as HTMLButtonElement +const waiting = (kind: 'deploy' | 'teardown') => + new RegExp(`The ${kind} job for crosspoint finished\\. Waiting for the inventory to confirm\\.`) + +function setTabHidden(hidden: boolean) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (hidden ? 'hidden' : 'visible'), + }) + document.dispatchEvent(new Event('visibilitychange', { bubbles: true })) +} + +afterEach(() => { + cleanup() + // Remove the own-property override so jsdom's `visible` default applies. + delete (document as { visibilityState?: unknown }).visibilityState + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Catalog: the inventory has to confirm a finished deploy or teardown', () => { + it('a deployed entry still bootstrapped shows the waiting line and no lifecycle, then the confirmed state', async () => { + const server = stubServer({ catalogLifecycle: 'bootstrapped', jobStatus: 'successful' }) + renderCatalog() + await arm(/Deploy/, /Confirm deploy/) + + // The job is done on the first poll; the hand-off is paced (2 s). + await waitFor(() => expect(document.body.textContent).toMatch(waiting('deploy')), { timeout: 8000 }) + expect(screen.getByText('unconfirmed')).toBeTruthy() + expect(screen.queryByText('bootstrapped')).toBeNull() + expect(button(/Deploy/).disabled).toBe(true) + expect(button(/Teardown/).disabled).toBe(true) + + server.catalogLifecycle = 'active' + await waitFor(() => expect(screen.getByText('active')).toBeTruthy(), { timeout: 6000 }) + expect(document.body.textContent).not.toMatch(waiting('deploy')) + expect(screen.queryByText('unconfirmed')).toBeNull() + expect(button(/Teardown/).disabled).toBe(false) + }, 30_000) + + it('a torn down entry still active shows the waiting line and no lifecycle, then the confirmed state', async () => { + const server = stubServer({ catalogLifecycle: 'active', jobStatus: 'successful' }) + renderCatalog() + await arm(/Teardown/, /Confirm teardown/) + + await waitFor(() => expect(document.body.textContent).toMatch(waiting('teardown')), { timeout: 8000 }) + expect(screen.getByText('unconfirmed')).toBeTruthy() + expect(screen.queryByText('active')).toBeNull() + expect(button(/Teardown/).disabled).toBe(true) + expect(button(/Deploy/).disabled).toBe(true) + + server.catalogLifecycle = 'bootstrapped' + await waitFor(() => expect(screen.getByText('bootstrapped')).toBeTruthy(), { timeout: 6000 }) + expect(document.body.textContent).not.toMatch(waiting('teardown')) + expect(button(/Deploy/).disabled).toBe(false) + }, 30_000) + + it('a failed job never says it is waiting for the inventory', async () => { + stubServer({ catalogLifecycle: 'bootstrapped', jobStatus: 'failed' }) + renderCatalog() + await arm(/Deploy/, /Confirm deploy/) + + // The in-flight tracker clears (the Deploy button returns) once the job completes. + await waitFor(() => expect(button(/Deploy/).disabled).toBe(false), { timeout: 8000 }) + expect(document.body.textContent).not.toMatch(waiting('deploy')) + expect(screen.queryByText('unconfirmed')).toBeNull() + expect(screen.getByText('bootstrapped')).toBeTruthy() + // Nothing is in flight for the other action either, so it does not read as running. + expect(screen.getByRole('button', { name: '⏏ Teardown' })).toBeTruthy() + }, 30_000) + + it('the job finishes while the tab is hidden: the page reaches the waiting line and confirms while hidden', async () => { + const server = stubServer({ catalogLifecycle: 'bootstrapped', jobStatus: 'running' }) + renderCatalog() + await arm(/Deploy/, /Confirm deploy/) + await screen.findByText(/job #901/, {}, { timeout: 5000 }) + + setTabHidden(true) + server.jobStatus = 'successful' + await waitFor(() => expect(document.body.textContent).toMatch(waiting('deploy')), { timeout: 10_000 }) + + server.catalogLifecycle = 'active' + const readsWhenFlipped = server.catalogReads + await waitFor(() => expect(screen.getByText('active')).toBeTruthy(), { timeout: 6000 }) + expect(server.catalogReads).toBeGreaterThan(readsWhenFlipped) + expect(document.body.textContent).not.toMatch(waiting('deploy')) + }, 40_000) +}) diff --git a/frontend/src/__tests__/catalogInventoryTimeout.test.tsx b/frontend/src/__tests__/catalogInventoryTimeout.test.tsx new file mode 100644 index 0000000..9582ca0 --- /dev/null +++ b/frontend/src/__tests__/catalogInventoryTimeout.test.tsx @@ -0,0 +1,110 @@ +/** + * The Catalog page waits a bounded time for the inventory to confirm a + * finished job. When the wait runs out, the card says the result is not + * confirmed instead of claiming it, and the catalog poll stops. The wait is + * shortened here so the test does not run for a minute. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import Catalog from '../pages/Catalog' +import type { CatalogEntry } from '../api/types' + +vi.mock('../lib/inventoryWait', () => ({ INVENTORY_WAIT_MS: 1500 })) + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +function catalogEntry(lifecycle: CatalogEntry['lifecycle']): CatalogEntry { + return { + key: 'crosspoint', + display_name: 'MXL Crosspoint', + summary: '', + ebu_layer: null, + ebu_vertical: null, + ebu_media_function_type: null, + ebu_lifecycle_owner: null, + lifecycle, + provision_image: null, + provision_netbox_service: null, + configure_awx_job_template: 'deploy-crosspoint', + finalise_awx_job_template: 'teardown-crosspoint', + dependencies: [], + ingress_url: null, + } +} + +const server = { catalogLifecycle: 'active' as CatalogEntry['lifecycle'], catalogReads: 0 } + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Catalog: the wait for the inventory is bounded', () => { + it('when the inventory never confirms, the card says so, stops polling, and still takes a later confirmation', async () => { + server.catalogLifecycle = 'active' + server.catalogReads = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = (typeof input === 'string' ? input : (input as Request).url).toString() + if (url.endsWith('/api/me')) { + return json({ + subject: 'ops', display_name: 'Ops', email: 'o@x', role: 'engineer', real_role: 'engineer', + groups: [], awx_configured: true, + }) + } + if (url.endsWith('/api/catalog')) { + server.catalogReads += 1 + return json({ entries: [catalogEntry(server.catalogLifecycle)] }) + } + if (url.endsWith('/api/media-workloads/grouped')) { + return json({ configured: true, degraded: false, workloads: [], invalid_instances: [] }) + } + if (/\/api\/catalog\/crosspoint\/teardown$/.test(url) && init?.method === 'POST') { + return json({ job_id: 901, status: 'launched', request_id: 'req-1' }) + } + if (url.match(/\/api\/catalog\/crosspoint\/status\/901$/)) { + return json({ job_id: 901, status: 'successful', is_done: true, is_running: false }) + } + return json({}) + }), + ) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }) + render( + + + + + , + ) + fireEvent.click(await screen.findByRole('button', { name: /Teardown/ })) + fireEvent.change(screen.getByPlaceholderText(/Reason/), { target: { value: 'planned' } }) + fireEvent.click(screen.getByRole('button', { name: /Confirm teardown/ })) + + const timedOut = + /The teardown job for crosspoint finished, but the inventory has not confirmed it yet\. Reload the page to check again\./ + await waitFor(() => expect(document.body.textContent).toMatch(timedOut), { timeout: 8000 }) + expect(document.body.textContent).not.toMatch(/Waiting for the inventory to confirm/) + expect(screen.getByText('unconfirmed')).toBeTruthy() + expect(screen.queryByText('active')).toBeNull() + + // The poll has stopped: a poll that kept running would read again within 3 s. + const readsAtTimeout = server.catalogReads + await new Promise((resolve) => setTimeout(resolve, 3600)) + expect(server.catalogReads).toBe(readsAtTimeout) + + // A later read that shows the result still replaces the notice. + server.catalogLifecycle = 'bootstrapped' + await act(async () => { + await queryClient.refetchQueries({ queryKey: ['catalog'] }) + }) + await waitFor(() => expect(screen.getByText('bootstrapped')).toBeTruthy()) + expect(document.body.textContent).not.toMatch(timedOut) + expect(screen.queryByText('unconfirmed')).toBeNull() + }, 30_000) +}) diff --git a/frontend/src/__tests__/provisionInventoryConfirm.test.tsx b/frontend/src/__tests__/provisionInventoryConfirm.test.tsx new file mode 100644 index 0000000..095c7c9 --- /dev/null +++ b/frontend/src/__tests__/provisionInventoryConfirm.test.tsx @@ -0,0 +1,202 @@ +/** + * After a deploy, the Provision stage must not claim the outcome from the + * catalog until the inventory confirms it. The automation job can finish + * before the inventory record changes; the stage then says the job finished + * and that it is waiting for the inventory, and shows the confirmed state + * once the record catches up. + * + * jsdom has no real visibility API, so the hidden state is simulated by + * redefining `document.visibilityState`. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import ProvisionStage from '../pages/MediaWorkloads/stages/ProvisionStage' +import type { CatalogEntry, MediaWorkload } from '../api/types' + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +function catalogEntry(lifecycle: CatalogEntry['lifecycle']): CatalogEntry { + return { + key: 'crosspoint', + display_name: 'MXL Crosspoint', + summary: '', + ebu_layer: null, + ebu_vertical: null, + ebu_media_function_type: null, + ebu_lifecycle_owner: null, + lifecycle, + provision_image: null, + provision_netbox_service: null, + configure_awx_job_template: 'deploy-crosspoint', + finalise_awx_job_template: 'teardown-crosspoint', + dependencies: [], + ingress_url: null, + } +} + +const WORKLOAD: MediaWorkload = { + slug: 'studio-a', + name: 'studio-a', + lifecycle: 'provision', + health: 'ok', + instances: [], + functions: [{ function_key: 'crosspoint', count: 1, running: 0, reconcile_pending: 0 }], +} + +interface Server { + catalogLifecycle: CatalogEntry['lifecycle'] + // How the deploy is launched: a job id straight away, or an operation that + // settles at `operationState`. + path: 'job' | 'operation' + jobStatus: string + operationState: string + catalogReads: number +} + +function stubServer(initial: Partial = {}): Server { + const server: Server = { + catalogLifecycle: 'bootstrapped', + path: 'job', + jobStatus: 'successful', + operationState: 'run_complete', + catalogReads: 0, + ...initial, + } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = (typeof input === 'string' ? input : (input as Request).url).toString() + if (url.endsWith('/api/me')) return json({ subject: 'ops', role: 'operator' }) + if (url.endsWith('/api/catalog')) { + server.catalogReads += 1 + return json({ entries: [catalogEntry(server.catalogLifecycle)] }) + } + if (url.match(/\/api\/catalog\/crosspoint\/deploy$/) && init?.method === 'POST') { + if (server.path === 'operation') { + return json({ + operation_id: 'op-dep', action: 'deploy', target: 'crosspoint', state: 'waking', + job_id: null, error: null, created_at: 't0', updated_at: 't0', request_id: 'req-dep', + }) + } + return json({ job_id: 801, status: 'launched', request_id: 'req-dep' }) + } + if (url.endsWith('/api/operations/op-dep')) { + return json({ + operation_id: 'op-dep', action: 'deploy', target: 'crosspoint', state: server.operationState, + job_id: 801, error: null, created_at: 't0', updated_at: 't1', + }) + } + if (url.match(/\/api\/catalog\/crosspoint\/status\/801$/)) { + const done = server.jobStatus !== 'running' + return json({ job_id: 801, status: server.jobStatus, is_done: done, is_running: !done }) + } + return json({}) + }), + ) + return server +} + +function renderStage() { + // The same QueryClient defaults as main.tsx, except retry. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }) + return render( + + + + + , + ) +} + +async function deploy() { + fireEvent.click(await screen.findByRole('button', { name: '▶ Deploy' })) + fireEvent.change(screen.getByPlaceholderText(/Reason/), { target: { value: 'planned' } }) + fireEvent.click(screen.getByRole('button', { name: 'Confirm deploy' })) +} + +const WAITING = /The deploy job for crosspoint finished\. Waiting for the inventory to confirm\./ + +function setTabHidden(hidden: boolean) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (hidden ? 'hidden' : 'visible'), + }) + document.dispatchEvent(new Event('visibilitychange', { bubbles: true })) +} + +afterEach(() => { + cleanup() + // Remove the own-property override so jsdom's `visible` default applies. + delete (document as { visibilityState?: unknown }).visibilityState + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Provision: the inventory has to confirm a finished deploy', () => { + it('a successful job with the catalog still bootstrapped shows the waiting line, then the confirmed state', async () => { + const server = stubServer({ catalogLifecycle: 'bootstrapped', jobStatus: 'successful' }) + renderStage() + await deploy() + + // The job is done on the first poll; the hand-off is paced (2 s). + await waitFor(() => expect(document.body.textContent).toMatch(WAITING), { timeout: 8000 }) + expect(screen.queryByRole('button', { name: '▶ Deploy' })).toBeNull() + expect(document.body.textContent).not.toContain('Already deployed.') + + server.catalogLifecycle = 'active' + await waitFor(() => expect(screen.getByText('Already deployed.')).toBeTruthy(), { timeout: 6000 }) + expect(document.body.textContent).not.toMatch(WAITING) + expect(screen.queryByRole('button', { name: '▶ Deploy' })).toBeNull() + }, 30_000) + + it('an operation that ends at run_complete is held to the same confirmation', async () => { + const server = stubServer({ path: 'operation', operationState: 'run_complete', catalogLifecycle: 'bootstrapped' }) + renderStage() + await deploy() + + await waitFor(() => expect(document.body.textContent).toMatch(WAITING), { timeout: 8000 }) + expect(screen.queryByRole('button', { name: '▶ Deploy' })).toBeNull() + + server.catalogLifecycle = 'active' + await waitFor(() => expect(screen.getByText('Already deployed.')).toBeTruthy(), { timeout: 6000 }) + expect(document.body.textContent).not.toMatch(WAITING) + }, 30_000) + + it('a failed job never says it is waiting for the inventory, and offers Deploy again', async () => { + stubServer({ catalogLifecycle: 'bootstrapped', jobStatus: 'failed' }) + renderStage() + await deploy() + + // The in-flight tracker clears (the Deploy button returns) once the job completes. + await screen.findByRole('button', { name: '▶ Deploy' }, { timeout: 8000 }) + expect(document.body.textContent).not.toMatch(WAITING) + expect(document.body.textContent).not.toContain('Already deployed.') + }, 30_000) + + it('the job finishes while the tab is hidden: the stage still reaches the waiting line and confirms while hidden', async () => { + const server = stubServer({ catalogLifecycle: 'bootstrapped', jobStatus: 'running' }) + renderStage() + await deploy() + await screen.findByText(/job #801/, {}, { timeout: 5000 }) + + setTabHidden(true) + server.jobStatus = 'successful' + await waitFor(() => expect(document.body.textContent).toMatch(WAITING), { timeout: 10_000 }) + + server.catalogLifecycle = 'active' + const readsWhenFlipped = server.catalogReads + await waitFor(() => expect(screen.getByText('Already deployed.')).toBeTruthy(), { timeout: 6000 }) + expect(server.catalogReads).toBeGreaterThan(readsWhenFlipped) + expect(document.body.textContent).not.toMatch(WAITING) + }, 40_000) +}) diff --git a/frontend/src/__tests__/workflowJobStatusBackground.test.tsx b/frontend/src/__tests__/workflowJobStatusBackground.test.tsx new file mode 100644 index 0000000..8670ac3 --- /dev/null +++ b/frontend/src/__tests__/workflowJobStatusBackground.test.tsx @@ -0,0 +1,147 @@ +/** + * The Activity jobs lane polls a launched workflow job by id. These tests pin + * that the status keeps updating while the tab is hidden, that coming back to + * the tab refreshes a job that is still running, and that a settled job stops + * being polled. + * + * jsdom and TanStack Query: the query core reads `document.visibilityState` + * and listens for `visibilitychange` on `window`. jsdom reports `visible` by + * default, so the hidden state is simulated by redefining + * `document.visibilityState`. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import JobsLane from '../pages/Activity/JobsLane' +import { useWorkflowJobStatus } from '../api/hooks' +import { settleQuery } from '../lib/queryState' + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +// The same QueryClient defaults as main.tsx, except retry: retries are off so +// a failed read never hides behind one. +function newQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }) +} + +function setTabHidden(hidden: boolean) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (hidden ? 'hidden' : 'visible'), + }) + document.dispatchEvent(new Event('visibilitychange', { bubbles: true })) +} + +interface Server { + status: string + jobReads: number +} + +function stubServer(initial: Partial = {}): Server { + const server: Server = { status: 'running', jobReads: 0, ...initial } + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = (typeof input === 'string' ? input : (input as Request).url).toString() + if (url.endsWith('/api/me')) { + return json({ + subject: 'ops', display_name: 'Ops', email: 'o@x', role: 'engineer', real_role: 'engineer', + groups: [], awx_configured: true, + }) + } + if (url.endsWith('/api/workflows')) { + return json({ templates: [{ id: 1, name: 'day0-bootstrap', description: 'Bootstrap a facility', type: 'job' }] }) + } + if (url.endsWith('/launch')) return json({ job_id: 55, status: 'launched', request_id: 'req-1' }) + if (url.endsWith('/api/workflows/jobs/55')) { + server.jobReads += 1 + return json({ + job_id: 55, status: server.status, name: 'day0-bootstrap', url: '', elapsed: 3.2, + failed: server.status === 'failed', + }) + } + return json({}) + }), + ) + return server +} + +function JobProbe() { + const { data } = settleQuery(useWorkflowJobStatus(55)) + return
{data ? data.status : 'none'}
+} + +function renderProbe() { + render( + + + , + ) +} + +async function launchOnJobsLane() { + render( + + + + + , + ) + fireEvent.click(await screen.findByRole('button', { name: /Launch/ })) + fireEvent.change(await screen.findByPlaceholderText(/Reason \(required/), { target: { value: 'scheduled' } }) + fireEvent.click(screen.getByRole('button', { name: 'Confirm launch' })) +} + +afterEach(() => { + cleanup() + // Remove the own-property override so jsdom's `visible` default applies. + delete (document as { visibilityState?: unknown }).visibilityState + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('useWorkflowJobStatus: a hidden tab does not delay the job outcome', () => { + it('the Jobs lane shows the outcome while the tab stays hidden', async () => { + const server = stubServer({ status: 'running' }) + await launchOnJobsLane() + await screen.findAllByText(/⟳ Running/) + + setTabHidden(true) + server.status = 'successful' + + // The poll interval is 5 s; the outcome has to arrive without the tab + // returning. The job shows on its card and in the Active Jobs panel. + expect((await screen.findAllByText('Successful', {}, { timeout: 8000 })).length).toBeGreaterThan(0) + }, 20_000) + + it('becoming visible again fetches the outcome faster than the next 5 s tick', async () => { + const server = stubServer({ status: 'running' }) + renderProbe() + await screen.findByText('running') + const reads = server.jobReads + + setTabHidden(true) + server.status = 'successful' + setTabHidden(false) + + await screen.findByText('successful', {}, { timeout: 1500 }) + expect(server.jobReads).toBeGreaterThan(reads) + }, 20_000) + + it('a settled job is not polled again, and returning to the tab does not refetch it', async () => { + const server = stubServer({ status: 'successful' }) + renderProbe() + await screen.findByText('successful') + const reads = server.jobReads + + setTabHidden(true) + setTabHidden(false) + // Longer than one 5 s poll interval: a poll that kept running would read again. + await new Promise((resolve) => setTimeout(resolve, 5600)) + + expect(server.jobReads).toBe(reads) + }, 20_000) +}) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index ae0f8eb..6539c0a 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -189,12 +189,23 @@ export function useCreateSetupRequestReceipt() { }) } +const SETTLED_JOB_STATUSES: readonly string[] = ['successful', 'failed', 'error', 'canceled'] + +function isJobSettled(data: WorkflowJobStatus | undefined): boolean { + return data !== undefined && SETTLED_JOB_STATUSES.includes(data.status) +} + export function useWorkflowJobStatus(jobId: number | null) { return useQuery({ queryKey: ['job', jobId], queryFn: () => apiCall(`/api/workflows/jobs/${jobId}`), enabled: jobId !== null, - refetchInterval: 5000, + // A job can outlast the time an operator stays on the tab, so the poll + // keeps running while the tab is hidden, and returning to a job that is + // still running refreshes it at once. Both stop once the job settles. + refetchIntervalInBackground: true, + refetchOnWindowFocus: (query) => !isJobSettled(query.state.data), + refetchInterval: (query) => (isJobSettled(query.state.data) ? false : 5000), }) } @@ -363,13 +374,22 @@ export function useAdminGroups() { const NO_KEYS: readonly string[] = [] -// `awaitingKeys` names entries whose automation job has finished but whose -// inventory record may not have caught up. While any of them still reports -// `active`, the catalog is re-read every 3 s, tab hidden or not: a hidden tab -// would otherwise skip every tick and a return within the stale time would not -// refetch either. The poll stops once none of them reads `active`, or when the -// component that passed the keys unmounts. -export function useCatalog(awaitingKeys: readonly string[] = NO_KEYS) { +interface CatalogWaits { + // Entries whose teardown job finished: pending while the inventory still + // reports them `active`. + awaitingTeardown?: readonly string[] + // Entries whose deploy job finished: pending while the inventory does not + // yet report them `active`. + awaitingDeploy?: readonly string[] +} + +// The wait lists name entries whose automation job has finished but whose +// inventory record may not have caught up. While any of them has not reached +// the state the job should have produced, the catalog is re-read every 3 s, +// tab hidden or not: a hidden tab would otherwise skip every tick and a return +// within the stale time would not refetch either. The poll stops once none of +// them is pending, or when the component that passed the lists unmounts. +export function useCatalog({ awaitingTeardown = NO_KEYS, awaitingDeploy = NO_KEYS }: CatalogWaits = {}) { return useQuery({ queryKey: ['catalog'], queryFn: () => apiCall('/api/catalog'), @@ -378,7 +398,12 @@ export function useCatalog(awaitingKeys: readonly string[] = NO_KEYS) { refetchIntervalInBackground: true, refetchInterval: (query) => { const entries = query.state.data?.entries ?? [] - return entries.some((e) => awaitingKeys.includes(e.key) && e.lifecycle === 'active') ? 3000 : false + const pending = entries.some( + (e) => + (awaitingTeardown.includes(e.key) && e.lifecycle === 'active') || + (awaitingDeploy.includes(e.key) && e.lifecycle !== 'active'), + ) + return pending ? 3000 : false }, }) } diff --git a/frontend/src/lib/inventoryWait.ts b/frontend/src/lib/inventoryWait.ts new file mode 100644 index 0000000..3f4258d --- /dev/null +++ b/frontend/src/lib/inventoryWait.ts @@ -0,0 +1,3 @@ +// How long a page waits for the inventory to confirm the result of a finished +// deploy or teardown job before it says the result is still unconfirmed. +export const INVENTORY_WAIT_MS = 60_000 diff --git a/frontend/src/pages/Catalog/index.tsx b/frontend/src/pages/Catalog/index.tsx index e495d99..247d980 100644 --- a/frontend/src/pages/Catalog/index.tsx +++ b/frontend/src/pages/Catalog/index.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { CatalogEntry } from '../../api/types' import ReasonConfirm from '../../components/ReasonConfirm' import { isValidWorkloadSlug } from '../../lib/workloadSlug' import { settleQuery } from '../../lib/queryState' +import { INVENTORY_WAIT_MS } from '../../lib/inventoryWait' import { useCatalog, useCurrentUser, @@ -33,6 +34,21 @@ interface EntryActionState { teardownOpId: string | null } +type ActionKind = 'deploy' | 'teardown' + +// A finished job whose result the inventory has not confirmed yet. `timedOut` +// is set once the wait has used up INVENTORY_WAIT_MS. +interface InventoryWait { + kind: ActionKind + timedOut: boolean +} + +// The inventory confirms a deploy when the entry reads `active`, and a +// teardown when it stops reading `active`. +function inventoryConfirms(entry: CatalogEntry, kind: ActionKind): boolean { + return kind === 'deploy' ? entry.lifecycle === 'active' : entry.lifecycle !== 'active' +} + export default function Catalog() { return ( @@ -55,7 +71,24 @@ function CatalogPage() { // direction from every other fix in this arc but the same root cause: // the CURRENT read's failure was never distinguished from "nothing to // show at all". - const { data: catalogData, loading: isLoading, failed: isError } = settleQuery(useCatalog()) + // + // `waits` holds the entries whose job finished but whose inventory record + // has not confirmed the result yet. While one is waiting, the catalog is + // polled (see useCatalog); once it times out, the poll stops. + const [waits, setWaits] = useState>({}) + const waitTimers = useRef(new Map>()) + useEffect(() => { + const timers = waitTimers.current + return () => { + timers.forEach(clearTimeout) + timers.clear() + } + }, []) + const waitingKeys = (kind: ActionKind) => + Object.keys(waits).filter((key) => waits[key].kind === kind && !waits[key].timedOut) + const { data: catalogData, loading: isLoading, failed: isError } = settleQuery( + useCatalog({ awaitingTeardown: waitingKeys('teardown'), awaitingDeploy: waitingKeys('deploy') }), + ) const { data: user } = settleQuery(useCurrentUser()) const deployMutation = useDeployCatalog() const teardownMutation = useTeardownCatalog() @@ -124,7 +157,45 @@ function CatalogPage() { } } - const handleJobComplete = (key: string, kind: 'deploy' | 'teardown') => { + const startWait = (key: string, kind: ActionKind) => { + const wait: InventoryWait = { kind, timedOut: false } + clearTimeout(waitTimers.current.get(key)) + setWaits((prev) => ({ ...prev, [key]: wait })) + waitTimers.current.set( + key, + setTimeout(() => { + // Only the wait that started this timer may expire. + setWaits((prev) => (prev[key] === wait ? { ...prev, [key]: { ...wait, timedOut: true } } : prev)) + }, INVENTORY_WAIT_MS), + ) + } + + const dropWait = (key: string) => { + clearTimeout(waitTimers.current.get(key)) + waitTimers.current.delete(key) + setWaits((prev) => { + if (!(key in prev)) return prev + const { [key]: _dropped, ...rest } = prev + return rest + }) + } + + // A wait ends as soon as the entry's own record shows the result, whether + // the poll or a later page read brought it in. + useEffect(() => { + if (!catalogData) return + const confirmed = Object.keys(waits).filter((key) => { + const entry = catalogData.entries.find((e) => e.key === key) + return !entry || inventoryConfirms(entry, waits[key].kind) + }) + confirmed.forEach(dropWait) + }, [catalogData, waits]) + + const handleJobComplete = (key: string, kind: ActionKind, jobStatus: string) => { + // A finished job is not yet a changed entry: the inventory has to confirm + // it. A failed job leaves nothing to wait for. + if (jobStatus === 'successful') startWait(key, kind) + else dropWait(key) setEntryActions((prev) => { const current = prev[key] ?? {} return { @@ -173,6 +244,13 @@ function CatalogPage() { const entries = catalogData?.entries ?? [] + // The wait that still applies to an entry: none once its own record + // confirms the result. + const waitFor = (entry: CatalogEntry): InventoryWait | null => { + const wait = waits[entry.key] + return wait && !inventoryConfirms(entry, wait.kind) ? wait : null + } + return (
Catalog @@ -205,6 +283,7 @@ function CatalogPage() { teardownError={teardownMutation.variables?.key === entry.key ? teardownMutation.error : null} onJobComplete={handleJobComplete} setEntryActions={setEntryActions} + inventoryWait={waitFor(entry)} /> ))}
@@ -226,6 +305,7 @@ function EntryCard({ teardownError, onJobComplete, setEntryActions, + inventoryWait, }: { entry: CatalogEntry actionState: EntryActionState @@ -235,10 +315,16 @@ function EntryCard({ isTearingDown: boolean deployError: unknown teardownError: unknown - onJobComplete: (key: string, kind: 'deploy' | 'teardown') => void + onJobComplete: (key: string, kind: ActionKind, status: string) => void setEntryActions: React.Dispatch>> + // Set while a finished job's result is not confirmed by the inventory yet. + // The entry's own lifecycle is then the old record, so it is neither shown + // as a state nor used to offer an action. + inventoryWait: InventoryWait | null }) { - const deployBadge = lifecycleBadge[entry.lifecycle] ?? 'bg-gray-900/30 text-gray-400' + const deployBadge = inventoryWait + ? 'bg-gray-900/30 text-gray-400' + : (lifecycleBadge[entry.lifecycle] ?? 'bg-gray-900/30 text-gray-400') // Graduated friction (hard gate 3): the Deploy/Teardown buttons arm a // reason panel; the write fires only on Confirm with a non-empty reason. const [arming, setArming] = useState<'deploy' | 'teardown' | null>(null) @@ -252,12 +338,14 @@ function EntryCard({ // Gate the buttons on this, not just the sub-second launch mutation // (isDeploying/isTearingDown), so they don't re-enable mid-job and invite a // double-click. The backend idempotency guard is the real defence; this is UX. - const deployInFlight = actionState.deployJobId !== null || actionState.deployOpId !== null - const teardownInFlight = actionState.teardownJobId !== null || actionState.teardownOpId !== null + // `!= null`, not `!== null`: the first deploy or teardown of an entry spreads + // an empty state, which leaves the other action's ids undefined, not null. + const deployInFlight = actionState.deployJobId != null || actionState.deployOpId != null + const teardownInFlight = actionState.teardownJobId != null || actionState.teardownOpId != null const deployDisabled = - entry.lifecycle === 'active' || !entry.configure_awx_job_template || isDeploying || deployInFlight + entry.lifecycle === 'active' || !entry.configure_awx_job_template || isDeploying || deployInFlight || inventoryWait !== null const teardownDisabled = - entry.lifecycle === 'bootstrapped' || !entry.finalise_awx_job_template || isTearingDown || teardownInFlight + entry.lifecycle === 'bootstrapped' || !entry.finalise_awx_job_template || isTearingDown || teardownInFlight || inventoryWait !== null return (
@@ -265,7 +353,7 @@ function EntryCard({

{entry.display_name}

- {entry.lifecycle} + {inventoryWait ? 'unconfirmed' : entry.lifecycle}

{entry.summary}

{/* EBU layer/vertical/type ontology is expert-tier vocabulary (UX @@ -289,7 +377,7 @@ function EntryCard({
- {entry.lifecycle === 'active' && entry.ingress_url && ( + {entry.lifecycle === 'active' && !inventoryWait && entry.ingress_url && ( )} + {inventoryWait && ( +

+ The {inventoryWait.kind} job for {entry.key} finished + {inventoryWait.timedOut + ? ', but the inventory has not confirmed it yet. Reload the page to check again.' + : '. Waiting for the inventory to confirm.'} +

+ )} + {entry.dependencies && entry.dependencies.length > 0 && (
Dependencies: @@ -557,8 +654,8 @@ function JobStatusLine({ }: { entryKey: string jobId: number - kind: 'deploy' | 'teardown' - onComplete: (key: string, kind: 'deploy' | 'teardown') => void + kind: ActionKind + onComplete: (key: string, kind: ActionKind, status: string) => void }) { // Same shape as OperationStatusLine above. const { data: jobStatus, failed } = settleQuery(useCatalogJobStatus(entryKey, jobId)) @@ -566,9 +663,10 @@ function JobStatusLine({ useEffect(() => { if (!jobStatus?.is_done) return - const timer = setTimeout(() => onComplete(entryKey, kind), 2000) + const finalStatus = jobStatus.status + const timer = setTimeout(() => onComplete(entryKey, kind, finalStatus), 2000) return () => clearTimeout(timer) - }, [jobStatus?.is_done, onComplete, entryKey, kind]) + }, [jobStatus?.is_done, jobStatus?.status, onComplete, entryKey, kind]) if (!jobStatus) { return ( diff --git a/frontend/src/pages/MediaWorkloads/stages/FinaliseStage.tsx b/frontend/src/pages/MediaWorkloads/stages/FinaliseStage.tsx index 9e2f7b4..58aee36 100644 --- a/frontend/src/pages/MediaWorkloads/stages/FinaliseStage.tsx +++ b/frontend/src/pages/MediaWorkloads/stages/FinaliseStage.tsx @@ -145,7 +145,7 @@ export default function FinaliseStage({ // the full reasoning (same shared PlanStage/CreateWorkload precedent). // // (umbrella #385) retrofitted onto settleQuery. - const { data: catalogData, loading: catalogLoading, failed: catalogFailed } = settleQuery(useCatalog(awaitingKeys)) + const { data: catalogData, loading: catalogLoading, failed: catalogFailed } = settleQuery(useCatalog({ awaitingTeardown: awaitingKeys })) const guardWrite = useGuardWrite() const teardownMutation = useTeardownCatalog() const purgeMutation = usePurgeWorkload() diff --git a/frontend/src/pages/MediaWorkloads/stages/ProvisionStage.tsx b/frontend/src/pages/MediaWorkloads/stages/ProvisionStage.tsx index 485f262..85fee81 100644 --- a/frontend/src/pages/MediaWorkloads/stages/ProvisionStage.tsx +++ b/frontend/src/pages/MediaWorkloads/stages/ProvisionStage.tsx @@ -12,7 +12,7 @@ import { useActivityStore } from '../../../store/activity' import ReasonConfirm from '../../../components/ReasonConfirm' import { requestFailureText } from '../../../lib/requestFailureText' import { isValidWorkloadSlug } from '../../../lib/workloadSlug' -import type { CatalogEntry, ClearForDeploymentResult, MediaWorkload } from '../../../api/types' +import type { CatalogEntry, ClearForDeploymentResult, MediaWorkload, Operation } from '../../../api/types' import type { StageActionId, StageState } from '../../../lib/workloadLifecycle' import ClearForDeployment from '../ClearForDeployment' import { lookupState, requestedBadge, requestedLabel, REQUESTED_TITLE } from '../stateBadges' @@ -92,7 +92,15 @@ export default function ProvisionStage({ // unhandled error path (verbatim the failure mode PlanStage.tsx's own // docstring names, already fixed there and in CreateWorkload.tsx). // (umbrella #385) retrofitted onto settleQuery. - const { data: catalogData, loading: catalogLoading, failed: catalogFailed } = settleQuery(useCatalog()) + // + // `awaitingKeys` are the entries whose deploy finished but whose inventory + // record may not read `active` yet: while any does, the catalog is polled + // (see useCatalog). It is component state on purpose, so a remount starts + // from the catalog alone. + const [awaitingKeys, setAwaitingKeys] = useState([]) + const { data: catalogData, loading: catalogLoading, failed: catalogFailed } = settleQuery( + useCatalog({ awaitingDeploy: awaitingKeys }), + ) // (umbrella #385) retrofitted // onto settleQuery — behavior-preserving (user is only ever read via // `?? 'unknown'` audit-trail fallbacks below, which already degrade @@ -149,6 +157,19 @@ export default function ProvisionStage({ const entries = (catalogData?.entries ?? []).filter((e) => functionKeys.includes(e.key)) + // A key leaves the list the moment its own entry reads `active`. Clearing + // the key then, not just hiding the text, keeps a later teardown of the same + // entry from reading as a deploy still unconfirmed. + const isAwaitingInventory = (key: string) => + awaitingKeys.includes(key) && (catalogData?.entries.some((e) => e.key === key && e.lifecycle !== 'active') ?? false) + useEffect(() => { + if (!catalogData) return + setAwaitingKeys((prev) => { + const next = prev.filter((key) => catalogData.entries.some((e) => e.key === key && e.lifecycle !== 'active')) + return next.length === prev.length ? prev : next + }) + }, [catalogData, awaitingKeys]) + const allowed = actions.includes('deploy') // "One btn-primary in source" proves nothing — this stage maps one @@ -183,7 +204,7 @@ export default function ProvisionStage({ // why removing it would be its own regression), not to pick which entry to // promote. const eligibleDeployEntries = allowed && !busy - ? entries.filter((e) => e.lifecycle !== 'active' && !activeTrack(track[e.key])) + ? entries.filter((e) => e.lifecycle !== 'active' && !activeTrack(track[e.key]) && !isAwaitingInventory(e.key)) : [] // umbrella #432 §D1: whether THIS stage shows a top-level primary control @@ -258,8 +279,18 @@ export default function ProvisionStage({ })) } - const handleJobComplete = (key: string) => { + // A finished deploy is not yet a deployed entry: the inventory has to + // confirm it. A failed job releases only its own entry. + const markDeployOutcome = (key: string, succeeded: boolean) => { + setAwaitingKeys((prev) => { + const others = prev.filter((k) => k !== key) + return succeeded ? [...others, key] : others + }) + } + + const handleJobComplete = (key: string, jobStatus: string) => { setTrack((prev) => ({ ...prev, [key]: EMPTY_TRACK })) + markDeployOutcome(key, jobStatus === 'successful') void queryClient.invalidateQueries({ queryKey: ['media-workloads-grouped'] }) void queryClient.invalidateQueries({ queryKey: ['catalog'] }) } @@ -276,8 +307,9 @@ export default function ProvisionStage({ // deploy that errors out from a normally-observed `error` state; this // isn't a new standard for the stage, just closing the same gap for the // states `error` alone didn't cover). - const handleOpTerminal = (key: string) => { + const handleOpTerminal = (key: string, operation: Operation) => { setTrack((prev) => ({ ...prev, [key]: EMPTY_TRACK })) + markDeployOutcome(key, operation.state === 'run_complete') void queryClient.invalidateQueries({ queryKey: ['media-workloads-grouped'] }) void queryClient.invalidateQueries({ queryKey: ['catalog'] }) } @@ -340,8 +372,9 @@ export default function ProvisionStage({ }} onOpLaunched={(jobId) => setTrack((prev) => ({ ...prev, [entry.key]: { jobId, opId: null } }))} onOpError={() => setTrack((prev) => ({ ...prev, [entry.key]: EMPTY_TRACK }))} - onOpTerminal={() => handleOpTerminal(entry.key)} - onJobComplete={() => handleJobComplete(entry.key)} + awaitingInventory={isAwaitingInventory(entry.key)} + onOpTerminal={(operation) => handleOpTerminal(entry.key, operation)} + onJobComplete={(status) => handleJobComplete(entry.key, status)} /> ))}
@@ -444,6 +477,7 @@ function ProvisionEntry({ onOpError, onOpTerminal, onJobComplete, + awaitingInventory, }: { entry: CatalogEntry workloadSlug: string @@ -458,16 +492,19 @@ function ProvisionEntry({ onDismissError: () => void onOpLaunched: (jobId: number) => void onOpError: () => void - /** umbrella #403: see ProvisionStage's handleOpTerminal — this entry - * doesn't need the settled operation itself, only that one reached. */ - onOpTerminal: () => void - onJobComplete: () => void + /** See ProvisionStage's handleOpTerminal. */ + onOpTerminal: (operation: Operation) => void + /** `status` is the job's final status. */ + onJobComplete: (status: string) => void + // The deploy job finished but the inventory does not list the entry as + // active yet, so offering Deploy again would act on a stale record. + awaitingInventory: boolean }) { const [arming, setArming] = useState(false) const [workload, setWorkload] = useState(workloadSlug) const workloadInvalid = workload.trim() !== '' && !isValidWorkloadSlug(workload.trim()) const inFlight = track.jobId !== null || track.opId !== null - const offered = allowed && !inFlight && entry.lifecycle !== 'active' + const offered = allowed && !inFlight && !awaitingInventory && entry.lifecycle !== 'active' return (
@@ -495,7 +532,13 @@ function ProvisionEntry({ {entry.lifecycle === 'active' && !inFlight && (

Already deployed.

)} - {entry.lifecycle !== 'active' && !allowed && !inFlight && ( + {awaitingInventory && !inFlight && ( +

+ The deploy job for {entry.key} finished. + Waiting for the inventory to confirm. +

+ )} + {entry.lifecycle !== 'active' && !allowed && !inFlight && !awaitingInventory && (

Provision isn't currently offering to deploy this template — see the rail state above. @@ -564,7 +607,11 @@ function ProvisionEntry({ )} {track.jobId != null && (

- + onJobComplete(status)} + />
)}
From 583d8909adaf96cd56dd1f447b026b938cfe4cf0 Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:56:23 +0100 Subject: [PATCH 2/2] fix(console): confirm watched operations that settle without a launched state on the catalog page The catalog page's operation status line acted on `launched` and `error` only. A watched deploy or teardown runs on past `launched`, and a poll can miss that state, so an operation that a poll first saw as `run_complete` never cleared its id and never started the inventory wait. The status line now also acts on the watched terminal states, taken from the shared terminal set. A clean finish counts as a successful job and starts the inventory wait; any other end releases the action. Watched states are labelled from the shared table. The page keeps its own status line because the shared one says "Launch did not start" for any operation with an error, which isn't true of a watched action that ran. Refs dmfdeploy/dmfdeploy#878 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- .../catalogInventoryConfirm.test.tsx | 62 ++++++++++++++++++- frontend/src/pages/Catalog/index.tsx | 37 ++++++++--- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/frontend/src/__tests__/catalogInventoryConfirm.test.tsx b/frontend/src/__tests__/catalogInventoryConfirm.test.tsx index 77b1025..c2fce4a 100644 --- a/frontend/src/__tests__/catalogInventoryConfirm.test.tsx +++ b/frontend/src/__tests__/catalogInventoryConfirm.test.tsx @@ -14,7 +14,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import Catalog from '../pages/Catalog' -import type { CatalogEntry } from '../api/types' +import type { CatalogEntry, OperationState } from '../api/types' function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) @@ -43,10 +43,14 @@ interface Server { catalogLifecycle: CatalogEntry['lifecycle'] jobStatus: string catalogReads: number + // When set, deploy and teardown return a watched operation (202) that + // reads at this state from the first poll on, never passing through + // `launched`. + operationState: OperationState | null } function stubServer(initial: Partial = {}): Server { - const server: Server = { catalogLifecycle: 'bootstrapped', jobStatus: 'successful', catalogReads: 0, ...initial } + const server: Server = { catalogLifecycle: 'bootstrapped', jobStatus: 'successful', catalogReads: 0, operationState: null, ...initial } vi.stubGlobal( 'fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -64,6 +68,17 @@ function stubServer(initial: Partial = {}): Server { if (url.endsWith('/api/media-workloads/grouped')) { return json({ configured: true, degraded: false, workloads: [], invalid_instances: [] }) } + if (/\/api\/catalog\/crosspoint\/(deploy|teardown)$/.test(url) && init?.method === 'POST' && server.operationState) { + return json({ operation_id: 'op-0123456789', state: 'waking', request_id: 'req-1' }, 202) + } + if (url.endsWith('/api/operations/op-0123456789') && server.operationState) { + return json({ + operation_id: 'op-0123456789', action: url.includes('teardown') ? 'teardown' : 'deploy', target: 'crosspoint', + state: server.operationState, job_id: null, + error: server.operationState === 'run_failed' ? 'job failed' : null, + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }) + } if (/\/api\/catalog\/crosspoint\/(deploy|teardown)$/.test(url) && init?.method === 'POST') { return json({ job_id: 901, status: 'launched', request_id: 'req-1' }) } @@ -182,4 +197,47 @@ describe('Catalog: the inventory has to confirm a finished deploy or teardown', expect(server.catalogReads).toBeGreaterThan(readsWhenFlipped) expect(document.body.textContent).not.toMatch(waiting('deploy')) }, 40_000) + + it('a deploy operation that goes straight to run_complete waits for the inventory, then confirms', async () => { + const server = stubServer({ catalogLifecycle: 'bootstrapped', operationState: 'run_complete' }) + renderCatalog() + await arm(/Deploy/, /Confirm deploy/) + + await waitFor(() => expect(document.body.textContent).toMatch(waiting('deploy')), { timeout: 8000 }) + expect(screen.getByText('unconfirmed')).toBeTruthy() + expect(button(/Deploy/).disabled).toBe(true) + + server.catalogLifecycle = 'active' + await waitFor(() => expect(screen.getByText('active')).toBeTruthy(), { timeout: 6000 }) + expect(document.body.textContent).not.toMatch(waiting('deploy')) + expect(screen.queryByText('unconfirmed')).toBeNull() + expect(button(/Teardown/).disabled).toBe(false) + }, 30_000) + + it('a teardown operation that goes straight to run_complete waits for the inventory, then confirms', async () => { + const server = stubServer({ catalogLifecycle: 'active', operationState: 'run_complete' }) + renderCatalog() + await arm(/Teardown/, /Confirm teardown/) + + await waitFor(() => expect(document.body.textContent).toMatch(waiting('teardown')), { timeout: 8000 }) + expect(screen.getByText('unconfirmed')).toBeTruthy() + expect(button(/Teardown/).disabled).toBe(true) + + server.catalogLifecycle = 'bootstrapped' + await waitFor(() => expect(screen.getByText('bootstrapped')).toBeTruthy(), { timeout: 6000 }) + expect(document.body.textContent).not.toMatch(waiting('teardown')) + expect(button(/Deploy/).disabled).toBe(false) + }, 30_000) + + it('a deploy operation that ends run_failed releases the action and claims no deployed state', async () => { + stubServer({ catalogLifecycle: 'bootstrapped', operationState: 'run_failed' }) + renderCatalog() + await arm(/Deploy/, /Confirm deploy/) + + await waitFor(() => expect(button(/Deploy/).disabled).toBe(false), { timeout: 8000 }) + expect(document.body.textContent).not.toMatch(waiting('deploy')) + expect(screen.queryByText('unconfirmed')).toBeNull() + expect(screen.getByText('bootstrapped')).toBeTruthy() + expect(screen.queryByText('active')).toBeNull() + }, 30_000) }) diff --git a/frontend/src/pages/Catalog/index.tsx b/frontend/src/pages/Catalog/index.tsx index 247d980..69e671e 100644 --- a/frontend/src/pages/Catalog/index.tsx +++ b/frontend/src/pages/Catalog/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' -import type { CatalogEntry } from '../../api/types' +import type { CatalogEntry, Operation } from '../../api/types' import ReasonConfirm from '../../components/ReasonConfirm' import { isValidWorkloadSlug } from '../../lib/workloadSlug' import { settleQuery } from '../../lib/queryState' @@ -13,12 +13,14 @@ import { useCatalogJobStatus, useOperationStatus, isOperation, + _WATCHED_TERMINAL_STATES, } from '../../api/hooks' import { useActivityStore } from '../../store/activity' import PageHeading from '../../components/PageHeading' import { usePageTitle } from '../../hooks/usePageTitle' import { useGuardWrite } from '../../lib/inventoryFreshness' import { InventoryWritesGate, InventoryWritesNotice } from '../MediaWorkloads' +import { OPERATION_CLASS, OPERATION_LABEL } from '../MediaWorkloads/stages/JobProgress' const lifecycleBadge: Record = { bootstrapped: 'bg-muted/20 text-muted', @@ -502,6 +504,7 @@ function EntryCard({ [entry.key]: { ...prev[entry.key], deployOpId: null }, })) }} + onTerminal={(operation) => onJobComplete(entry.key, 'deploy', operationOutcome(operation))} />
)} @@ -526,6 +529,7 @@ function EntryCard({ [entry.key]: { ...prev[entry.key], teardownOpId: null }, })) }} + onTerminal={(operation) => onJobComplete(entry.key, 'teardown', operationOutcome(operation))} />
)} @@ -561,10 +565,18 @@ function EntryCard({ /* ─── operation status line (polls via hook) ─── */ +// A watched operation that finished cleanly counts as a successful job, so the +// page waits for the inventory to confirm it; any other end leaves nothing to +// wait for. +function operationOutcome(operation: Operation): string { + return operation.state === 'run_complete' ? 'successful' : operation.state +} + function OperationStatusLine({ operationId, onLaunched, onError, + onTerminal, }: { // entryKey + kind are part of the call-site contract (passed by every caller) // but the status line keys off operationId / operation.state; accept them in @@ -574,6 +586,7 @@ function OperationStatusLine({ kind: 'deploy' | 'teardown' onLaunched: (jobId: number) => void onError: () => void + onTerminal: (operation: Operation) => void }) { // (umbrella #385) A settled failed // refetch retained the last-good `operation` with no notice at all — @@ -590,12 +603,21 @@ function OperationStatusLine({ } else if (operation.state === 'error') { console.error('Operation failed:', operation.error) timer = setTimeout(() => onError(), 3000) + } else if (_WATCHED_TERMINAL_STATES.includes(operation.state)) { + // A watched action runs on past `launched`, which a poll can miss, so + // its final state is the only signal that always arrives. A clean + // finish hands off promptly; anything else stays up long enough to read. + const settledOperation = operation + timer = setTimeout( + () => onTerminal(settledOperation), + operation.state === 'run_complete' ? 1000 : 3000, + ) } return () => { if (timer) clearTimeout(timer) } - }, [operation, onLaunched, onError]) + }, [operation, onLaunched, onError, onTerminal]) if (!operation) { return ( @@ -605,18 +627,15 @@ function OperationStatusLine({ ) } - // umbrella #347: Record (not an inferred 4-key literal) — - // Operation.state now spans the full #202 WP2 watched-action vocabulary - // (run_complete/run_failed/…), which this pre-existing widget never - // switched over to observing; the fallback below keeps it honest about an - // unrecognized value instead of rendering `undefined`. + // Operation.state spans the watched-action states too; those take their + // wording from the shared table so no raw state name reaches the page. const STATE_LABEL: Record = { waking: 'Waking AWX', launching: 'Launching job', launched: 'Launched', error: 'Error', } - const stateLabel = STATE_LABEL[operation.state] ?? operation.state + const stateLabel = STATE_LABEL[operation.state] ?? OPERATION_LABEL[operation.state] ?? operation.state const STATE_CLASS: Record = { waking: 'text-yellow-300', @@ -624,7 +643,7 @@ function OperationStatusLine({ launched: 'text-green-400', error: 'text-red-400', } - const stateClass = STATE_CLASS[operation.state] ?? 'text-muted' + const stateClass = STATE_CLASS[operation.state] ?? OPERATION_CLASS[operation.state] ?? 'text-muted' return (