diff --git a/docs/fleet-recovery-qualification-20261007.md b/docs/fleet-recovery-qualification-20261007.md
index 1d916efd..16321fc6 100644
--- a/docs/fleet-recovery-qualification-20261007.md
+++ b/docs/fleet-recovery-qualification-20261007.md
@@ -150,3 +150,18 @@ DOMContentLoaded/60-second fixture passed both widths. Logs:
`/tmp/archy-fleet-acceptance-browser-retry.log`. Owned browser/server jobs stopped.
This is source-browser evidence, not a production build or real identity/transport
acceptance. Backend provenance qualification and coupled release remain required.
+
+The backend provenance follow-up explicitly returns `history_available=false`
+for federation identities whose collector history cannot be trusted. The UI now
+consumes that flag: it clears any supplied history entries and displays “History
+is unavailable for this node.” Changing selection clears the prior message;
+legacy `history`/`entries` forms remain supported. A failed history refresh marks
+retained history or unavailability instead of silently implying success. A new
+composable plus real detail-component fixture verifies the unavailable flag wins
+even when entries accompany it, and does not leak into a newly selected node.
+Focused Fleet suite after this follow-up: **29 tests in five files passed**;
+log `/tmp/archy-fleet-history-final.log`.
+The exact app-project typecheck also passed again after the history follow-up,
+with the same 2 GB bound; log `/tmp/archy-fleet-history-typecheck.log`.
+Completed logs and browser fixture sources are archived at
+`~/.local/state/archipelago/release-qualification/fleet-acceptance-20261007/`.
diff --git a/neode-ui/src/views/Fleet.vue b/neode-ui/src/views/Fleet.vue
index 60364a5c..32162d71 100644
--- a/neode-ui/src/views/Fleet.vue
+++ b/neode-ui/src/views/Fleet.vue
@@ -125,6 +125,7 @@
:node="fleet.selectedNode.value"
:node-id="fleet.selectedNodeId.value"
:history-loading="fleet.nodeHistoryLoading.value"
+ :history-message="fleet.nodeHistoryMessage.value"
:history-labels="fleet.nodeHistoryLabels.value"
:cpu-datasets="fleet.nodeHistoryCpuDatasets.value"
:mem-datasets="fleet.nodeHistoryMemDatasets.value"
diff --git a/neode-ui/src/views/fleet/FleetNodeDetail.vue b/neode-ui/src/views/fleet/FleetNodeDetail.vue
index b9d08e51..4577d03e 100644
--- a/neode-ui/src/views/fleet/FleetNodeDetail.vue
+++ b/neode-ui/src/views/fleet/FleetNodeDetail.vue
@@ -32,6 +32,7 @@
+
{{ historyMessage }}
Loading history...
@@ -129,6 +130,7 @@ defineProps<{
node: FleetNode | null
nodeId: string
historyLoading: boolean
+ historyMessage?: string
historyLabels: string[]
cpuDatasets: ChartDataset[]
memDatasets: ChartDataset[]
diff --git a/neode-ui/src/views/fleet/__tests__/FleetPartialFailure.test.ts b/neode-ui/src/views/fleet/__tests__/FleetPartialFailure.test.ts
index 48908e83..5541b400 100644
--- a/neode-ui/src/views/fleet/__tests__/FleetPartialFailure.test.ts
+++ b/neode-ui/src/views/fleet/__tests__/FleetPartialFailure.test.ts
@@ -2,8 +2,9 @@ import { afterEach, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { rpcClient } from '@/api/rpc-client'
-import { useFleetData } from '../useFleetData'
+import { useFleetData, normalizeFleetNode } from '../useFleetData'
import FleetAlerts from '../FleetAlerts.vue'
+import FleetNodeDetail from '../FleetNodeDetail.vue'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() })
@@ -82,3 +83,31 @@ it('ignores an older alerts failure after a newer successful refresh', async ()
expect(fleet.alertsLoading.value).toBe(false)
} finally { wrapper.unmount() }
})
+
+it('reports explicitly unavailable history and clears the message when changing nodes', async () => {
+ vi.mocked(rpcClient.call).mockImplementation(({ method, params }) => {
+ if (method !== 'telemetry.fleet-node-history') return Promise.resolve({ nodes: [], alerts: [] }) as never
+ return Promise.resolve((params as { node_id: string }).node_id === 'trusted'
+ ? { history_available: false, entries: [{ timestamp: 'must-not-render', cpu_pct: 99 }] }
+ : { entries: [{ timestamp: 'legacy-available', cpu_pct: 12 }] }) as never
+ })
+ let fleet!: ReturnType
+ const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
+ try {
+ await flushPromises()
+ fleet.selectNode('trusted'); await flushPromises()
+ expect(fleet.nodeHistoryMessage.value).toContain('unavailable')
+ expect(fleet.nodeHistory.value).toEqual([])
+ const detail = mount(FleetNodeDetail, { props: {
+ node: normalizeFleetNode({ node_id: 'trusted' }), nodeId: 'trusted',
+ historyLoading: false, historyMessage: fleet.nodeHistoryMessage.value,
+ historyLabels: [], cpuDatasets: [], memDatasets: [], diskDatasets: [], chartWidth: 300,
+ }, global: { mocks: { $ver: (v: string) => v }, stubs: { LineChart: true } } })
+ expect(detail.get('[role="status"]').text()).toContain('History is unavailable')
+ expect(detail.text()).not.toContain('CPU History')
+ detail.unmount()
+ fleet.selectNode('legacy'); await flushPromises()
+ expect(fleet.nodeHistoryMessage.value).toBe('')
+ expect(fleet.nodeHistory.value[0]?.timestamp).toBe('legacy-available')
+ } finally { wrapper.unmount() }
+})
diff --git a/neode-ui/src/views/fleet/useFleetData.ts b/neode-ui/src/views/fleet/useFleetData.ts
index 22cbab62..5d74b83a 100644
--- a/neode-ui/src/views/fleet/useFleetData.ts
+++ b/neode-ui/src/views/fleet/useFleetData.ts
@@ -281,6 +281,7 @@ export function useFleetData() {
const selectedNodeId = ref(cached.selectedNodeId ?? null)
const nodeHistory = ref([])
const nodeHistoryLoading = ref(false)
+ const nodeHistoryMessage = ref('')
const autoRefresh = ref(true)
const lastRefreshed = ref(cached.lastRefreshed ?? '')
const sortBy = ref(cached.sortBy ?? 'status')
@@ -423,15 +424,25 @@ export function useFleetData() {
const request = ++historyRequest
nodeHistoryLoading.value = true
try {
- const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
+ const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[]; history_available?: boolean }>({
method: 'telemetry.fleet-node-history',
params: { node_id: nodeId },
})
if (request === historyRequest && selectedNodeId.value === nodeId) {
- nodeHistory.value = normalizeNodeHistoryResponse(data)
+ if (data.history_available === false) {
+ nodeHistory.value = []
+ nodeHistoryMessage.value = 'History is unavailable for this node.'
+ } else {
+ nodeHistory.value = normalizeNodeHistoryResponse(data)
+ nodeHistoryMessage.value = ''
+ }
}
} catch {
- // Non-critical
+ if (request === historyRequest && selectedNodeId.value === nodeId) {
+ nodeHistoryMessage.value = nodeHistory.value.length
+ ? 'History refresh failed. Showing the last received history.'
+ : 'History is unavailable for this node.'
+ }
} finally {
if (request === historyRequest) nodeHistoryLoading.value = false
}
@@ -521,6 +532,7 @@ export function useFleetData() {
historyRequest++
nodeHistory.value = []
nodeHistoryLoading.value = false
+ nodeHistoryMessage.value = ''
if (newId) {
fetchNodeHistory(newId)
} else {
@@ -599,7 +611,7 @@ export function useFleetData() {
return {
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading, alertsErrorMessage,
- selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
+ selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading, nodeHistoryMessage,
autoRefresh, lastRefreshed, sortBy, chartWidth, now,
onlineCount, offlineCount, unknownCount, healthyCount, fleetHealthPct,
avgCpu, avgMem, avgDisk, sortedNodes, allAppIds,