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,