mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
feat(webui): rate badges, refresh labels, anchor links
Providers tab (Feature A): a new ProviderRateBadge component renders each provider's day-window success rate (attempts-failures)/attempts — always shown when attempts>0 (100% muted, <99% amber via the chart palette's --color-chart-tokens-out token, <90% destructive, reusing the Badge component's existing secondary/outline/destructive idioms rather than inventing a new variant), a distinct "no traffic" badge when attempts=0, and the live minute-window count in its title attribute. The same component renders beside a ModelChip inside a provider's accordion, but only when that model is degraded (<99% with attempts>0) so a long, healthy model list stays uncluttered. The math and its threshold constants live in lib/provider-rate.ts, unit-tested directly (vitest's node environment needs no component mount). Feature B: a provider's "refreshed ..." label (lib/format.ts's new refreshLabel) now reads "discovery off" when DiscoveryEnabled is false, "pending" when it is true but lastRefresh is still unset, and otherwise the unchanged relative-time text — replacing the misleading "refreshed never" a discovery-disabled provider showed before. Feature C: composables/useTabHash syncs App.vue's active tab to the URL hash (#providers/#usage/#charts/#targets, matching the existing TabsTrigger values) with no router dependency. On load it reads the current hash (falling back to "providers" for an absent or invalid one); on tab change it writes the hash back via history.replaceState (no scroll jump, no new history entry per click); a hashchange listener handles back/forward and hand-edited URLs. hashToTab/tabToHash are exported as pure functions so the mapping is unit-tested directly, independent of window/history/location — every DOM touch inside useTabHash itself is guarded behind one hasWindow check so the module still imports cleanly under vitest's node test environment. AuthGate never reads or writes the hash, so a tab named in the URL survives the unauthenticated -> authenticated transition unchanged.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
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.
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}`
|
||||
}
|
||||
|
||||
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