mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
refactor(webui): extract repeated dashboard elements into shared components
Markup and helpers that were copied between pages now live in one place: - ChartCard, StatDisabledAlert, StatItem, DetailField, BudgetRatioBars, LabeledSelect, TargetPicker and LoadStateView components - lib/scope.ts, lib/columns.ts and lib/chart-palette.ts - messageOf, isAuthRejection, and the USD to micro-USD conversion helpers - one monthly cost projection, one date formatter, and the useCopyFeedback composable Behaviour is unchanged. Before and after screenshots of every page match at 390 and 1440 px. Empty-state text now comes from the shared EmptyState component, and the user run-out date uses the short date format. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCopyFeedback } from './useCopyFeedback'
|
||||
|
||||
describe('useCopyFeedback', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts idle', () => {
|
||||
const { state } = useCopyFeedback()
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
|
||||
it.each(['copied', 'selected', 'failed'] as const)('set(%s) records that outcome', (outcome) => {
|
||||
const { state, set } = useCopyFeedback()
|
||||
set(outcome)
|
||||
expect(state.value).toBe(outcome)
|
||||
})
|
||||
|
||||
it('reverts to idle after 1500ms', () => {
|
||||
const { state, set } = useCopyFeedback()
|
||||
set('copied')
|
||||
vi.advanceTimersByTime(1499)
|
||||
expect(state.value).toBe('copied')
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
|
||||
it('a second set() before the first reset restarts the 1500ms window rather than stacking timers', () => {
|
||||
const { state, set } = useCopyFeedback()
|
||||
set('copied')
|
||||
vi.advanceTimersByTime(1000)
|
||||
set('selected')
|
||||
vi.advanceTimersByTime(1000)
|
||||
// 2000ms since the first set(), but only 1000ms since the second —
|
||||
// still showing the second outcome, not yet reset.
|
||||
expect(state.value).toBe('selected')
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { onUnmounted, ref, type Ref } from 'vue'
|
||||
|
||||
/** RESET_MS is how long a copy-feedback state (`copied`/`selected`/`failed`) stays visible before reverting to `idle` — SnippetBlock.vue's and ModelChip.vue's own identical 1500ms. */
|
||||
const RESET_MS = 1500
|
||||
|
||||
/** CopyFeedbackState mirrors lib/clipboard.ts's copyText own return union, plus the resting `idle` state neither SnippetBlock.vue nor ModelChip.vue had a name for before this composable. */
|
||||
export type CopyFeedbackState = 'idle' | 'copied' | 'selected' | 'failed'
|
||||
|
||||
export interface UseCopyFeedback {
|
||||
/** The current feedback state — drives a caller's own icon/label swap. */
|
||||
state: Ref<CopyFeedbackState>
|
||||
/** Records a copyText() outcome and schedules the reset-to-idle timer, clearing any still-pending one first (a rapid double-click restarts the window rather than stacking two timers). */
|
||||
set: (result: 'copied' | 'selected' | 'failed') => void
|
||||
}
|
||||
|
||||
/**
|
||||
* useCopyFeedback (reuse-audit.md F10) is the ONE "show a transient copy
|
||||
* outcome, then revert to idle after 1500ms" composable SnippetBlock.vue
|
||||
* and ModelChip.vue used to each hand-roll — the same `state` ref/
|
||||
* `resetTimer`/`clearTimeout`-on-unmount triple, with the identical
|
||||
* 1500ms magic number declared independently in both. Calls
|
||||
* `onUnmounted` itself (this composable is only ever invoked from a
|
||||
* component's own `setup()`/`<script setup>`, same as every other
|
||||
* composable in this panel), so a caller no longer needs its own cleanup
|
||||
* hook just for this timer.
|
||||
*/
|
||||
export function useCopyFeedback(): UseCopyFeedback {
|
||||
const state = ref<CopyFeedbackState>('idle')
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function set(result: 'copied' | 'selected' | 'failed'): void {
|
||||
state.value = result
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
state.value = 'idle'
|
||||
}, RESET_MS)
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(resetTimer))
|
||||
|
||||
return { state, set }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTargetPicker } from './useTargetPicker'
|
||||
|
||||
describe('useTargetPicker', () => {
|
||||
it('defaults to targetKind "group" with an empty targetName', () => {
|
||||
const picker = useTargetPicker(() => ['eng'], () => ['alice'])
|
||||
expect(picker.targetKind.value).toBe('group')
|
||||
expect(picker.targetName.value).toBe('')
|
||||
})
|
||||
|
||||
it('targetNames reads groupNames() while targetKind is "group", userNames() while "user"', () => {
|
||||
const picker = useTargetPicker(() => ['eng', 'ops'], () => ['alice', 'bob'])
|
||||
expect(picker.targetNames.value).toEqual(['eng', 'ops'])
|
||||
picker.targetKind.value = 'user'
|
||||
expect(picker.targetNames.value).toEqual(['alice', 'bob'])
|
||||
})
|
||||
|
||||
it('nameValid applies isValidName for a group target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
picker.targetName.value = 'valid-name.1'
|
||||
expect(picker.nameValid.value).toBe(true)
|
||||
picker.targetName.value = 'has spaces'
|
||||
expect(picker.nameValid.value).toBe(false)
|
||||
})
|
||||
|
||||
it('nameValid applies isValidUserName (non-empty only) for a user target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
picker.targetKind.value = 'user'
|
||||
picker.targetName.value = 'has spaces are fine for a user'
|
||||
expect(picker.nameValid.value).toBe(true)
|
||||
picker.targetName.value = ' '
|
||||
expect(picker.nameValid.value).toBe(false)
|
||||
})
|
||||
|
||||
it('snippetLabel names middleware.yaml for a group target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
expect(picker.snippetLabel.value).toBe('Paste into middleware.yaml')
|
||||
})
|
||||
|
||||
it('snippetLabel names the user\'s users.json line, with the current target name, for a user target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
picker.targetKind.value = 'user'
|
||||
picker.targetName.value = 'alice'
|
||||
expect(picker.snippetLabel.value).toBe('Merge into alice’s users.json line')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import { isValidName, isValidUserName } from '@/lib/snippets'
|
||||
|
||||
/** GrantTargetKind — named to avoid colliding with lib/target-columns.ts's own unrelated TargetKind ('mcp' | 'agent'). */
|
||||
export type GrantTargetKind = 'group' | 'user'
|
||||
|
||||
export interface UseTargetPicker {
|
||||
targetKind: Ref<GrantTargetKind>
|
||||
targetName: Ref<string>
|
||||
/** Every configured name for the CURRENT targetKind — groupNames() while 'group', userNames() while 'user'. */
|
||||
targetNames: ComputedRef<string[]>
|
||||
/** A group name follows NAME_PATTERN (isValidName); a user name only needs to be non-empty (isValidUserName) — auth.go's own two different name rules. */
|
||||
nameValid: ComputedRef<boolean>
|
||||
/** The SnippetBlock label every ChangeHelper form using this picker shows once its snippet is ready. */
|
||||
snippetLabel: ComputedRef<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* useTargetPicker (reuse-audit.md F12) is the ONE "Target kind, then Group
|
||||
* or User name" picker GrantForm.vue and LimitsForm.vue used to each
|
||||
* hand-roll — the same targetKind/targetName refs, targetNames/nameValid
|
||||
* computeds (down to the identical doc comments), and SnippetBlock label
|
||||
* ternary. `groupNames`/`userNames` are getters, not plain arrays, so a
|
||||
* caller can pass `() => props.groupNames` and keep this reactive to a
|
||||
* prop that changes after mount (destructuring `props.groupNames`
|
||||
* directly would freeze it at the value read at call time).
|
||||
*/
|
||||
export function useTargetPicker(groupNames: () => string[], userNames: () => string[]): UseTargetPicker {
|
||||
const targetKind = ref<GrantTargetKind>('group')
|
||||
const targetName = ref('')
|
||||
|
||||
const targetNames = computed(() => (targetKind.value === 'group' ? groupNames() : userNames()))
|
||||
|
||||
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
|
||||
|
||||
const snippetLabel = computed(() =>
|
||||
targetKind.value === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName.value}’s users.json line`,
|
||||
)
|
||||
|
||||
return { targetKind, targetName, targetNames, nameValid, snippetLabel }
|
||||
}
|
||||
Reference in New Issue
Block a user