feat(ui): Credentials + OpenWrtGateway render from cached resources — B4 complete
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m10s

- Credentials: identity.list + identity.list-credentials become
  useCachedResource entries; explicit reloads still toast on failure.
- OpenWrtGateway: openwrt.get-status caches in the shared store (revisits
  paint the last router state instantly); the connect flow's
  params/No-router-configured semantics are preserved on top of the entry.
- ContainerApps assessed and left as-is: its Pinia store already persists
  across navigation, keeps last data on error, and gates the spinner on
  empty — same class as Apps/Marketplace/Fleet.

This closes the B4 rollout list from docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-28 06:27:05 -04:00
parent 8fd72b947a
commit 8907cc47d9
3 changed files with 56 additions and 45 deletions

View File

@ -199,8 +199,9 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import BackButton from '@/components/BackButton.vue'
interface Identity {
@ -228,9 +229,30 @@ interface Credential {
status: string
}
const identities = ref<Identity[]>([])
const credentials = ref<Credential[]>([])
const loadingCreds = ref(false)
// Cached: revisits paint identities/credentials instantly and revalidate
// behind them (errors keep the last-known lists).
const identitiesRes = useCachedResource<Identity[]>({
key: 'credentials.identities',
fetcher: async (signal) => {
const result = await rpcClient.call<{ identities: Identity[] }>({
method: 'identity.list', params: {}, signal, dedup: true, maxRetries: 1,
})
return result?.identities || []
},
})
const credentialsRes = useCachedResource<Credential[]>({
key: 'credentials.list',
fetcher: async (signal) => {
const result = await rpcClient.call<{ credentials: Credential[] }>({
method: 'identity.list-credentials', params: {}, signal, dedup: true, maxRetries: 1,
})
return result?.credentials || []
},
})
const identities = computed(() => identitiesRes.data.value ?? [])
const credentials = computed(() => credentialsRes.data.value ?? [])
const loadingCreds = computed(() =>
credentialsRes.loadState.value === 'loading' || credentialsRes.loadState.value === 'refreshing')
const selectedCredential = ref<Credential | null>(null)
const credCopied = ref(false)
const revoking = ref(false)
@ -280,31 +302,10 @@ function formatClaims(subject: Record<string, unknown>): string {
return JSON.stringify(claims, null, 2)
}
async function loadIdentities() {
try {
const result = await rpcClient.call<{ identities: Identity[] }>({
method: 'identity.list',
params: {},
})
identities.value = result.identities || []
} catch (e) {
identities.value = []
if (import.meta.env.DEV) console.warn('Failed to load identities:', e)
}
}
async function loadCredentials() {
loadingCreds.value = true
try {
const result = await rpcClient.call<{ credentials: Credential[] }>({
method: 'identity.list-credentials',
params: {},
})
credentials.value = result.credentials || []
} catch (e) {
showToast(`Failed to load credentials: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error')
} finally {
loadingCreds.value = false
await credentialsRes.refresh()
if (credentialsRes.error.value) {
showToast(`Failed to load credentials: ${credentialsRes.error.value}`, 'error')
}
}
@ -404,9 +405,8 @@ async function copyCredentialJson() {
setTimeout(() => { credCopied.value = false }, 2000)
}
onMounted(async () => {
await Promise.all([loadIdentities(), loadCredentials()])
})
// Both resources fetch themselves on first use (skipping the fetch entirely
// when the cached value is fresh).
defineExpose({ credentials, loadCredentials })
</script>

View File

@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import Credentials from '../Credentials.vue'
import { rpcClient } from '@/api/rpc-client'
@ -42,6 +43,8 @@ describe('Credentials', () => {
const wrapper = mount(Credentials, {
global: {
// The cached-resource layer pulls the Pinia resources store in setup.
plugins: [createPinia()],
mocks: {
$router: { push: vi.fn() },
},

View File

@ -2,6 +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 BackButton from '@/components/BackButton.vue'
const router = useRouter()
@ -73,8 +74,15 @@ interface ScannedNetwork {
encryption: string
}
const status = ref<RouterStatus | null>(null)
const loading = ref(true)
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<RouterStatus>('server.openwrt-status')
const loading = computed(() => {
const s = statusEntry().loadState
return s === 'loading' || s === 'refreshing' || (s === 'idle' && statusEntry().data === null)
})
const error = ref('')
const host = ref('')
const sshUser = ref('root')
@ -115,25 +123,25 @@ const dhcpLimit = ref(150)
const masqEnabled = ref(true)
async function load(params?: Record<string, string>) {
loading.value = true
error.value = ''
try {
status.value = await rpcClient.call<RouterStatus>({
await resources.refresh<RouterStatus>('server.openwrt-status', () =>
rpcClient.call<RouterStatus>({
method: 'openwrt.get-status',
params: params ?? {},
timeout: 30000,
})
showConnectForm.value = false
if (params) connectedParams.value = params
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (msg.includes('No router configured')) {
dedup: true,
maxRetries: 1,
}))
const err = statusEntry().error
if (err) {
if (err.includes('No router configured')) {
showConnectForm.value = true
} else {
error.value = msg
error.value = err
}
} finally {
loading.value = false
} else {
showConnectForm.value = false
if (params) connectedParams.value = params
}
}