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
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:
parent
7c6c487a03
commit
ec89901fa9
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user