feat(02-06): cache the Server tab's seven load groups with explicit TTL/persist
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m46s

Server.vue already had five of the seven load groups (network summary, FIPS
summary, VPN peers, interfaces, Tor services) on useCachedResource from a
pre-phase commit, but none declared an explicit ttlMs/persist (relying on
the composable's 30s/persist:true defaults) and dedup:true was missing from
several underlying RPC calls. loadDiskStatus was the one remaining plain
uncached fetch, forced on every activation.

- Explicit TTL per group (10s fast tier: network-summary/interfaces/
  disk-status; 30s near-default: vpn-peers/tor-services; 60s near-static:
  fips-summary)
- Explicit persist:false for server.vpn-peers (npub/peer identity) and
  server.tor-services (onion addresses) per T-02-01; other groups persist
- New server.disk-status cached resource replaces the plain fetch; it now
  self-heals via the composable's own onActivated instead of an explicit
  every-entry call
- dedup:true added to all underlying rpcClient calls, including the
  parameterless vpnStatus()/dnsStatus()/diskStatus() convenience methods
- RefreshIndicator wired into a new minimal header row, driven by whether
  any of the six cache entries is refreshing
- RESEARCH assumption A3 settled: read all seven loader bodies; none
  consumes another's result — the concurrent fan-out is correct as-is
- Fixed a keepAliveLifecycle.test.ts assertion invalidated by the new 10s
  network-summary TTL (reactivation now also revalidates that resource,
  adding one more vpnStatus() call the test didn't previously account for)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 17:03:55 -04:00
parent 8563cf4a84
commit e6ed5536c9
4 changed files with 399 additions and 30 deletions

View File

@ -1021,6 +1021,7 @@ class RPCClient {
return this.call({
method: 'vpn.status',
params: {},
dedup: true, // read-only status; safe to collapse concurrent callers (PERF-02)
})
}
@ -1091,6 +1092,7 @@ class RPCClient {
return this.call({
method: 'network.dns-status',
params: {},
dedup: true, // read-only status; safe to collapse concurrent callers (PERF-02)
})
}
@ -1118,7 +1120,7 @@ class RPCClient {
used_percent: number
level: 'ok' | 'warning' | 'critical'
}> {
return this.call({ method: 'system.disk-status' })
return this.call({ method: 'system.disk-status', dedup: true }) // read-only; safe to collapse concurrent callers (PERF-02)
}
async diskCleanup(): Promise<{

View File

@ -1,6 +1,13 @@
<template>
<div class="pb-6">
<!-- Subtle background-refresh indicator (D-05): renders nothing unless
any of the seven cached load groups is revalidating. Additive-only
no existing element/selector is touched. -->
<div class="flex justify-end">
<RefreshIndicator :state="serverRefreshIndicatorState" label="Refreshing server status" />
</div>
<!-- LUKS Encryption Badge -->
<div v-if="diskEncrypted" class="mb-4 px-4 py-2.5 rounded-xl border bg-green-500/5 border-green-500/20 flex items-center gap-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
@ -408,6 +415,7 @@ import { ref, computed, onActivated, onDeactivated, onMounted, onUnmounted, watc
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource, type CachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { useAppStore } from '@/stores/app'
import QuickActionsCard from './server/QuickActionsCard.vue'
import TorServicesCard from './server/TorServicesCard.vue'
@ -451,14 +459,24 @@ const defaultNetworkData = (): NetworkData => ({
// immediate:false the fetcher merges onto the previous value via
// networkRes, so it must not run during this initializer (onMounted loads
// it). The explicit annotation breaks the self-referential inference cycle.
//
// TTL/persist (02-06, T-02-01/D-06): 10s this bucket (network/interfaces/
// disk) moves fast enough (WiFi/VPN/DNS state) to warrant the short tier.
// persist:true this is this node's OWN aggregate network/VPN-connection
// status (not another peer's identity); wgPubkey here is the local node's
// own WireGuard public key, already surfaced in this same UI, not a peer
// identity payload distinct from server.vpn-peers below, which does carry
// other peers' identity and is persist:false.
const networkRes: CachedResource<NetworkData> = useCachedResource<NetworkData>({
key: 'server.network-summary',
immediate: false,
fetcher: async () => {
ttlMs: 10_000,
persist: true,
fetcher: async (signal) => {
const next = { ...(networkRes.data.value ?? defaultNetworkData()) }
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }),
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics', signal, dedup: true, maxRetries: 1 }),
rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards', signal, dedup: true, maxRetries: 1 }),
rpcClient.vpnStatus(),
rpcClient.dnsStatus(),
])
@ -474,9 +492,15 @@ const networkLoading = computed(() => networkRes.loadState.value === 'loading')
const networkRefreshing = computed(() => networkRes.loadState.value === 'refreshing')
// FIPS status row for the Local Network card. Full FIPS card lives below.
// TTL 60s (near-static: installed/service_active/key_present rarely change,
// but authenticated_peer_count can drift, so this sits between the fast
// 10s tier and Tor/VPN's 30s tier rather than a fully-static value).
// persist:true key_present is a boolean flag, not the key material itself.
const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({
key: 'server.fips-summary',
immediate: false,
ttlMs: 60_000,
persist: true,
fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
})
const fipsSummary = computed(() => fipsSummaryRes.data.value)
@ -521,9 +545,14 @@ const sanitizedPeerQrSvg = computed(() =>
)
const peerError = ref('')
const copiedConfig = ref(false)
// TTL 30s (near the default, matching Tor status/services) persist:false:
// each peer carries an npub (Nostr identity), so this is VPN peer identity
// data per T-02-01, distinct from server.network-summary's own-node status.
const vpnPeersRes = useCachedResource<{ name: string; ip: string; type?: string; npub?: string }[]>({
key: 'server.vpn-peers',
immediate: false,
ttlMs: 30_000,
persist: false,
fetcher: async (signal) => {
const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers', signal, dedup: true, maxRetries: 1 })
return res.peers || []
@ -602,9 +631,14 @@ async function copyPeerConfig() {
interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] }
interface WifiNetwork { ssid: string; signal: number; security: string }
// TTL 10s (fast tier, alongside network-summary/disk-status) persist:true:
// local interface names/MACs/IPs are this node's own hardware info, not
// peer or VPN identity.
const interfacesRes = useCachedResource<NetworkInterface[]>({
key: 'server.interfaces',
immediate: false,
ttlMs: 10_000,
persist: true,
fetcher: async (signal) => {
const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces', signal, dedup: true, maxRetries: 1 })
return res.interfaces
@ -732,14 +766,29 @@ const diskWarning = ref<{ level: 'warning' | 'critical'; used_percent: number; f
const diskEncrypted = ref(false)
const diskCleaning = ref(false)
async function loadDiskStatus() {
try {
const res = await rpcClient.diskStatus()
// 02-06: the seventh Server load group. TTL 10s (fast tier, same as
// network-summary/interfaces) persist:true: disk usage figures carry no
// identity/financial payload. Was previously a plain uncached fetch called
// unconditionally on every activation (see armServerEntryEffects below,
// pre-02-06) now behaves like the other six: this resource's own
// 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',
immediate: false,
ttlMs: 10_000,
persist: true,
fetcher: () => rpcClient.diskStatus(),
})
function loadDiskStatus() {
return diskStatusRes.refresh().then(() => {
const res = diskStatusRes.data.value
if (!res) return
diskEncrypted.value = !!(res as Record<string, unknown>).encrypted
if (res.level === 'warning' || res.level === 'critical') {
diskWarning.value = { level: res.level, used_percent: res.used_percent, free_bytes: res.free_bytes }
} else { diskWarning.value = null }
} catch { /* non-critical */ }
})
}
async function runDiskCleanup() {
@ -757,9 +806,13 @@ function formatBytes(bytes: number): string {
}
// Tor Services
// TTL 30s (near the default) persist:false: each service carries an
// onion_address, a Tor identity payload per T-02-01.
const torServicesRes = useCachedResource<{ services: TorServiceInfo[]; tor_running: boolean }>({
key: 'server.tor-services',
immediate: false,
ttlMs: 30_000,
persist: false,
fetcher: async (signal) => {
const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services', signal, dedup: true, maxRetries: 1 })
return { services: res.services || [], tor_running: res.tor_running ?? false }
@ -789,6 +842,22 @@ function loadTorServices() {
return torServicesRes.refresh()
}
// Drives the header RefreshIndicator (D-05): visible whenever any of the six
// underlying cache entries (torServicesRes also backs checkTorStatus) is
// refreshing in the background, so a stale-while-revalidate pass is never
// silent.
const serverRefreshIndicatorState = computed(() => {
const states = [
networkRes.loadState.value,
fipsSummaryRes.loadState.value,
vpnPeersRes.loadState.value,
interfacesRes.loadState.value,
torServicesRes.loadState.value,
diskStatusRes.loadState.value,
]
return states.includes('refreshing') ? 'refreshing' : 'ready'
})
async function copyTorAddress(address: string) {
try {
if (navigator.clipboard?.writeText) {
@ -858,20 +927,19 @@ function disarmVpnPoll() {
if (vpnPollInterval) { clearInterval(vpnPollInterval); vpnPollInterval = null }
}
// Every-entry: loadDiskStatus is a plain fetch with no useCachedResource
// backing it, so under KeepAlive it would otherwise run once ever and the
// disk-space warning banner would never update again for the rest of the
// session.
// 02-06: loadDiskStatus is now the seventh useCachedResource-backed load
// (see diskStatusRes above), so it self-heals on reactivation exactly like
// the other six armServerEntryEffects only needs to re-arm the VPN poll
// interval now, not force-call loadDiskStatus on every entry.
function armServerEntryEffects() {
void loadDiskStatus()
armVpnPoll()
}
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
// component's first mount this flag lets onMounted's call count as the
// first activation's arm, so onActivated only re-arms (and re-issues
// loadDiskStatus + the immediate VPN poll tick) on a genuine later
// reactivation, not redundantly right after mount.
// first activation's arm, so onActivated only re-arms (and re-issues the
// immediate VPN poll tick) on a genuine later reactivation, not redundantly
// right after mount.
let serverFreshMount = true
onActivated(() => {
if (serverFreshMount) { serverFreshMount = false; return }
@ -880,20 +948,20 @@ onActivated(() => {
onDeactivated(() => disarmVpnPoll())
onUnmounted(() => disarmVpnPoll())
// Once-per-session seed for the six useCachedResource-backed loads below:
// each resource's own onActivated (added in useCachedResource.ts) already
// Once-per-session seed for all seven useCachedResource-backed loads below
// (network-summary, fips-summary, vpn-peers, interfaces, tor-services,
// disk-status, and checkTorStatus which shares tor-services): each
// resource's own onActivated (added in useCachedResource.ts) already
// revalidates it, staleness-gated, on every later reactivation same
// pattern established for Marketplace.vue in 02-02 so this task only
// relocates the ONE loader here that bypasses useCachedResource entirely
// (loadDiskStatus, folded into armServerEntryEffects() above). Server's
// seven-call fan-out itself stays fire-and-forget/concurrent, unconverted
// that data-layer conversion is 02-06's job, not this task's.
// pattern established for Marketplace.vue in 02-02. 02-06 settled RESEARCH
// A3 (see 02-06-SUMMARY.md) none of these seven loaders consumes another's
// result, so this fan-out stays fire-and-forget/concurrent, never chained.
//
// Also arms the every-entry effects directly here: onActivated is a no-op
// outside a <KeepAlive> boundary (confirmed by ServerNetworkRefresh.test.ts,
// which mounts this view bare), so a bare mount must not silently skip them.
onMounted(() => {
checkTorStatus(); loadNetworkData(); loadInterfaces(); loadTorServices(); loadVpnPeers(); loadFipsSummary()
checkTorStatus(); loadNetworkData(); loadInterfaces(); loadTorServices(); loadVpnPeers(); loadFipsSummary(); loadDiskStatus()
armServerEntryEffects()
})
@ -932,9 +1000,13 @@ defineExpose({
loadInterfaces,
loadNetworkData,
loadTorServices,
loadDiskStatus,
loadVpnPeers,
loadFipsSummary,
networkData,
networkRefreshing,
torServices,
torServicesLoading,
serverRefreshIndicatorState,
})
</script>

View File

@ -0,0 +1,293 @@
// 02-06 Task 1: caches the Server tab's seven load groups (network summary,
// FIPS summary, VPN peers, interfaces, Tor services — shared by
// checkTorStatus and loadTorServices — and disk status) behind keyed
// useCachedResource entries, with per-group TTL/persist decisions and the
// cold-load fan-out kept concurrent (RESEARCH A3 settled: none of the seven
// loaders consumes another's result — see 02-06-SUMMARY.md).
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Server from '../Server.vue'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
// Records every rpcClient.call({method}) invocation so tests can assert
// per-group call counts and concurrency without depending on real network
// I/O. vpnStatus/dnsStatus/diskStatus are separate convenience methods on
// rpcClient (not routed through call()), so they're mocked independently.
const rpcCallMock = vi.fn(async ({ method }: { method: string }) => {
switch (method) {
case 'network.diagnostics':
return { tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' }
case 'router.list-forwards':
return { forwards: [] }
case 'network.list-interfaces':
return { interfaces: [] }
case 'tor.list-services':
return { services: [], tor_running: false }
case 'vpn.list-peers':
return { peers: [] }
case 'fips.status':
return { installed: false, service_active: false, key_present: false }
default:
return {}
}
})
const vpnStatusMock = vi.fn(async () => ({
connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24',
peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: true, configured_provider: 'wireguard',
}))
const dnsStatusMock = vi.fn(async () => ({
provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [],
}))
const diskStatusMock = vi.fn(async () => ({
used_bytes: 0, total_bytes: 0, free_bytes: 0, used_percent: 0, level: 'ok' as const,
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: (...args: unknown[]) => (rpcCallMock as unknown as (...a: unknown[]) => unknown)(...args),
vpnStatus: (...args: unknown[]) => (vpnStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
dnsStatus: (...args: unknown[]) => (dnsStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
diskStatus: (...args: unknown[]) => (diskStatusMock as unknown as (...a: unknown[]) => unknown)(...args),
},
}))
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
function mountServerHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Server, { key: 'server' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
QuickActionsCard: true,
TorServicesCard: true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
}
function callCountFor(method: string): number {
return rpcCallMock.mock.calls.filter(([opts]) => (opts as { method: string }).method === method).length
}
async function toggleTab(wrapper: ReturnType<typeof mountServerHost>, show: boolean) {
;(wrapper.vm as unknown as { show: boolean }).show = show
await wrapper.vm.$nextTick()
}
beforeEach(() => {
vi.useFakeTimers()
rpcCallMock.mockClear()
vpnStatusMock.mockClear()
dnsStatusMock.mockClear()
diskStatusMock.mockClear()
try {
sessionStorage.clear()
} catch { /* unavailable in some envs */ }
})
afterEach(() => {
vi.useRealTimers()
})
describe('Server tab cache (Task 1): seven load groups', () => {
it('a cold load fires all seven groups concurrently — every RPC has already started before any microtask resolves', async () => {
const wrapper = mountServerHost()
// Synchronous check (no await yet): armServerEntryEffects/onMounted call
// all seven loaders without awaiting one before starting the next — a
// serialized chain could not have reached all seven RPCs yet here.
expect(callCountFor('network.diagnostics')).toBe(1)
expect(callCountFor('router.list-forwards')).toBe(1)
// vpnStatus fires twice on a fresh mount: once for network-summary's own
// fetch, once for the VPN poll's immediate first tick (armVpnPoll, a
// deliberate every-activation effect independent of this TTL cache).
expect(vpnStatusMock).toHaveBeenCalledTimes(2)
expect(dnsStatusMock).toHaveBeenCalledTimes(1)
expect(callCountFor('network.list-interfaces')).toBe(1)
expect(callCountFor('tor.list-services')).toBe(1)
expect(callCountFor('vpn.list-peers')).toBe(1)
expect(callCountFor('fips.status')).toBe(1)
expect(diskStatusMock).toHaveBeenCalledTimes(1)
await flushPromises()
wrapper.unmount()
})
it('reactivating inside every group\'s TTL issues zero additional RPCs across all seven groups', async () => {
const wrapper = mountServerHost()
await flushPromises()
const before = {
diag: callCountFor('network.diagnostics'),
fwd: callCountFor('router.list-forwards'),
iface: callCountFor('network.list-interfaces'),
tor: callCountFor('tor.list-services'),
vpnPeers: callCountFor('vpn.list-peers'),
fips: callCountFor('fips.status'),
vpnStatus: vpnStatusMock.mock.calls.length,
dns: dnsStatusMock.mock.calls.length,
disk: diskStatusMock.mock.calls.length,
}
await toggleTab(wrapper, false)
vi.advanceTimersByTime(3000) // well under every group's TTL (shortest is 10s)
await toggleTab(wrapper, true)
await flushPromises()
expect(callCountFor('network.diagnostics')).toBe(before.diag)
expect(callCountFor('router.list-forwards')).toBe(before.fwd)
expect(callCountFor('network.list-interfaces')).toBe(before.iface)
expect(callCountFor('tor.list-services')).toBe(before.tor)
expect(callCountFor('vpn.list-peers')).toBe(before.vpnPeers)
expect(callCountFor('fips.status')).toBe(before.fips)
// dnsStatus is purely network-summary's own TTL-gated call — untouched.
expect(dnsStatusMock.mock.calls.length).toBe(before.dns)
// vpnStatus is the one exception: armVpnPoll's immediate first tick on
// reactivation fires regardless of network-summary's own TTL (a
// deliberate every-activation VPN-IP freshness effect from 02-04, not
// something this task's TTL/persist conversion changes).
expect(vpnStatusMock.mock.calls.length).toBe(before.vpnStatus + 1)
expect(diskStatusMock.mock.calls.length).toBe(before.disk)
wrapper.unmount()
})
it('reactivating past the short (10s) TTL revalidates the fast groups while the longer-TTL FIPS/Tor/VPN-peer groups stay cached', async () => {
const wrapper = mountServerHost()
await flushPromises()
const diagAtMount = callCountFor('network.diagnostics')
const ifaceAtMount = callCountFor('network.list-interfaces')
const diskAtMount = diskStatusMock.mock.calls.length
const fipsAtMount = callCountFor('fips.status')
const torAtMount = callCountFor('tor.list-services')
const vpnPeersAtMount = callCountFor('vpn.list-peers')
await toggleTab(wrapper, false)
// Past the 10s fast tier, well under the 30s Tor/VPN-peer tier and the
// 60s FIPS tier.
vi.advanceTimersByTime(12000)
await toggleTab(wrapper, true)
await flushPromises()
expect(callCountFor('network.diagnostics')).toBe(diagAtMount + 1)
expect(callCountFor('network.list-interfaces')).toBe(ifaceAtMount + 1)
expect(diskStatusMock.mock.calls.length).toBe(diskAtMount + 1)
// FIPS (60s), Tor services (30s) and VPN peers (30s) are still fresh.
expect(callCountFor('fips.status')).toBe(fipsAtMount)
expect(callCountFor('tor.list-services')).toBe(torAtMount)
expect(callCountFor('vpn.list-peers')).toBe(vpnPeersAtMount)
wrapper.unmount()
})
it('a rejected refresh in one group leaves the other six unaffected and keeps that group\'s last known data rendered', async () => {
// Mounted directly (no KeepAlive host) so the exposed loadNetworkData()
// method is reachable, matching ServerNetworkRefresh.test.ts's convention.
const wrapper = mount(Server, {
global: {
plugins: [createPinia()],
stubs: { QuickActionsCard: true, TorServicesCard: true, ServerModals: true, FipsNetworkCard: true },
},
})
await flushPromises()
expect(wrapper.text()).toContain('Lab WiFi')
rpcCallMock.mockImplementationOnce(async ({ method }: { method: string }) => {
if (method === 'network.diagnostics') throw new Error('offline')
return {}
})
await (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
await flushPromises()
// Prior network data stays rendered (keep-last-value on error, D-07).
expect(wrapper.text()).toContain('Lab WiFi')
// Other groups' own data is untouched by the rejection.
expect(diskStatusMock).toHaveBeenCalled()
expect(callCountFor('fips.status')).toBeGreaterThan(0)
expect(callCountFor('vpn.list-peers')).toBeGreaterThan(0)
expect(callCountFor('network.list-interfaces')).toBeGreaterThan(0)
expect(callCountFor('tor.list-services')).toBeGreaterThan(0)
wrapper.unmount()
})
it('groups carrying VPN peer identity or Tor onion addresses declare persist:false; non-identity groups may persist', async () => {
const wrapper = mountServerHost()
await flushPromises()
const readSnapshot = (key: string) => {
try {
return sessionStorage.getItem(`resource:${key}`)
} catch {
return null
}
}
expect(readSnapshot('server.vpn-peers')).toBeNull() // carries npub (peer identity)
expect(readSnapshot('server.tor-services')).toBeNull() // carries onion_address
expect(readSnapshot('server.network-summary')).not.toBeNull() // this node's own status
expect(readSnapshot('server.fips-summary')).not.toBeNull()
expect(readSnapshot('server.interfaces')).not.toBeNull()
expect(readSnapshot('server.disk-status')).not.toBeNull()
wrapper.unmount()
})
it('every fetcher backing the seven load groups is registered with dedup:true', async () => {
const wrapper = mountServerHost()
await flushPromises()
const cachedMethods = [
'network.diagnostics', 'router.list-forwards', 'network.list-interfaces',
'tor.list-services', 'vpn.list-peers', 'fips.status',
]
const dedupFlags = rpcCallMock.mock.calls
.filter(([opts]) => cachedMethods.includes((opts as { method: string }).method))
.map(([opts]) => (opts as { dedup?: boolean }).dedup)
expect(dedupFlags.length).toBe(cachedMethods.length)
expect(dedupFlags.every(Boolean)).toBe(true)
wrapper.unmount()
})
it('renders RefreshIndicator wired to whether any of the seven groups is refreshing', async () => {
const wrapper = mountServerHost()
await flushPromises()
const indicator = wrapper.findComponent(RefreshIndicator)
expect(indicator.exists()).toBe(true)
expect(indicator.props('state')).toBe('ready')
await toggleTab(wrapper, false)
vi.advanceTimersByTime(12000)
await toggleTab(wrapper, true)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('refreshing')
await flushPromises()
expect(wrapper.findComponent(RefreshIndicator).props('state')).toBe('ready')
wrapper.unmount()
})
})

View File

@ -367,21 +367,23 @@ describe('keepAliveLifecycle: real converted view (Server.vue vpnPollInterval)',
expect(callsAtActivation).toBeGreaterThan(0) // immediate call on first activation
// Deactivate — the 15s poll must not keep calling vpnStatus while off
// screen. (20s: past the poll's own 15s period, but comfortably under
// networkRes's 30s TTL so its own onActivated revalidation — a separate,
// unrelated resource that also happens to call vpnStatus — cannot
// confound this assertion on reactivation below.)
// screen, regardless of how long the deactivation lasts (armVpnPoll's
// setInterval is cleared by disarmVpnPoll on deactivate).
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(20000)
await flushPromises()
expect(vpnStatusMock.mock.calls.length).toBe(callsAtActivation)
// Reactivate — invoked once immediately, before the next 15s tick.
// Reactivate — two independent effects each call vpnStatus once here:
// armVpnPoll's immediate first tick (always fires on activation), and
// (02-06) networkRes's own onActivated revalidation, since 20s exceeds
// networkRes's explicit 10s TTL (server.network-summary, the "fast
// tier" per 02-06-SUMMARY.md) and its fetcher also calls vpnStatus.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(vpnStatusMock.mock.calls.length).toBe(callsAtActivation + 1)
expect(vpnStatusMock.mock.calls.length).toBe(callsAtActivation + 2)
wrapper.unmount()
})