mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +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:
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
@@ -11,19 +13,23 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import CsvExportButton from '@/components/CsvExportButton.vue'
|
||||
import ModelUsageChart from '@/components/ModelUsageChart.vue'
|
||||
import UsageChart from '@/components/UsageChart.vue'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { filterModelsByPrefix } from '@/lib/model-filter'
|
||||
import { modelRankingCsv } from '@/lib/usage-csv'
|
||||
import { groupMatches, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import {
|
||||
type ChartTab,
|
||||
MODEL_METRIC_LABEL,
|
||||
MODEL_WINDOW_LABEL,
|
||||
MODEL_RANKING_LIMIT,
|
||||
type ModelMetric,
|
||||
type TimeSeriesTab,
|
||||
useHistoryStore,
|
||||
WINDOW_LABEL,
|
||||
WINDOW_SPAN,
|
||||
} from '@/stores/history'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
|
||||
@@ -81,15 +87,40 @@ const scopeOptions = computed(() => {
|
||||
const windows: HistoryWindow[] = ['hour', 'day', 'month']
|
||||
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
|
||||
|
||||
// --- Models-tab prefix filter (F6, dashboard-plan.md) ---
|
||||
//
|
||||
// history.modelFilter is set by ProvidersView.vue's provider-header link
|
||||
// (nav.goToModels(`${p.name}/`), stores/nav.ts — WP-B1) or restored from
|
||||
// the `#charts?tab=models&filter=...` hash (lib/hash-state.ts). P3 review
|
||||
// fix: setModelFilter now also sends the prefix to the SERVER and
|
||||
// refetches (stores/history.ts's own state doc comment on modelFilter),
|
||||
// so this re-slice is a harmless second pass over an already-narrowed
|
||||
// ranking, not the only filtering step — see lib/model-filter.ts's own
|
||||
// doc comment.
|
||||
const filteredModelRanking = computed(() => filterModelsByPrefix(history.modelRanking, history.modelFilter))
|
||||
|
||||
/**
|
||||
* windowLabel picks the right window-tab text for whichever query is
|
||||
* actually active: the Models tab reads a single current bucket (review
|
||||
* finding — MODEL_WINDOW_LABEL's own doc comment, stores/history.ts), the
|
||||
* other three tabs read a real span (WINDOW_LABEL). Same Tabs control
|
||||
* either way — only the label changes.
|
||||
* Exports exactly the ranking rows on screen (filter applied, server order
|
||||
* kept). P6 review fix: passes the resolved DISPLAY label constants
|
||||
* (MODEL_METRIC_LABEL/WINDOW_LABEL), not the raw enum values
|
||||
* ("cost"/"hour") modelRankingCsv's own doc comment always said the caller
|
||||
* should — the header used to read the literal "cost (hour, span 24)"
|
||||
* with no stated unit. `isCostMetric` states whether the CURRENTLY ranked
|
||||
* metric is cost, so modelRankingCsv can convert the raw micro-USD `value`
|
||||
* to a plain decimal USD number and name the unit in the header.
|
||||
*/
|
||||
function windowLabel(window: HistoryWindow): string {
|
||||
return history.tab === 'models' ? MODEL_WINDOW_LABEL[window] : WINDOW_LABEL[window]
|
||||
function modelsCsv(): string {
|
||||
return modelRankingCsv(
|
||||
filteredModelRanking.value,
|
||||
MODEL_METRIC_LABEL[history.modelMetric],
|
||||
WINDOW_LABEL[history.window],
|
||||
WINDOW_SPAN[history.window],
|
||||
history.modelMetric === 'cost',
|
||||
)
|
||||
}
|
||||
|
||||
function clearModelFilter(): void {
|
||||
history.setModelFilter('')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +142,13 @@ function onModelMetricChange(value: unknown): void {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void history.refresh()
|
||||
// The picker's model list is fetched here (initial load) and again on
|
||||
// any window change (history.ts's own setWindow) — nowhere else (Q1,
|
||||
// dashboard-plan.md DECISIONS). The selection's own series/ranking
|
||||
// fetch no longer needs a separate call here: startAutoRefresh's poller
|
||||
// (lib/polling.ts) ticks once immediately on start, which is that
|
||||
// initial fetch.
|
||||
void history.fetchModelOptions()
|
||||
history.startAutoRefresh()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
@@ -120,7 +157,16 @@ onUnmounted(() => {
|
||||
|
||||
// The scope list is only known once the Usage response has loaded — if the
|
||||
// previously-selected scope disappears (a user/group config change), fall
|
||||
// back to the total scope rather than requesting a now-unknown id.
|
||||
// back to the total scope rather than requesting a now-unknown id. This is
|
||||
// ALSO what guards a bogus scope restored from a hand-edited URL hash (F9,
|
||||
// dashboard-plan.md: lib/hash-state.ts's parseChartsParams only validates
|
||||
// SHAPE — "total"/"user:.+"/"group:.+"/"model:.+" — never that the id
|
||||
// actually exists): `watch` without `{ immediate: true }` skips its first
|
||||
// evaluation, so a hash-restored scope like "user:ghost" survives the
|
||||
// (likely still-empty, pre-fetch) initial scopeOptions and is only judged
|
||||
// once dashboard.usage genuinely loads and this computed's value actually
|
||||
// changes — at which point a truly nonexistent id resets to 'total' here,
|
||||
// exactly like any other vanished scope.
|
||||
watch(scopeOptions, (options) => {
|
||||
if (!options.some((o) => o.value === history.scope)) {
|
||||
history.setScope('total')
|
||||
@@ -169,7 +215,7 @@ watch(scopeOptions, (options) => {
|
||||
|
||||
<Tabs :model-value="history.window" @update:model-value="(v) => history.setWindow(v as HistoryWindow)">
|
||||
<TabsList>
|
||||
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ windowLabel(w) }}</TabsTrigger>
|
||||
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -189,11 +235,25 @@ watch(scopeOptions, (options) => {
|
||||
<p v-else-if="history.loading && !history.loaded" class="text-sm text-muted-foreground">Loading…</p>
|
||||
<template v-if="history.tab === 'models'">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Models with usage in the selected window, ranked by
|
||||
Models with usage over the last {{ WINDOW_LABEL[history.window] }}, ranked by
|
||||
{{ MODEL_METRIC_LABEL[history.modelMetric].toLowerCase() }}. Each row is the provider that served the
|
||||
traffic, so a model reached through more than one provider appears once per provider.
|
||||
</p>
|
||||
<ModelUsageChart :metric="history.modelMetric" :models="history.modelRanking" />
|
||||
<p v-if="history.modelFilter" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-muted-foreground">Filtered to</span>
|
||||
<span class="font-mono text-xs">{{ history.modelFilter }}</span>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="Clear model filter" @click="clearModelFilter">
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</p>
|
||||
<!-- P3: the ranking is a top-N (MODEL_RANKING_LIMIT), never the complete set — say so whenever the fetched ranking is exactly that long, so a full list doesn't silently read as "this is everything". -->
|
||||
<p v-if="history.modelRanking.length === MODEL_RANKING_LIMIT" class="text-xs text-muted-foreground">
|
||||
Top {{ MODEL_RANKING_LIMIT }} models
|
||||
</p>
|
||||
<ModelUsageChart :metric="history.modelMetric" :models="filteredModelRanking" />
|
||||
<div class="flex justify-end">
|
||||
<CsvExportButton :filename="`usage-models-${history.window}.csv`" :build="modelsCsv" />
|
||||
</div>
|
||||
</template>
|
||||
<UsageChart v-else :tab="timeSeriesTab" :window="history.window" :series-by-metric="history.seriesByMetric" />
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* ConfigWarningsBanner surfaces GET /admin/api/overview's `warnings`/
|
||||
* `warningsDropped` (F10, dashboard-plan.md — promoted warnf calls
|
||||
* collected during newGateway's own construction window: metrics.path
|
||||
* collisions, a zero cache ttl, multi-provider registry ids, retry x
|
||||
* failover). Renders nothing at all for an empty `warnings` array — App.vue
|
||||
* only mounts this under the header when there is something to show, but
|
||||
* the component stays self-guarding too so it is safe to render
|
||||
* unconditionally.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
warnings: string[]
|
||||
/** Warnings logged beyond the server's own cap (configWarningsCap, llmgateway.go) that were dropped, not kept — omitted (undefined) entirely when nothing was dropped. */
|
||||
warningsDropped?: number
|
||||
}>()
|
||||
|
||||
/** Collapsed by default: the header/status line already reads cleanly, and a config warning is something an operator investigates once, not something that needs to stay open on every visit. */
|
||||
const expanded = ref(false)
|
||||
|
||||
function toggleExpanded(): void {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Alert v-if="props.warnings.length > 0" variant="warn">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="size-4" aria-hidden="true" />
|
||||
<AlertTitle class="flex items-center justify-between gap-2">
|
||||
<span>{{ props.warnings.length }} configuration {{ props.warnings.length === 1 ? 'warning' : 'warnings' }}</span>
|
||||
<!--
|
||||
P11 review fix (WCAG 2.5.3 Label in Name): the aria-label used to
|
||||
be the fixed text "Toggle configuration warning details", which
|
||||
shares NO words with the visible "Show"/"Hide" label a sighted
|
||||
reader sees — a speech-input user saying "click show" would not
|
||||
match this control's accessible name at all. The aria-label now
|
||||
STARTS WITH the same word the button visibly displays, so the
|
||||
accessible name always contains the visible label.
|
||||
-->
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-expanded="expanded"
|
||||
:aria-label="`${expanded ? 'Hide' : 'Show'} configuration warning details`"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
{{ expanded ? 'Hide' : 'Show' }}
|
||||
</Button>
|
||||
</AlertTitle>
|
||||
<AlertDescription v-if="expanded">
|
||||
<ul class="mt-1 list-disc space-y-0.5 pl-4 font-mono text-xs">
|
||||
<li v-for="(warning, i) in props.warnings" :key="i">{{ warning }}</li>
|
||||
</ul>
|
||||
<p v-if="props.warningsDropped" class="mt-1 text-xs">and {{ props.warningsDropped }} more were not kept</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { formatCost } from '@/lib/format'
|
||||
import type { LimitsConfig } from '@/types/api'
|
||||
|
||||
/**
|
||||
* CostForecastCard renders the Usage tab's fleet-wide cost projection —
|
||||
* F7, placed under the Total card (UsageView.vue). All math is delegated
|
||||
* to lib/forecast.ts's pure functions; this component only formats and
|
||||
* lays the result out.
|
||||
*
|
||||
* `now` is a REQUIRED prop, not read from `new Date()` inside this
|
||||
* component (coordinator brief: "now passed as prop for determinism") —
|
||||
* the caller (UsageView.vue) supplies it, keeping every `Date.now()`-
|
||||
* flavored read in this feature at the boundary rather than buried inside
|
||||
* a component, same discipline lib/forecast.ts's own functions already
|
||||
* follow by taking `now`/`elapsedFraction` as plain arguments.
|
||||
*
|
||||
* P1 review fix: this used to be optional with a `withDefaults(...,
|
||||
* { now: () => new Date() })` default. Vue caches a prop's default value
|
||||
* ONCE per component instance (propsDefaults) — the very first time it is
|
||||
* read, not on every render — so a caller that omitted `now` froze this
|
||||
* card's projection at whatever instant it happened to mount, never
|
||||
* advancing again for as long as the tab stayed open. `now` is now
|
||||
* required: UsageView.vue passes the ONE shared reactive clock
|
||||
* (composables/useNow.ts) every time-dependent read on this tab shares,
|
||||
* so there is no default left to accidentally freeze.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** usage.total.costPerMonthMicroUsd — fleet-wide month-to-date spend. */
|
||||
mtdMicros: number
|
||||
/** usage.total.limits — costPerMonthUSD is the only field this card reads. */
|
||||
limits?: LimitsConfig
|
||||
/** The shared reactive clock (composables/useNow.ts) — never defaulted here, see this comment block above. */
|
||||
now: Date
|
||||
}>()
|
||||
|
||||
const elapsedFraction = computed(() => monthProgress(props.now))
|
||||
const projectedMicros = computed(() => projectMonthEnd(props.mtdMicros, elapsedFraction.value))
|
||||
const forecast = computed(() => headroom(projectedMicros.value, props.limits?.costPerMonthUSD))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost forecast</CardTitle>
|
||||
<CardDescription>Projected month-end spend, extrapolated from month-to-date usage (UTC calendar month).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Month to date</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(mtdMicros) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Projected month-end</p>
|
||||
<p v-if="projectedMicros === null" class="text-sm text-muted-foreground">not enough data yet</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-lg font-semibold tabular-nums"
|
||||
:class="forecast.willExceed ? 'text-destructive' : undefined"
|
||||
:title="forecast.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
|
||||
>
|
||||
{{ formatCost(projectedMicros) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="forecast.limitMicros !== null">
|
||||
<p class="text-muted-foreground">Limit</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(forecast.limitMicros) }}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { faCircleCheck, faCircleXmark, faFileCsv } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { downloadText, type DownloadOutcome } from '@/lib/download'
|
||||
|
||||
/**
|
||||
* CsvExportButton is the one "Export CSV" control every table export in
|
||||
* this panel uses — F8. Owned by WP-B1 (usage-csv.ts, this component) but
|
||||
* consumed unmodified by WP-B2's Models tab, which builds its own CSV text
|
||||
* via lib/usage-csv.ts's modelRankingCsv and passes it through the same
|
||||
* `build` prop — one export affordance, not a copy per tab (vue.md: "if
|
||||
* you've written it twice, you owe an abstraction").
|
||||
*
|
||||
* `build` is called fresh on every click, never memoized — an export
|
||||
* always reflects whatever rows are CURRENTLY visible (filtered + sorted)
|
||||
* at click time, matching the coordinator brief ("exports filtered+sorted
|
||||
* rows"), never a stale snapshot captured at mount.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** The downloaded file's name, e.g. "usage-users.csv". */
|
||||
filename: string
|
||||
/** Builds the CSV text (lib/csv.ts's toCsv, via usage-csv.ts) on demand. */
|
||||
build: () => string
|
||||
}>()
|
||||
|
||||
/**
|
||||
* exporting guards a rapid double-click from firing two downloads (or two
|
||||
* copyText fallback prompts) back to back — downloadText (lib/download.ts)
|
||||
* is async only because its clipboard fallback is, so this window is
|
||||
* normally imperceptibly short, but a real one regardless.
|
||||
*/
|
||||
const exporting = ref(false)
|
||||
|
||||
/** P5 review fix: downloadText's own return value used to be discarded entirely — a failed export (or a silent clipboard/selection fallback) looked identical to a successful one. `feedback` holds the most recent outcome; the label/icon below reflect it for FEEDBACK_MS, then the button reverts to its normal resting state. */
|
||||
const feedback = ref<DownloadOutcome | null>(null)
|
||||
const FEEDBACK_MS = 2500
|
||||
let feedbackTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const FEEDBACK_LABEL: Record<DownloadOutcome, string> = {
|
||||
downloaded: 'Downloaded',
|
||||
copied: 'Copied',
|
||||
selected: 'Selected — press ⌘/Ctrl+C',
|
||||
failed: 'Export failed',
|
||||
}
|
||||
|
||||
const label = computed(() => (feedback.value ? FEEDBACK_LABEL[feedback.value] : 'Export CSV'))
|
||||
const isFailure = computed(() => feedback.value === 'failed')
|
||||
|
||||
function clearFeedbackAfterDelay(): void {
|
||||
if (feedbackTimer !== undefined) clearTimeout(feedbackTimer)
|
||||
feedbackTimer = setTimeout(() => {
|
||||
feedback.value = null
|
||||
feedbackTimer = undefined
|
||||
}, FEEDBACK_MS)
|
||||
}
|
||||
|
||||
async function onClick(): Promise<void> {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
feedback.value = await downloadText(props.filename, props.build())
|
||||
clearFeedbackAfterDelay()
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (feedbackTimer !== undefined) clearTimeout(feedbackTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="exporting" @click="onClick">
|
||||
<FontAwesomeIcon v-if="feedback === 'failed'" :icon="faCircleXmark" class="size-3.5 text-destructive" aria-hidden="true" />
|
||||
<FontAwesomeIcon
|
||||
v-else-if="feedback"
|
||||
:icon="faCircleCheck"
|
||||
class="size-3.5 text-chart-requests"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<FontAwesomeIcon v-else :icon="faFileCsv" class="size-3.5" aria-hidden="true" />
|
||||
<span :class="isFailure ? 'text-destructive' : undefined">{{ label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
valueUpdater,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
/**
|
||||
@@ -39,9 +38,24 @@ const props = defineProps<{
|
||||
rowClass?: (row: TData) => string | undefined
|
||||
/** Shown in the empty-state row when data is empty. */
|
||||
emptyMessage?: string
|
||||
/**
|
||||
* Optional CONTROLLED sort state (P6, `v-model:sorting`). Omitted (the
|
||||
* default) keeps every existing call site's original UNCONTROLLED
|
||||
* behavior unchanged — this component owns `internalSorting` itself and
|
||||
* a caller has no way to read the current sort. Passed, a caller (e.g.
|
||||
* UsageView.vue's Users export, which needs to export rows in the
|
||||
* table's own current sort order — the plan's own "exports
|
||||
* filtered+sorted rows" requirement) instead owns the ref: every header
|
||||
* click emits `update:sorting` with the new state rather than mutating
|
||||
* anything locally here.
|
||||
*/
|
||||
sorting?: SortingState
|
||||
}>()
|
||||
|
||||
const sorting = ref<SortingState>([])
|
||||
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
|
||||
|
||||
/** internalSorting is read only when props.sorting is undefined (uncontrolled mode) — see the `sorting` prop's own doc comment above. */
|
||||
const internalSorting = ref<SortingState>([])
|
||||
|
||||
const table = useVueTable({
|
||||
get data() {
|
||||
@@ -52,10 +66,15 @@ const table = useVueTable({
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: (updater) => valueUpdater(updater, sorting),
|
||||
onSortingChange: (updater) => {
|
||||
const current = props.sorting ?? internalSorting.value
|
||||
const next = typeof updater === 'function' ? updater(current) : updater
|
||||
if (props.sorting !== undefined) emit('update:sorting', next)
|
||||
else internalSorting.value = next
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value
|
||||
return props.sorting ?? internalSorting.value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { eventsColumns } from '@/lib/events-columns'
|
||||
import { EVENT_KIND_LABEL, EVENT_KINDS, filterEvents } from '@/lib/events-filter'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
|
||||
// This component backs the new "Events" tab (App.vue, F3) — GET
|
||||
// /admin/api/events (admin.go/events.go: adminEventsResponse). Polled only
|
||||
// while this view is mounted (onMounted/onUnmounted below), unlike the
|
||||
// Providers/Usage/Targets dashboard poll which runs for the app's whole
|
||||
// session — see stores/events.ts's own doc comment.
|
||||
const events = useEventsStore()
|
||||
|
||||
const ALL_KINDS = 'all'
|
||||
|
||||
// kindFilter/userFilter live on the store, not as local refs (F9,
|
||||
// hash-state): composables/useHashState.ts reads/writes them directly so
|
||||
// this filter round-trips through the URL hash. userFilter binds through
|
||||
// useSearchQuery's own external-ref option, the same way UsageView.vue
|
||||
// binds nav.usageQuery — see useSearchQuery.ts's own doc comment.
|
||||
const { kindFilter, userFilter } = storeToRefs(events)
|
||||
const { normalized: normalizedUserQuery } = useSearchQuery(userFilter)
|
||||
|
||||
/** kindSelectValue adapts store's '' ("every kind") to the Select's own ALL_KINDS sentinel — Radix Select items cannot use an empty-string value. */
|
||||
const kindSelectValue = computed(() => (kindFilter.value === '' ? ALL_KINDS : kindFilter.value))
|
||||
|
||||
const filteredEvents = computed(() =>
|
||||
filterEvents(events.events, {
|
||||
kind: kindFilter.value,
|
||||
user: normalizedUserQuery.value,
|
||||
}),
|
||||
)
|
||||
|
||||
function onKindChange(value: unknown): void {
|
||||
if (typeof value !== 'string') return
|
||||
events.setKindFilter(value === ALL_KINDS ? '' : value)
|
||||
}
|
||||
|
||||
const columns = eventsColumns()
|
||||
|
||||
/**
|
||||
* emptyMessage mirrors TargetsView.vue/UsageView.vue's own three-way
|
||||
* pattern: distinguishes "nothing recorded at all" from "nothing matches
|
||||
* the active filter".
|
||||
*
|
||||
* P9 review fix: gated on events.lastUpdated (null until the first fetch
|
||||
* settles, stores/events.ts) — this used to read events.events.length
|
||||
* directly, which is an empty array from the store's OWN initial state,
|
||||
* before any fetch has even started. That produced "no events recorded"
|
||||
* for a fraction of a second (or longer, on a slow poll) on every mount,
|
||||
* indistinguishable from the real "genuinely nothing has ever happened"
|
||||
* case — a false negative, not a loading state.
|
||||
*/
|
||||
const emptyMessage = computed(() => {
|
||||
if (!events.lastUpdated) return 'loading…'
|
||||
if (events.events.length === 0) return 'no events recorded'
|
||||
if (kindFilter.value !== '' || normalizedUserQuery.value) return 'no events match the current filter'
|
||||
return 'none'
|
||||
})
|
||||
|
||||
/**
|
||||
* sourceCaption states plainly whether the list below is fleet-wide or
|
||||
* this-replica-only — the Events view must never present a replica-only
|
||||
* fallback as if it were the fleet-wide picture.
|
||||
*
|
||||
* P9 review fix: gated on events.lastUpdated, mirroring emptyMessage's own
|
||||
* doc comment above — the store's initial state (source: 'replica',
|
||||
* replica: '', capacity: 0) otherwise rendered as "This replica only
|
||||
* (unknown) — Redis is unreachable or unconfigured" before the first
|
||||
* fetch had even resolved, misreporting a genuine degraded-Redis state
|
||||
* that had not actually been observed yet.
|
||||
*/
|
||||
const sourceCaption = computed(() => {
|
||||
if (!events.lastUpdated) return 'loading…'
|
||||
if (events.source === 'redis') return 'Fleet-wide, across every replica'
|
||||
return `This replica only (${events.replica || 'unknown'}) — Redis is unreachable or unconfigured`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
events.startPolling()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
events.stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Alert v-if="events.degraded" variant="destructive">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="size-4" aria-hidden="true" />
|
||||
<AlertTitle>Fleet-wide event log unavailable</AlertTitle>
|
||||
<AlertDescription>
|
||||
Redis is configured but could not be read — showing this replica's own in-memory events only, which may miss
|
||||
events recorded by other replicas.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Events</CardTitle>
|
||||
<!-- P9: a loading state until the first fetch settles (events.lastUpdated), instead of showing the store's initial (pre-fetch) source/capacity as if it were real. -->
|
||||
<CardDescription v-if="!events.lastUpdated">Loading…</CardDescription>
|
||||
<CardDescription v-else>
|
||||
{{ sourceCaption }} — rate limit, budget, and upstream events, newest first (capacity
|
||||
{{ events.capacity }}).
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<SearchInput v-model="userFilter" placeholder="Filter by user or group" class="w-56" />
|
||||
<Select :model-value="kindSelectValue" @update:model-value="onKindChange">
|
||||
<SelectTrigger class="w-44" aria-label="Filter by kind">
|
||||
<SelectValue placeholder="Kind" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="ALL_KINDS">All kinds</SelectItem>
|
||||
<SelectItem v-for="k in EVENT_KINDS" :key="k" :value="k">{{ EVENT_KIND_LABEL[k] }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="events.error" class="mb-3 text-sm text-destructive">{{ events.error }}</p>
|
||||
<DataTable :columns="columns" :data="filteredEvents" :empty-message="emptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ import { computed, h, reactive, watch } from 'vue'
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
|
||||
import ScopeLink from '@/components/ScopeLink.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -25,6 +26,7 @@ import { formatAgo, formatContextWindow, formatLatencyMs, formatUntil, refreshDe
|
||||
import { isModelDegraded } from '@/lib/provider-rate'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRateView, AdminProvenanceView, AdminProviderView } from '@/types/api'
|
||||
|
||||
// This component backs the "Providers" tab (App.vue) — a UI-label rename
|
||||
@@ -34,6 +36,31 @@ import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRa
|
||||
// API surface.
|
||||
const dashboard = useDashboardStore()
|
||||
const overview = computed(() => dashboard.overview)
|
||||
const nav = useNavStore()
|
||||
|
||||
/**
|
||||
* goToProviderModels navigates to the Charts Models ranking, pre-filtered
|
||||
* to this provider's own models (F6, dashboard-plan.md: `nav.goToModels`).
|
||||
*
|
||||
* P2 review fix: this used to back a `<span role="link" tabindex="0">`
|
||||
* nested INSIDE AccordionTrigger's own real `<button>` — invalid per the
|
||||
* HTML button content model (no descendant may carry a `tabindex`
|
||||
* attribute, not just no nested button/anchor) and effectively invisible
|
||||
* to assistive technology (ARIA's `button` role declares "children
|
||||
* presentational: true", so the nested span was never announced as its
|
||||
* own link — see ScopeLink.vue's own doc comment for the identical
|
||||
* finding there). The provider-name label is now a real `<button>`,
|
||||
* rendered as a SIBLING of AccordionTrigger rather than nested inside it
|
||||
* (this component's template) — a plain click handler is enough: a click
|
||||
* on a sibling element never bubbles INTO the trigger's own listener
|
||||
* (event bubbling only travels through actual ancestors of the click
|
||||
* target), so the stopPropagation/Enter-only-keydown machinery this used
|
||||
* to need is gone along with the span it protected — native `<button>`
|
||||
* keyboard activation (Enter AND Space) is correct here for free.
|
||||
*/
|
||||
function goToProviderModels(providerName: string): void {
|
||||
nav.goToModels(`${providerName}/`)
|
||||
}
|
||||
|
||||
// --- provider expand/collapse (shadcn-vue Accordion) ---
|
||||
//
|
||||
@@ -232,23 +259,33 @@ function latencyObservationCount(v: AdminLatencyView): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* PER_REPLICA_CAVEAT is appended to every latency badge's title (task
|
||||
* brief's correctness rule 1): this figure comes from the same
|
||||
* perReplicaCaveat is appended to every latency/provenance badge's title
|
||||
* (task brief's correctness rule 1): this figure comes from the same
|
||||
* in-process accumulator the rate-limit rejection counter reads, never
|
||||
* Redis, so it reflects only whichever of this deployment's several
|
||||
* Traefik replicas answered the current poll — never a fleet-wide
|
||||
* average, and a freshly restarted pod shows a short window.
|
||||
* average, and a freshly restarted pod shows a short window. F4
|
||||
* (dashboard-plan.md) names the CONCRETE replica (admin.go's g.replica,
|
||||
* echoed as overview.replica) rather than the abstract warning alone — a
|
||||
* reader can now tell not just THAT this figure is replica-scoped, but
|
||||
* WHICH replica it came from. Falls back to the generic "this replica"
|
||||
* phrasing for a version-skewed server build that has not shipped the
|
||||
* field yet (undefined, never a fabricated id).
|
||||
*/
|
||||
const PER_REPLICA_CAVEAT = 'Per-replica, in-process only — not a fleet-wide average across this deployment’s replicas.'
|
||||
function perReplicaCaveat(): string {
|
||||
const replica = overview.value?.replica
|
||||
const who = replica ? `replica ${replica}` : 'this replica'
|
||||
return `Per-replica (${who}), in-process only — not a fleet-wide average across this deployment’s replicas.`
|
||||
}
|
||||
|
||||
/** streamingLatencyTitle is the streaming ttfb badge's hover/title detail. */
|
||||
function streamingLatencyTitle(v: AdminLatencyView): string {
|
||||
return `Average time to first byte, the load-sensitive reading for streaming traffic. ${latencyObservationCount(v)}. ${PER_REPLICA_CAVEAT}`
|
||||
return `Average time to first byte, the load-sensitive reading for streaming traffic. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
/** nonStreamingLatencyTitle is the non-streaming avg-duration badge's hover/title detail — explicit that this is NOT a latency/ttfb reading (correctness rule 2). */
|
||||
function nonStreamingLatencyTitle(v: AdminLatencyView): string {
|
||||
return `Average total response time, including generation — not a load-sensitive signal like streaming ttfb, since this provider buffers the whole completion before sending anything. ${latencyObservationCount(v)}. ${PER_REPLICA_CAVEAT}`
|
||||
return `Average total response time, including generation — not a load-sensitive signal like streaming ttfb, since this provider buffers the whole completion before sending anything. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
// --- usage provenance (feat: expose token-accounting provenance) ---
|
||||
@@ -276,13 +313,13 @@ function unbilledProvenance(p: AdminProviderView): AdminProvenanceView | undefin
|
||||
/** estimatedProvenanceTitle is the estimated-accounting badge's hover/title detail. */
|
||||
function estimatedProvenanceTitle(v: AdminProvenanceView): string {
|
||||
const plural = v.requests === 1 ? '' : 's'
|
||||
return `${v.requests} non-streaming response${plural} carried no usage from the provider — prompt tokens were estimated from request body size instead (${v.tokens} tokens billed from the estimate), completion billed as zero. ${PER_REPLICA_CAVEAT}`
|
||||
return `${v.requests} non-streaming response${plural} carried no usage from the provider — prompt tokens were estimated from request body size instead (${v.tokens} tokens billed from the estimate), completion billed as zero. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
/** unbilledProvenanceTitle is the unbilled-accounting badge's hover/title detail — explicit about the budget-exposure implication (task brief's own framing). */
|
||||
function unbilledProvenanceTitle(v: AdminProvenanceView): string {
|
||||
const plural = v.requests === 1 ? '' : 's'
|
||||
return `${v.requests} streaming response${plural} carried no usage from the provider — the request was counted but zero tokens were billed. If this keeps happening, this provider may be serving completions entirely free against every budget. ${PER_REPLICA_CAVEAT}`
|
||||
return `${v.requests} streaming response${plural} carried no usage from the provider — the request was counted but zero tokens were billed. If this keeps happening, this provider may be serving completions entirely free against every budget. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
// --- model aliases (sortable DataTable, operator directive) ---
|
||||
@@ -331,6 +368,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
:icon="overview?.redis.configured ? faCircleCheck : faCircleXmark"
|
||||
:class="overview?.redis.configured ? 'text-chart-requests' : 'text-muted-foreground'"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Redis
|
||||
</CardTitle>
|
||||
@@ -338,7 +376,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
<CardContent class="flex flex-col gap-1 text-sm">
|
||||
<p class="font-medium">{{ overview?.redis.configured ? 'Configured' : 'Not configured' }}</p>
|
||||
<p v-if="overview?.redis.lastErr" class="flex items-start gap-1.5 text-destructive">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ overview.redis.lastErr }}{{ formatAgo(overview.redis.lastErrAt) }}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -351,6 +389,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
:icon="overview?.cache.enabled ? faCircleCheck : faCircleXmark"
|
||||
:class="overview?.cache.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Response cache
|
||||
</CardTitle>
|
||||
@@ -368,6 +407,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
:icon="overview?.retry.enabled ? faCircleCheck : faCircleXmark"
|
||||
:class="overview?.retry.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
|
||||
class="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Retry
|
||||
</CardTitle>
|
||||
@@ -398,11 +438,37 @@ const aliasEmptyMessage = computed(() =>
|
||||
>
|
||||
no providers match "{{ modelQuery }}"
|
||||
</p>
|
||||
<Accordion v-else v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
|
||||
<template v-else>
|
||||
<!-- F4 (dashboard-plan.md): latency/provenance badges below are per-replica, in-process figures — this caption names which replica this poll answered from, up front, instead of leaving it to each badge's own hover title. -->
|
||||
<p class="mb-2 text-xs text-muted-foreground">{{ perReplicaCaveat() }}</p>
|
||||
<Accordion v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
|
||||
<AccordionItem v-for="p in filteredProviders" :key="p.name" :value="p.name">
|
||||
<AccordionTrigger>
|
||||
<!--
|
||||
P2 review fix: the provider-name link moved OUTSIDE
|
||||
AccordionTrigger — see goToProviderModels' own doc comment
|
||||
above for why. `py-2.5` matches AccordionTrigger.vue's own
|
||||
vertical padding so the two line up in this row.
|
||||
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
|
||||
wraps the trigger button, since AccordionTrigger's own
|
||||
`class` prop lands on the inner button, not the header — see
|
||||
AccordionTrigger.vue's doc comment. Without it the header
|
||||
shrinks to content width, so the chevron drifts left of the
|
||||
row's right edge and the empty space to its right stops
|
||||
being clickable. The sr-only span gives the toggle button an
|
||||
accessible name that names the provider, since the visible
|
||||
text beside it is just badges and counts.
|
||||
-->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 cursor-pointer rounded-sm py-2.5 font-medium hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
:aria-label="`View ${p.name}'s models in Charts`"
|
||||
:title="`View ${p.name}'s models in Charts`"
|
||||
@click="goToProviderModels(p.name)"
|
||||
>{{ p.name }}</button>
|
||||
<AccordionTrigger header-class="flex-1">
|
||||
<span class="sr-only">Provider {{ p.name }} details</span>
|
||||
<span class="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1 pr-2 text-left">
|
||||
<span class="font-medium">{{ p.name }}</span>
|
||||
<Badge as="span" variant="secondary" class="font-normal">{{ p.type }}</Badge>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ p.modelCount }} models</span>
|
||||
<ProviderRateBadge
|
||||
@@ -488,7 +554,8 @@ const aliasEmptyMessage = computed(() =>
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">{{ refreshLabel(p.discoveryEnabled, p.lastRefresh) }}</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
@@ -516,6 +583,9 @@ const aliasEmptyMessage = computed(() =>
|
||||
:input-per-m-tok-usd="modelMetaFor(p, m).inputPerMTokUsd"
|
||||
:output-per-m-tok-usd="modelMetaFor(p, m).outputPerMTokUsd"
|
||||
/>
|
||||
<!-- ScopeLink (F6): jumps to Charts, scoped to this exact model — renders here as a real <button> inside AccordionContent (not AccordionTrigger), same as the provider-name button above (P2 review fix moved both out of the trigger). -->
|
||||
|
||||
<ScopeLink :label="routableModelId(p.name, m)" :scope="`model:${routableModelId(p.name, m)}`" />
|
||||
<!--
|
||||
Context chip, visible, muted (feature v0.23) — only
|
||||
when known. Cost stays hover-only on ModelChip itself
|
||||
@@ -550,6 +620,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<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>
|
||||
@@ -72,7 +72,7 @@ function onClear(): void {
|
||||
class="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
@click="onClear"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" />
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ratioTier } from '@/lib/usage-bars'
|
||||
|
||||
/**
|
||||
* UsageBar renders one BudgetRatio (lib/usage-bars.ts) as a compact
|
||||
* horizontal meter — F1, usage-vs-limit bars. A real `role="meter"`
|
||||
* (not a `<progress>` element: a meter reports a measurement against a
|
||||
* range, which is exactly a used/limit reading, where `<progress>`
|
||||
* implies a task moving toward completion) carries the accessible
|
||||
* reading via `aria-valuenow`/`aria-valuemin`/`aria-valuemax`/
|
||||
* `aria-valuetext`, so a screen reader announces the same number a
|
||||
* sighted reader sees in the fill width and the `title` tooltip.
|
||||
*
|
||||
* Used two places (usage-columns.ts's numeric cells, UsageView.vue's
|
||||
* group detail grid) — both pass a fully-computed BudgetRatio plus the
|
||||
* caller's own human-readable `valueText`, so this component has no
|
||||
* formatting opinion of its own (formatCost/formatExactInt stay the
|
||||
* caller's job, same "logic lives in lib/, not the component" split
|
||||
* every other shared component in this panel follows).
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** used / limit — may exceed 1 (a scope's usage window can roll past its limit before the next request is rejected). */
|
||||
ratio: number
|
||||
/** The accessible name for this meter, e.g. "req/day" or "cost/month". */
|
||||
label: string
|
||||
/** The human-readable "used of limit" text — both the `title` tooltip and `aria-valuetext`, e.g. "500 / 1,000" or "$1.20 / $5.00". */
|
||||
valueText: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const tier = computed(() => ratioTier(props.ratio))
|
||||
|
||||
/**
|
||||
* widthPct clamps the visible fill (and, via valueNow below, the
|
||||
* announced aria-valuenow) to 100% — a ratio above 1.0 (over the limit)
|
||||
* still reads as a full bar, with the RED tier and valueText's own real
|
||||
* "used / limit" numbers carrying the "how far over" detail instead of an
|
||||
* overflowing bar or an aria-valuenow exceeding its own declared
|
||||
* aria-valuemax (WAI-ARIA meter role: valuenow must stay within
|
||||
* valuemin/valuemax).
|
||||
*/
|
||||
const widthPct = computed(() => Math.min(100, Math.max(0, props.ratio * 100)))
|
||||
const valueNow = computed(() => Math.round(widthPct.value))
|
||||
|
||||
const TIER_FILL_CLASS: Record<ReturnType<typeof ratioTier>, string> = {
|
||||
normal: 'bg-primary',
|
||||
amber: 'bg-bar-amber',
|
||||
red: 'bg-destructive',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
role="meter"
|
||||
:aria-label="label"
|
||||
:aria-valuenow="valueNow"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuetext="valueText"
|
||||
:title="valueText"
|
||||
:class="cn('relative inline-block h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-muted align-middle', props.class)"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 rounded-full"
|
||||
:class="TIER_FILL_CLASS[tier]"
|
||||
:style="{ width: `${widthPct}%` }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { SortingState } from '@tanstack/vue-table'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
@@ -21,9 +22,15 @@ const props = defineProps<{
|
||||
secondaryValue: (entry: AdminUsageEntryView) => string
|
||||
/** Shown in the empty-state row when entries is empty — e.g. a filtered-search "no X match ..." message. Falls through to DataTable's own 'none' default when omitted. */
|
||||
emptyMessage?: string
|
||||
/** P1: the shared reactive clock (composables/useNow.ts) — threaded into usageColumns() below so this table's own costMonthProj column recomputes as the clock ticks, instead of freezing at whatever instant this computed first ran. */
|
||||
now: Date
|
||||
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except UsageView.vue's top-level Users table, whose export must reflect the table's current sort. */
|
||||
sorting?: SortingState
|
||||
}>()
|
||||
|
||||
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue))
|
||||
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
|
||||
|
||||
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,5 +39,7 @@ const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumn
|
||||
:data="entries"
|
||||
:empty-message="emptyMessage"
|
||||
:row-class="(entry) => (entry.storeDown ? 'bg-destructive/10' : undefined)"
|
||||
:sorting="sorting"
|
||||
@update:sorting="(v) => emit('update:sorting', v)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { SortingState } from '@tanstack/vue-table'
|
||||
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import CostForecastCard from '@/components/CostForecastCard.vue'
|
||||
import CsvExportButton from '@/components/CsvExportButton.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import ScopeLink from '@/components/ScopeLink.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import SortHeaderButton from '@/components/SortHeaderButton.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { valueUpdater } from '@/components/ui/table'
|
||||
import UsageBar from '@/components/UsageBar.vue'
|
||||
import UsageTable from '@/components/UsageTable.vue'
|
||||
import { useNow } from '@/composables/useNow'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { formatCost, formatLimits } from '@/lib/format'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
import { BUDGET_RATIO_LABEL, type BudgetRatio, budgetRatios } from '@/lib/usage-bars'
|
||||
import { usageColumns } from '@/lib/usage-columns'
|
||||
import { usageCsv } from '@/lib/usage-csv'
|
||||
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
@@ -23,6 +33,17 @@ import type { AdminUsageEntryView } from '@/types/api'
|
||||
const dashboard = useDashboardStore()
|
||||
const usage = computed(() => dashboard.usage)
|
||||
|
||||
// The one shared reactive clock every time-dependent read on this tab uses
|
||||
// (P1, composables/useNow.ts's own doc comment) — CostForecastCard's own
|
||||
// elapsedFraction, usageColumns()'s `now` parameter (both the real Users/
|
||||
// Members <table>s below and the headless Groups toolbar's groupColumns),
|
||||
// and groupCostProjection all read THIS ref, never their own `new Date()`,
|
||||
// so a long-open tab's cost projections advance together instead of
|
||||
// freezing at whatever instant each one happened to first run.
|
||||
const { now, start: startClock, stop: stopClock } = useNow()
|
||||
onMounted(startClock)
|
||||
onUnmounted(stopClock)
|
||||
|
||||
/** memberCounts maps a group name to its member count — only the Overview response carries it. */
|
||||
const memberCounts = computed<Record<string, number>>(() => {
|
||||
const out: Record<string, number> = {}
|
||||
@@ -45,7 +66,9 @@ function memberCountOf(entry: AdminUsageEntryView): string {
|
||||
// Matching logic lives in lib/usage-search.ts, which ChartsView.vue's own
|
||||
// scope-picker filter calls directly too (groupMatches, userMatches) — no
|
||||
// second copy of "does this user/group match?") ---
|
||||
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
|
||||
// Bound to nav.usageQuery so the search text round-trips through the URL hash (#usage?q=…, lib/hash-state.ts).
|
||||
const { usageQuery } = storeToRefs(useNavStore())
|
||||
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery(usageQuery)
|
||||
|
||||
/**
|
||||
* filteredGroups is every group matching the query — own name OR a
|
||||
@@ -158,14 +181,14 @@ const usersEmptyMessage = computed(() =>
|
||||
// SortHeaderButton DataTable.vue's real <th>s use (same icons, same
|
||||
// toggleSorting call, same accessible name), just laid out as a toolbar
|
||||
// instead of <th>s, because there is no <table> here to put a <th> in.
|
||||
const groupColumns = usageColumns('Name', 'Members', memberCountOf)
|
||||
const groupColumns = computed(() => usageColumns('Name', 'Members', memberCountOf, now.value))
|
||||
const groupSorting = ref<SortingState>([])
|
||||
const groupsTable = useVueTable({
|
||||
get data() {
|
||||
return filteredGroups.value
|
||||
},
|
||||
get columns() {
|
||||
return groupColumns
|
||||
return groupColumns.value
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -257,6 +280,100 @@ const GLOB_HINT = 'Glob pattern — matches like a shell wildcard (*, ?, [...])'
|
||||
function isGlobPattern(entry: string): boolean {
|
||||
return /[*?[]/.test(entry)
|
||||
}
|
||||
|
||||
// --- F1 usage-vs-limit bars (group detail grid) ---
|
||||
//
|
||||
// usage-columns.ts's own numericColumn already renders a bar beside each
|
||||
// TABLE cell that has a configured limit (Users table, and — reused — the
|
||||
// Members table inside an expanded group). A group's own accordion detail
|
||||
// grid (the dl/dd block below, distinct from its nested Members table) has
|
||||
// no per-cell numeric columns at all, so it renders budgetRatios directly
|
||||
// as one compact row of labeled bars instead — the coordinator brief's own
|
||||
// phrasing ("Group detail grid ... renders bars from budgetRatios").
|
||||
|
||||
// BUDGET_RATIO_LABEL (P10/P11 review fix): imported from lib/usage-bars.ts
|
||||
// — this used to be a second, independently-declared copy here (BUDGET_LABEL)
|
||||
// with slightly different text ("tok/day" here vs usage-columns.ts's own
|
||||
// bar label), the exact DRY drift the coordinator's review flagged. The one
|
||||
// shared definition also spells out "tokens (in+out)/day" (P10) for the
|
||||
// tokDay/tokMonth ids — accurate for a screen-reader user, who has no OTHER
|
||||
// column beside this compact grid's own labeled bar to infer the combined-
|
||||
// total measurement from.
|
||||
|
||||
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title) — cost ids in dollars (formatCost, the micro-USD unit budgetRatios already computed them in), every other id as a plain thousands-separated count. */
|
||||
function budgetValueText(ratio: BudgetRatio): string {
|
||||
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
|
||||
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* groupCostProjection (F7) mirrors usage-columns.ts's own costMonthProjColumn
|
||||
* math for one group row — the coordinator brief's own phrasing ("also in
|
||||
* group grid"): the group detail dl/dd block is not a DataTable column, so
|
||||
* it cannot reuse that column's ColumnDef cell renderer directly, but the
|
||||
* underlying lib/forecast.ts math (monthProgress/projectMonthEnd/headroom)
|
||||
* is identical. `now` is the shared clock (useNow, above) — the SAME
|
||||
* instant every other time-dependent read on this tab uses — not a fresh
|
||||
* `new Date()` read per call, so this grid's own projection never disagrees
|
||||
* with CostForecastCard's or a column's mid-render.
|
||||
*/
|
||||
function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: string; willExceed: boolean } {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
|
||||
if (projected === null) return { text: 'not enough data yet', willExceed: false }
|
||||
return { text: formatCost(projected), willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
|
||||
}
|
||||
|
||||
// --- F8 CSV export ---
|
||||
//
|
||||
// Each button exports whatever rows are CURRENTLY visible: Groups export
|
||||
// in the Groups toolbar's own sort order (sortedGroupRows, already
|
||||
// computed below for the headless sort toolbar); Users export in the
|
||||
// Users <table>'s own CURRENT column sort (P6 review fix — this used to
|
||||
// export in filtered order alone, a self-declared deviation from the plan's
|
||||
// "exports filtered+sorted rows"). The Users DataTable's sort state is
|
||||
// LIFTED UP here via DataTable.vue's `v-model:sorting` (that component's
|
||||
// own doc comment) — usersSorting below is bound two ways: down into the
|
||||
// rendered UsageTable (template) so a header click writes back here, and
|
||||
// into a second, headless useVueTable instance (usersTable) that applies
|
||||
// the identical sort to filteredUsers so usersCsv can read it back in the
|
||||
// same order the reader is actually looking at — the same "one sort state,
|
||||
// two consumers" shape sortedGroupRows/groupsTable already used for Groups.
|
||||
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value))
|
||||
const usersSorting = ref<SortingState>([])
|
||||
const usersTable = useVueTable({
|
||||
get data() {
|
||||
return filteredUsers.value
|
||||
},
|
||||
get columns() {
|
||||
return usersColumns.value
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: (updater) => valueUpdater(updater, usersSorting),
|
||||
state: {
|
||||
get sorting() {
|
||||
return usersSorting.value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function usersCsv(): string {
|
||||
return usageCsv(
|
||||
usersTable.getRowModel().rows.map((row) => row.original),
|
||||
'users',
|
||||
groupNameOf,
|
||||
now.value,
|
||||
)
|
||||
}
|
||||
function groupsCsv(): string {
|
||||
return usageCsv(
|
||||
sortedGroupRows.value.map((row) => row.original),
|
||||
'groups',
|
||||
memberCountOf,
|
||||
now.value,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -268,11 +385,18 @@ function isGlobPattern(entry: string): boolean {
|
||||
<CardTitle>Total</CardTitle>
|
||||
<CardDescription>Usage summed across every user and group combined.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-5">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Requests/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.requestsPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<!-- F4: fleet-wide requests denied by a limit check today (types/api.ts's own doc comment) — destructive tint once positive, mirroring usage-columns.ts's own rejDay column convention. -->
|
||||
<p class="text-muted-foreground">Rejected/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums" :class="usage.total.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
|
||||
<CompactNumber :value="usage.total.rejectionsPerDay" />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Tokens in/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensInPerDay" /></p>
|
||||
@@ -288,10 +412,18 @@ function isGlobPattern(entry: string): boolean {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- F7: fleet-wide cost forecast, directly under the Total card (coordinator brief). -->
|
||||
<CostForecastCard v-if="usage" :mtd-micros="usage.total.costPerMonthMicroUsd" :limits="usage.total.limits" :now="now" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Groups</CardTitle>
|
||||
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Groups</CardTitle>
|
||||
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
|
||||
</div>
|
||||
<CsvExportButton filename="usage-groups.csv" :build="groupsCsv" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
@@ -303,19 +435,40 @@ function isGlobPattern(entry: string): boolean {
|
||||
|
||||
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroupValues" type="multiple" class="rounded-md border px-3">
|
||||
<AccordionItem v-for="row in sortedGroupRows" :key="row.original.id" :value="row.original.id">
|
||||
<AccordionTrigger>
|
||||
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
|
||||
<span class="font-medium">{{ row.original.id }}</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.requestsPerDay" /> req/day
|
||||
<!--
|
||||
P2 review fix: ScopeLink moved OUTSIDE AccordionTrigger — a
|
||||
real interactive element can never be a valid descendant of
|
||||
another interactive element (AccordionTrigger's own <button>,
|
||||
reka-ui), so it is now a sibling in this header row instead
|
||||
of nested inside the trigger's own slot. `py-2.5` matches
|
||||
AccordionTrigger.vue's own vertical padding so the two line
|
||||
up, keeping the same visual row the nested version had.
|
||||
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
|
||||
wraps the trigger button, since AccordionTrigger's own
|
||||
`class` prop lands on the inner button, not the header — see
|
||||
AccordionTrigger.vue's doc comment. Without it the header
|
||||
shrinks to content width, so the chevron drifts left of the
|
||||
row's right edge and the empty space to its right stops
|
||||
being clickable. The sr-only span gives the toggle button an
|
||||
accessible name that names the group, since the visible text
|
||||
beside it is just counts.
|
||||
-->
|
||||
<div class="flex items-center gap-2">
|
||||
<ScopeLink class="shrink-0 py-2.5 font-medium" :label="row.original.id" :scope="`group:${row.original.id}`" />
|
||||
<AccordionTrigger header-class="flex-1">
|
||||
<span class="sr-only">Group {{ row.original.id }} details</span>
|
||||
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.requestsPerDay" /> req/day
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<!--
|
||||
Access block (group-access-display task): the group's
|
||||
@@ -404,6 +557,13 @@ function isGlobPattern(entry: string): boolean {
|
||||
<CompactNumber v-else :value="row.original.requestsPerMinute" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">rejected/day</dt>
|
||||
<dd class="tabular-nums" :class="!row.original.storeDown && row.original.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.rejectionsPerDay" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokIn/day</dt>
|
||||
<dd class="tabular-nums">
|
||||
@@ -436,13 +596,36 @@ function isGlobPattern(entry: string): boolean {
|
||||
<dt class="text-xs text-muted-foreground">cost/month</dt>
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<!-- F7: projected month-end cost, extrapolated from this group's own month-to-date spend (mirrors usage-columns.ts's costMonthProj column). -->
|
||||
<dt class="text-xs text-muted-foreground">cost/month (proj.)</dt>
|
||||
<dd
|
||||
class="tabular-nums"
|
||||
:class="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'text-destructive' : undefined"
|
||||
:title="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
|
||||
>
|
||||
{{ row.original.storeDown ? '?' : groupCostProjection(row.original, now).text }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<!-- F1: usage-vs-limit bars, one per configured limit — budgetRatios (lib/usage-bars.ts) returns nothing for an unlimited group or a storeDown one, so this whole row is simply absent then. -->
|
||||
<div v-if="budgetRatios(row.original).length" class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<span
|
||||
v-for="ratio in budgetRatios(row.original)"
|
||||
:key="ratio.id"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ BUDGET_RATIO_LABEL[ratio.id] }}
|
||||
<UsageBar :ratio="ratio.ratio" :label="BUDGET_RATIO_LABEL[ratio.id]" :value-text="budgetValueText(ratio)" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Members</p>
|
||||
<UsageTable
|
||||
id-label="Name"
|
||||
secondary-column-label="Group"
|
||||
:entries="visibleMembers(row.original)"
|
||||
:secondary-value="groupNameOf"
|
||||
:now="now"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -453,7 +636,10 @@ function isGlobPattern(entry: string): boolean {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CsvExportButton filename="usage-users.csv" :build="usersCsv" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UsageTable
|
||||
@@ -462,6 +648,8 @@ function isGlobPattern(entry: string): boolean {
|
||||
:entries="filteredUsers"
|
||||
:secondary-value="groupNameOf"
|
||||
:empty-message="usersEmptyMessage"
|
||||
:now="now"
|
||||
v-model:sorting="usersSorting"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -10,13 +10,25 @@ import {
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<AccordionTriggerProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
/**
|
||||
* Class applied to the AccordionHeader (the <h3> wrapper), not the
|
||||
* trigger button. Reka-ui's AccordionTrigger declares its own `class`
|
||||
* prop, so a `class` passed to this component lands on the inner
|
||||
* button, never on the header — pass `flex-1` here (not to `class`)
|
||||
* when the trigger sits next to a sibling in a flex row, so the header
|
||||
* (and the button it wraps) fills the remaining row width instead of
|
||||
* shrinking to content width.
|
||||
*/
|
||||
headerClass?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'headerClass')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionHeader :class="cn('flex', props.headerClass)">
|
||||
<AccordionTrigger
|
||||
data-slot="accordion-trigger"
|
||||
v-bind="delegatedProps"
|
||||
|
||||
@@ -10,6 +10,15 @@ export const alertVariants = cva('grid gap-0.5 rounded-lg border px-2.5 py-2 tex
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
|
||||
/**
|
||||
* warn: a non-destructive "worth a look, not broken" tier —
|
||||
* reuses the panel's shared --status-warn token (main.css,
|
||||
* already the Providers tab's degraded badge and the usage-bar
|
||||
* amber threshold) instead of `destructive`'s red, which implies
|
||||
* a hard failure. Same structure as `destructive` above, only the
|
||||
* color changes.
|
||||
*/
|
||||
warn: 'text-status-warn bg-card *:data-[slot=alert-description]:text-status-warn/90 *:[svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
Reference in New Issue
Block a user