feat(02-03): OpenWrt gateway status caches on repeat visits; CloudFolder left as-is
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m43s

- OpenWrtGateway.vue: manual resources-store usage (no TTL gating — every
  mount force-refetched) replaced with useCachedResource (key
  server.openwrt-status, no item id — one gateway per node, fixed route
  with no :id param). load() keeps its explicit force-refresh semantics
  for connect/tollgate actions via a pendingParams closure; onMounted now
  only force-fetches when the cache is missing or past its 30s TTL.
- Fixed a real bug this conversion exposed (Rule 1): `loading` conflated
  'refreshing' with 'loading', hiding the already-rendered status panels
  behind the full skeleton on every background revalidation. Every mount
  used to force a fetch, so this was previously masked — cached content
  never got a chance to render before the skeleton took over. Now only a
  true first-load (no data yet) blocks on the skeleton (D-07).
- secondaryScreenCache.test.ts: repeat-open call-count coverage for
  OpenWrtGateway (within-TTL: one fetch; after TTL: cached paint + one
  more fetch).
- CloudFolder.vue: left unchanged. See 02-03-SUMMARY.md for the
  cache-placement decision and why a clean useCachedResource conversion
  needs a cloud.ts change outside this plan's files_modified scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 08:21:53 -04:00
parent 7c6c487a03
commit ec89901fa9
2 changed files with 135 additions and 18 deletions

View File

@ -9,6 +9,7 @@ import { createPinia, type Pinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDetails from '../AppDetails.vue'
import MarketplaceAppDetails from '../MarketplaceAppDetails.vue'
import OpenWrtGateway from '../server/OpenWrtGateway.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
@ -326,3 +327,82 @@ describe('MarketplaceAppDetails.vue — per-item cached catalog versions', () =>
expect(requestedIds).toEqual(['mkt-alpha', 'mkt-beta'])
})
})
describe('OpenWrtGateway.vue — cached router status (no item id: one gateway per node)', () => {
beforeEach(() => {
sessionStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
function makeStatus(hostname: string) {
return {
host: '192.168.1.1',
hostname,
uptime_secs: 100,
release: {},
tollgate: { installed: false },
wifi_interfaces: [],
wan: { configured: false, ssid: '', assoc_ssid: '', encryption: '', ip: '', internet: false, radio0_disabled: false, sta_iface: '', sta_state: '' },
}
}
function mountGateway(pinia: Pinia) {
return mount(OpenWrtGateway, {
global: { plugins: [pinia] },
})
}
it('repeat mount inside the TTL issues exactly one openwrt.get-status fetch total', async () => {
const pinia = createPinia()
let calls = 0
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'openwrt.get-status') {
calls++
return Promise.resolve(makeStatus('router-1'))
}
return Promise.resolve({})
})
const w1 = mountGateway(pinia)
await flushPromises()
expect(w1.text()).toContain('router-1')
w1.unmount()
const w2 = mountGateway(pinia)
await flushPromises()
expect(w2.text()).toContain('router-1')
w2.unmount()
expect(calls).toBe(1)
})
it('a repeat mount after the TTL lapses shows cached data on the first frame and refetches exactly once more', async () => {
const pinia = createPinia()
vi.useFakeTimers({ shouldAdvanceTime: true })
let calls = 0
vi.mocked(rpcClient.call).mockImplementation((req: { method: string }) => {
if (req.method === 'openwrt.get-status') {
calls++
return Promise.resolve(makeStatus('router-1'))
}
return Promise.resolve({})
})
const w1 = mountGateway(pinia)
await flushPromises()
w1.unmount()
vi.advanceTimersByTime(31_000)
const w2 = mountGateway(pinia)
await w2.vm.$nextTick()
expect(w2.text()).toContain('router-1')
await flushPromises()
w2.unmount()
expect(calls).toBe(2)
})
})

View File

@ -2,7 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
import { useCachedResource } from '@/composables/useCachedResource'
import BackButton from '@/components/BackButton.vue'
const router = useRouter()
@ -74,14 +74,38 @@ interface ScannedNetwork {
encryption: string
}
const status = computed(() => statusEntry().data)
// Router status is cached in the shared resources store so revisits paint
// the last-known state instantly while a fresh read runs behind it.
const resources = useResourcesStore()
const statusEntry = () => resources.entry<RouterStatus>('server.openwrt-status')
// Router status is cached via the shared useCachedResource hook so revisits
// paint the last-known state instantly and a mount inside the TTL issues no
// new RPC. No item id in this key there is exactly one configured gateway
// per node, not a per-item collection (route is the fixed `server/openwrt`,
// no :id param). `load(params)` is the explicit force-refresh path (connect,
// tollgate provision/save all want an unconditional re-fetch); `pendingParams`
// lets the fetcher read the params `load()` was called with without changing
// the cache key.
let pendingParams: Record<string, string> | undefined
const routerResource = useCachedResource<RouterStatus>({
key: 'server.openwrt-status',
fetcher: (signal) => rpcClient.call<RouterStatus>({
method: 'openwrt.get-status',
params: pendingParams ?? {},
signal,
timeout: 30000,
dedup: true,
maxRetries: 1,
}),
ttlMs: 30_000,
immediate: false, // initial fetch is gated explicitly in onMounted below
})
const status = computed(() => routerResource.data.value)
// 'refreshing' deliberately excluded (D-07, keep-last-value): a background
// TTL revalidation must not hide the already-rendered status panels behind
// the full loading skeleton only a true first-load (no data at all yet)
// blocks on it. Previously this conflated the two, so a cache hit would
// still briefly show the skeleton on every mount instead of painting
// instantly.
const loading = computed(() => {
const s = statusEntry().loadState
return s === 'loading' || s === 'refreshing' || (s === 'idle' && statusEntry().data === null)
const s = routerResource.loadState.value
return routerResource.data.value === null && (s === 'loading' || s === 'idle')
})
const error = ref('')
const host = ref('')
@ -122,17 +146,14 @@ const dhcpStart = ref(100)
const dhcpLimit = ref(150)
const masqEnabled = ref(true)
// Force-fetch: always refetches regardless of TTL. Used by every explicit
// user action (connect, tollgate provision/save) that must reflect a fresh
// server-side state, and by onMounted below when the cache is missing/stale.
async function load(params?: Record<string, string>) {
error.value = ''
await resources.refresh<RouterStatus>('server.openwrt-status', () =>
rpcClient.call<RouterStatus>({
method: 'openwrt.get-status',
params: params ?? {},
timeout: 30000,
dedup: true,
maxRetries: 1,
}))
const err = statusEntry().error
pendingParams = params
await routerResource.refresh()
const err = routerResource.error.value
if (err) {
if (err.includes('No router configured')) {
showConnectForm.value = true
@ -371,7 +392,23 @@ const boardName = computed(() => {
return status.value.release.openwrt_board_name || ''
})
onMounted(() => load())
// Only force-fetch when the cache is missing or past its TTL, so a repeat
// open inside the TTL paints from cache with no new RPC. On a within-TTL
// remount, still reconcile the connect-form/error UI state from the cached
// entry (mirrors what a completed load() call would have set).
onMounted(() => {
if (routerResource.data.value === null || routerResource.isStale.value) {
void load()
return
}
const err = routerResource.error.value
if (err) {
if (err.includes('No router configured')) showConnectForm.value = true
else error.value = err
} else {
showConnectForm.value = false
}
})
</script>
<template>