Merge main into archy-hwconfig — reconcile probe/dedup/name work

Both sides independently fixed the serial-alias dedup and the ESP32
boot-reset races; kept the branch's defer-to-auto-detect for unpinned
preferred paths (single probe pass per cycle) on top of main's
advert-name threading, Reticulum name propagation and radio-first
routing. Modal keeps main's 'Set Recommended' naming + probe progress
bar alongside the branch's in-app firmware flasher step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-28 19:03:19 -04:00
co-authored by Claude Fable 5
173 changed files with 10967 additions and 1925 deletions
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useResourcesStore } from '../resources'
import { useCachedResource } from '@/composables/useCachedResource'
describe('resources store — stale-while-revalidate semantics', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('first fetch goes idle → loading → ready with data', async () => {
const store = useResourcesStore()
const e = store.entry<string>('k1')
expect(e.loadState).toBe('idle')
const p = store.refresh('k1', async () => 'hello')
expect(e.loadState).toBe('loading')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe('hello')
expect(e.fetchedAt).not.toBeNull()
})
it('sticky-ready: refresh never regresses ready → loading', async () => {
const store = useResourcesStore()
await store.refresh('k2', async () => 1)
const e = store.entry<number>('k2')
const p = store.refresh('k2', async () => 2)
expect(e.loadState).toBe('refreshing')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe(2)
})
it('keeps last-known data on refresh error (ready + error set)', async () => {
const store = useResourcesStore()
await store.refresh('k3', async () => 'good')
const e = store.entry<string>('k3')
await store.refresh('k3', async () => {
throw new Error('boom')
})
expect(e.data).toBe('good')
expect(e.loadState).toBe('ready')
expect(e.error).toBe('boom')
})
it('errors with no prior data land in error state', async () => {
const store = useResourcesStore()
await store.refresh('k4', async () => {
throw new Error('down')
})
const e = store.entry('k4')
expect(e.loadState).toBe('error')
expect(e.data).toBeNull()
})
it('dedups concurrent refreshes for the same key', async () => {
const store = useResourcesStore()
const fetcher = vi.fn(async () => 'once')
const p1 = store.refresh('k5', fetcher)
const p2 = store.refresh('k5', fetcher)
await Promise.all([p1, p2])
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('hydrates a new entry from the sessionStorage snapshot', async () => {
const store = useResourcesStore()
await store.refresh('k6', async () => ({ n: 42 }))
// Fresh pinia = fresh memory cache, same sessionStorage.
setActivePinia(createPinia())
const store2 = useResourcesStore()
const e = store2.entry<{ n: number }>('k6')
expect(e.loadState).toBe('ready')
expect(e.data).toEqual({ n: 42 })
})
it('optimistic update applies immediately and rollback restores', async () => {
const store = useResourcesStore()
await store.refresh('k7', async () => ['a'])
const e = store.entry<string[]>('k7')
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
expect(e.data).toEqual(['a', 'b'])
rollback()
expect(e.data).toEqual(['a'])
})
it('invalidate marks stale and debounce-runs subscribers', async () => {
const store = useResourcesStore()
await store.refresh('k8', async () => 1)
const revalidate = vi.fn()
store.subscribe('k8', revalidate)
store.invalidate('k8')
expect(store.entry('k8').fetchedAt).toBeNull()
expect(revalidate).not.toHaveBeenCalled()
vi.advanceTimersByTime(900)
expect(revalidate).toHaveBeenCalledTimes(1)
})
})
describe('useCachedResource composable', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
})
it('fetches immediately when stale and exposes reactive views', async () => {
const fetcher = vi.fn(async () => 'data')
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
await r.refresh()
expect(fetcher).toHaveBeenCalled()
expect(r.data.value).toBe('data')
expect(r.loadState.value).toBe('ready')
expect(r.isStale.value).toBe(false)
})
it('does not refetch within TTL (instant render from cache)', async () => {
const fetcher = vi.fn(async () => 'v1')
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
await r1.refresh()
// Second component using the same key inside the TTL: no new fetch.
const fetcher2 = vi.fn(async () => 'v2')
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
expect(r2.data.value).toBe('v1')
expect(fetcher2).not.toHaveBeenCalled()
})
})
+28 -12
View File
@@ -8,6 +8,11 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false)
const error = ref<string | null>(null)
const authenticated = ref(false)
// Per-path listing cache: re-entering a folder paints the last listing
// immediately (no spinner) while the fresh listing loads behind it.
const pathCache = new Map<string, FileBrowserItem[]>()
// Last-wins guard for overlapping navigations (fast folder hopping).
let navSeq = 0
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
@@ -36,7 +41,22 @@ export const useCloudStore = defineStore('cloud', () => {
}
async function navigate(path: string): Promise<void> {
loading.value = true
const seq = ++navSeq
const apply = (p: string, result: FileBrowserItem[]) => {
pathCache.set(p, result)
if (seq !== navSeq) return // a newer navigation superseded this one
items.value = result
currentPath.value = p
}
// Stale-while-revalidate: show the cached listing for this path
// immediately (no spinner), then refresh it underneath.
const cached = pathCache.get(path)
if (cached) {
items.value = cached
currentPath.value = path
} else {
loading.value = true
}
error.value = null
try {
if (!authenticated.value) {
@@ -47,9 +67,7 @@ export const useCloudStore = defineStore('cloud', () => {
}
}
try {
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
apply(path, await fileBrowserClient.listDirectory(path))
} catch {
// Directory may not exist — try to create it, then retry
if (path !== '/') {
@@ -57,23 +75,20 @@ export const useCloudStore = defineStore('cloud', () => {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const dirName = path.substring(path.lastIndexOf('/') + 1)
await fileBrowserClient.createFolder(parentPath, dirName)
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
apply(path, await fileBrowserClient.listDirectory(path))
} catch {
// Fall back to root
const result = await fileBrowserClient.listDirectory('/')
items.value = result
currentPath.value = '/'
apply('/', await fileBrowserClient.listDirectory('/'))
}
} else {
throw new Error('Failed to list root directory')
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load files'
// Keep showing the cached listing on a failed revalidate.
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally {
loading.value = false
if (seq === navSeq) loading.value = false
}
}
@@ -112,6 +127,7 @@ export const useCloudStore = defineStore('cloud', () => {
items.value = []
loading.value = false
error.value = null
pathCache.clear()
}
return {
+19 -2
View File
@@ -291,12 +291,16 @@ export const useMeshStore = defineStore('mesh', () => {
async function fetchStatus() {
try {
loading.value = true
error.value = null
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
status.value = res
trackDetectedDevices(res)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
// Don't clobber a user-action error (broadcast/configure/send) — this
// runs on a 5s poll, and the old `error.value = null` on entry meant
// any real error banner survived at most one poll tick.
if (!error.value) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
}
} finally {
loading.value = false
}
@@ -1018,6 +1022,18 @@ export const useMeshStore = defineStore('mesh', () => {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
}
/** Ask the backend to actively re-query the radio's contact table (and by
* extension re-drain daemon events for Reticulum) — the server-side half
* of the Refresh button; refreshAll() alone only re-reads caches. */
async function refreshRadio(): Promise<boolean> {
try {
const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
return !!res.refreshed
} catch {
return false
}
}
return {
status,
peers,
@@ -1049,6 +1065,7 @@ export const useMeshStore = defineStore('mesh', () => {
broadcastIdentity,
configure,
refreshAll,
refreshRadio,
markChatRead,
clearViewingChat,
sendInvoice,
+166
View File
@@ -0,0 +1,166 @@
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
//
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
// keys its router-view by route.path, so each visit unmounted and refetched
// everything. This store is the single place resource state lives instead:
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
// snapshot), and `useCachedResource` renders them instantly while
// revalidating in the background.
//
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
// hand-rolled versions):
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
// refreshes show as 'refreshing' so the UI keeps the data visible.
// - keep-last-known-value on error: a failed revalidate leaves data in place
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
import { defineStore } from 'pinia'
import { reactive } from 'vue'
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
export interface ResourceEntry<T = unknown> {
data: T | null
loadState: ResourceLoadState
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
fetchedAt: number | null
error: string | null
}
const SNAPSHOT_PREFIX = 'resource:'
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
try {
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
} catch {
/* corrupt/absent snapshot — fall through to a fresh fetch */
}
return null
}
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
try {
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
} catch {
/* quota exceeded or unserializable — memory cache still works */
}
}
export const useResourcesStore = defineStore('resources', () => {
const entries = reactive(new Map<string, ResourceEntry>())
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
const inflight = new Map<string, Promise<void>>()
const revalidators = new Map<string, Set<() => void>>()
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** Get (or create) the reactive entry for a key, hydrating from the
* sessionStorage snapshot on first sight so revisits after a reload paint
* before any RPC completes. Pass `persist: false` to skip snapshots. */
function entry<T>(key: string, persist = true): ResourceEntry<T> {
let e = entries.get(key)
if (!e) {
const snap = persist ? readSnapshot<T>(key) : null
e = reactive<ResourceEntry>({
data: snap ? snap.data : null,
loadState: snap ? 'ready' : 'idle',
fetchedAt: snap ? snap.fetchedAt : null,
error: null,
})
entries.set(key, e)
}
return e as ResourceEntry<T>
}
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
* Concurrent calls for the same key share one in-flight fetch. */
function refresh<T>(
key: string,
fetcher: () => Promise<T>,
opts: { persist?: boolean } = {},
): Promise<void> {
const existing = inflight.get(key)
if (existing) return existing
const e = entry<T>(key, opts.persist ?? true)
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
const p = (async () => {
try {
const data = await fetcher()
e.data = data
e.error = null
e.fetchedAt = Date.now()
e.loadState = 'ready'
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
} catch (err) {
e.error = err instanceof Error ? err.message : String(err)
// Keep last-known data visible; only 'error' when we have nothing.
e.loadState = e.data !== null ? 'ready' : 'error'
} finally {
inflight.delete(key)
}
})()
inflight.set(key, p)
return p
}
/** Mark a key stale and (debounced) re-run every mounted subscriber's
* fetcher. Call after a mutation or on a relevant WS push. */
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
const e = entries.get(key)
if (e) e.fetchedAt = null
const subs = revalidators.get(key)
if (!subs || subs.size === 0) return
const t = invalidateTimers.get(key)
if (t) clearTimeout(t)
invalidateTimers.set(
key,
setTimeout(() => {
invalidateTimers.delete(key)
for (const fn of subs) fn()
}, opts.debounceMs ?? 800),
)
}
/** Register a live revalidator for a key (used by useCachedResource);
* returns an unsubscribe fn. */
function subscribe(key: string, revalidate: () => void): () => void {
let subs = revalidators.get(key)
if (!subs) {
subs = new Set()
revalidators.set(key, subs)
}
subs.add(revalidate)
return () => {
subs.delete(revalidate)
}
}
/** Optimistically apply `update` to the cached value; returns a rollback.
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
const e = entry<T>(key)
const before = e.data
const beforeState = e.loadState
e.data = update(before)
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
return () => {
e.data = before
e.loadState = beforeState
}
}
/** Drop a key entirely (memory + snapshot). */
function evict(key: string): void {
entries.delete(key)
try {
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
} catch {
/* noop */
}
}
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
})
+31 -1
View File
@@ -2,9 +2,38 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { DataModel } from '../types/api'
import type { DataModel, PatchOperation } from '../types/api'
import { wsClient, applyDataPatch } from '../api/websocket'
import { rpcClient } from '../api/rpc-client'
import { useResourcesStore } from './resources'
/** Unescape one JSON-pointer segment (RFC 6901: ~1 → '/', ~0 → '~'). */
function pointerSegment(path: string, prefix: string): string {
const seg = path.slice(prefix.length).split('/')[0] ?? ''
return seg.replace(/~1/g, '/').replace(/~0/g, '~')
}
/** B5: bridge /ws/db pushes into the cached-resource layer. Each patch op
* maps to the resource keys whose backing data it changes; invalidate()
* debounces (800ms) and only refetches keys with mounted subscribers, so a
* patch storm costs one revalidation per key. The 30s staleness
* reconciliation stays as the backstop for anything unmapped. */
function invalidateResourcesForPatch(patch: PatchOperation[]): void {
const resources = useResourcesStore()
for (const op of patch) {
const path = op.path ?? ''
if (path.startsWith('/peer-health/')) {
// A peer flipping reachability changes both its browse result and the
// federation node list's online state.
const onion = pointerSegment(path, '/peer-health/')
if (onion) resources.invalidate(`cloud.peer-browse:${onion}`)
resources.invalidate('federation.nodes')
} else if (path.startsWith('/package-data/')) {
// App installs/uninstalls add or remove their tor services.
resources.invalidate('server.tor-services')
}
}
}
export const useSyncStore = defineStore('sync', () => {
// State
@@ -108,6 +137,7 @@ export const useSyncStore = defineStore('sync', () => {
try {
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
data.value = applyDataPatch(data.value, update.patch)
invalidateResourcesForPatch(update.patch)
// Mark as connected once we receive any valid patch
if (!isConnected.value) {
isConnected.value = true