feat(ui): Server page renders from cached resources (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s
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:
parent
1a306c7450
commit
ea254f63af
@ -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('')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user