diff --git a/frontend/src/__tests__/catalogInventoryConfirm.test.tsx b/frontend/src/__tests__/catalogInventoryConfirm.test.tsx new file mode 100644 index 0000000..c2fce4a --- /dev/null +++ b/frontend/src/__tests__/catalogInventoryConfirm.test.tsx @@ -0,0 +1,243 @@ +/** + * 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, OperationState } 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 + // 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, operationState: null, ...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' && 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' }) + } + 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) + + 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/__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..69e671e 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 type { CatalogEntry, Operation } 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, @@ -12,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', @@ -33,6 +36,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 +73,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 +159,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 +246,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 +285,7 @@ function CatalogPage() { teardownError={teardownMutation.variables?.key === entry.key ? teardownMutation.error : null} onJobComplete={handleJobComplete} setEntryActions={setEntryActions} + inventoryWait={waitFor(entry)} /> ))}
@@ -226,6 +307,7 @@ function EntryCard({ teardownError, onJobComplete, setEntryActions, + inventoryWait, }: { entry: CatalogEntry actionState: EntryActionState @@ -235,10 +317,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 +340,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 +355,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 +379,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: @@ -405,6 +504,7 @@ function EntryCard({ [entry.key]: { ...prev[entry.key], deployOpId: null }, })) }} + onTerminal={(operation) => onJobComplete(entry.key, 'deploy', operationOutcome(operation))} />
)} @@ -429,6 +529,7 @@ function EntryCard({ [entry.key]: { ...prev[entry.key], teardownOpId: null }, })) }} + onTerminal={(operation) => onJobComplete(entry.key, 'teardown', operationOutcome(operation))} />
)} @@ -464,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 @@ -477,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 — @@ -493,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 ( @@ -508,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', @@ -527,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 (
@@ -557,8 +673,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 +682,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)} + />
)}