Merge #807b206b: fix(ui): show live free disk capacity with TB units
Demo images / Build & push demo images (push) Failing after 40s
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:
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,7 +89,9 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
|
||||
load_avg_1?: number
|
||||
load_avg_5?: 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.memUsed = res.mem_used_bytes
|
||||
stats.memTotal = res.mem_total_bytes
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="monitoring-stat-card">
|
||||
<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-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 class="monitoring-stat-card">
|
||||
<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 {
|
||||
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_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
|
||||
@@ -806,10 +806,12 @@ const diskCleaning = ref(false)
|
||||
// onActivated (useCachedResource.ts) revalidates it staleness-gated on
|
||||
// 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 }>({
|
||||
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,
|
||||
ttlMs: 10_000,
|
||||
persist: true,
|
||||
persist: false,
|
||||
fetcher: () => rpcClient.diskStatus(),
|
||||
})
|
||||
// Reactivation refreshes the cached resource directly. Derive its display
|
||||
@@ -833,7 +835,8 @@ async function runDiskCleanup() {
|
||||
}
|
||||
|
||||
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 >= mb) return `${(bytes / mb).toFixed(0)} MB`
|
||||
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.
|
||||
expect(readSnapshot('server.fips-summary')).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()
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<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 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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user