Files
pangolin/src/components/AiUsageAnalyticsData.tsx
T
2026-08-11 18:02:17 -04:00

298 lines
11 KiB
TypeScript

"use client";
import { cn } from "@app/lib/cn";
import {
aiUsageAnalyticsFiltersSchema,
aiUsageAnalyticsQueries,
type AiUsageAnalyticsFilters
} from "@app/lib/queries";
import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, XIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
import { Button } from "./ui/button";
import { Card, CardHeader } from "./ui/card";
import { Label } from "./ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { Separator } from "./ui/separator";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { HorizontalTabs, type TabItem } from "./HorizontalTabs";
import { OverviewTab } from "./ai-usage-analytics/OverviewTab";
import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
import { UsersRolesTab } from "./ai-usage-analytics/UsersRolesTab";
export type AiUsageAnalyticsDataProps = {
orgId: string;
};
const AI_USAGE_ANALYTICS_QUERY_PREFIX = ["AI_USAGE_ANALYTICS"];
export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
const searchParams = useSearchParams();
const path = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
const filters = aiUsageAnalyticsFiltersSchema.parse(
Object.fromEntries(searchParams.entries())
);
const isEmptySearchParams = Object.values(filters).every(
(v) => v === undefined
);
const dateRange = {
startDate: filters.timeStart
? new Date(filters.timeStart)
: getSevenDaysAgo(),
endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date()
};
const { data: filterOptions } = useQuery(
aiUsageAnalyticsQueries.filterOptions({
orgId: props.orgId,
filters: {
timeStart: filters.timeStart,
timeEnd: filters.timeEnd
}
})
);
const isFetching =
useIsFetching({
queryKey: [...AI_USAGE_ANALYTICS_QUERY_PREFIX, props.orgId]
}) > 0;
function setFilter(key: keyof AiUsageAnalyticsFilters, value?: string) {
const newSearch = new URLSearchParams(searchParams);
newSearch.delete(key);
if (value !== undefined) {
newSearch.set(key, value);
}
router.replace(`${path}?${newSearch.toString()}`);
}
function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) {
const newSearch = new URLSearchParams(searchParams);
const timeRegex =
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\d{1,2}))?$/;
if (start.date) {
const startDate = new Date(start.date);
if (start.time) {
const time = timeRegex.exec(start.time);
const groups = time?.groups ?? {};
startDate.setHours(Number(groups.hours));
startDate.setMinutes(Number(groups.minutes));
if (groups.seconds) {
startDate.setSeconds(Number(groups.seconds));
}
}
newSearch.set("timeStart", startDate.toISOString());
}
if (end.date) {
const endDate = new Date(end.date);
if (end.time) {
const time = timeRegex.exec(end.time);
const groups = time?.groups ?? {};
endDate.setHours(Number(groups.hours));
endDate.setMinutes(Number(groups.minutes));
if (groups.seconds) {
endDate.setSeconds(Number(groups.seconds));
}
}
newSearch.set("timeEnd", endDate.toISOString());
}
router.replace(`${path}?${newSearch.toString()}`);
}
function getDateTime(date: Date) {
return `${date.getHours()}:${date.getMinutes()}`;
}
const providerOptions = (filterOptions?.providers ?? []).map((p) => ({
value: String(p.id),
label: p.name ?? `Provider #${p.id}`
}));
const modelOptions = (filterOptions?.models ?? []).map((m) => ({
value: m,
label: m
}));
const resourceOptions = (filterOptions?.resources ?? []).map((r) => ({
value: String(r.id),
label: r.name ?? `Resource #${r.id}`
}));
const roleOptions = (filterOptions?.roles ?? []).map((r) => ({
value: String(r.id),
label: r.name ?? `Role #${r.id}`
}));
const userOptions = (filterOptions?.users ?? []).map((u) => ({
value: u.id,
label: u.email ?? u.id
}));
const tabs: TabItem[] = [
{ title: "Overview", href: "#" },
{ title: "Provider Usage", href: "#" },
{ title: "Resources", href: "#" },
{ title: "Users & Roles", href: "#" }
];
return (
<div className="flex flex-col gap-5">
<Card>
<CardHeader className="flex flex-col gap-4">
<div className="flex flex-col lg:flex-row items-start lg:items-end w-full gap-2">
<DateRangePicker
startValue={{
date: dateRange.startDate,
time: dateRange.startDate
? getDateTime(dateRange.startDate)
: undefined
}}
endValue={{
date: dateRange.endDate,
time: dateRange.endDate
? getDateTime(dateRange.endDate)
: undefined
}}
onRangeChange={handleTimeRangeUpdate}
className="flex-wrap gap-2"
/>
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
<div className="flex flex-wrap items-end gap-2">
<FilterSelect
id="providerId"
label="Provider"
value={filters.providerId?.toString()}
options={providerOptions}
placeholder="All Providers"
onValueChange={(v) => setFilter("providerId", v)}
/>
<FilterSelect
id="model"
label="Model"
value={filters.model}
options={modelOptions}
placeholder="All Models"
onValueChange={(v) => setFilter("model", v)}
/>
<FilterSelect
id="resourceId"
label="Resource"
value={filters.resourceId?.toString()}
options={resourceOptions}
placeholder="All Resources"
onValueChange={(v) => setFilter("resourceId", v)}
/>
<FilterSelect
id="roleId"
label="Role"
value={filters.roleId?.toString()}
options={roleOptions}
placeholder="All Roles"
onValueChange={(v) => setFilter("roleId", v)}
/>
<FilterSelect
id="userId"
label="User"
value={filters.userId}
options={userOptions}
placeholder="All Users"
onValueChange={(v) => setFilter("userId", v)}
/>
{!isEmptySearchParams && (
<Button
variant="ghost"
onClick={() => router.replace(path)}
className="gap-2"
>
<XIcon className="size-4" />
Reset Filters
</Button>
)}
</div>
</div>
<div className="flex justify-end">
<Button
variant="outline"
onClick={() =>
queryClient.invalidateQueries({
queryKey: [
...AI_USAGE_ANALYTICS_QUERY_PREFIX,
props.orgId
]
})
}
disabled={isFetching}
className="gap-2"
>
<RefreshCw
className={cn(
"size-4",
isFetching && "animate-spin"
)}
/>
Refresh
</Button>
</div>
</CardHeader>
</Card>
<HorizontalTabs items={tabs} clientSide>
<OverviewTab orgId={props.orgId} filters={filters} />
<ProvidersTab orgId={props.orgId} filters={filters} />
<ResourcesTab orgId={props.orgId} filters={filters} />
<UsersRolesTab orgId={props.orgId} filters={filters} />
</HorizontalTabs>
</div>
);
}
type FilterSelectProps = {
id: string;
label: string;
value?: string;
options: { value: string; label: string }[];
placeholder: string;
onValueChange: (value?: string) => void;
};
function FilterSelect(props: FilterSelectProps) {
return (
<div className="flex flex-col items-start gap-2 w-44">
<Label htmlFor={props.id}>{props.label}</Label>
<Select
onValueChange={(newValue) =>
props.onValueChange(
newValue === "all" ? undefined : newValue
)
}
value={props.value ?? "all"}
>
<SelectTrigger id={props.id} className="w-full">
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent className="w-full">
<SelectItem value="all">{props.placeholder}</SelectItem>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}