From ea254f63afd162ce50e86940b8f47023cee3f0a7 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 28 Jul 2026 05:45:17 -0400 Subject: [PATCH] feat(ui): Server page renders from cached resources (B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit network summary (4-RPC allSettled aggregate), fips row, vpn peers, interfaces, and tor services become useCachedResource entries — revisits paint instantly, background refreshes keep content on screen. Mutations write through the cache: DNS apply + the 15s vpn poll patch the network aggregate via optimistic() instead of refetching all four RPCs; peer removal filters the cached list. loading/refreshing flags derive from entry loadState (drops the hand-rolled hasLoaded bookkeeping). Co-Authored-By: Claude Fable 5 --- neode-ui/src/views/Server.vue | 173 +++++++++++++++++++++------------- 1 file changed, 106 insertions(+), 67 deletions(-) diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 71a1568a..6d819d9b 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -407,6 +407,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import DOMPurify from 'dompurify' import { rpcClient } from '@/api/rpc-client' +import { useCachedResource, type CachedResource } from '@/composables/useCachedResource' import { useAppStore } from '@/stores/app' import QuickActionsCard from './server/QuickActionsCard.vue' import TorServicesCard from './server/TorServicesCard.vue' @@ -434,18 +435,51 @@ const torStatusColor = computed(() => { const autoSyncEnabled = ref(true) const logCount = ref(0) -// Network data -const networkLoading = ref(true) -const networkRefreshing = ref(false) -const networkHasLoaded = ref(false) -const networkData = ref({ - wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A', +// Network data — a cached aggregate over four RPCs (allSettled: a failing +// one keeps that slice's previous values). Revisits paint instantly. +interface NetworkData { + wifiCount: string; wifiSsid: string | null; torConnected: boolean; forwardCount: string + vpnConnected: boolean; vpnProvider: string; vpnIp: string; wgIp: string; wgPubkey: string + vpnHostname: string; vpnPeers: number + dnsProvider: string; dnsServers: string[]; dnsDoH: boolean +} +const defaultNetworkData = (): NetworkData => ({ + wifiCount: 'N/A', wifiSsid: null, torConnected: false, forwardCount: 'N/A', vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0, - dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false, + dnsProvider: 'system', dnsServers: [], dnsDoH: false, }) +// immediate:false — the fetcher merges onto the previous value via +// networkRes, so it must not run during this initializer (onMounted loads +// it). The explicit annotation breaks the self-referential inference cycle. +const networkRes: CachedResource = useCachedResource({ + key: 'server.network-summary', + immediate: false, + fetcher: async () => { + const next = { ...(networkRes.data.value ?? defaultNetworkData()) } + const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([ + rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }), + rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }), + rpcClient.vpnStatus(), + rpcClient.dnsStatus(), + ]) + if (diagRes.status === 'fulfilled') { next.torConnected = diagRes.value.tor_connected; next.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; next.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null } + if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; next.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` } + if (vpnRes.status === 'fulfilled') { next.vpnConnected = vpnRes.value.connected; next.vpnProvider = vpnRes.value.provider ?? ''; next.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); next.wgIp = vpnRes.value.wg_ip ?? ''; next.wgPubkey = (vpnRes.value as Record).wg_pubkey as string ?? '' } + if (dnsRes.status === 'fulfilled') { next.dnsProvider = dnsRes.value.provider; next.dnsServers = dnsRes.value.resolv_conf_servers ?? []; next.dnsDoH = dnsRes.value.doh_enabled } + return next + }, +}) +const networkData = computed(() => networkRes.data.value ?? defaultNetworkData()) +const networkLoading = computed(() => networkRes.loadState.value === 'loading') +const networkRefreshing = computed(() => networkRes.loadState.value === 'refreshing') // FIPS status row for the Local Network card. Full FIPS card lives below. -const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null) +const fipsSummaryRes = useCachedResource<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ + key: 'server.fips-summary', + immediate: false, + fetcher: (signal) => rpcClient.call({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }), +}) +const fipsSummary = computed(() => fipsSummaryRes.data.value) const fipsRowLabel = computed(() => { const s = fipsSummary.value if (!s) return '…' @@ -467,32 +501,12 @@ const fipsRowTextClass = computed(() => { if (s.anchor_connected === false) return 'text-orange-400' return 'text-green-400' }) -async function loadFipsSummary() { - try { - fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' }) - } catch { /* backend too old */ } +function loadFipsSummary() { + return fipsSummaryRes.refresh() } -async function loadNetworkData() { - const initialLoad = !networkHasLoaded.value - networkLoading.value = initialLoad - networkRefreshing.value = !initialLoad - try { - const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([ - rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }), - rpcClient.call<{ forwards: unknown[] }>({ method: 'router.list-forwards' }), - rpcClient.vpnStatus(), - rpcClient.dnsStatus(), - ]) - if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; networkData.value.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null } - if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` } - if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record).wg_pubkey as string ?? '' } - if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled } - } catch { /* keep existing/default values */ } finally { - networkHasLoaded.value = true - networkLoading.value = false - networkRefreshing.value = false - } +function loadNetworkData() { + return networkRes.refresh() } // VPN peer management @@ -507,13 +521,18 @@ const sanitizedPeerQrSvg = computed(() => ) const peerError = ref('') const copiedConfig = ref(false) -const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([]) +const vpnPeersRes = useCachedResource<{ name: string; ip: string; type?: string; npub?: string }[]>({ + key: 'server.vpn-peers', + immediate: false, + fetcher: async (signal) => { + const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers', signal, dedup: true, maxRetries: 1 }) + return res.peers || [] + }, +}) +const vpnPeers = computed(() => vpnPeersRes.data.value ?? []) -async function loadVpnPeers() { - try { - const res = await rpcClient.call<{ peers: { name: string; ip: string }[] }>({ method: 'vpn.list-peers' }) - vpnPeers.value = res.peers || [] - } catch { /* no peers */ } +function loadVpnPeers() { + return vpnPeersRes.refresh() } async function createPeer() { @@ -557,7 +576,7 @@ async function removePeer(name: string) { removingPeer.value = name try { await rpcClient.call({ method: 'vpn.remove-peer', params: { name } }) - vpnPeers.value = vpnPeers.value.filter(p => p.name !== name) + vpnPeersRes.optimistic(cur => (cur ?? []).filter(p => p.name !== name)) } catch { /* ignore */ } finally { removingPeer.value = '' } } @@ -583,10 +602,17 @@ async function copyPeerConfig() { interface NetworkInterface { name: string; type: string; state: string; mac: string; ipv4: string[] } interface WifiNetwork { ssid: string; signal: number; security: string } -const interfacesLoading = ref(true) -const interfacesRefreshing = ref(false) -const interfacesHaveLoaded = ref(false) -const allInterfaces = ref([]) +const interfacesRes = useCachedResource({ + key: 'server.interfaces', + immediate: false, + fetcher: async (signal) => { + const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces', signal, dedup: true, maxRetries: 1 }) + return res.interfaces + }, +}) +const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading') +const interfacesRefreshing = computed(() => interfacesRes.loadState.value === 'refreshing') +const allInterfaces = computed(() => interfacesRes.data.value ?? []) const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi')) const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi')) @@ -637,19 +663,19 @@ async function applyDnsConfig(customServers: string) { const res = await rpcClient.configureDns(params) // Never trust the response shape: an undefined `servers` used to reach the // dnsDisplayLabel computed and crash the whole page render on `.length`. - networkData.value.dnsProvider = res?.provider ?? provider - networkData.value.dnsServers = Array.isArray(res?.servers) ? res.servers : (params.servers ?? []) - networkData.value.dnsDoH = !!res?.doh_enabled + // Write-through to the cached aggregate (the RPC already succeeded). + networkRes.optimistic(cur => ({ + ...(cur ?? defaultNetworkData()), + dnsProvider: res?.provider ?? provider, + dnsServers: Array.isArray(res?.servers) ? res.servers : (params.servers ?? []), + dnsDoH: !!res?.doh_enabled, + })) showDnsModal.value = false } catch (e) { dnsError.value = e instanceof Error ? e.message : 'DNS configuration failed.' } finally { dnsApplying.value = false } } -async function loadInterfaces() { - const initialLoad = !interfacesHaveLoaded.value - const hadInterfaces = allInterfaces.value.length > 0 - interfacesLoading.value = initialLoad - interfacesRefreshing.value = !initialLoad - try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false } +function loadInterfaces() { + return interfacesRes.refresh() } async function toggleWifiRadio(iface: NetworkInterface) { @@ -731,9 +757,18 @@ function formatBytes(bytes: number): string { } // Tor Services -const torServices = ref([]) -const torServicesLoading = ref(false) -const torDaemonRunning = ref(false) +const torServicesRes = useCachedResource<{ services: TorServiceInfo[]; tor_running: boolean }>({ + key: 'server.tor-services', + immediate: false, + fetcher: async (signal) => { + const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services', signal, dedup: true, maxRetries: 1 }) + return { services: res.services || [], tor_running: res.tor_running ?? false } + }, +}) +const torServices = computed(() => torServicesRes.data.value?.services ?? []) +const torServicesLoading = computed(() => + torServicesRes.loadState.value === 'loading' || torServicesRes.loadState.value === 'refreshing') +const torDaemonRunning = computed(() => torServicesRes.data.value?.tor_running ?? false) const torRestarting = ref(false) const torRotating = ref(false) const torDeleting = ref(false) @@ -750,11 +785,8 @@ const availableAppsForTor = computed(() => { .sort((a, b) => a.title.localeCompare(b.title)) }) -async function loadTorServices() { - const hadServices = torServices.value.length > 0 - torServicesLoading.value = true - try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false } - catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false } +function loadTorServices() { + return torServicesRes.refresh() } async function copyTorAddress(address: string) { @@ -798,14 +830,18 @@ async function createService(name: string, port: number | null) { onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() }) -// Poll VPN status every 15s so IP updates after pairing +// Poll VPN status every 15s so IP updates after pairing (write-through to +// the cached aggregate without refetching the other three RPCs) const vpnPollInterval = setInterval(async () => { try { const vpnRes = await rpcClient.vpnStatus() - networkData.value.vpnConnected = vpnRes.connected - networkData.value.vpnProvider = vpnRes.provider ?? '' - networkData.value.vpnIp = (vpnRes.ip_address ?? '').replace(/\/\d+$/, '') - networkData.value.wgIp = vpnRes.wg_ip ?? '' + networkRes.optimistic(cur => ({ + ...(cur ?? defaultNetworkData()), + vpnConnected: vpnRes.connected, + vpnProvider: vpnRes.provider ?? '', + vpnIp: (vpnRes.ip_address ?? '').replace(/\/\d+$/, ''), + wgIp: vpnRes.wg_ip ?? '', + })) } catch { /* ignore */ } }, 15000) onUnmounted(() => clearInterval(vpnPollInterval)) @@ -829,8 +865,11 @@ async function restartServices() { async function checkTorStatus() { checkingTor.value = true; torStatusLabel.value = 'checking' - try { const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' } - catch { torStatusLabel.value = 'stopped' } finally { checkingTor.value = false } + try { + await torServicesRes.refresh() + if (torServicesRes.error.value) torStatusLabel.value = 'stopped' + else torStatusLabel.value = torServices.value.some(s => s.onion_address) ? 'running' : 'stopped' + } finally { checkingTor.value = false } } const logsToast = ref('')