From c2d2b00c5ce0114a0e40200d0d40de90add76f3f Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 06:50:29 -0400 Subject: [PATCH] Age Fleet status locally during stalled or paused telemetry refresh --- neode-ui/src/views/Fleet.vue | 3 +- neode-ui/src/views/fleet/FleetNodeGrid.vue | 5 +- .../fleet/__tests__/FleetFreshness.test.ts | 48 ++++++++++++++++ .../fleet/__tests__/FleetNodeGrid.test.ts | 14 +++++ neode-ui/src/views/fleet/useFleetData.ts | 57 +++++++++++-------- 5 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 neode-ui/src/views/fleet/__tests__/FleetFreshness.test.ts diff --git a/neode-ui/src/views/Fleet.vue b/neode-ui/src/views/Fleet.vue index 1009c337..4e8e39ef 100644 --- a/neode-ui/src/views/Fleet.vue +++ b/neode-ui/src/views/Fleet.vue @@ -99,6 +99,7 @@ {{ fleet.autoRefresh.value ? 'Auto-refreshing every 60s' : 'Auto-refresh paused' }} - · Last updated {{ fleet.lastRefreshed.value ? timeAgo(fleet.lastRefreshed.value) : 'never' }} + · Last updated {{ fleet.lastRefreshed.value ? timeAgo(fleet.lastRefreshed.value, fleet.now.value) : 'never' }}

diff --git a/neode-ui/src/views/fleet/FleetNodeGrid.vue b/neode-ui/src/views/fleet/FleetNodeGrid.vue index fd637dd3..d631c6f9 100644 --- a/neode-ui/src/views/fleet/FleetNodeGrid.vue +++ b/neode-ui/src/views/fleet/FleetNodeGrid.vue @@ -32,7 +32,7 @@
{{ fleetNodeDisplayName(node) }}
@@ -84,7 +84,7 @@
{{ node.uptime_secs === null ? 'Uptime unavailable' : 'Up ' + formatUptime(node.uptime_secs) }} - {{ fleetStatus(node.reported_at) === 'unknown' ? 'Status unknown' : fleetStatus(node.reported_at) === 'stale' ? 'Not reporting · last seen ' + timeAgo(node.reported_at) : 'Seen ' + timeAgo(node.reported_at) }} + {{ fleetStatus(node.reported_at, now) === 'unknown' ? 'Status unknown' : fleetStatus(node.reported_at, now) === 'stale' ? 'Not reporting · last seen ' + timeAgo(node.reported_at, now) : 'Seen ' + timeAgo(node.reported_at, now) }}
@@ -98,6 +98,7 @@ import { } from './useFleetData' defineProps<{ + now?: number nodes: FleetNode[] sortedNodes: FleetNode[] sortBy: SortOption diff --git a/neode-ui/src/views/fleet/__tests__/FleetFreshness.test.ts b/neode-ui/src/views/fleet/__tests__/FleetFreshness.test.ts new file mode 100644 index 00000000..7f46603c --- /dev/null +++ b/neode-ui/src/views/fleet/__tests__/FleetFreshness.test.ts @@ -0,0 +1,48 @@ +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' + +vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) +afterEach(() => { vi.useRealTimers(); sessionStorage.clear(); vi.clearAllMocks() }) + +it('ages cached reports while refresh is paused and cleans up its clock', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-06T12:00:00Z')) + sessionStorage.setItem('archipelago.fleet.cache.v2', JSON.stringify({ nodes: [{ + node_id: 'node', reported_at: '2026-10-06T11:30:10Z', cpu_pct: 20, mem_pct: 30, disk_pct: 40, + }] })) + vi.mocked(rpcClient.call).mockRejectedValue(new Error('Offline')) + let fleet!: ReturnType + const wrapper = mount(defineComponent({ setup() { fleet = useFleetData(); return () => null } })) + await flushPromises() + expect(fleet.onlineCount.value).toBe(1) + expect(fleet.avgCpu.value).toBe(20) + fleet.toggleAutoRefresh() + const calls = vi.mocked(rpcClient.call).mock.calls.length + await vi.advanceTimersByTimeAsync(15_000) + expect(fleet.onlineCount.value).toBe(0) + expect(fleet.offlineCount.value).toBe(1) + expect(fleet.avgCpu.value).toBeNull() + expect(rpcClient.call).toHaveBeenCalledTimes(calls) + wrapper.unmount() + expect(vi.getTimerCount()).toBe(0) +}) + +it('does not rearm timers when requests finish after unmount', async () => { + vi.useFakeTimers() + const intervals = vi.spyOn(globalThis, 'setInterval') + const clear = vi.spyOn(globalThis, 'clearInterval') + 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 } })) + const started = intervals.mock.results.map(result => result.value) + wrapper.unmount() + for (const timer of started) expect(clear).toHaveBeenCalledWith(timer) + const intervalCalls = intervals.mock.calls.length + completions.forEach(done => done({ nodes: [], alerts: [] })) + await flushPromises() + expect(intervals).toHaveBeenCalledTimes(intervalCalls) + intervals.mockRestore(); clear.mockRestore() +}) diff --git a/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts b/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts index 9bbb8dcc..2360a106 100644 --- a/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts +++ b/neode-ui/src/views/fleet/__tests__/FleetNodeGrid.test.ts @@ -25,6 +25,20 @@ describe('fleet unavailable readings', () => { expect(wrapper.text()).not.toContain('NaN') wrapper.unmount() }) + it('updates last-seen text and status when only the local clock changes', async () => { + const start = Date.parse('2026-10-06T12:00:00Z') + const nodes = [normalizeFleetNode({ node_id: 'node', reported_at: '2026-10-06T11:30:10Z' })] + const wrapper = mount(FleetNodeGrid, { + props: { nodes, sortedNodes: nodes, sortBy: 'status', selectedNodeId: null, now: start }, + global: { mocks: { $ver: (v: string) => v } }, + }) + expect(wrapper.text()).toContain('Seen 29m ago') + expect(wrapper.find('.fleet-dot-online').exists()).toBe(true) + await wrapper.setProps({ now: start + 15_000 }) + expect(wrapper.text()).toContain('Not reporting · last seen 30m ago') + expect(wrapper.find('.fleet-dot-offline').exists()).toBe(true) + wrapper.unmount() + }) it('renders empty averages without claiming zero load', () => { const wrapper = mount(FleetOverviewCards, {props: { nodeCount: 1, onlineCount: 0, offlineCount: 0, unknownCount: 1, diff --git a/neode-ui/src/views/fleet/useFleetData.ts b/neode-ui/src/views/fleet/useFleetData.ts index 202bc68e..eef790c2 100644 --- a/neode-ui/src/views/fleet/useFleetData.ts +++ b/neode-ui/src/views/fleet/useFleetData.ts @@ -54,8 +54,7 @@ export function formatUptime(secs: number | null): string { return `${mins}m` } -export function timeAgo(dateStr: string): string { - const now = Date.now() +export function timeAgo(dateStr: string, now = Date.now()): string { const then = new Date(dateStr).getTime() if (!Number.isFinite(then) || then > now + 60_000) return 'Unknown' const diffMs = now - then @@ -70,22 +69,22 @@ export function timeAgo(dateStr: string): string { return `${diffDays}d ago` } -export function fleetStatus(reportedAt: string): 'online' | 'stale' | 'unknown' { - const age = Date.now() - new Date(reportedAt).getTime() +export function fleetStatus(reportedAt: string, now = Date.now()): 'online' | 'stale' | 'unknown' { + const age = now - new Date(reportedAt).getTime() if (!Number.isFinite(age) || age < -60_000) return 'unknown' return age < 30 * 60 * 1000 ? 'online' : 'stale' } -export function isOnline(reportedAt: string): boolean { - return fleetStatus(reportedAt) === 'online' +export function isOnline(reportedAt: string, now = Date.now()): boolean { + return fleetStatus(reportedAt, now) === 'online' } export function formatMetric(value: number | null, digits = 0): string { return value === null ? '—' : `${value.toFixed(digits)}%` } -export function averageMetric(nodes: FleetNode[], field: 'cpu_pct' | 'mem_pct' | 'disk_pct'): number | null { - const values = nodes.filter(n => isOnline(n.reported_at)).map(n => n[field]).filter((v): v is number => v !== null && Number.isFinite(v)) +export function averageMetric(nodes: FleetNode[], field: 'cpu_pct' | 'mem_pct' | 'disk_pct', now = Date.now()): number | null { + const values = nodes.filter(n => isOnline(n.reported_at, now)).map(n => n[field]).filter((v): v is number => v !== null && Number.isFinite(v)) return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null } @@ -150,21 +149,21 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [ { label: 'Name', value: 'name' }, ] -function reportTime(node: FleetNode): number { - return fleetStatus(node.reported_at) === 'unknown' ? 0 : new Date(node.reported_at).getTime() +function reportTime(node: FleetNode, now: number): number { + return fleetStatus(node.reported_at, now) === 'unknown' ? 0 : new Date(node.reported_at).getTime() } -export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNode[] { +export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption, now = Date.now()): FleetNode[] { const sorted = [...nodes] switch (sortBy) { case 'status': sorted.sort((a, b) => { const rank = { online: 0, unknown: 1, stale: 2 } - return rank[fleetStatus(a.reported_at)] - rank[fleetStatus(b.reported_at)] + return rank[fleetStatus(a.reported_at, now)] - rank[fleetStatus(b.reported_at, now)] }) break case 'last-seen': - sorted.sort((a, b) => reportTime(b) - reportTime(a)) + sorted.sort((a, b) => reportTime(b, now) - reportTime(a, now)) break case 'name': sorted.sort((a, b) => fleetNodeDisplayName(a).localeCompare(fleetNodeDisplayName(b))) @@ -256,6 +255,9 @@ function writeFleetCache(state: FleetCache) { // --- Composable --- export function useFleetData() { + const now = ref(Date.now()) + let clockTimer: ReturnType | null = null + let active = true const cached = readFleetCache() const loading = ref(!(cached.nodes?.length ?? 0)) const errorMessage = ref('') @@ -274,28 +276,28 @@ export function useFleetData() { // --- Computed --- - const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length) - const offlineCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'stale').length) - const unknownCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at) === 'unknown').length) - const healthyCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at) && n.cpu_pct !== null && n.mem_pct !== null && n.disk_pct !== null && n.recent_alerts.length === 0).length) + const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at, now.value)).length) + const offlineCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at, now.value) === 'stale').length) + const unknownCount = computed(() => nodes.value.filter(n => fleetStatus(n.reported_at, now.value) === 'unknown').length) + const healthyCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at, now.value) && n.cpu_pct !== null && n.mem_pct !== null && n.disk_pct !== null && n.recent_alerts.length === 0).length) const fleetHealthPct = computed(() => { if (!nodes.value.length) return 0 return Math.round((healthyCount.value / nodes.value.length) * 100) }) - const avgCpu = computed(() => averageMetric(nodes.value, 'cpu_pct')) + const avgCpu = computed(() => averageMetric(nodes.value, 'cpu_pct', now.value)) - const avgMem = computed(() => averageMetric(nodes.value, 'mem_pct')) + const avgMem = computed(() => averageMetric(nodes.value, 'mem_pct', now.value)) - const avgDisk = computed(() => averageMetric(nodes.value, 'disk_pct')) + const avgDisk = computed(() => averageMetric(nodes.value, 'disk_pct', now.value)) const selectedNode = computed(() => { if (!selectedNodeId.value) return null return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null }) - const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value)) + const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value, now.value)) const allAppIds = computed(() => { const appSet = new Set() @@ -426,7 +428,7 @@ export function useFleetData() { function toggleAutoRefresh() { autoRefresh.value = !autoRefresh.value - if (autoRefresh.value) { + if (autoRefresh.value && active) { startAutoRefresh() } else { stopAutoRefresh() @@ -516,17 +518,24 @@ export function useFleetData() { // first, so calling it from both onMounted and onActivated on a fresh // KeepAlive-wrapped mount is safe and idempotent. function armFleetPoll() { + active = true + now.value = Date.now() + if (clockTimer) clearInterval(clockTimer) + clockTimer = setInterval(() => { now.value = Date.now() }, 15_000) if (autoRefresh.value) startAutoRefresh() } function disarmFleetPoll() { + active = false + if (clockTimer) clearInterval(clockTimer) + clockTimer = null stopAutoRefresh() } onMounted(async () => { updateChartWidth() window.addEventListener('resize', updateChartWidth) - await refreshAll() armFleetPoll() + await refreshAll() }) onActivated(() => { @@ -545,7 +554,7 @@ export function useFleetData() { return { loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading, selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading, - autoRefresh, lastRefreshed, sortBy, chartWidth, + autoRefresh, lastRefreshed, sortBy, chartWidth, now, onlineCount, offlineCount, unknownCount, healthyCount, fleetHealthPct, avgCpu, avgMem, avgDisk, sortedNodes, allAppIds, nodeHistoryLabels, nodeHistoryCpuDatasets, nodeHistoryMemDatasets, nodeHistoryDiskDatasets,