first commit
This commit is contained in:
6
ui/app/workspace/model-catalog/layout.tsx
Normal file
6
ui/app/workspace/model-catalog/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ModelCatalogPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/model-catalog")({
|
||||
component: ModelCatalogPage,
|
||||
});
|
||||
5
ui/app/workspace/model-catalog/page.tsx
Normal file
5
ui/app/workspace/model-catalog/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ModelCatalogView from "./views/modelCatalogView";
|
||||
|
||||
export default function ModelCatalogPage() {
|
||||
return <ModelCatalogView />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
|
||||
export function ModelCatalogEmptyState() {
|
||||
return (
|
||||
<div className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<LayoutGrid className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-muted-foreground text-xl font-medium">No providers configured yet</h1>
|
||||
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
|
||||
Configure your first model provider to see an overview of all providers, API keys, models, and usage metrics.
|
||||
</div>
|
||||
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
|
||||
<Button asChild data-testid="modelcatalog-configure-providers-cta">
|
||||
<Link to="/workspace/providers">Configure Providers</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
ui/app/workspace/model-catalog/views/modelCatalogTable.tsx
Normal file
201
ui/app/workspace/model-catalog/views/modelCatalogTable.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { ProviderLabels } from "@/lib/constants/logs";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
function formatCost(dollars: number) {
|
||||
return `$${dollars.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export interface ModelCatalogRow {
|
||||
providerName: string;
|
||||
isCustom: boolean;
|
||||
baseProviderType?: string;
|
||||
modelsUsed: string[];
|
||||
totalTraffic24h: number;
|
||||
totalCost24h: number;
|
||||
}
|
||||
|
||||
interface ModelCatalogTableProps {
|
||||
rows: ModelCatalogRow[];
|
||||
providers: string[];
|
||||
providerFilter: string;
|
||||
onProviderFilterChange: (value: string) => void;
|
||||
totalProviders: number;
|
||||
totalModels: number;
|
||||
totalRequests24h: number;
|
||||
totalCost24h: number;
|
||||
isLoadingModels: boolean;
|
||||
}
|
||||
|
||||
export default function ModelCatalogTable({
|
||||
rows,
|
||||
providers,
|
||||
providerFilter,
|
||||
onProviderFilterChange,
|
||||
totalProviders,
|
||||
totalModels,
|
||||
totalRequests24h,
|
||||
totalCost24h,
|
||||
isLoadingModels,
|
||||
}: ModelCatalogTableProps) {
|
||||
const summaryCards = [
|
||||
{ label: "Total Providers", value: totalProviders.toLocaleString() },
|
||||
{ label: "Total Models", value: totalModels.toLocaleString() },
|
||||
{ label: "Total Requests (24h)", value: totalRequests24h.toLocaleString() },
|
||||
{ label: "Total Cost (24h)", value: formatCost(totalCost24h) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{summaryCards.map((card) => (
|
||||
<Card key={card.label} className="py-4 shadow-none">
|
||||
<CardContent className="px-4">
|
||||
<p className="text-muted-foreground text-xs">{card.label}</p>
|
||||
<p className="mt-1 text-xl font-semibold">{card.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header + Filter */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Catalog</h2>
|
||||
<p className="text-muted-foreground text-sm">Overview of all configured providers, models, and usage.</p>
|
||||
</div>
|
||||
<Select
|
||||
value={providerFilter || "all"}
|
||||
onValueChange={(val) => onProviderFilterChange(val === "all" ? "" : val)}
|
||||
data-testid="model-catalog-provider-filter"
|
||||
>
|
||||
<SelectTrigger className="w-[200px]" data-testid="model-catalog-provider-trigger">
|
||||
<SelectValue placeholder="All Providers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Providers</SelectItem>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{ProviderLabels[p as keyof typeof ProviderLabels] || p}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1">
|
||||
Models
|
||||
<Tooltip>
|
||||
<TooltipTrigger data-testid="model-catalog-models-info-trigger">
|
||||
<Info className="text-muted-foreground h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Models used in the last 30 days</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Total Traffic (24h)</TableHead>
|
||||
<TableHead className="text-right">Total Cost (24h)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
<span className="text-muted-foreground text-sm">No matching providers found.</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.providerName}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<RenderProviderIcon
|
||||
provider={(row.isCustom ? row.baseProviderType : row.providerName) as ProviderIconType}
|
||||
size="sm"
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{row.isCustom
|
||||
? row.providerName
|
||||
: ProviderLabels[row.providerName as keyof typeof ProviderLabels] || row.providerName}
|
||||
</span>
|
||||
{row.isCustom && (
|
||||
<Badge variant="secondary" className="text-muted-foreground px-1.5 py-0.5 text-[10px] font-bold">
|
||||
CUSTOM
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isLoadingModels ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
<Skeleton className="h-5 w-32 rounded-full" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ModelsUsedCell models={row.modelsUsed} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">{row.totalTraffic24h.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">{formatCost(row.totalCost24h)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsUsedCell({ models: rawModels }: { models: string[] }) {
|
||||
const models = Array.from(new Set(rawModels.filter(Boolean)));
|
||||
if (models.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">-</span>;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
const visible = models.slice(0, MAX_VISIBLE);
|
||||
const remaining = models.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{visible.map((m) => (
|
||||
<Badge key={m} variant="outline" className="text-xs font-normal">
|
||||
{m}
|
||||
</Badge>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger data-testid="model-catalog-models-overflow-trigger">
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
+{remaining} more
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
{models.slice(MAX_VISIBLE).join(", ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
170
ui/app/workspace/model-catalog/views/modelCatalogView.tsx
Normal file
170
ui/app/workspace/model-catalog/views/modelCatalogView.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import FullPageLoader from "@/components/fullPageLoader";
|
||||
import { NoPermissionView } from "@/components/noPermissionView";
|
||||
import { ProviderNames } from "@/lib/constants/logs";
|
||||
import { useGetModelsQuery, useGetProvidersQuery, useLazyGetLogsModelHistogramQuery, useLazyGetLogsStatsQuery } from "@/lib/store";
|
||||
import { KnownProvider } from "@/lib/types/config";
|
||||
import { LogStats } from "@/lib/types/logs";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ModelCatalogEmptyState } from "./modelCatalogEmptyState";
|
||||
import ModelCatalogTable, { ModelCatalogRow } from "./modelCatalogTable";
|
||||
|
||||
export default function ModelCatalogView() {
|
||||
const hasAccess = useRbac(RbacResource.ModelProvider, RbacOperation.View);
|
||||
|
||||
const [providerFilter, setProviderFilter] = useState("");
|
||||
const [statsMap, setStatsMap] = useState<Map<string, LogStats>>(new Map());
|
||||
const [modelsUsedMap, setModelsUsedMap] = useState<Map<string, string[]>>(new Map());
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(true);
|
||||
|
||||
const {
|
||||
data: providers,
|
||||
isLoading: isLoadingProviders,
|
||||
error: providersError,
|
||||
refetch: refetchProviders,
|
||||
} = useGetProvidersQuery(undefined, { skip: !hasAccess });
|
||||
const { data: modelsData } = useGetModelsQuery({ unfiltered: true }, { skip: !hasAccess });
|
||||
|
||||
// Global 24h stats for summary cards (lazy so we get fresh timestamps)
|
||||
const [triggerGlobalStats, { data: globalStats }] = useLazyGetLogsStatsQuery();
|
||||
|
||||
// Per-provider traffic stats (lazy, fired when providers load)
|
||||
const [triggerStats] = useLazyGetLogsStatsQuery();
|
||||
const [triggerModelHistogram] = useLazyGetLogsModelHistogramQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) return;
|
||||
const now = new Date().toISOString();
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
triggerGlobalStats({ filters: { start_time: dayAgo, end_time: now } });
|
||||
}, [hasAccess, triggerGlobalStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providers || providers.length === 0) return;
|
||||
let cancelled = false;
|
||||
const now = new Date().toISOString();
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
Promise.all(
|
||||
providers.map((p) =>
|
||||
triggerStats({ filters: { providers: [p.name], start_time: dayAgo, end_time: now } })
|
||||
.unwrap()
|
||||
.then((stats) => [p.name, stats] as const)
|
||||
.catch(
|
||||
() =>
|
||||
[
|
||||
p.name,
|
||||
{
|
||||
total_requests: 0,
|
||||
success_rate: 0,
|
||||
user_facing_success_rate: 0,
|
||||
average_latency: 0,
|
||||
user_facing_total_requests:0,
|
||||
total_tokens: 0,
|
||||
total_cost: 0
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
).then((results) => {
|
||||
if (!cancelled) setStatsMap(new Map(results));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providers, triggerStats]);
|
||||
|
||||
// Per-provider models used in last 30 days
|
||||
useEffect(() => {
|
||||
if (!providers || providers.length === 0) return;
|
||||
let cancelled = false;
|
||||
setIsLoadingModels(true);
|
||||
const now = new Date().toISOString();
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
Promise.all(
|
||||
providers.map((p) =>
|
||||
triggerModelHistogram({ filters: { providers: [p.name], start_time: monthAgo, end_time: now } })
|
||||
.unwrap()
|
||||
.then((data): [string, string[]] => [p.name, data.models ?? []])
|
||||
.catch((): [string, string[]] => [p.name, []]),
|
||||
),
|
||||
).then((results) => {
|
||||
if (!cancelled) {
|
||||
setModelsUsedMap(new Map(results));
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providers, triggerModelHistogram]);
|
||||
|
||||
// Build table rows
|
||||
const rows: ModelCatalogRow[] = useMemo(() => {
|
||||
if (!providers) return [];
|
||||
|
||||
return providers.map((p) => {
|
||||
const isCustom = !ProviderNames.includes(p.name as KnownProvider);
|
||||
const modelsUsed = modelsUsedMap.get(p.name) ?? [];
|
||||
|
||||
const providerStats = statsMap.get(p.name);
|
||||
const totalTraffic24h = providerStats?.total_requests ?? 0;
|
||||
const totalCost24h = providerStats?.total_cost ?? 0;
|
||||
|
||||
return {
|
||||
providerName: p.name,
|
||||
isCustom,
|
||||
baseProviderType: p.custom_provider_config?.base_provider_type,
|
||||
modelsUsed,
|
||||
totalTraffic24h,
|
||||
totalCost24h,
|
||||
};
|
||||
});
|
||||
}, [providers, statsMap, modelsUsedMap]);
|
||||
|
||||
// Filter rows by provider
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!providerFilter) return rows;
|
||||
return rows.filter((r) => r.providerName === providerFilter);
|
||||
}, [rows, providerFilter]);
|
||||
|
||||
if (isLoadingProviders) {
|
||||
return <FullPageLoader />;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return <NoPermissionView entity="model catalog" />;
|
||||
}
|
||||
|
||||
if (providersError) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="text-muted-foreground text-sm">Failed to load providers</p>
|
||||
<button type="button" data-testid="model-catalog-retry-btn" onClick={refetchProviders} className="text-sm underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!providers || providers.length === 0) {
|
||||
return <ModelCatalogEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<ModelCatalogTable
|
||||
rows={filteredRows}
|
||||
providers={(providers ?? []).map((p) => p.name)}
|
||||
providerFilter={providerFilter}
|
||||
onProviderFilterChange={setProviderFilter}
|
||||
totalProviders={(providers ?? []).length}
|
||||
totalModels={modelsData?.total ?? 0}
|
||||
totalRequests24h={globalStats?.total_requests ?? 0}
|
||||
totalCost24h={globalStats?.total_cost ?? 0}
|
||||
isLoadingModels={isLoadingModels}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user