mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
feat(webui): redesign the admin dashboard around admin tasks
A sidebar replaces the flat tabs with seven pages, and one filter bar sets the time range, comparison period and scope for all of them. - Home: spend against budget, forecast, request rate, error rate, open breakers, top users and models, recent events and warnings. - Spend: spend over time with comparison, budget burn-down, an attribution drill-down from group to user to model, pricing health and cost avoided by free models. - Consumers: a directory with last seen and limit bars, an access matrix, and a detail page per user. - Models: the catalog with billing prices, fleet latency and error rates, and provider health. - Reliability: error, timeout, failover, 429 and 402 trends, events, and latency per provider. - Config: the redacted config and a change helper that emits YAML or users.json snippets to paste into git. - MCP & Agents: callers per target. Page state lives in the URL hash, and old tab links map to the new pages. The replaced views are removed. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
@@ -1,148 +1,97 @@
|
||||
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 { buildHash, parseGlobalParams, parseHashState, parsePageParams } from '@/lib/hash-state'
|
||||
import { DEFAULT_CMP, DEFAULT_RANGE } from '@/lib/range'
|
||||
import { DEFAULT_SCOPE, useFiltersStore } from '@/stores/filters'
|
||||
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'
|
||||
/**
|
||||
* DEFAULT_METRIC mirrors stores/history.ts's own initial `modelMetric`
|
||||
* state (free-models-plan.md: 'req', not 'cost' — a cost-default ranking
|
||||
* hides every free model). An omitted `metric` hash param, or one this
|
||||
* build does not recognize (parseChartsParams' own independent-field
|
||||
* validation, lib/hash-state.ts), falls back to this value here; an
|
||||
* explicit `metric=cost` in the hash still parses and applies normally.
|
||||
*/
|
||||
const DEFAULT_METRIC: ModelMetric = 'req'
|
||||
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:
|
||||
* useHashState (redesign-plan.md section 3.1) is the single composable
|
||||
* that keeps the URL hash and the two shell stores it drives — stores/
|
||||
* filters.ts (range/cmp/scope) and stores/nav.ts (page/params) — 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).
|
||||
* - On creation, it parses the CURRENT hash exactly once and applies it:
|
||||
* the three global filters to filters.setFilters, and the page plus
|
||||
* its own opaque param record to nav.goTo. Every per-page STORE
|
||||
* (spend/consumers/models/reliability, each owned by its own work
|
||||
* package) is responsible for reading nav.params itself when its page
|
||||
* mounts — this composable never touches a page-specific store, by
|
||||
* design (stores/nav.ts's own doc comment).
|
||||
* - A single computed re-derives the hash from filters' current state
|
||||
* plus nav.page/nav.params, 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.
|
||||
* isAuthenticated"` branch — filters.setFilters/nav.goTo are plain
|
||||
* synchronous state writes, safe to call before an admin key is stored,
|
||||
* so the hash's selection is already restored by the time the reader
|
||||
* authenticates.
|
||||
*
|
||||
* 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.
|
||||
* environment, vite.config.ts): every DOM/history/location access sits
|
||||
* behind one hasWindow check computed once. 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 filters = useFiltersStore()
|
||||
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 { page, query } = parseHashState(hash)
|
||||
const globals = parseGlobalParams(query)
|
||||
filters.setFilters({
|
||||
range: globals.range ?? DEFAULT_RANGE,
|
||||
cmp: globals.cmp ?? DEFAULT_CMP,
|
||||
scope: globals.scope ?? DEFAULT_SCOPE,
|
||||
})
|
||||
nav.goTo(page, parsePageParams(query))
|
||||
}
|
||||
|
||||
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, {})
|
||||
const params: Record<string, string> = {}
|
||||
if (filters.range !== DEFAULT_RANGE) params.range = filters.range
|
||||
if (filters.cmp !== DEFAULT_CMP) params.cmp = filters.cmp
|
||||
if (filters.scope !== DEFAULT_SCOPE) params.scope = filters.scope
|
||||
// Page params are merged in AFTER the three global keys, so the
|
||||
// built query string always lists range/cmp/scope first — readable,
|
||||
// and matches redesign-plan.md section 3.1's own example ordering.
|
||||
// No current page uses a param named range/cmp/scope (see stores/
|
||||
// nav.ts's own doc comment on `params` staying opaque), so this
|
||||
// merge never actually overwrites one of the three above in
|
||||
// practice; it is a plain Object.assign, not a defensive filter.
|
||||
Object.assign(params, nav.params)
|
||||
return buildHash(nav.page, params)
|
||||
})
|
||||
|
||||
watch(currentHash, (hash) => {
|
||||
if (!hasWindow) return
|
||||
if (window.location.hash === hash) return
|
||||
window.history.replaceState(null, '', hash)
|
||||
})
|
||||
// `immediate: true` (P3 item 24): applyFromHash already normalized
|
||||
// filters/nav state from window.location.hash above, synchronously,
|
||||
// before this watch is even registered — so currentHash's FIRST
|
||||
// computed value is already the canonical hash for whatever the reader
|
||||
// arrived with. Without `immediate`, that first value is never written
|
||||
// back until some LATER state change happens to fire the watch, so a
|
||||
// legacy or garbage hash (`#charts`, `#nope?range=30d`) stayed in the
|
||||
// address bar — correct app state, stale URL — until the reader
|
||||
// happened to touch a filter.
|
||||
watch(
|
||||
currentHash,
|
||||
(hash) => {
|
||||
if (!hasWindow) return
|
||||
if (window.location.hash === hash) return
|
||||
window.history.replaceState(null, '', hash)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onHashChange(): void {
|
||||
applyFromHash(window.location.hash)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
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()`. */
|
||||
/** The shared clock — every time-dependent read on the Consumers page (CostForecastCard's elapsedFraction, usageColumns()'s own `now` parameter, ConsumerDirectory.vue's groupCostProjection, UserDetail.vue's runOutDate) 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
|
||||
@@ -20,9 +20,12 @@ export interface UseNow {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* useNow (P1) is the ONE reactive clock every time-dependent read across
|
||||
* the admin UI shares (today: SpendPage.vue's cost forecast, UserDetail.vue's
|
||||
* run-out date, ConsumerDirectory.vue's group cost projection — see this
|
||||
* file's own UseNow doc comment for the full current-caller list), 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
|
||||
@@ -30,12 +33,14 @@ export interface UseNow {
|
||||
* 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.
|
||||
* usageColumns(...))` and the pre-redesign Usage tab's own headless
|
||||
* Groups toolbar columns (UsageView.vue, deleted) both called it with no
|
||||
* reactive dependency on the current time, so neither ever recomputed
|
||||
* either.
|
||||
* - The pre-redesign Usage tab's own groupCostProjection (UsageView.vue,
|
||||
* deleted) 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
|
||||
@@ -43,11 +48,13 @@ export interface UseNow {
|
||||
* 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.
|
||||
* This composable does NOT call onMounted/onUnmounted itself — each of its
|
||||
* three current callers (SpendPage.vue, UserDetail.vue,
|
||||
* ConsumerDirectory.vue) owns its OWN clock instance's lifecycle
|
||||
* explicitly via the returned start/stop (`onMounted(startClock)` /
|
||||
* `onUnmounted(stopClock)`), 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())
|
||||
|
||||
@@ -24,8 +24,10 @@ describe('useSearchQuery', () => {
|
||||
|
||||
// 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.
|
||||
// through an external owner (e.g. ConsumerDirectory.vue's own
|
||||
// `nav.params.q`-backed computed, or EventsView.vue's store-backed
|
||||
// `userFilter` — useSearchQuery.ts's own doc comment) 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')
|
||||
|
||||
@@ -3,14 +3,15 @@ import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* useSearchQuery is the one raw-query → {normalized, hasQuery} adapter
|
||||
* every search filter in this panel uses — ProvidersView's model search,
|
||||
* UsageView's user/group search, ChartsView's scope-picker search all had
|
||||
* their own copy of this identical query/normalizedQuery/hasQuery
|
||||
* pairing; extracted so it exists once (vue.md: "if you've written it
|
||||
* twice, you owe an abstraction"). Callers still own their own matching
|
||||
* logic entirely (ProvidersView's modelMatches/providerMatches,
|
||||
* lib/usage-search.ts's helpers) — this only normalizes the raw input the
|
||||
* same way every call site already agreed on: trimmed, lowercased.
|
||||
* every search filter in this panel uses — ProviderHealthPanel's
|
||||
* model/provider search, ConsumerDirectory's user/group search
|
||||
* (lib/usage-search.ts), EventsView's user/group filter, TargetsView's
|
||||
* target search, each with its own matching logic but the identical
|
||||
* query/normalizedQuery/hasQuery pairing; extracted so it exists once
|
||||
* (vue.md: "if you've written it twice, you owe an abstraction"). Callers
|
||||
* still own their own matching logic entirely — this only normalizes the
|
||||
* raw input the same way every call site already agreed on: trimmed,
|
||||
* lowercased.
|
||||
*
|
||||
* No `clear()` here — clearing is just setting `query.value = ''`, and
|
||||
* every caller already gets that for free from SearchInput.vue's own
|
||||
@@ -19,14 +20,19 @@ import { computed, ref } from 'vue'
|
||||
* 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.
|
||||
* reads and writes instead of owning a private local one, so that ref's
|
||||
* own search text round-trips through the URL hash. Two current callers
|
||||
* pass it: ConsumerDirectory.vue binds a local `computed` get/set over
|
||||
* `nav.params.q` directly (stores/nav.ts's own `params` is opaque, so the
|
||||
* page owns interpreting/writing its own `q` key — see that computed's
|
||||
* own doc comment for why this replaced an earlier, page-specific
|
||||
* `nav.usageQuery` special case), and EventsView.vue binds
|
||||
* `storeToRefs(events).userFilter`, which composables/useHashState.ts
|
||||
* reads/writes directly against the same hash. Every OTHER caller
|
||||
* (ProviderHealthPanel's model/provider search, TargetsView's target
|
||||
* search) omits it and keeps its private, hash-independent ref instead —
|
||||
* passing `external` changes nothing about `normalized`/`hasQuery`'s own
|
||||
* derivation, only where the raw value itself lives.
|
||||
*/
|
||||
export function useSearchQuery(external?: Ref<string>) {
|
||||
const query = external ?? ref('')
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hashToTab, tabToHash } from './useTabHash'
|
||||
|
||||
const TAB_VALUES = ['providers', 'usage', 'charts', 'targets'] as const
|
||||
|
||||
describe('hashToTab', () => {
|
||||
it('maps a recognized hash (with leading #) to its tab value', () => {
|
||||
expect(hashToTab('#usage', TAB_VALUES, 'providers')).toBe('usage')
|
||||
})
|
||||
|
||||
it('maps a recognized hash without a leading # too (defensive: a caller might pass window.location.hash.slice(1) already)', () => {
|
||||
expect(hashToTab('charts', TAB_VALUES, 'providers')).toBe('charts')
|
||||
})
|
||||
|
||||
it('falls back to defaultTab for an empty hash (absent, load with no hash at all)', () => {
|
||||
expect(hashToTab('', TAB_VALUES, 'providers')).toBe('providers')
|
||||
})
|
||||
|
||||
it('falls back to defaultTab for a bare "#" (empty fragment)', () => {
|
||||
expect(hashToTab('#', TAB_VALUES, 'providers')).toBe('providers')
|
||||
})
|
||||
|
||||
it('falls back to defaultTab for an unrecognized hash (hand-edited URL)', () => {
|
||||
expect(hashToTab('#nonexistent-tab', TAB_VALUES, 'providers')).toBe('providers')
|
||||
})
|
||||
|
||||
it('is case-sensitive: a differently-cased match still falls back to defaultTab', () => {
|
||||
expect(hashToTab('#Providers', TAB_VALUES, 'providers')).toBe('providers')
|
||||
})
|
||||
|
||||
it('every valid tab round-trips to itself', () => {
|
||||
for (const tab of TAB_VALUES) {
|
||||
expect(hashToTab(`#${tab}`, TAB_VALUES, 'providers')).toBe(tab)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('tabToHash', () => {
|
||||
it('prefixes the tab value with "#"', () => {
|
||||
expect(tabToHash('targets')).toBe('#targets')
|
||||
})
|
||||
|
||||
it('round-trips through hashToTab back to the same tab', () => {
|
||||
for (const tab of TAB_VALUES) {
|
||||
expect(hashToTab(tabToHash(tab), TAB_VALUES, 'providers')).toBe(tab)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
// 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
|
||||
* "#") to one of validTabs. An absent, empty, or unrecognized hash
|
||||
* returns defaultTab — never throws, and never returns a value outside
|
||||
* validTabs. Matching is a plain string comparison: a tab value is
|
||||
* already a fixed lowercase identifier (App.vue's TabsTrigger value=
|
||||
* strings), never re-cased anywhere in this pipeline.
|
||||
*
|
||||
* Two intentional URL-normalization quirks (folded review minors, v0.22
|
||||
* review round), both worth naming rather than leaving implicit:
|
||||
*
|
||||
* 1. Case sensitivity: "#Providers" does NOT match "providers" — it falls
|
||||
* back to defaultTab like any other unrecognized hash, rather than
|
||||
* being normalized case-insensitively. A URL a person typed or edited
|
||||
* 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:
|
||||
* 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
|
||||
return (validTabs as readonly string[]).includes(raw) ? (raw as T) : defaultTab
|
||||
}
|
||||
|
||||
/** tabToHash is hashToTab's inverse: the URL hash (leading "#" included) one tab value maps to. */
|
||||
export function tabToHash(tab: string): string {
|
||||
return `#${tab}`
|
||||
}
|
||||
Reference in New Issue
Block a user