Files
traefik-llmgateway/webui/src/components/ScopeLink.vue
T
lukaszraczylo 4261efc41b 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
2026-09-23 13:09:57 +01:00

70 lines
3.3 KiB
Vue

<script setup lang="ts">
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
import { useNavStore } from '@/stores/nav'
/**
* ScopeLink (F6) is the one "jump to this scope's Charts view" control in
* this panel: usage-columns.ts's id cell (Users/Groups tables and the
* nested Members table), UsageView.vue's Groups accordion header, and
* WP-B2's ProvidersView.vue per-model chips all render one of these
* instead of each hand-rolling their own click-through-to-Charts wiring.
*
* A real `<button type="button">` (P2 review fix). This used to be a
* `<span role="link" tabindex="0">`, justified at the time by the Groups
* accordion trigger use site nesting it INSIDE shadcn-vue's
* AccordionTrigger — itself a real `<button>` (reka-ui's own
* AccordionTrigger primitive). That justification was wrong on its own
* terms: the HTML button content model forbids ANY descendant carrying a
* `tabindex` attribute, not just a nested button/anchor, and ARIA's
* `button` role additionally declares "children presentational: true", so
* assistive technology flattened the inner span and never exposed it as
* its own link at all — axe flags this as `nested-interactive`. Reka's
* AccordionTrigger also only listens for `click` (not `keydown`), so
* `stopPropagation()` on click and Enter suppressed the accordion's own
* toggle, but Space on the focused span did nothing and fell through to
* page scroll instead of activating anything.
*
* The actual fix is structural, not a markup trick: every call site now
* renders this component as a SIBLING of whatever it used to nest inside
* (UsageView.vue's Groups header, ProvidersView.vue's provider-name
* header — see both templates), never a descendant of another interactive
* element, so a real `<button>` is valid there. No `role` override: a
* plain button's implicit role is already correct here (activating it
* performs an action — a programmatic Pinia-store navigation, not a
* traditional `href` follow — which is exactly what a native `<button>`,
* not `<a>`, is for; WAI-ARIA's first rule of ARIA use is not to override
* a native element's semantics without a reason, and there is none left
* here). `stopPropagation` on click stays — harmless everywhere (a table
* cell, ProvidersView's per-model chips) and still correct defensively at
* the Groups/Providers headers, where this button sits next to, not
* inside, the accordion's own trigger.
*/
const props = defineProps<{
/** The visible + accessible label, e.g. a user id, group id, or model id. */
label: string
/** The scope string useNavStore.goToCharts expects — "total" | "user:{id}" | "group:{id}" | "model:{id}" (history.ts's own convention). */
scope: string
}>()
const nav = useNavStore()
function onClick(event: MouseEvent): void {
event.stopPropagation()
nav.goToCharts(props.scope)
}
</script>
<template>
<button
type="button"
:aria-label="`View ${label} in Charts`"
:title="`View ${label} in Charts`"
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
@click="onClick"
>
<span>{{ label }}</span>
<FontAwesomeIcon :icon="faChartLine" class="size-3" aria-hidden="true" />
</button>
</template>