fix(webui): resilient dashboard polling (SF7)

useDashboardStore.refresh() used Promise.all across overview/usage/
targets: one route rejecting threw before any of the three state
fields were assigned, so a single flaky GET /admin/api/targets blanked
the already-working Providers and Usage views too.

Switched to Promise.allSettled with per-section assignment: a
fulfilled result updates its own field, a rejected one leaves the
PREVIOUS value in place rather than nulling it. lastUpdated still
advances on a partial success (some fresh data did arrive); error
names which section(s) failed. A 401/403 on any section still defers
to the auth store exactly as before (AdminApiError check unchanged).

Added dashboard.spec.ts — the first Pinia store under vitest in this
package (a store needs no DOM, so it fits the existing 'node'
environment without pulling in jsdom); vite.config.ts's own test-scope
comment updated to say so.
This commit is contained in:
2026-08-21 19:16:32 +01:00
parent 8afb903227
commit a25ebf0567
3 changed files with 187 additions and 22 deletions
+40 -16
View File
@@ -30,25 +30,49 @@ export const useDashboardStore = defineStore('dashboard', {
async refresh(): Promise<void> {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
try {
const [overview, usage, targets] = await Promise.all([
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
adminFetch<AdminUsageResponse>('/admin/api/usage'),
adminFetch<AdminTargetsResponse>('/admin/api/targets'),
])
this.overview = overview
this.usage = usage
this.targets = targets
const [overviewResult, usageResult, targetsResult] = await Promise.allSettled([
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
adminFetch<AdminUsageResponse>('/admin/api/usage'),
adminFetch<AdminTargetsResponse>('/admin/api/targets'),
])
// Each section is assigned independently (review round 2, v0.21
// fix): one endpoint failing (e.g. GET /admin/api/targets) must
// never blank the OTHER two, already-populated sections. The
// earlier Promise.all threw on the FIRST rejection and updated
// nothing at all — a single flaky route degraded every view in the
// dashboard, not just its own.
if (overviewResult.status === 'fulfilled') this.overview = overviewResult.value
if (usageResult.status === 'fulfilled') this.usage = usageResult.value
if (targetsResult.status === 'fulfilled') this.targets = targetsResult.value
const failures: { label: string; reason: unknown }[] = []
if (overviewResult.status === 'rejected') failures.push({ label: 'overview', reason: overviewResult.reason })
if (usageResult.status === 'rejected') failures.push({ label: 'usage', reason: usageResult.reason })
if (targetsResult.status === 'rejected') failures.push({ label: 'targets', reason: targetsResult.reason })
if (failures.length === 0) {
this.lastUpdated = new Date()
this.error = ''
} catch (err) {
// A 401/403 already rejected the key (lib/api.ts) — the AuthGate
// takes over the view, nothing left to report here.
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
return
}
this.error = err instanceof Error ? err.message : String(err)
return
}
// A 401/403 on ANY section already rejected the key (lib/api.ts) —
// the AuthGate takes over the whole view in that case, matching the
// pre-existing single-Promise.all behavior; nothing left to report.
const authRejected = failures.some(
({ reason }) => reason instanceof AdminApiError && (reason.status === 401 || reason.status === 403),
)
if (authRejected) return
// A partial failure still advances lastUpdated: at least one
// section genuinely has fresh data, even though `error` (below)
// still surfaces that something is degraded.
if (failures.length < 3) this.lastUpdated = new Date()
this.error = failures
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
.join('; ')
},
/** startPolling is idempotent and safe to call before a key is stored — refresh() no-ops until then. */
startPolling(): void {