feat(02-03): OpenWrt gateway status caches on repeat visits; CloudFolder left as-is
Demo images / Build & push demo images (push) Successful in 3m43s
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:
co-authored by
Claude Fable 5
parent
7c6c487a03
commit
ec89901fa9
@@ -9,6 +9,7 @@ import { createPinia, type Pinia } from 'pinia'
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import AppDetails from '../AppDetails.vue'
|
import AppDetails from '../AppDetails.vue'
|
||||||
import MarketplaceAppDetails from '../MarketplaceAppDetails.vue'
|
import MarketplaceAppDetails from '../MarketplaceAppDetails.vue'
|
||||||
|
import OpenWrtGateway from '../server/OpenWrtGateway.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
vi.mock('@/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'])
|
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 { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useResourcesStore } from '@/stores/resources'
|
import { useCachedResource } from '@/composables/useCachedResource'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -74,14 +74,38 @@ interface ScannedNetwork {
|
|||||||
encryption: string
|
encryption: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = computed(() => statusEntry().data)
|
// Router status is cached via the shared useCachedResource hook so revisits
|
||||||
// Router status is cached in the shared resources store so revisits paint
|
// paint the last-known state instantly and a mount inside the TTL issues no
|
||||||
// the last-known state instantly while a fresh read runs behind it.
|
// new RPC. No item id in this key — there is exactly one configured gateway
|
||||||
const resources = useResourcesStore()
|
// per node, not a per-item collection (route is the fixed `server/openwrt`,
|
||||||
const statusEntry = () => resources.entry<RouterStatus>('server.openwrt-status')
|
// 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 loading = computed(() => {
|
||||||
const s = statusEntry().loadState
|
const s = routerResource.loadState.value
|
||||||
return s === 'loading' || s === 'refreshing' || (s === 'idle' && statusEntry().data === null)
|
return routerResource.data.value === null && (s === 'loading' || s === 'idle')
|
||||||
})
|
})
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const host = ref('')
|
const host = ref('')
|
||||||
@@ -122,17 +146,14 @@ const dhcpStart = ref(100)
|
|||||||
const dhcpLimit = ref(150)
|
const dhcpLimit = ref(150)
|
||||||
const masqEnabled = ref(true)
|
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>) {
|
async function load(params?: Record<string, string>) {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
await resources.refresh<RouterStatus>('server.openwrt-status', () =>
|
pendingParams = params
|
||||||
rpcClient.call<RouterStatus>({
|
await routerResource.refresh()
|
||||||
method: 'openwrt.get-status',
|
const err = routerResource.error.value
|
||||||
params: params ?? {},
|
|
||||||
timeout: 30000,
|
|
||||||
dedup: true,
|
|
||||||
maxRetries: 1,
|
|
||||||
}))
|
|
||||||
const err = statusEntry().error
|
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.includes('No router configured')) {
|
if (err.includes('No router configured')) {
|
||||||
showConnectForm.value = true
|
showConnectForm.value = true
|
||||||
@@ -371,7 +392,23 @@ const boardName = computed(() => {
|
|||||||
return status.value.release.openwrt_board_name || ''
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Reference in New Issue
Block a user