Keep Fleet outage state visible and reject superseded responses
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# Fleet recovery qualification — 7 October 2026
|
||||||
|
|
||||||
|
Status: source regression fixes qualified; deployed-browser and distributed
|
||||||
|
transport acceptance remain open. This supplements tasks 8, 10 and 12 without
|
||||||
|
claiming that the full Fleet acceptance matrix is complete.
|
||||||
|
|
||||||
|
## Findings and changes
|
||||||
|
|
||||||
|
A failed status poll only surfaced an error during the initial load. Once reports
|
||||||
|
were cached, network failures silently left the previous view in place. The view
|
||||||
|
now keeps those reports, selection and sort order, with an explicit failed-refresh
|
||||||
|
notice and Retry. An initial failure without reports remains an unavailable state.
|
||||||
|
A successful refresh clears the notice. Report timestamps continue ageing; a
|
||||||
|
monitoring failure is not relabelled as proof that a peer is offline.
|
||||||
|
|
||||||
|
Concurrent refreshes could overwrite newer status/alerts with older replies.
|
||||||
|
Status, alerts and refresh completion now track request generations. Replies
|
||||||
|
arriving after unmount cannot repopulate the session cache or change the view.
|
||||||
|
Malformed status envelopes cannot advance the successful-refresh timestamp.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Focused Vitest run: **21 tests passed in four files**, zero failures, including
|
||||||
|
actual Fleet view rendering with retained cards, outage notice and successful
|
||||||
|
Retry. Other cases cover a missing cache, late status/alert replies, a malformed
|
||||||
|
status response, late responses after unmount, real zero versus unavailable
|
||||||
|
metrics, report ageing, sorting and selected-node history isolation.
|
||||||
|
|
||||||
|
Log: `/tmp/archy-fleet-recovery-tests-final.log`.
|
||||||
|
This is local component/composable evidence, not a live-node outage test.
|
||||||
|
No node service, peer relationship, wallet or payment was changed.
|
||||||
|
|
||||||
|
## Remaining acceptance matrix
|
||||||
|
|
||||||
|
| Area | Established evidence | Remaining |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Report rendering | Local normalization/render tests; earlier dev/Yaya metrics evidence in progress ledger | Updated sender/receiver comparison across supported versions |
|
||||||
|
| Status freshness | Clock-driven ageing, unknown versus stale, no synthetic metric zero | Real partition, reconnect and transport delivery |
|
||||||
|
| Refresh failures | Visible retained reports; empty failure; Retry recovery | Candidate browser at phone/desktop widths and deployed artifact |
|
||||||
|
| Concurrent replies | Newest status/alerts win; no unmount cache repopulation | Distributed delay/out-of-order delivery |
|
||||||
|
| Selection/sorting | Retained across failure; history request isolation | Larger-fleet navigation and keyboard checks |
|
||||||
|
| Authorization | No permission changes in this patch | Unauthorized/expired-trust actual-handler matrix |
|
||||||
|
| Remote actions/updates | Outside this patch | Disposable-node success/partial failure/restart/update matrix |
|
||||||
|
| FIPS | Existing preferred authenticated transport unchanged | Measured latency, fallback, reconnect and mixed capabilities |
|
||||||
|
|
||||||
|
No current-node restart or deliberate network outage was performed. Live fault
|
||||||
|
qualification must preserve wallets, installed applications and operator intent.
|
||||||
@@ -71,13 +71,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- 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>
|
<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>
|
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard Content -->
|
<!-- Dashboard Content -->
|
||||||
<template v-else>
|
<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
|
<FleetOverviewCards
|
||||||
:node-count="fleet.nodes.value.length"
|
:node-count="fleet.nodes.value.length"
|
||||||
:online-count="fleet.onlineCount.value"
|
: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)
|
expect(fleet.nodeHistoryLoading.value).toBe(false)
|
||||||
wrapper.unmount()
|
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)
|
const chartWidth = ref(300)
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let historyRequest = 0
|
let historyRequest = 0
|
||||||
|
let statusRequest = 0
|
||||||
|
let alertsRequest = 0
|
||||||
|
let refreshRequest = 0
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
// --- Computed ---
|
// --- Computed ---
|
||||||
|
|
||||||
@@ -339,35 +343,43 @@ export function useFleetData() {
|
|||||||
// --- Data Fetching ---
|
// --- Data Fetching ---
|
||||||
|
|
||||||
async function fetchFleetStatus() {
|
async function fetchFleetStatus() {
|
||||||
|
const request = ++statusRequest
|
||||||
try {
|
try {
|
||||||
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
||||||
method: 'telemetry.fleet-status',
|
method: 'telemetry.fleet-status',
|
||||||
})
|
})
|
||||||
if (data?.nodes) {
|
if (disposed || request !== statusRequest) return
|
||||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
if (!Array.isArray(data?.nodes) || data.nodes.some(node => !node || typeof node !== 'object')) {
|
||||||
lastRefreshed.value = new Date().toISOString()
|
throw new Error('Invalid fleet response')
|
||||||
writeFleetCache({
|
|
||||||
nodes: nodes.value,
|
|
||||||
fleetAlerts: fleetAlerts.value,
|
|
||||||
lastRefreshed: lastRefreshed.value,
|
|
||||||
selectedNodeId: selectedNodeId.value,
|
|
||||||
sortBy: sortBy.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||||
if (loading.value) {
|
lastRefreshed.value = new Date().toISOString()
|
||||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to load fleet data'
|
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() {
|
async function fetchFleetAlerts() {
|
||||||
|
const request = ++alertsRequest
|
||||||
alertsLoading.value = true
|
alertsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await rpcClient.call<{ alerts: FleetAlert[] }>({
|
const data = await rpcClient.call<{ alerts: FleetAlert[] }>({
|
||||||
method: 'telemetry.fleet-alerts',
|
method: 'telemetry.fleet-alerts',
|
||||||
})
|
})
|
||||||
if (data?.alerts) {
|
if (disposed || request !== alertsRequest) return
|
||||||
|
if (Array.isArray(data?.alerts)) {
|
||||||
fleetAlerts.value = data.alerts
|
fleetAlerts.value = data.alerts
|
||||||
writeFleetCache({
|
writeFleetCache({
|
||||||
nodes: nodes.value,
|
nodes: nodes.value,
|
||||||
@@ -380,7 +392,7 @@ export function useFleetData() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Non-critical, retry on next poll
|
// Non-critical, retry on next poll
|
||||||
} finally {
|
} finally {
|
||||||
alertsLoading.value = false
|
if (!disposed && request === alertsRequest) alertsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,14 +415,16 @@ export function useFleetData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
|
const request = ++refreshRequest
|
||||||
loading.value = !nodes.value.length
|
loading.value = !nodes.value.length
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
errorMessage.value = ''
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (!disposed && request === refreshRequest) {
|
||||||
refreshing.value = false
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +568,8 @@ export function useFleetData() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
++historyRequest
|
||||||
disarmFleetPoll()
|
disarmFleetPoll()
|
||||||
window.removeEventListener('resize', updateChartWidth)
|
window.removeEventListener('resize', updateChartWidth)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user