mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
Every tab row scrolls sideways instead of being clipped by its card. On phones this hid tabs, such as the Config change helper's last forms at 360-390px. The login button has a larger touch target, and long names are truncated with a tooltip. Loading and empty states: - Skeletons show only while a selection has no data yet, never over data already on screen during a refresh. - Empty states name the next step to take. - Error states offer a Retry button that shows progress, and each card keeps its own error. - Toasts confirm copies and key generation, and report background failures once per streak through polite and assertive live regions. Other changes: - The access matrix switches between Models, MCP servers and Agents, and says "none configured" only once its data has loaded. - Home ranks the top 5 users by cost, requests or tokens. It requests only the needed metrics and warns before a range exceeds the server's key cap. - MCP and agent deep links accept a bare target name. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
152 lines
7.8 KiB
TypeScript
152 lines
7.8 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
|
|
import { AdminApiError, adminFetch } from '@/lib/api'
|
|
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { useToastsStore } from '@/stores/toasts'
|
|
import type { AdminOverviewResponse, AdminTargetsResponse, AdminUsageResponse } from '@/types/api'
|
|
|
|
/** How often the Providers/Usage/Targets views poll (matches the replaced vanilla-JS page). */
|
|
const POLL_MS = 5000
|
|
|
|
/**
|
|
* useDashboardStore polls GET /admin/api/overview, GET /admin/api/usage,
|
|
* and GET /admin/api/targets together every POLL_MS — the fleet-wide,
|
|
* always-current data every page in the redesigned shell reads from
|
|
* (Home's KPI tiles, ConsumerDirectory/UserDetail's own usage rows,
|
|
* ProviderHealthPanel's provider list, TargetsView's target list). A
|
|
* single store (not three) because every route is always fetched
|
|
* together — the old vanilla-JS page's own Promise.all([overview, usage])
|
|
* pattern, extended to targets (Feature B, v0.21) rather than given its
|
|
* own separate poll timer.
|
|
*/
|
|
export const useDashboardStore = defineStore('dashboard', {
|
|
state: () => ({
|
|
overview: null as AdminOverviewResponse | null,
|
|
usage: null as AdminUsageResponse | null,
|
|
targets: null as AdminTargetsResponse | null,
|
|
lastUpdated: null as Date | null,
|
|
error: '',
|
|
poller: undefined as VisibilityPoller | undefined,
|
|
/**
|
|
* refreshing guards against overlapping polls (review finding): the 5s
|
|
* setInterval has no in-flight check on its own, so a round trip that
|
|
* takes longer than POLL_MS (a slow Redis behind /admin/api/overview,
|
|
* say) would otherwise stack a second refresh() on top of the first —
|
|
* two requests per section in flight at once, free to resolve in
|
|
* either order and briefly show older data after the newer one already
|
|
* landed. A tick that finds a refresh still in flight simply skips —
|
|
* the next tick (or the already-in-flight one finishing) catches up,
|
|
* so the dashboard never resorts to comparing response ordering.
|
|
*/
|
|
refreshing: false,
|
|
/** toastedForFailure guards states-plan.md item 3's "one toast per failure streak until it recovers" — set once a background-refresh-failure toast has fired for the CURRENT ongoing failure streak, reset the moment a refresh fully succeeds again (failures.length === 0 in doRefresh). */
|
|
toastedForFailure: false,
|
|
}),
|
|
actions: {
|
|
async refresh(): Promise<void> {
|
|
const auth = useAuthStore()
|
|
if (!auth.isAuthenticated) return
|
|
if (this.refreshing) return
|
|
this.refreshing = true
|
|
try {
|
|
await this.doRefresh()
|
|
} finally {
|
|
this.refreshing = false
|
|
}
|
|
},
|
|
/** doRefresh is refresh()'s actual body, split out so the in-flight guard above wraps it in one place rather than every early-return branch below needing its own `finally`. */
|
|
async doRefresh(): Promise<void> {
|
|
// Captured BEFORE this tick's results are assigned below — overview/
|
|
// usage/targets are never cleared on a failed fetch (only ever
|
|
// assigned on a FULFILLED result), so this is a true "did the reader
|
|
// already have something on screen before this attempt" read, not
|
|
// just "did the previous refresh fully succeed" (a first attempt
|
|
// that partially succeeds still advances lastUpdated below, which
|
|
// would otherwise make a same-tick check on `lastUpdated` see this
|
|
// attempt's own partial success instead of the PRIOR state).
|
|
const hadDataBefore = this.overview !== null || this.usage !== null || this.targets !== null
|
|
|
|
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 = ''
|
|
this.toastedForFailure = false
|
|
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('; ')
|
|
|
|
// states-plan.md item 3: a background refresh failure while data is
|
|
// ALREADY shown gets a toast instead of wiping content (the header's
|
|
// own inline `error` text already covers that case too, but a toast
|
|
// surfaces it even when the reader is looking at a different page
|
|
// than the one showing the stale data) — never fired on a genuine
|
|
// first-load failure (hadDataBefore false), which is an ErrorState's
|
|
// job instead, and never repeated on every tick of an ONGOING
|
|
// failure streak (toastedForFailure).
|
|
if (hadDataBefore && !this.toastedForFailure) {
|
|
useToastsStore().push({ kind: 'error', message: `Dashboard refresh failed: ${this.error}` })
|
|
}
|
|
this.toastedForFailure = true
|
|
},
|
|
/**
|
|
* startPolling is idempotent and safe to call before a key is stored —
|
|
* refresh() no-ops until then. Driven by the shared
|
|
* createVisibilityPoller (lib/polling.ts, F5) rather than a bare
|
|
* setInterval: an immediate tick (the same "fetch once, then poll"
|
|
* shape this action already had), then every POLL_MS while the tab is
|
|
* visible, pausing while it is hidden and catching up the moment it is
|
|
* looked at again.
|
|
*/
|
|
startPolling(): void {
|
|
if (this.poller) return
|
|
this.poller = createVisibilityPoller({ intervalMs: POLL_MS, tick: () => void this.refresh() })
|
|
this.poller.start()
|
|
},
|
|
// P11 review fix: a stopPolling action mirroring events.ts's own
|
|
// stop() used to live here too, but nothing outside its OWN spec ever
|
|
// called it — App.vue mounts this store's poller once, for the app's
|
|
// whole session, and never unmounts it (unlike events.ts, whose store
|
|
// IS torn down when EventsView unmounts — the same page-scoped
|
|
// pattern the pre-redesign Usage/Charts tabs' own now-deleted stores
|
|
// used to follow too). Dead application-facing API surface, removed;
|
|
// `poller` itself stays (startPolling's own idempotency guard still
|
|
// needs it).
|
|
},
|
|
})
|