Show unavailable Fleet history explicitly

This commit is contained in:
archipelago
2026-10-07 20:42:18 -04:00
parent f5a1806ae3
commit fd98b38364
5 changed files with 64 additions and 5 deletions
@@ -150,3 +150,18 @@ DOMContentLoaded/60-second fixture passed both widths. Logs:
`/tmp/archy-fleet-acceptance-browser-retry.log`. Owned browser/server jobs stopped. `/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 This is source-browser evidence, not a production build or real identity/transport
acceptance. Backend provenance qualification and coupled release remain required. 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/`.
+1
View File
@@ -125,6 +125,7 @@
:node="fleet.selectedNode.value" :node="fleet.selectedNode.value"
:node-id="fleet.selectedNodeId.value" :node-id="fleet.selectedNodeId.value"
:history-loading="fleet.nodeHistoryLoading.value" :history-loading="fleet.nodeHistoryLoading.value"
:history-message="fleet.nodeHistoryMessage.value"
:history-labels="fleet.nodeHistoryLabels.value" :history-labels="fleet.nodeHistoryLabels.value"
:cpu-datasets="fleet.nodeHistoryCpuDatasets.value" :cpu-datasets="fleet.nodeHistoryCpuDatasets.value"
:mem-datasets="fleet.nodeHistoryMemDatasets.value" :mem-datasets="fleet.nodeHistoryMemDatasets.value"
@@ -32,6 +32,7 @@
</div> </div>
<!-- History Charts --> <!-- History Charts -->
<p v-if="historyMessage" role="status" class="text-sm text-amber-200 mb-4">{{ historyMessage }}</p>
<div v-if="historyLoading && !historyLabels.length" class="text-white/40 text-sm py-4 text-center mb-4"> <div v-if="historyLoading && !historyLabels.length" class="text-white/40 text-sm py-4 text-center mb-4">
Loading history... Loading history...
</div> </div>
@@ -129,6 +130,7 @@ defineProps<{
node: FleetNode | null node: FleetNode | null
nodeId: string nodeId: string
historyLoading: boolean historyLoading: boolean
historyMessage?: string
historyLabels: string[] historyLabels: string[]
cpuDatasets: ChartDataset[] cpuDatasets: ChartDataset[]
memDatasets: ChartDataset[] memDatasets: ChartDataset[]
@@ -2,8 +2,9 @@ import { afterEach, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useFleetData } from '../useFleetData' import { useFleetData, normalizeFleetNode } from '../useFleetData'
import FleetAlerts from '../FleetAlerts.vue' import FleetAlerts from '../FleetAlerts.vue'
import FleetNodeDetail from '../FleetNodeDetail.vue'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() }) 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) expect(fleet.alertsLoading.value).toBe(false)
} finally { wrapper.unmount() } } 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<typeof useFleetData>
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() }
})
+15 -3
View File
@@ -281,6 +281,7 @@ export function useFleetData() {
const selectedNodeId = ref<string | null>(cached.selectedNodeId ?? null) const selectedNodeId = ref<string | null>(cached.selectedNodeId ?? null)
const nodeHistory = ref<NodeHistoryEntry[]>([]) const nodeHistory = ref<NodeHistoryEntry[]>([])
const nodeHistoryLoading = ref(false) const nodeHistoryLoading = ref(false)
const nodeHistoryMessage = ref('')
const autoRefresh = ref(true) const autoRefresh = ref(true)
const lastRefreshed = ref(cached.lastRefreshed ?? '') const lastRefreshed = ref(cached.lastRefreshed ?? '')
const sortBy = ref<SortOption>(cached.sortBy ?? 'status') const sortBy = ref<SortOption>(cached.sortBy ?? 'status')
@@ -423,15 +424,25 @@ export function useFleetData() {
const request = ++historyRequest const request = ++historyRequest
nodeHistoryLoading.value = true nodeHistoryLoading.value = true
try { 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', method: 'telemetry.fleet-node-history',
params: { node_id: nodeId }, params: { node_id: nodeId },
}) })
if (request === historyRequest && selectedNodeId.value === nodeId) { if (request === historyRequest && selectedNodeId.value === nodeId) {
if (data.history_available === false) {
nodeHistory.value = []
nodeHistoryMessage.value = 'History is unavailable for this node.'
} else {
nodeHistory.value = normalizeNodeHistoryResponse(data) nodeHistory.value = normalizeNodeHistoryResponse(data)
nodeHistoryMessage.value = ''
}
} }
} catch { } 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 { } finally {
if (request === historyRequest) nodeHistoryLoading.value = false if (request === historyRequest) nodeHistoryLoading.value = false
} }
@@ -521,6 +532,7 @@ export function useFleetData() {
historyRequest++ historyRequest++
nodeHistory.value = [] nodeHistory.value = []
nodeHistoryLoading.value = false nodeHistoryLoading.value = false
nodeHistoryMessage.value = ''
if (newId) { if (newId) {
fetchNodeHistory(newId) fetchNodeHistory(newId)
} else { } else {
@@ -599,7 +611,7 @@ export function useFleetData() {
return { return {
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading, alertsErrorMessage, loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading, alertsErrorMessage,
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading, selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading, nodeHistoryMessage,
autoRefresh, lastRefreshed, sortBy, chartWidth, now, autoRefresh, lastRefreshed, sortBy, chartWidth, now,
onlineCount, offlineCount, unknownCount, healthyCount, fleetHealthPct, onlineCount, offlineCount, unknownCount, healthyCount, fleetHealthPct,
avgCpu, avgMem, avgDisk, sortedNodes, allAppIds, avgCpu, avgMem, avgDisk, sortedNodes, allAppIds,