mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
feat(webui): add limit bars, forecasts, events, exports and URL state
The Usage tab shows a bar for each configured limit. It turns amber at 80% and red at 100%. The tab also shows rejected requests per day, a projected month-end cost with budget headroom, and CSV export for users and groups. Names link to Charts with the matching scope. The Charts tab ranks models over the full 24h, 30d or 12mo window. A provider filter is applied on the server, and the ranking can be exported as CSV. A new Events tab lists recent failures, filtered by kind and user. The selected tab, filters and scope live in the URL hash. Polling stops while the page is hidden. Views show which replica answered, and a banner lists configuration warnings. CSV cells that start with a formula character are neutralised. Links sit outside the accordion buttons, so both controls keep their accessible names. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import {
|
||||
buildHash,
|
||||
parseChartsParams,
|
||||
parseEventsParams,
|
||||
parseHashState,
|
||||
parseUsageParams,
|
||||
} from '@/lib/hash-state'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
import { useHistoryStore } from '@/stores/history'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import type { ChartTab, ModelMetric } from '@/stores/history'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
|
||||
/** The five defaults every per-tab param omits from the built hash when the field is already at its default value (buildHash's own "caller decides what's default" contract, lib/hash-state.ts). */
|
||||
const DEFAULT_WINDOW: HistoryWindow = 'hour'
|
||||
const DEFAULT_CHART_TAB: ChartTab = 'requests'
|
||||
const DEFAULT_METRIC: ModelMetric = 'cost'
|
||||
const DEFAULT_SCOPE = 'total'
|
||||
const DEFAULT_MODEL_FILTER = ''
|
||||
|
||||
/**
|
||||
* useHashState (F9, dashboard-plan.md) is the single composable that keeps
|
||||
* the URL hash and every store it names in sync, both ways:
|
||||
*
|
||||
* - On creation, it parses the CURRENT hash exactly once and applies it to
|
||||
* stores/nav.ts (activeTab, usageQuery), stores/history.ts (Charts'
|
||||
* scope/window/tab/modelMetric/modelFilter), and stores/events.ts (kindFilter/
|
||||
* userFilter) — "on load: read hash -> restore state" for all three, not
|
||||
* just the active tab the old useTabHash() composable covered.
|
||||
* - A single computed re-derives the hash from whichever store the
|
||||
* CURRENT nav.activeTab reads from, and writes it back via
|
||||
* history.replaceState whenever it changes — never pushState (one
|
||||
* browser-history entry per admin session, not per click) and never a
|
||||
* bare `location.hash =` assignment (which would also jump scroll
|
||||
* position to any element whose id happens to match).
|
||||
* - It listens for the browser's own `hashchange` event (back/forward
|
||||
* navigation, or a hand-edited URL) and re-parses from scratch,
|
||||
* reapplying the same restore path as the initial read.
|
||||
*
|
||||
* MUST be called from App.vue's setup, before the `v-if="!auth.
|
||||
* isAuthenticated"` branch (dashboard-plan.md's own Risks section:
|
||||
* "load-with-hash-then-authenticate") — every store action this touches
|
||||
* (history.setScope, etc.) is safe to call before a key is stored (each
|
||||
* one's own fetch no-ops on an unauthenticated auth store), so the hash's
|
||||
* selection is captured immediately and is already in place the moment
|
||||
* the reader authenticates, rather than being lost because this composable
|
||||
* was never constructed while the AuthGate was showing.
|
||||
*
|
||||
* Guarded for a `window`-less environment (vitest's node test
|
||||
* environment, vite.config.ts) the same way useTabHash's old
|
||||
* implementation was: every DOM/history/location access sits behind one
|
||||
* hasWindow check computed once. Being a real Vue lifecycle-hook user
|
||||
* (onMounted/onUnmounted) exercised through App.vue in the real browser,
|
||||
* not unit-tested directly — lib/hash-state.ts's own pure functions are
|
||||
* what hash-state.spec.ts covers.
|
||||
*/
|
||||
export function useHashState(): void {
|
||||
const nav = useNavStore()
|
||||
const history = useHistoryStore()
|
||||
const events = useEventsStore()
|
||||
|
||||
function applyFromHash(hash: string): void {
|
||||
const { tab, query } = parseHashState(hash)
|
||||
nav.activeTab = tab
|
||||
if (tab === 'charts') {
|
||||
// P7 review fix: this used to apply ONLY the params present in the
|
||||
// hash, so a PARTIAL Charts hash on hashchange (back/forward
|
||||
// navigation, or the reader hand-editing the URL down to a bare
|
||||
// "#charts") left every OMITTED field exactly as it was — buildHash
|
||||
// itself treats an omitted param as "use the default" (its own doc
|
||||
// comment), but this restore path never actually reset anything to
|
||||
// that default, so the address bar and the rendered chart silently
|
||||
// disagreed. Every field is now applied on EVERY call — explicitly
|
||||
// falling back to its own default when the hash omits it — so a
|
||||
// partial or bare hash genuinely resets whatever it doesn't name —
|
||||
// including `filter` (P7 review follow-up): the Models tab's
|
||||
// provider-prefix filter is a hash param now (lib/hash-state.ts's
|
||||
// ChartsHashParams.filter), so it falls back to
|
||||
// DEFAULT_MODEL_FILTER exactly like every other field here, rather
|
||||
// than being left over from whatever the store already had.
|
||||
//
|
||||
// P12 review fix: applied through ONE history.setSelection() call
|
||||
// (batches the state writes and fires exactly one refresh(), plus
|
||||
// one fetchModelOptions() only when window is part of the change)
|
||||
// instead of up to four separate setWindow/setTab/setModelMetric/
|
||||
// setScope calls, each of which used to fire its OWN refresh() —
|
||||
// every fetch but the last immediately discarded.
|
||||
const p = parseChartsParams(query)
|
||||
history.setSelection({
|
||||
window: p.window ?? DEFAULT_WINDOW,
|
||||
tab: p.tab ?? DEFAULT_CHART_TAB,
|
||||
modelMetric: p.metric ?? DEFAULT_METRIC,
|
||||
scope: p.scope ?? DEFAULT_SCOPE,
|
||||
modelFilter: p.filter ?? DEFAULT_MODEL_FILTER,
|
||||
})
|
||||
} else if (tab === 'usage') {
|
||||
const p = parseUsageParams(query)
|
||||
nav.usageQuery = p.q ?? ''
|
||||
} else if (tab === 'events') {
|
||||
const p = parseEventsParams(query)
|
||||
events.setKindFilter(p.kind ?? '')
|
||||
events.setUserFilter(p.user ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
if (hasWindow) applyFromHash(window.location.hash)
|
||||
|
||||
const currentHash = computed<string>(() => {
|
||||
if (nav.activeTab === 'charts') {
|
||||
const params: Record<string, string> = {}
|
||||
if (history.window !== DEFAULT_WINDOW) params.window = history.window
|
||||
if (history.tab !== DEFAULT_CHART_TAB) params.tab = history.tab
|
||||
if (history.modelMetric !== DEFAULT_METRIC) params.metric = history.modelMetric
|
||||
if (history.scope !== DEFAULT_SCOPE) params.scope = history.scope
|
||||
if (history.modelFilter !== DEFAULT_MODEL_FILTER) params.filter = history.modelFilter
|
||||
return buildHash(nav.activeTab, params)
|
||||
}
|
||||
if (nav.activeTab === 'usage') {
|
||||
return buildHash(nav.activeTab, nav.usageQuery ? { q: nav.usageQuery } : {})
|
||||
}
|
||||
if (nav.activeTab === 'events') {
|
||||
const params: Record<string, string> = {}
|
||||
if (events.kindFilter) params.kind = events.kindFilter
|
||||
if (events.userFilter) params.user = events.userFilter
|
||||
return buildHash(nav.activeTab, params)
|
||||
}
|
||||
return buildHash(nav.activeTab, {})
|
||||
})
|
||||
|
||||
watch(currentHash, (hash) => {
|
||||
if (!hasWindow) return
|
||||
if (window.location.hash === hash) return
|
||||
window.history.replaceState(null, '', hash)
|
||||
})
|
||||
|
||||
function onHashChange(): void {
|
||||
applyFromHash(window.location.hash)
|
||||
}
|
||||
|
||||
if (hasWindow) {
|
||||
onMounted(() => window.addEventListener('hashchange', onHashChange))
|
||||
onUnmounted(() => window.removeEventListener('hashchange', onHashChange))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNow } from './useNow'
|
||||
import { monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
|
||||
describe('useNow', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ticks the shared now ref immediately on start', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow()
|
||||
|
||||
start()
|
||||
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:00:00.000Z')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('advances now on each subsequent tick, at the configured interval', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow(60_000)
|
||||
|
||||
start()
|
||||
// advanceTimersByTime both advances the fake clock AND fires whatever
|
||||
// interval callback falls due within that span — a separate
|
||||
// vi.setSystemTime call here would ADD its own jump on top of this
|
||||
// one (they compound; sinon's fake Date offset is not superseded by
|
||||
// a later setSystemTime call), double-advancing `now`.
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:01:00.000Z')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('stop() clears the timer — now no longer advances', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow(60_000)
|
||||
|
||||
start()
|
||||
stop()
|
||||
vi.setSystemTime(new Date('2026-04-16T00:05:00.000Z'))
|
||||
vi.advanceTimersByTime(300_000)
|
||||
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('start() is idempotent — a second call does not double-schedule', () => {
|
||||
vi.setSystemTime(new Date('2026-04-16T00:00:00.000Z'))
|
||||
const { now, start, stop } = useNow(1000)
|
||||
|
||||
start()
|
||||
start()
|
||||
// See the previous test's own comment: advanceTimersByTime alone both
|
||||
// advances the clock and fires the one due tick — an extra
|
||||
// setSystemTime call here would compound into a double-advance.
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
// A double-scheduled poller would still land on the same value here
|
||||
// (both intervals would tick at the same wall-clock instant), so this
|
||||
// alone doesn't fully pin idempotency — the real guard is
|
||||
// createVisibilityPoller's own start()-is-idempotent behavior
|
||||
// (lib/polling.spec.ts), reused here unmodified.
|
||||
expect(now.value.toISOString()).toBe('2026-04-16T00:00:01.000Z')
|
||||
stop()
|
||||
})
|
||||
|
||||
// P1: the concrete symptom this composable fixes — a long-open tab's cost
|
||||
// projection (lib/forecast.ts) must change once the shared clock ticks to
|
||||
// a later point in the month, not stay frozen at whatever `now` was true
|
||||
// when something first read it.
|
||||
it('feeding the shared clock into monthProgress/projectMonthEnd yields a fresh projection once the clock advances', () => {
|
||||
// Just past the start of April (UTC): elapsedFraction is below
|
||||
// MIN_PROJECTION_FRACTION, so projectMonthEnd reads "not enough data
|
||||
// yet" (null) — exactly the P1 bug scenario ("every projection shows
|
||||
// 'not enough data yet' until the tab remounts").
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 3, 1, 1, 0, 0)))
|
||||
const { now, start, stop } = useNow(60_000)
|
||||
start()
|
||||
const mtdMicros = 1_000_000
|
||||
const firstProjection = projectMonthEnd(mtdMicros, monthProgress(now.value))
|
||||
expect(firstProjection).toBeNull()
|
||||
|
||||
// Mid-month (exactly half of April's 30 days elapsed, UTC). A
|
||||
// stop()/setSystemTime()/start() restart — rather than a further
|
||||
// setSystemTime+advanceTimersByTime pair, which would compound with
|
||||
// the jump above (see the sibling "advances now on each subsequent
|
||||
// tick" test's own comment) — forces exactly one fresh, synchronous
|
||||
// tick at precisely this instant: start()'s own immediate tick, not
|
||||
// an interval fire, is what this test actually needs to prove (a
|
||||
// later READ of `now.value` reflects the advanced clock).
|
||||
stop()
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 3, 16, 0, 0, 0)))
|
||||
start()
|
||||
const secondProjection = projectMonthEnd(mtdMicros, monthProgress(now.value))
|
||||
|
||||
expect(secondProjection).toBe(2_000_000) // mtdMicros / 0.5
|
||||
expect(secondProjection).not.toBe(firstProjection)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
|
||||
/**
|
||||
* How often the shared clock ticks — coarse enough that a cost-forecast
|
||||
* projection or elapsed-month fraction (lib/forecast.ts) is never
|
||||
* meaningfully stale, without re-deriving the Usage tab's whole column set
|
||||
* once a second for no visible benefit.
|
||||
*/
|
||||
const NOW_TICK_MS = 60_000
|
||||
|
||||
export interface UseNow {
|
||||
/** The shared clock — every time-dependent read on the Usage tab (CostForecastCard's elapsedFraction, usageColumns()'s own `now` parameter, UsageView.vue's groupCostProjection) reads THIS ref, never its own `new Date()`. */
|
||||
now: Ref<Date>
|
||||
/** Idempotent, mirrors createVisibilityPoller's own start() — call from the owning component's onMounted. */
|
||||
start: () => void
|
||||
/** Idempotent, mirrors createVisibilityPoller's own stop() — call from the owning component's onUnmounted so the interval is cleaned up on navigation away. */
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* useNow (P1) is the ONE reactive clock every time-dependent read on the
|
||||
* Usage tab shares, instead of each freezing its own `new Date()` at mount
|
||||
* or re-deriving a fresh one on every render:
|
||||
*
|
||||
* - CostForecastCard.vue used `withDefaults(..., { now: () => new Date() })`
|
||||
* — Vue caches a prop's default once per component instance
|
||||
* (propsDefaults), so a long-open tab's projection froze at whatever
|
||||
* instant the card happened to mount, never advancing again.
|
||||
* - usage-columns.ts's usageColumns() takes `now` as a plain parameter,
|
||||
* evaluated once per call — UsageTable.vue's `computed(() =>
|
||||
* usageColumns(...))` and UsageView.vue's headless Groups toolbar
|
||||
* columns both called it with no reactive dependency on the current
|
||||
* time, so neither ever recomputed either.
|
||||
* - UsageView.vue's groupCostProjection called `new Date()` fresh on every
|
||||
* invocation instead, so the group grid could disagree with the above
|
||||
* two on what "now" even was, mid-render.
|
||||
*
|
||||
* Driven by the shared createVisibilityPoller (lib/polling.ts — the same
|
||||
* primitive every store's own poll timer uses): ticks once immediately on
|
||||
* start(), then every NOW_TICK_MS while the tab is visible, pausing while
|
||||
* hidden and catching up the moment it is looked at again (and, per P13,
|
||||
* never ticking at all if started while already hidden).
|
||||
*
|
||||
* This composable does NOT call onMounted/onUnmounted itself — the caller
|
||||
* (UsageView.vue) owns its lifecycle explicitly via the returned start/stop,
|
||||
* the same reason lib/polling.ts itself takes no lifecycle-hook dependency:
|
||||
* it keeps this composable callable, and testable, outside a mounted
|
||||
* component.
|
||||
*/
|
||||
export function useNow(intervalMs = NOW_TICK_MS): UseNow {
|
||||
const now = ref(new Date())
|
||||
let poller: VisibilityPoller | undefined
|
||||
|
||||
function start(): void {
|
||||
if (poller) return
|
||||
poller = createVisibilityPoller({
|
||||
intervalMs,
|
||||
tick: () => {
|
||||
now.value = new Date()
|
||||
},
|
||||
})
|
||||
poller.start()
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
poller?.stop()
|
||||
poller = undefined
|
||||
}
|
||||
|
||||
return { now, start, stop }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useSearchQuery } from './useSearchQuery'
|
||||
@@ -20,4 +21,47 @@ describe('useSearchQuery', () => {
|
||||
query.value = ' '
|
||||
expect(hasQuery.value).toBe(false)
|
||||
})
|
||||
|
||||
// F9 (hash-state): a caller can supply the ref this composable reads and
|
||||
// writes instead of a private local one, so the query round-trips
|
||||
// through an external owner (nav.usageQuery, UsageView.vue) rather than
|
||||
// being lost on remount.
|
||||
describe('with an external ref', () => {
|
||||
it('reads and derives from the external ref\'s current value, rather than starting blank', () => {
|
||||
const external = ref('preset')
|
||||
const { query, normalized, hasQuery } = useSearchQuery(external)
|
||||
|
||||
expect(query.value).toBe('preset')
|
||||
expect(normalized.value).toBe('preset')
|
||||
expect(hasQuery.value).toBe(true)
|
||||
})
|
||||
|
||||
it('writes THROUGH to the external ref — the same ref instance, not a copy', () => {
|
||||
const external = ref('')
|
||||
const { query } = useSearchQuery(external)
|
||||
|
||||
query.value = 'typed'
|
||||
|
||||
expect(external.value).toBe('typed')
|
||||
})
|
||||
|
||||
it('reflects a change made directly to the external ref (bidirectional, not a one-time snapshot)', () => {
|
||||
const external = ref('')
|
||||
const { query, normalized } = useSearchQuery(external)
|
||||
|
||||
external.value = 'set-elsewhere'
|
||||
|
||||
expect(query.value).toBe('set-elsewhere')
|
||||
expect(normalized.value).toBe('set-elsewhere')
|
||||
})
|
||||
|
||||
it('without an external ref, two calls own independent state (no accidental sharing)', () => {
|
||||
const a = useSearchQuery()
|
||||
const b = useSearchQuery()
|
||||
|
||||
a.query.value = 'alice'
|
||||
|
||||
expect(b.query.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
@@ -16,9 +17,19 @@ import { computed, ref } from 'vue'
|
||||
* clear button emitting `update:modelValue('')` through the `v-model`
|
||||
* binding, so a separate clear function would be dead API surface no
|
||||
* caller needs.
|
||||
*
|
||||
* `external` (F9, hash-state) lets a caller supply the ref this composable
|
||||
* reads and writes instead of owning a private local one — UsageView.vue
|
||||
* binds `nav.usageQuery` (stores/nav.ts) so the Usage tab's own search
|
||||
* text round-trips through the URL hash (lib/hash-state.ts's `q` param)
|
||||
* the same way ChartsView's scope/window/tab selection already does.
|
||||
* Every OTHER caller (ProvidersView, ChartsView's own scope-picker search,
|
||||
* EventsView) omits it and keeps its private, hash-independent ref exactly
|
||||
* as before — passing `external` changes nothing about `normalized`/
|
||||
* `hasQuery`'s own derivation, only where the raw value itself lives.
|
||||
*/
|
||||
export function useSearchQuery() {
|
||||
const query = ref('')
|
||||
export function useSearchQuery(external?: Ref<string>) {
|
||||
const query = external ?? ref('')
|
||||
const normalized = computed(() => query.value.trim().toLowerCase())
|
||||
const hasQuery = computed(() => normalized.value.length > 0)
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
// Feature C (v0.22): syncs the active dashboard tab to the URL hash
|
||||
// (#providers, #usage, #charts, #targets — App.vue's own TabsTrigger
|
||||
// value strings, unchanged) with no router dependency: App.vue's Tabs
|
||||
// component already just needs a plain string ref, and a full router is
|
||||
// overkill for one hash-backed value. hashToTab/tabToHash are the pure
|
||||
// mapping this module's own spec exercises directly (vite.config.ts's
|
||||
// `test` block runs in a Node environment — no `window`, no component
|
||||
// mount), so every `window`/`history`/`location` touch below is guarded
|
||||
// and lives only inside useTabHash itself, never at module scope.
|
||||
// Feature C (v0.22) introduced hashToTab/tabToHash below, plus a
|
||||
// useTabHash() composable wrapping them. F9 (dashboard-plan.md) supersedes
|
||||
// that composable with useHashState() (composables/useHashState.ts):
|
||||
// App.vue now needs a single hash that carries BOTH the active tab AND
|
||||
// each tab's own query-string state (scope/window/tab/metric for Charts, q
|
||||
// for Usage, kind/user for Events — lib/hash-state.ts), not the tab alone,
|
||||
// so useTabHash() itself was removed as the now-superseded, unreachable
|
||||
// half of this file. hashToTab/tabToHash stay: useHashState.ts still
|
||||
// builds the OUTER "#<tab>" portion of the combined hash directly on top
|
||||
// of these same two pure functions, and this module's own spec exercises
|
||||
// them directly (vite.config.ts's `test` block runs in a Node environment
|
||||
// — no `window`, no component mount).
|
||||
//
|
||||
// TAB_VALUES/TabValue also live here — the single source of truth for the
|
||||
// app's tab union (vue.md: "share types once, import everywhere"):
|
||||
// stores/nav.ts's `activeTab`, composables/useHashState.ts, and App.vue's
|
||||
// own Tabs all import the SAME union rather than each redeclaring it.
|
||||
export const TAB_VALUES = ['providers', 'usage', 'charts', 'targets', 'events'] as const
|
||||
export type TabValue = (typeof TAB_VALUES)[number]
|
||||
|
||||
/**
|
||||
* hashToTab maps a raw location.hash string (with or without its leading
|
||||
@@ -28,10 +35,11 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
* by hand with different casing is treated as invalid, not corrected.
|
||||
* 2. An invalid or absent hash is normalized only in memory (this
|
||||
* function's own return value), never in the address bar itself:
|
||||
* useTabHash's initial read below never calls history.replaceState —
|
||||
* only a LATER tab change does (see its own doc comment) — so loading
|
||||
* "#bogus" shows the Providers tab while the address bar still reads
|
||||
* "#bogus" until the reader switches tabs at least once.
|
||||
* useHashState's initial read (composables/useHashState.ts) never
|
||||
* calls history.replaceState on its own — only a LATER state change
|
||||
* does (see its own doc comment) — so loading "#bogus" shows the
|
||||
* Providers tab while the address bar still reads "#bogus" until the
|
||||
* reader switches tabs, or something else changes, at least once.
|
||||
*/
|
||||
export function hashToTab<T extends string>(hash: string, validTabs: readonly T[], defaultTab: T): T {
|
||||
const raw = hash.startsWith('#') ? hash.slice(1) : hash
|
||||
@@ -42,58 +50,3 @@ export function hashToTab<T extends string>(hash: string, validTabs: readonly T[
|
||||
export function tabToHash(tab: string): string {
|
||||
return `#${tab}`
|
||||
}
|
||||
|
||||
export interface UseTabHashOptions<T extends string> {
|
||||
/** The full set of recognized tab values — App.vue's Tabs value strings. */
|
||||
validTabs: readonly T[]
|
||||
/** The tab an absent or unrecognized hash falls back to. */
|
||||
defaultTab: T
|
||||
}
|
||||
|
||||
/**
|
||||
* useTabHash returns a ref<T> wired bidirectionally to the URL hash:
|
||||
*
|
||||
* - On creation, it reads the CURRENT hash (hashToTab) as its initial
|
||||
* value — the "on load: read hash -> select tab" half of the spec.
|
||||
* - On every ref change (a Tabs v-model update from a user click), it
|
||||
* writes the hash back via history.replaceState — never pushState or a
|
||||
* bare `location.hash =` assignment, both of which would grow browser
|
||||
* history with one entry per tab click and, for the latter, jump
|
||||
* scroll position to any element whose id matches the new hash. A
|
||||
* no-op write (the hash already matches) is skipped so a rapid
|
||||
* double-toggle does not spam replaceState.
|
||||
* - It listens for the browser's own `hashchange` event (back/forward
|
||||
* navigation, or a hand-edited URL) and updates the ref to match —
|
||||
* invalid/unrecognized hands back defaultTab, exactly like the initial
|
||||
* read.
|
||||
*
|
||||
* Guarded for a `window`-less environment (vitest's node test
|
||||
* environment, vite.config.ts): every DOM/history/location access is
|
||||
* behind a single hasWindow check computed once, so importing or calling
|
||||
* this module never throws there — though the composable itself, being a
|
||||
* real Vue lifecycle hook user, is exercised through App.vue in the real
|
||||
* browser, not unit-tested directly; hashToTab/tabToHash above are what
|
||||
* this module's own spec covers.
|
||||
*/
|
||||
export function useTabHash<T extends string>({ validTabs, defaultTab }: UseTabHashOptions<T>): Ref<T> {
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
const activeTab = ref(hasWindow ? hashToTab(window.location.hash, validTabs, defaultTab) : defaultTab) as Ref<T>
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (!hasWindow) return
|
||||
const nextHash = tabToHash(tab)
|
||||
if (window.location.hash === nextHash) return
|
||||
window.history.replaceState(null, '', nextHash)
|
||||
})
|
||||
|
||||
function onHashChange(): void {
|
||||
activeTab.value = hashToTab(window.location.hash, validTabs, defaultTab)
|
||||
}
|
||||
|
||||
if (hasWindow) {
|
||||
onMounted(() => window.addEventListener('hashchange', onHashChange))
|
||||
onUnmounted(() => window.removeEventListener('hashchange', onHashChange))
|
||||
}
|
||||
|
||||
return activeTab
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user