Merge #807b206b: fix(ui): show live free disk capacity with TB units
Demo images / Build & push demo images (push) Failing after 40s

nostr:nevent1qqsgq7eqdd4633p8glspy29wev487tjjmnccnc0qrhh5zk72y4dzapcpz3mhxue69uhhyetvv9ujumn8d96zuer9wc59jes7

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

system.stats not deduplicated; Server disk status uses non-persisted server.disk-status-v2; Home and Monitoring show "X free / Y total" with TB formatting. 19 focused tests pass.
This commit is contained in:
archipelago
2026-10-10 12:56:40 -04:00
8 changed files with 198 additions and 7 deletions
@@ -93,3 +93,37 @@ describe('homeStatus — B16 bitcoin sync status retain (no vanish, no stale-as-
expect(store.bitcoinStale).toBe(true) // labeled "Updating…" since we have no live numbers yet expect(store.bitcoinStale).toBe(true) // labeled "Updating…" since we have no live numbers yet
}) })
}) })
describe('homeStatus — live disk capacity (dedup disabled for system.stats)', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('issues a fresh system.stats request on every refresh instead of a deduplicated snapshot', async () => {
const store = useHomeStatusStore()
mockedRpc.call.mockResolvedValue({
cpu_usage_percent: 5,
mem_used_bytes: 1,
mem_total_bytes: 2,
disk_used_bytes: 1,
disk_total_bytes: 2,
uptime_secs: 60,
})
await store.refreshSystemStats()
await store.refreshSystemStats()
const statsCalls = mockedRpc.call.mock.calls.filter(
([opts]) => (opts as { method?: string }).method === 'system.stats'
)
expect(statsCalls).toHaveLength(2)
for (const [opts] of statsCalls) {
// Persisted/deduplicated snapshots can keep showing the pre-cleanup
// volume for minutes after files are removed; capacity must be live.
expect((opts as { dedup?: boolean }).dedup).toBe(false)
}
expect(store.stats.diskUsed).toBe(1)
expect(store.stats.diskTotal).toBe(2)
})
})
+3 -1
View File
@@ -89,7 +89,9 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
load_avg_1?: number load_avg_1?: number
load_avg_5?: number load_avg_5?: number
load_avg_15?: number load_avg_15?: number
}>({ method: 'system.stats', dedup: true }) // Disk capacity must be live: persisted/deduplicated snapshots can keep
// showing the pre-cleanup volume for minutes after files are removed.
}>({ method: 'system.stats', dedup: false })
stats.cpuPercent = res.cpu_usage_percent stats.cpuPercent = res.cpu_usage_percent
stats.memUsed = res.mem_used_bytes stats.memUsed = res.mem_used_bytes
stats.memTotal = res.mem_total_bytes stats.memTotal = res.mem_total_bytes
+2 -1
View File
@@ -34,7 +34,7 @@
<div class="monitoring-stat-card"> <div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p> <p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.diskUsage') }}</p>
<p class="text-2xl font-bold text-white">{{ diskPercent }}%</p> <p class="text-2xl font-bold text-white">{{ diskPercent }}%</p>
<p class="text-xs text-white/40">{{ formatBytes(liveSystem.disk_used_bytes) }} / {{ formatBytes(liveSystem.disk_total_bytes) }}</p> <p class="text-xs text-white/40">{{ formatBytes(Math.max(0, liveSystem.disk_total_bytes - liveSystem.disk_used_bytes)) }} free / {{ formatBytes(liveSystem.disk_total_bytes) }}</p>
</div> </div>
<div class="monitoring-stat-card"> <div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p> <p class="text-xs text-white/50 uppercase tracking-wide">{{ t('monitoring.network') }}</p>
@@ -444,6 +444,7 @@ function healthColor(entry: HealthEntry): string {
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes >= 1_099_511_627_776) return `${(bytes / 1_099_511_627_776).toFixed(1)} TB`
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB` if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB` if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB` if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
+6 -3
View File
@@ -806,10 +806,12 @@ const diskCleaning = ref(false)
// onActivated (useCachedResource.ts) revalidates it staleness-gated on // onActivated (useCachedResource.ts) revalidates it staleness-gated on
// reactivation, so the explicit every-entry call is no longer needed. // reactivation, so the explicit every-entry call is no longer needed.
const diskStatusRes = useCachedResource<{ used_bytes: number; total_bytes: number; free_bytes: number; used_percent: number; level: 'ok' | 'warning' | 'critical'; encrypted?: boolean }>({ const diskStatusRes = useCachedResource<{ used_bytes: number; total_bytes: number; free_bytes: number; used_percent: number; level: 'ok' | 'warning' | 'critical'; encrypted?: boolean }>({
key: 'server.disk-status', // v2: persisted snapshots kept showing the pre-cleanup volume for minutes
// after files were removed; disk capacity must always be live.
key: 'server.disk-status-v2',
immediate: false, immediate: false,
ttlMs: 10_000, ttlMs: 10_000,
persist: true, persist: false,
fetcher: () => rpcClient.diskStatus(), fetcher: () => rpcClient.diskStatus(),
}) })
// Reactivation refreshes the cached resource directly. Derive its display // Reactivation refreshes the cached resource directly. Derive its display
@@ -833,7 +835,8 @@ async function runDiskCleanup() {
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
const gb = 1024 * 1024 * 1024; const mb = 1024 * 1024 const tb = 1024 * 1024 * 1024 * 1024; const gb = 1024 * 1024 * 1024; const mb = 1024 * 1024
if (bytes >= tb) return `${(bytes / tb).toFixed(1)} TB`
if (bytes >= gb) return `${(bytes / gb).toFixed(1)} GB` if (bytes >= gb) return `${(bytes / gb).toFixed(1)} GB`
if (bytes >= mb) return `${(bytes / mb).toFixed(0)} MB` if (bytes >= mb) return `${(bytes / mb).toFixed(0)} MB`
return `${(bytes / 1024).toFixed(0)} KB` return `${(bytes / 1024).toFixed(0)} KB`
@@ -0,0 +1,89 @@
// Focused regression for the 2026-10-10 disk display change: Monitoring's
// disk card must show FREE capacity (live, post-cleanup accurate) rather than
// the used/total pair, with TB-scale formatting.
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import Monitoring from '@/views/Monitoring.vue'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
const TB = 1024 ** 4
const GB = 1024 ** 3
beforeEach(() => {
vi.clearAllMocks()
mockedRpc.call.mockImplementation(async ({ method }: { method: string }) => {
switch (method) {
case 'system.stats':
return {
cpu_usage_percent: 5,
mem_used_bytes: 1,
mem_total_bytes: 2,
disk_used_bytes: 512 * GB,
disk_total_bytes: 2 * TB,
uptime_secs: 60,
load_avg_1: 0.1,
load_avg_5: 0.1,
load_avg_15: 0.1,
}
case 'monitoring.current':
return {
timestamp: 0,
rpc_latency_ms: 1,
ws_connections: 0,
system: {
cpu_percent: 5,
mem_used_bytes: 1,
mem_total_bytes: 2,
disk_used_bytes: 512 * GB,
disk_total_bytes: 2 * TB,
net_rx_bytes: 0,
net_tx_bytes: 0,
load_avg_1: 0.1,
load_avg_5: 0.1,
load_avg_15: 0.1,
},
containers: [],
}
case 'monitoring.history':
return { resolution: 'minute', count: 0, data: [] }
case 'monitoring.alerts':
return { alerts: [] }
case 'monitoring.alert-rules':
return { rules: [] }
default:
return {}
}
})
})
describe('Monitoring disk display', () => {
it('shows free capacity with TB units instead of used/total', async () => {
const wrapper = mount(Monitoring, {
global: {
plugins: [createPinia()],
stubs: { LineChart: true, ToggleSwitch: true, BackButton: true },
},
})
await flushPromises()
expect(wrapper.text()).toContain('1.5 TB free / 2.0 TB')
expect(wrapper.text()).not.toContain('512 GB / 2.0 TB')
wrapper.unmount()
})
})
@@ -254,7 +254,11 @@ describe('Server tab cache (Task 1): seven load groups', () => {
// installed/service_active/key_present fields were considered. // installed/service_active/key_present fields were considered.
expect(readSnapshot('server.fips-summary')).toBeNull() expect(readSnapshot('server.fips-summary')).toBeNull()
expect(readSnapshot('server.interfaces')).not.toBeNull() expect(readSnapshot('server.interfaces')).not.toBeNull()
expect(readSnapshot('server.disk-status')).not.toBeNull() // Disk capacity must stay live: persisted snapshots kept rendering the
// pre-cleanup volume for minutes after files were removed (operator
// report 2026-10-10). The v2 key is intentionally persist:false;
// reactivation refetches instead of restoring a stale snapshot.
expect(readSnapshot('server.disk-status-v2')).toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
+1 -1
View File
@@ -51,7 +51,7 @@
<div class="p-4 bg-white/5 rounded-lg"> <div class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/60">{{ t('home.disk') }}</p> <p class="text-xs text-white/60">{{ t('home.disk') }}</p>
<p class="text-sm font-medium" :class="gaugeTextColor(stats.diskPercent)">{{ formatBytes(stats.diskUsed) }} / {{ formatBytes(stats.diskTotal) }}</p> <p class="text-sm font-medium" :class="gaugeTextColor(stats.diskPercent)">{{ formatBytes(Math.max(0, stats.diskTotal - stats.diskUsed)) }} free / {{ formatBytes(stats.diskTotal) }}</p>
</div> </div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden"> <div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.diskPercent)" :style="{ width: stats.diskPercent + '%' }"></div> <div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(stats.diskPercent)" :style="{ width: stats.diskPercent + '%' }"></div>
@@ -0,0 +1,58 @@
// Focused regression for the 2026-10-10 disk display change: the Home System
// card must show FREE capacity (live, post-cleanup accurate) rather than the
// used/total pair, which kept rendering the pre-cleanup volume after files
// were removed. Also covers TB-scale formatting.
import { mount, RouterLinkStub } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import HomeSystemCard from '../HomeSystemCard.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
const TB = 1024 ** 4
const GB = 1024 ** 3
function stats(overrides: Record<string, unknown> = {}) {
return {
cpuPercent: 10,
memUsed: 100,
memTotal: 200,
memPercent: 50,
diskUsed: 512 * GB,
diskTotal: 2 * TB,
diskPercent: 25,
bitcoinSyncPercent: 100,
bitcoinBlockHeight: 800000,
bitcoinAvailable: false,
...overrides,
}
}
describe('HomeSystemCard disk display', () => {
it('shows free capacity with TB units instead of used/total', () => {
const wrapper = mount(HomeSystemCard, {
props: { animate: false, loaded: true, stats: stats(), uptimeDisplay: 'up 1 day' },
global: { stubs: { RouterLink: RouterLinkStub } },
})
const text = wrapper.text()
expect(text).toContain('1.5 TB free / 2.0 TB')
expect(text).not.toContain('512 GB / 2.0 TB')
wrapper.unmount()
})
it('never renders a negative free value when usage exceeds the total', () => {
const wrapper = mount(HomeSystemCard, {
props: {
animate: false,
loaded: true,
stats: stats({ diskUsed: 3 * TB, diskTotal: 2 * TB, diskPercent: 150 }),
uptimeDisplay: 'up 1 day',
},
global: { stubs: { RouterLink: RouterLinkStub } },
})
expect(wrapper.text()).toContain('0 B free / 2.0 TB')
wrapper.unmount()
})
})