Keep Fleet outage state visible and reject superseded responses
This commit is contained in:
@@ -71,13 +71,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="fleet.errorMessage.value" class="glass-card p-6 mb-6">
|
||||
<div v-else-if="fleet.errorMessage.value && !fleet.nodes.value.length" class="glass-card p-6 mb-6">
|
||||
<div class="alert-error rounded-lg mb-4">{{ fleet.errorMessage.value }}</div>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<div v-if="fleet.errorMessage.value" role="status" class="glass-card p-4 mb-4 flex flex-wrap items-center gap-3">
|
||||
<p class="text-sm text-white/80 flex-1 min-w-0">{{ fleet.errorMessage.value }}</p>
|
||||
<button class="glass-button text-sm px-4 py-2 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Retrying...' : 'Retry' }}
|
||||
</button>
|
||||
</div>
|
||||
<FleetOverviewCards
|
||||
:node-count="fleet.nodes.value.length"
|
||||
:online-count="fleet.onlineCount.value"
|
||||
|
||||
@@ -68,3 +68,71 @@ it('keeps a late node history response out of the newly selected node', async ()
|
||||
expect(fleet.nodeHistoryLoading.value).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves cached reports and selection with a visible failure, then clears it on recovery', async () => {
|
||||
sessionStorage.setItem('archipelago.fleet.cache.v2', JSON.stringify({
|
||||
nodes: [{ node_id: 'retained', reported_at: '2026-10-07T12:00:00Z', cpu_pct: 0 }],
|
||||
selectedNodeId: 'retained', sortBy: 'name', lastRefreshed: '2026-10-07T12:00:00Z',
|
||||
}))
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Disconnected'))
|
||||
let fleet!: ReturnType<typeof useFleetData>
|
||||
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(fleet.errorMessage.value).toContain('Showing the last received reports')
|
||||
expect(fleet.nodes.value[0]?.cpu_pct).toBe(0)
|
||||
expect(fleet.selectedNodeId.value).toBe('retained')
|
||||
expect(fleet.sortBy.value).toBe('name')
|
||||
expect(fleet.lastRefreshed.value).toBe('2026-10-07T12:00:00Z')
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ nodes: [{ node_id: 'retained', cpu_pct: 22 }], alerts: [] })
|
||||
await fleet.refreshAll()
|
||||
expect(fleet.errorMessage.value).toBe('')
|
||||
expect(fleet.nodes.value[0]?.cpu_pct).toBe(22)
|
||||
expect(fleet.selectedNodeId.value).toBe('retained')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('ignores older status and alert responses arriving after a newer refresh', async () => {
|
||||
const statuses: Array<(value: unknown) => void> = []
|
||||
const alerts: Array<(value: unknown) => void> = []
|
||||
vi.mocked(rpcClient.call).mockImplementation(({ method }) => new Promise(done => {
|
||||
(method === 'telemetry.fleet-status' ? statuses : alerts).push(done)
|
||||
}) as never)
|
||||
let fleet!: ReturnType<typeof useFleetData>
|
||||
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
|
||||
try {
|
||||
const latest = fleet.refreshAll()
|
||||
statuses[1]!({ nodes: [{ node_id: 'newer', cpu_pct: 20 }] })
|
||||
alerts[1]!({ alerts: [{ node_id: 'newer', rule: 'cpu_high', message: 'New', timestamp: '' }] })
|
||||
await latest
|
||||
statuses[0]!({ nodes: [{ node_id: 'older', cpu_pct: 99 }] })
|
||||
alerts[0]!({ alerts: [] })
|
||||
await flushPromises()
|
||||
expect(fleet.nodes.value.map(n => n.node_id)).toEqual(['newer'])
|
||||
expect(fleet.fleetAlerts.value[0]?.message).toBe('New')
|
||||
expect(JSON.parse(sessionStorage.getItem('archipelago.fleet.cache.v2')!).nodes[0].node_id).toBe('newer')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('does not mark a malformed status response as a successful refresh', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ nodes: [null], alerts: [] })
|
||||
let fleet!: ReturnType<typeof useFleetData>
|
||||
const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } }))
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(fleet.errorMessage.value).toContain('unavailable')
|
||||
expect(fleet.lastRefreshed.value).toBe('')
|
||||
expect(fleet.nodes.value).toEqual([])
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('does not repopulate the session cache when requests finish after unmount', async () => {
|
||||
const completions: Array<(value: unknown) => void> = []
|
||||
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(done => { completions.push(done) }) as never)
|
||||
const wrapper = mount(defineComponent({ setup() { useFleetData(); return () => null } }))
|
||||
wrapper.unmount()
|
||||
sessionStorage.clear()
|
||||
completions.forEach(done => done({ nodes: [{ node_id: 'old-session' }], alerts: [] }))
|
||||
await flushPromises()
|
||||
expect(sessionStorage.getItem('archipelago.fleet.cache.v2')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import Fleet from '../../Fleet.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
afterEach(() => { sessionStorage.clear(); vi.clearAllMocks() })
|
||||
|
||||
const stubs = {
|
||||
BackButton: true, FleetOverviewCards: true, FleetAlerts: true,
|
||||
FleetNodeDetail: true, FleetContainerMatrix: true,
|
||||
}
|
||||
|
||||
it('keeps cached node cards visible beside the outage warning and recovers with Retry', async () => {
|
||||
sessionStorage.setItem('archipelago.fleet.cache.v2', JSON.stringify({
|
||||
nodes: [{ node_id: 'retained', node_name: 'Retained node', cpu_pct: 0 }],
|
||||
}))
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = mount(Fleet, { global: { stubs } })
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="status"]').text()).toContain('Showing the last received reports')
|
||||
expect(wrapper.get('.fleet-node-card').text()).toContain('Retained node')
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ nodes: [{ node_id: 'retained', node_name: 'Recovered node', cpu_pct: 0 }], alerts: [] })
|
||||
await wrapper.get('[role="status"] button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(wrapper.get('.fleet-node-card').text()).toContain('Recovered node')
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it('shows an actionable unavailable state when there are no saved reports', async () => {
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = mount(Fleet, { global: { stubs } })
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Fleet data is unavailable')
|
||||
expect(wrapper.find('.fleet-node-card').exists()).toBe(false)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === 'Retry')).toBe(true)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
@@ -274,6 +274,10 @@ export function useFleetData() {
|
||||
const chartWidth = ref(300)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let historyRequest = 0
|
||||
let statusRequest = 0
|
||||
let alertsRequest = 0
|
||||
let refreshRequest = 0
|
||||
let disposed = false
|
||||
|
||||
// --- Computed ---
|
||||
|
||||
@@ -339,35 +343,43 @@ export function useFleetData() {
|
||||
// --- Data Fetching ---
|
||||
|
||||
async function fetchFleetStatus() {
|
||||
const request = ++statusRequest
|
||||
try {
|
||||
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
||||
method: 'telemetry.fleet-status',
|
||||
})
|
||||
if (data?.nodes) {
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
if (disposed || request !== statusRequest) return
|
||||
if (!Array.isArray(data?.nodes) || data.nodes.some(node => !node || typeof node !== 'object')) {
|
||||
throw new Error('Invalid fleet response')
|
||||
}
|
||||
} catch (err) {
|
||||
if (loading.value) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to load fleet data'
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
errorMessage.value = ''
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
} catch {
|
||||
if (!disposed && request === statusRequest) {
|
||||
errorMessage.value = nodes.value.length
|
||||
? 'Fleet refresh failed. Showing the last received reports; current node status could not be checked.'
|
||||
: 'Fleet data is unavailable. Check your connection and retry.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFleetAlerts() {
|
||||
const request = ++alertsRequest
|
||||
alertsLoading.value = true
|
||||
try {
|
||||
const data = await rpcClient.call<{ alerts: FleetAlert[] }>({
|
||||
method: 'telemetry.fleet-alerts',
|
||||
})
|
||||
if (data?.alerts) {
|
||||
if (disposed || request !== alertsRequest) return
|
||||
if (Array.isArray(data?.alerts)) {
|
||||
fleetAlerts.value = data.alerts
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
@@ -380,7 +392,7 @@ export function useFleetData() {
|
||||
} catch {
|
||||
// Non-critical, retry on next poll
|
||||
} finally {
|
||||
alertsLoading.value = false
|
||||
if (!disposed && request === alertsRequest) alertsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,14 +415,16 @@ export function useFleetData() {
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const request = ++refreshRequest
|
||||
loading.value = !nodes.value.length
|
||||
refreshing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
if (!disposed && request === refreshRequest) {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +568,8 @@ export function useFleetData() {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
++historyRequest
|
||||
disarmFleetPoll()
|
||||
window.removeEventListener('resize', updateChartWidth)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user