mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
Every tab row scrolls sideways instead of being clipped by its card. On phones this hid tabs, such as the Config change helper's last forms at 360-390px. The login button has a larger touch target, and long names are truncated with a tooltip. Loading and empty states: - Skeletons show only while a selection has no data yet, never over data already on screen during a refresh. - Empty states name the next step to take. - Error states offer a Retry button that shows progress, and each card keeps its own error. - Toasts confirm copies and key generation, and report background failures once per streak through polite and assertive live regions. Other changes: - The access matrix switches between Models, MCP servers and Agents, and says "none configured" only once its data has loaded. - Home ranks the top 5 users by cost, requests or tokens. It requests only the needed metrics and warns before a range exceeds the server's key cap. - MCP and agent deep links accept a bare target name. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
151 lines
6.9 KiB
Vue
151 lines
6.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
|
|
import DataTable from '@/components/DataTable.vue'
|
|
import EmptyState from '@/components/EmptyState.vue'
|
|
import ErrorState from '@/components/ErrorState.vue'
|
|
import SearchInput from '@/components/SearchInput.vue'
|
|
import SkeletonTable from '@/components/SkeletonTable.vue'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { useSearchQuery } from '@/composables/useSearchQuery'
|
|
import { loadState } from '@/lib/load-state'
|
|
import { composeTargetId, targetColumns } from '@/lib/target-columns'
|
|
import type { TargetKind } from '@/lib/target-columns'
|
|
import { useDashboardStore } from '@/stores/dashboard'
|
|
import type { AdminTargetView } from '@/types/api'
|
|
|
|
// This component backs the "MCP & Agents" page (redesign-plan.md section
|
|
// 3.4, TargetsPage.vue) — GET /admin/api/targets (admin.go:
|
|
// adminTargetsResponse, Feature B v0.21).
|
|
const dashboard = useDashboardStore()
|
|
const targets = computed(() => dashboard.targets)
|
|
|
|
/**
|
|
* select (redesign-plan.md section 3.4's "TargetCallers.vue expander")
|
|
* fires with the composed "mcp/{name}"/"agent/{name}" id and the target's
|
|
* own display name — TargetsPage.vue listens and renders TargetCallers.vue
|
|
* for whichever target the reader last clicked. Two separate
|
|
* targetColumns() instantiations below (MCP servers, Agents) close over
|
|
* their own fixed `kind` so a click always composes the correct prefix,
|
|
* without this component needing to carry a per-row kind field itself.
|
|
*/
|
|
const emit = defineEmits<{ select: [id: string, label: string] }>()
|
|
|
|
function onSelect(kind: TargetKind): (t: AdminTargetView) => void {
|
|
return (t) => emit('select', composeTargetId(kind, t.name), t.name)
|
|
}
|
|
|
|
// Simple, single-field substring match on name — no separate lib/*-search
|
|
// module: ProviderHealthPanel.vue's own aliasMatches/providerMatches stay
|
|
// inline for the identical reason (one field, one comparison), reserving
|
|
// the extracted-and-tested lib/usage-search.ts treatment for
|
|
// ConsumerDirectory.vue's genuinely multi-entity (user-or-group-member)
|
|
// matching semantics.
|
|
const { query, normalized: normalizedQuery, hasQuery } = useSearchQuery()
|
|
|
|
function nameMatches(t: AdminTargetView): boolean {
|
|
return t.name.toLowerCase().includes(normalizedQuery.value)
|
|
}
|
|
|
|
const filteredMCPServers = computed<AdminTargetView[]>(() => {
|
|
const all = targets.value?.mcpServers ?? []
|
|
return hasQuery.value ? all.filter(nameMatches) : all
|
|
})
|
|
const filteredAgents = computed<AdminTargetView[]>(() => {
|
|
const all = targets.value?.agents ?? []
|
|
return hasQuery.value ? all.filter(nameMatches) : all
|
|
})
|
|
|
|
/** emptyMessage mirrors ConsumerDirectory.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
|
|
function emptyMessage(configuredCount: number, filteredCount: number): string {
|
|
if (!configuredCount) return 'none configured'
|
|
if (hasQuery.value && filteredCount === 0) return `no targets match "${query.value}"`
|
|
return 'none'
|
|
}
|
|
|
|
const mcpEmptyMessage = computed(() => emptyMessage(targets.value?.mcpServers.length ?? 0, filteredMCPServers.value.length))
|
|
const agentsEmptyMessage = computed(() => emptyMessage(targets.value?.agents.length ?? 0, filteredAgents.value.length))
|
|
|
|
/**
|
|
* targetsLoadState (lib/load-state.ts) — GET /admin/api/targets used to
|
|
* have NO loading/error handling at all here: `targets` mapped a
|
|
* not-yet-loaded/errored dashboard.targets straight to `[]` (`?? []`
|
|
* above), which DataTable then rendered as a confirmed, empty table —
|
|
* exactly the same class of bug lib/access-matrix.ts's matrixColumnsState
|
|
* fixed for the Access matrix (Task A). 'ready' once dashboard.targets
|
|
* has loaded at least once, even if both lists are genuinely empty —
|
|
* `noTargetsAtAll` below is the SEPARATE "loaded, but nothing configured"
|
|
* check, same split as matrixColumnsState's own loaded-vs-empty
|
|
* precedence.
|
|
*/
|
|
const targetsLoadState = computed(() => loadState({ loading: true, hasData: dashboard.targets !== null, error: dashboard.error }))
|
|
|
|
/**
|
|
* noTargetsAtAll (states-plan.md item 2: "MCP & Agents: 'No MCP servers
|
|
* or agents configured'") is true only once targets have genuinely
|
|
* loaded with BOTH lists empty — read off the raw configured counts, not
|
|
* the filtered ones, so an active search query never flips this true (a
|
|
* query matching nothing is `mcpEmptyMessage`/`agentsEmptyMessage`'s own
|
|
* "no targets match" case instead, a routine filter interaction, not a
|
|
* fleet-configuration fact worth the same combined empty-state).
|
|
*/
|
|
const noTargetsAtAll = computed(
|
|
() => targetsLoadState.value === 'ready' && (targets.value?.mcpServers.length ?? 0) === 0 && (targets.value?.agents.length ?? 0) === 0,
|
|
)
|
|
|
|
// Two separate column-def sets (lib/target-columns.ts) — MCP servers and
|
|
// Agents each close over their own fixed `kind` (see onSelect above) so a
|
|
// name click composes the right "mcp/…"/"agent/…" id. Stateless ColumnDef
|
|
// objects otherwise, safe to reuse across DataTable's two separate
|
|
// useVueTable instances (each owns its own sorting state).
|
|
const mcpColumns = targetColumns(onSelect('mcp'))
|
|
const agentColumns = targetColumns(onSelect('agent'))
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col gap-6">
|
|
<SearchInput v-model="query" placeholder="Filter MCP servers or agents" class="max-w-sm" />
|
|
|
|
<Card v-if="noTargetsAtAll">
|
|
<CardContent>
|
|
<EmptyState
|
|
title="No MCP servers or agents configured."
|
|
description="Add an mcpServers or agents entry to the middleware config to see them here."
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<template v-else>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>MCP servers</CardTitle>
|
|
<CardDescription>
|
|
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see
|
|
its callers.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<SkeletonTable v-if="targetsLoadState === 'skeleton'" :rows="3" :cols="mcpColumns.length" />
|
|
<ErrorState v-else-if="targetsLoadState === 'error'" :message="dashboard.error" />
|
|
<DataTable v-else :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Agents</CardTitle>
|
|
<CardDescription>
|
|
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
|
|
see its callers.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<SkeletonTable v-if="targetsLoadState === 'skeleton'" :rows="3" :cols="agentColumns.length" />
|
|
<ErrorState v-else-if="targetsLoadState === 'error'" :message="dashboard.error" />
|
|
<DataTable v-else :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
|
|
</CardContent>
|
|
</Card>
|
|
</template>
|
|
</div>
|
|
</template>
|