From ad53b886da78bb2065112418760f617c98237cb7 Mon Sep 17 00:00:00 2001
From: znerol2 <6800371+znerol2@users.noreply.github.com>
Date: Sat, 7 Nov 2026 14:54:39 +0100
Subject: [PATCH] fix(console): close two pending-state gaps
A topology poll whose sources held a null entry crashed the views that
read it, and an entry without an id was shown or offered as a source.
Validate every entry with one shared check and treat anything else as
malformed, which fails closed as before; the pending switch snapshot
still holds.
ReasonConfirm also told the operator to confirm again when inventory
went stale while a request was already in flight. Hide that guidance
while pending; it returns once the request settles.
Closes dmfdeploy/dmfdeploy#863
Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com>
---
.../reasonConfirmPendingStale.test.tsx | 38 +++++++++++++++++++
.../__tests__/switchSourcePending.test.tsx | 32 +++++++++++++++-
frontend/src/components/ReasonConfirm.tsx | 4 +-
frontend/src/lib/topologySources.ts | 17 +++++++++
.../src/pages/MediaWorkloads/WorkloadHome.tsx | 3 +-
.../MediaWorkloads/stages/ConfigureStage.tsx | 3 +-
.../MediaWorkloads/stages/DesignStage.tsx | 3 +-
7 files changed, 94 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/__tests__/reasonConfirmPendingStale.test.tsx
create mode 100644 frontend/src/lib/topologySources.ts
diff --git a/frontend/src/__tests__/reasonConfirmPendingStale.test.tsx b/frontend/src/__tests__/reasonConfirmPendingStale.test.tsx
new file mode 100644
index 0000000..667d40c
--- /dev/null
+++ b/frontend/src/__tests__/reasonConfirmPendingStale.test.tsx
@@ -0,0 +1,38 @@
+/**
+ * dmfdeploy/dmfdeploy#863: once a request is sent, ReasonConfirm must not tell
+ * the operator to confirm again when the inventory goes stale. The pre-submit
+ * guidance returns after the request settles.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import ReasonConfirm from '../components/ReasonConfirm'
+import { InventoryWritesBlockedProvider } from '../lib/inventoryFreshness'
+
+afterEach(cleanup)
+
+const GUIDANCE = /Confirm again after the next successful read/
+
+function panel(blocked: boolean, pending: boolean) {
+ return (
+
The inventory is out of date, so this change cannot be sent right now. Your reason is kept. Confirm again after the next successful read. diff --git a/frontend/src/lib/topologySources.ts b/frontend/src/lib/topologySources.ts new file mode 100644 index 0000000..2b45d97 --- /dev/null +++ b/frontend/src/lib/topologySources.ts @@ -0,0 +1,17 @@ +import type { InstanceTopology } from '../api/types' + +/** + * True when `topology.sources` is an array whose every entry is an object + * with a string `id`. Anything else is malformed: reading `s.id` on a `null` + * entry throws, and an id-less entry would be shown or offered as a source + * with no id, so a consumer treats the whole payload as unknown instead. + */ +export function hasValidSources(topology: InstanceTopology | null | undefined): topology is InstanceTopology { + const sources: unknown = topology?.sources + return ( + Array.isArray(sources) && + sources.every( + (s) => typeof s === 'object' && s !== null && typeof (s as { id?: unknown }).id === 'string', + ) + ) +} diff --git a/frontend/src/pages/MediaWorkloads/WorkloadHome.tsx b/frontend/src/pages/MediaWorkloads/WorkloadHome.tsx index f2a8bd2..fe218a7 100644 --- a/frontend/src/pages/MediaWorkloads/WorkloadHome.tsx +++ b/frontend/src/pages/MediaWorkloads/WorkloadHome.tsx @@ -11,6 +11,7 @@ import { LOCKED_REASON } from './WorkloadSetup' import WorkloadTile from './WorkloadTile' import InstanceLiveModal from './InstanceLiveModal' import { settleQuery } from '../../lib/queryState' +import { hasValidSources } from '../../lib/topologySources' import { LIVE_TILE_CAP, useDocumentVisible, usePrefersReducedMotion } from './liveView' import PageHeading from '../../components/PageHeading' import { usePageTitle } from '../../hooks/usePageTitle' @@ -745,7 +746,7 @@ function InstanceActiveSource({ onResolved: (instance: string, has: boolean) => void }) { const topology = useInstanceTopology(instance, { enabled: hasTopology !== false }) - const has = !topology.isError && Boolean(topology.data && Array.isArray(topology.data.sources)) + const has = !topology.isError && hasValidSources(topology.data) useEffect(() => onResolved(instance, has), [instance, has, onResolved]) // An instance without a topology renders nothing — not an empty card, not diff --git a/frontend/src/pages/MediaWorkloads/stages/ConfigureStage.tsx b/frontend/src/pages/MediaWorkloads/stages/ConfigureStage.tsx index 78fe1b6..a014fdd 100644 --- a/frontend/src/pages/MediaWorkloads/stages/ConfigureStage.tsx +++ b/frontend/src/pages/MediaWorkloads/stages/ConfigureStage.tsx @@ -8,6 +8,7 @@ import StageCard from './StageCard' import { SWITCH_REQUEST_FAILED_TEXT, switchFailureText } from '../switchResultText' import { settleQuery } from '../../../lib/queryState' import { useGuardWrite } from '../../../lib/inventoryFreshness' +import { hasValidSources } from '../../../lib/topologySources' /** * Configure — source selection and the switch, relocated from @@ -184,7 +185,7 @@ function InstanceSwitchControl({ const pending = switchMutation.isPending const frozen = pending ? submitted : null const live = - topology.failed || !topology.data || !Array.isArray(topology.data.sources) ? null : topology.data + topology.failed || !hasValidSources(topology.data) ? null : topology.data if (!live && !frozen) return null const sources = live?.sources ?? [] diff --git a/frontend/src/pages/MediaWorkloads/stages/DesignStage.tsx b/frontend/src/pages/MediaWorkloads/stages/DesignStage.tsx index 99a6e6a..a9e033e 100644 --- a/frontend/src/pages/MediaWorkloads/stages/DesignStage.tsx +++ b/frontend/src/pages/MediaWorkloads/stages/DesignStage.tsx @@ -1,4 +1,5 @@ import { useInstanceTopology } from '../../../api/hooks' +import { hasValidSources } from '../../../lib/topologySources' import type { CatalogEntry, MediaWorkload } from '../../../api/types' import type { StageState } from '../../../lib/workloadLifecycle' import StageCard from './StageCard' @@ -192,7 +193,7 @@ function InstanceComposition({ instance, hasTopology }: { instance: string; hasT // // (umbrella #385) retrofitted onto settleQuery. const topology = settleQuery(useInstanceTopology(instance, { enabled: hasTopology !== false })) - if (topology.failed || !topology.data || !Array.isArray(topology.data.sources)) return null + if (topology.failed || !hasValidSources(topology.data)) return null const { sources, active_source } = topology.data if (sources.length === 0) return null