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
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:
parent
8563cf4a84
commit
e6ed5536c9
@ -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<{
|
||||
|
||||
@ -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>
|
||||
|
||||
293
neode-ui/src/views/__tests__/serverTabCache.test.ts
Normal file
293
neode-ui/src/views/__tests__/serverTabCache.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user