From ec89901fa99a29bfe6dfdbe7ff6ddba7f0c7d3f3 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 08:21:53 -0400 Subject: [PATCH] feat(02-03): OpenWrt gateway status caches on repeat visits; CloudFolder left as-is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../__tests__/secondaryScreenCache.test.ts | 80 +++++++++++++++++++ neode-ui/src/views/server/OpenWrtGateway.vue | 73 ++++++++++++----- 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/neode-ui/src/views/__tests__/secondaryScreenCache.test.ts b/neode-ui/src/views/__tests__/secondaryScreenCache.test.ts index 8fb238ed..1ba07a81 100644 --- a/neode-ui/src/views/__tests__/secondaryScreenCache.test.ts +++ b/neode-ui/src/views/__tests__/secondaryScreenCache.test.ts @@ -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) + }) +}) diff --git a/neode-ui/src/views/server/OpenWrtGateway.vue b/neode-ui/src/views/server/OpenWrtGateway.vue index c959096e..00e92e09 100644 --- a/neode-ui/src/views/server/OpenWrtGateway.vue +++ b/neode-ui/src/views/server/OpenWrtGateway.vue @@ -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('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 | undefined +const routerResource = useCachedResource({ + key: 'server.openwrt-status', + fetcher: (signal) => rpcClient.call({ + 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) { error.value = '' - await resources.refresh('server.openwrt-status', () => - rpcClient.call({ - 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 + } +})