Expose Fleet alert failures and distinguish report provenance

This commit is contained in:
archipelago
2026-10-07 20:42:17 -04:00
parent 07dd1d545c
commit f5a1806ae3
7 changed files with 192 additions and 5 deletions
+1
View File
@@ -117,6 +117,7 @@
<FleetAlerts
:alerts="fleet.fleetAlerts.value"
:alerts-loading="fleet.alertsLoading.value"
:error-message="fleet.alertsErrorMessage.value"
/>
<FleetNodeDetail
+3 -1
View File
@@ -2,10 +2,11 @@
<div class="glass-card p-5 mb-6">
<h3 class="text-sm font-medium text-white/80 mb-4">Fleet Alerts</h3>
<p v-if="errorMessage" role="status" class="text-sm text-amber-200 mb-3">{{ errorMessage }}</p>
<div v-if="alertsLoading" class="text-white/40 text-sm py-4 text-center">
Loading alerts...
</div>
<div v-else-if="!alerts.length" class="text-white/40 text-sm py-4 text-center">
<div v-else-if="!alerts.length && !errorMessage" class="text-white/40 text-sm py-4 text-center">
No alerts across the fleet.
</div>
<div v-else class="space-y-2 max-h-80 overflow-y-auto">
@@ -37,5 +38,6 @@ import { type FleetAlert, alertSeverityDot, alertTypeLabel, formatTimestamp } fr
defineProps<{
alerts: FleetAlert[]
alertsLoading: boolean
errorMessage?: string
}>()
</script>
+3 -1
View File
@@ -42,6 +42,8 @@
{{ fleetNodeSubtitle(node) }}
</div>
<div class="mb-3 text-xs text-white/50">{{ fleetSourceLabel(node) }}</div>
<div class="space-y-2 mb-3">
<div class="fleet-metric-row">
<span class="text-xs text-white/50">CPU</span>
@@ -94,7 +96,7 @@
<script setup lang="ts">
import {
type FleetNode, type SortOption, SORT_OPTIONS,
isOnline, fleetStatus, formatMetric, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
isOnline, fleetStatus, formatMetric, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle, fleetSourceLabel,
} from './useFleetData'
defineProps<{
@@ -51,3 +51,26 @@ describe('fleet unavailable readings', () => {
wrapper.unmount()
})
})
it('labels collector, legacy, and incomplete provenance as unverified', () => {
const nodes = [
normalizeFleetNode({ node_id: 'trusted', source: 'federation', trust_level: 'trusted', identity_authenticated: true }),
normalizeFleetNode({ node_id: 'collector', source: 'collector', trust_level: 'unverified', identity_authenticated: false }),
normalizeFleetNode({ node_id: 'legacy', source: 'federation', trust_level: 'trusted' }),
normalizeFleetNode({ node_id: 'unknown' }),
normalizeFleetNode({ node_id: 'denied', source: 'federation', trust_level: 'untrusted', identity_authenticated: true }),
]
const wrapper = mount(FleetNodeGrid, {
props: { nodes, sortedNodes: nodes, sortBy: 'status', selectedNodeId: null },
global: { mocks: { $ver: (v: string) => v } },
})
try {
const cards = wrapper.findAll('.fleet-node-card')
expect(cards[0]!.text()).toContain('Trusted federation')
expect(cards[1]!.text()).toContain('Collector · identity unverified')
for (const card of cards.slice(2)) {
expect(card.text()).toContain('Source unverified')
expect(card.text()).not.toContain('Trusted federation')
}
} finally { wrapper.unmount() }
})
@@ -0,0 +1,84 @@
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 FleetAlerts from '../FleetAlerts.vue'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() })
it.each(['Unauthorized', 'Method not found', 'Timeout'])('keeps mixed-version nodes usable when alerts fail: %s', async reason => {
vi.mocked(rpcClient.call).mockImplementation(({ method }) => method === 'telemetry.fleet-status'
? Promise.resolve({ nodes: [{ node_id: 'legacy', version: 'old' }, { node_id: 'current', cpu_pct: 0, mem_pct: 0, disk_pct: 0 }] }) as never
: Promise.reject(new Error(reason)))
let fleet!: ReturnType<typeof useFleetData>
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
try {
await flushPromises()
expect(fleet.nodes.value.map(n => n.node_id)).toEqual(['legacy', 'current'])
expect(fleet.nodes.value[0]?.cpu_pct).toBeNull()
expect(fleet.nodes.value[1]?.cpu_pct).toBe(0)
expect(fleet.errorMessage.value).toBe('')
expect(fleet.alertsErrorMessage.value).toContain('unavailable')
expect(fleet.alertsLoading.value).toBe(false)
const panel = mount(FleetAlerts, { props: { alerts: [], alertsLoading: false, errorMessage: fleet.alertsErrorMessage.value } })
expect(panel.text()).not.toContain('No alerts across the fleet')
expect(panel.get('[role="status"]').text()).toContain('unavailable')
panel.unmount()
vi.mocked(rpcClient.call).mockResolvedValue({ nodes: [], alerts: [] })
await fleet.refreshAll()
expect(fleet.alertsErrorMessage.value).toBe('')
} finally { wrapper.unmount() }
})
it('retains previous alerts on malformed refresh without calling them current', async () => {
const alert = { node_id: 'node', rule: 'cpu_high', message: 'Earlier alert', timestamp: '2026-10-07T12:00:00Z' }
sessionStorage.setItem('archipelago.fleet.cache.v2', JSON.stringify({ fleetAlerts: [alert] }))
vi.mocked(rpcClient.call).mockResolvedValue({ nodes: [], alerts: [null] })
let fleet!: ReturnType<typeof useFleetData>
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
try {
await flushPromises()
expect(fleet.fleetAlerts.value).toEqual([alert])
expect(fleet.alertsErrorMessage.value).toContain('last received alerts')
const panel = mount(FleetAlerts, { props: { alerts: fleet.fleetAlerts.value, alertsLoading: false, errorMessage: fleet.alertsErrorMessage.value } })
expect(panel.text()).toContain('Earlier alert')
expect(panel.get('[role="status"]').text()).toContain('last received alerts')
panel.unmount()
} finally { wrapper.unmount() }
})
it('does not apply selected-node history after unmount', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(({ method }) => method === 'telemetry.fleet-node-history'
? new Promise(done => { finish = done }) as never
: Promise.resolve({ nodes: [], alerts: [] }) as never)
let fleet!: ReturnType<typeof useFleetData>
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
await flushPromises()
fleet.selectNode('node'); await flushPromises()
wrapper.unmount()
finish({ entries: [{ timestamp: 'late', cpu_pct: 80 }] })
await flushPromises()
expect(fleet.nodeHistory.value).toEqual([])
})
it('ignores an older alerts failure after a newer successful refresh', async () => {
let failOld!: (reason: Error) => void
let alertCalls = 0
vi.mocked(rpcClient.call).mockImplementation(({ method }) => {
if (method === 'telemetry.fleet-status') return Promise.resolve({ nodes: [] }) as never
if (++alertCalls === 1) return new Promise((_, reject) => { failOld = reject }) as never
return Promise.resolve({ alerts: [] }) as never
})
let fleet!: ReturnType<typeof useFleetData>
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
try {
await fleet.refreshAll()
failOld(new Error('Old permission denial'))
await flushPromises()
expect(fleet.alertsErrorMessage.value).toBe('')
expect(fleet.alertsLoading.value).toBe(false)
} finally { wrapper.unmount() }
})
+26 -3
View File
@@ -8,6 +8,9 @@ import type { ChartDataset } from '@/components/LineChart.vue'
export interface FleetNode {
node_id: string
identity_authenticated?: boolean
source?: string
trust_level?: string
node_name?: string | null
hostname?: string | null
server_url?: string | null
@@ -137,6 +140,12 @@ export function fleetNodeDisplayName(node: FleetNode): string {
return name || node.node_id.slice(0, 8)
}
export function fleetSourceLabel(node: FleetNode): string {
if (node.source === 'collector') return 'Collector · identity unverified'
if (node.source === 'federation' && node.trust_level === 'trusted' && node.identity_authenticated === true) return 'Trusted federation'
return 'Source unverified'
}
export function fleetNodeSubtitle(node: FleetNode): string {
const host = node.hostname?.trim()
if (host && host !== fleetNodeDisplayName(node)) return host
@@ -188,6 +197,9 @@ function numberOrZero(value: unknown): number {
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
return {
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
identity_authenticated: node.identity_authenticated === true,
source: typeof node.source === 'string' ? node.source : 'unknown',
trust_level: typeof node.trust_level === 'string' ? node.trust_level : 'unverified',
node_name: typeof node.node_name === 'string' ? node.node_name : null,
hostname: typeof node.hostname === 'string' ? node.hostname : null,
server_url: typeof node.server_url === 'string' ? node.server_url : null,
@@ -265,6 +277,7 @@ export function useFleetData() {
const fleetAlerts = ref<FleetAlert[]>(cached.fleetAlerts ?? [])
const refreshing = ref(false)
const alertsLoading = ref(false)
const alertsErrorMessage = ref('')
const selectedNodeId = ref<string | null>(cached.selectedNodeId ?? null)
const nodeHistory = ref<NodeHistoryEntry[]>([])
const nodeHistoryLoading = ref(false)
@@ -379,8 +392,14 @@ export function useFleetData() {
method: 'telemetry.fleet-alerts',
})
if (disposed || request !== alertsRequest) return
if (Array.isArray(data?.alerts)) {
if (!Array.isArray(data?.alerts) || data.alerts.some(alert => !alert ||
typeof alert.node_id !== 'string' || typeof alert.rule !== 'string' ||
typeof alert.message !== 'string' || typeof alert.timestamp !== 'string')) {
throw new Error('Invalid fleet alerts response')
}
{
fleetAlerts.value = data.alerts
alertsErrorMessage.value = ''
writeFleetCache({
nodes: nodes.value,
fleetAlerts: fleetAlerts.value,
@@ -390,7 +409,11 @@ export function useFleetData() {
})
}
} catch {
// Non-critical, retry on next poll
if (!disposed && request === alertsRequest) {
alertsErrorMessage.value = fleetAlerts.value.length
? 'Alert refresh failed. Showing the last received alerts.'
: 'Fleet alerts are unavailable. Retry refresh to check for alerts.'
}
} finally {
if (!disposed && request === alertsRequest) alertsLoading.value = false
}
@@ -575,7 +598,7 @@ export function useFleetData() {
})
return {
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading, alertsErrorMessage,
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
autoRefresh, lastRefreshed, sortBy, chartWidth, now,
onlineCount, offlineCount, unknownCount, healthyCount, fleetHealthPct,