From fd98b38364f23eaa4a381434be498d2e5f1207ea Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 20:41:08 -0400 Subject: [PATCH] Show unavailable Fleet history explicitly --- docs/fleet-recovery-qualification-20261007.md | 15 +++++++++ neode-ui/src/views/Fleet.vue | 1 + neode-ui/src/views/fleet/FleetNodeDetail.vue | 2 ++ .../__tests__/FleetPartialFailure.test.ts | 31 ++++++++++++++++++- neode-ui/src/views/fleet/useFleetData.ts | 20 +++++++++--- 5 files changed, 64 insertions(+), 5 deletions(-) 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,