feat(ui): Server page renders from cached resources (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s

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 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-28 05:45:17 -04:00
parent 1a306c7450
commit ea254f63af

View File

@ -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<NetworkData> = useCachedResource<NetworkData>({
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<string, unknown>).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<string, unknown>).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<NetworkInterface[]>([])
const interfacesRes = useCachedResource<NetworkInterface[]>({
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<TorServiceInfo[]>([])
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<string | false>(false)
const torDeleting = ref<string | false>(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('')