mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
refactor(ui): move logs and usage text onto i18n (#49992)
Route the page-authored English on the Logs, Usage and Telemetry pages, the log detail drawer and the shared usage widgets through t() and Trans, with 1,250 messages added to en.json under 10 new top-level keys. The rendered English is unchanged.
This commit is contained in:
parent
53740a4426
commit
d9fbfbd03b
115 changed files with 4151 additions and 1446 deletions
|
|
@ -1081,6 +1081,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/components/UsagePageView.tsx": {
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/users/_components/BulkEditUsers.tsx": {
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import DeletedKeysPage from "@/components/DeletedKeysPage/DeletedKeysPage";
|
||||
|
|
@ -13,6 +14,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
|||
type LogsTab = "request logs" | "audit logs" | "deleted keys" | "deleted teams";
|
||||
|
||||
export default function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken, userRole, userId, token, premiumUser } = useAuthorized();
|
||||
const [activeTab, setActiveTab] = useState<LogsTab>("request logs");
|
||||
const canViewAuditLogs = useCan("viewAuditLogs");
|
||||
|
|
@ -23,7 +25,12 @@ export default function LogsPage() {
|
|||
|
||||
if (credentialsPending || identityPending) {
|
||||
return (
|
||||
<div role="status" aria-busy="true" aria-label="Loading" className="flex h-64 items-center justify-center">
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label={t("logs.page.loading")}
|
||||
className="flex h-64 items-center justify-center"
|
||||
>
|
||||
<UiLoadingSpinner className="size-8 text-primary" />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -33,10 +40,12 @@ export default function LogsPage() {
|
|||
<Page className="h-full">
|
||||
<PageTabs value={activeTab} onValueChange={(value: LogsTab) => setActiveTab(value)}>
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="request logs">Request Logs</PageTabsTrigger>
|
||||
{canViewAuditLogs && <PageTabsTrigger value="audit logs">Audit Logs</PageTabsTrigger>}
|
||||
<PageTabsTrigger value="deleted keys">Deleted Keys</PageTabsTrigger>
|
||||
{canViewDeletedTeams && <PageTabsTrigger value="deleted teams">Deleted Teams</PageTabsTrigger>}
|
||||
<PageTabsTrigger value="request logs">{t("logs.page.tabs.requestLogs")}</PageTabsTrigger>
|
||||
{canViewAuditLogs && <PageTabsTrigger value="audit logs">{t("logs.page.tabs.auditLogs")}</PageTabsTrigger>}
|
||||
<PageTabsTrigger value="deleted keys">{t("logs.page.tabs.deletedKeys")}</PageTabsTrigger>
|
||||
{canViewDeletedTeams && (
|
||||
<PageTabsTrigger value="deleted teams">{t("logs.page.tabs.deletedTeams")}</PageTabsTrigger>
|
||||
)}
|
||||
</PageTabsList>
|
||||
|
||||
<PageTabsContent value="request logs" keepMounted>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -18,17 +19,18 @@ export function TelemetryEnterpriseDialog({
|
|||
onOpenChange: (open: boolean) => void;
|
||||
premiumUser: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => onOpenChange(next)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{premiumUser ? "Contact your enterprise representative" : "Telemetry is an enterprise-only feature"}
|
||||
{premiumUser ? t("telemetry.enterpriseDialog.premiumTitle") : t("telemetry.enterpriseDialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{premiumUser
|
||||
? "Telemetry can't be turned on from this page. Reach out to your LiteLLM enterprise representative to turn it on for this proxy."
|
||||
: "Telemetry is only available on LiteLLM Enterprise, and this proxy isn't on an enterprise license."}
|
||||
? t("telemetry.enterpriseDialog.premiumDescription")
|
||||
: t("telemetry.enterpriseDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter showCloseButton />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { FieldLabel } from "@/components/ui/field";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
|
@ -21,21 +23,21 @@ import { TelemetryEnterpriseDialog } from "./TelemetryEnterpriseDialog";
|
|||
|
||||
const TABS = ["proxy", "ui"] as const;
|
||||
|
||||
const destinationText = (settings: TelemetrySettings): string => {
|
||||
const every = `every ${settings.flush_interval_seconds} s`;
|
||||
const destinationText = (settings: TelemetrySettings, t: TFunction): string => {
|
||||
const every = t("telemetry.destinationText.every", { seconds: settings.flush_interval_seconds });
|
||||
switch (settings.destination) {
|
||||
case "http":
|
||||
return `One JSON report per worker ${every} and on shutdown, by HTTP POST to LITELLM_TELEMETRY_ENDPOINT. A failed send is retried next window`;
|
||||
return t("telemetry.destinationText.http", { every });
|
||||
case "local_table":
|
||||
return `One report per worker ${every} and on shutdown, stored in this proxy's LiteLLM_TelemetryReport table for ${settings.retention_days} days. Nothing leaves the proxy. Admins can export from GET /telemetry/reports`;
|
||||
return t("telemetry.destinationText.localTable", { every, count: settings.retention_days });
|
||||
case null:
|
||||
return "No destination is chosen, so nothing is recorded";
|
||||
return t("telemetry.destinationText.none");
|
||||
}
|
||||
};
|
||||
|
||||
const DESTINATION_LABELS: Record<TelemetryDestination, string> = {
|
||||
local_table: "Local table (LiteLLM_TelemetryReport in this proxy's database)",
|
||||
http: "HTTP (POST to LITELLM_TELEMETRY_ENDPOINT)",
|
||||
const DESTINATION_LABELS: Record<TelemetryDestination, ParseKeys> = {
|
||||
local_table: "telemetry.destination.localTable",
|
||||
http: "telemetry.destination.http",
|
||||
};
|
||||
|
||||
type SettingsUpdate = ReturnType<typeof useUpdateTelemetrySettings>;
|
||||
|
|
@ -57,15 +59,14 @@ function DestinationPicker({
|
|||
update: SettingsUpdate;
|
||||
requestEnable: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unchangeable = !settings.editable || settings.destination_set_by_environment;
|
||||
const locked = unchangeable || update.isPending || !isOn(settings);
|
||||
const missing = settings.destination === null && enabledGroups(settings).size > 0;
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="font-medium">Destination</h3>
|
||||
{missing && (
|
||||
<p className="text-xs text-destructive">Telemetry is on but no destination is chosen, so nothing is recorded</p>
|
||||
)}
|
||||
<h3 className="font-medium">{t("telemetry.destination.title")}</h3>
|
||||
{missing && <p className="text-xs text-destructive">{t("telemetry.destination.missing")}</p>}
|
||||
<RadioGroup
|
||||
value={settings.destination ?? ""}
|
||||
disabled={locked}
|
||||
|
|
@ -80,14 +81,14 @@ function DestinationPicker({
|
|||
<RadioGroupItem
|
||||
value={destination}
|
||||
disabled={!settings.available_destinations.includes(destination)}
|
||||
aria-label={DESTINATION_LABELS[destination]}
|
||||
aria-label={t(DESTINATION_LABELS[destination])}
|
||||
/>
|
||||
{DESTINATION_LABELS[destination]}
|
||||
{t(DESTINATION_LABELS[destination])}
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{settings.destination_set_by_environment && (
|
||||
<p className="text-xs text-muted-foreground">Set by LITELLM_TELEMETRY_DESTINATION</p>
|
||||
<p className="text-xs text-muted-foreground">{t("telemetry.destination.setByEnvironment")}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
|
@ -104,6 +105,7 @@ function GroupList({
|
|||
update: SettingsUpdate;
|
||||
requestEnable: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const requires: Requires = new Map(settings.groups.map((info) => [info.group, info.requires ?? null]));
|
||||
const enabled = enabledGroups(settings);
|
||||
const depths = relativeDepths(groups, requires);
|
||||
|
|
@ -129,18 +131,27 @@ function GroupList({
|
|||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
aria-label={copy.title}
|
||||
aria-label={t(copy.title)}
|
||||
checked={enabled.has(group)}
|
||||
disabled={locked}
|
||||
onCheckedChange={(on) => onToggle(group, on)}
|
||||
/>
|
||||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||
<div className="text-sm font-medium text-foreground">{copy.title}</div>
|
||||
{copy.slices !== undefined && <p>Slices by: {copy.slices}</p>}
|
||||
<div className="text-sm font-medium text-foreground">{t(copy.title)}</div>
|
||||
{copy.slices !== undefined && (
|
||||
<p>
|
||||
{t("telemetry.groupList.slicesBy")}
|
||||
{t(copy.slices)}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{copy.slices !== undefined ? "Counts" : "Sends"}: {copy.counts}
|
||||
{copy.slices !== undefined ? t("telemetry.groupList.counts") : t("telemetry.groupList.sends")}:{" "}
|
||||
{t(copy.counts)}
|
||||
</p>
|
||||
<p>
|
||||
{t("telemetry.groupList.helpsCatch")}
|
||||
{t(copy.helps)}
|
||||
</p>
|
||||
<p>Helps catch: {copy.helps}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
@ -159,32 +170,32 @@ function TelemetrySwitch({
|
|||
update: SettingsUpdate;
|
||||
requestEnable: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const on = isOn(settings);
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Switch
|
||||
aria-label="Telemetry"
|
||||
aria-label={t("telemetry.switch.label")}
|
||||
checked={on}
|
||||
disabled={!settings.editable || update.isPending}
|
||||
onCheckedChange={(next) =>
|
||||
next ? requestEnable() : update.mutate({ groups: [], destination: savedDestination(settings) })
|
||||
}
|
||||
/>
|
||||
<span aria-hidden>{on ? "On" : "Off"}</span>
|
||||
<span aria-hidden>{on ? t("telemetry.switch.on") : t("telemetry.switch.off")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportPreview({ settings }: { settings: TelemetrySettings }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="space-y-1">
|
||||
<h3 className="font-medium">
|
||||
{settings.report_is_sample ? "Sample report" : "Last report sent from this worker"}
|
||||
{settings.report_is_sample ? t("telemetry.report.sampleTitle") : t("telemetry.report.lastTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings.report_is_sample
|
||||
? "Made-up traffic through the real filter, with the groups that are on (all groups while telemetry is off)"
|
||||
: "Exactly what this worker last sent or stored"}
|
||||
{settings.report_is_sample ? t("telemetry.report.sampleDescription") : t("telemetry.report.lastDescription")}
|
||||
</p>
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-muted p-3 text-xs">
|
||||
{JSON.stringify(settings.report, null, 2)}
|
||||
|
|
@ -194,31 +205,35 @@ function ReportPreview({ settings }: { settings: TelemetrySettings }) {
|
|||
}
|
||||
|
||||
export default function TelemetrySettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useUrlTab(TABS, "proxy");
|
||||
const { data: settings, isLoading, error } = useTelemetrySettings();
|
||||
const update = useUpdateTelemetrySettings();
|
||||
const { premiumUser } = useAuthorized();
|
||||
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
|
||||
const requestEnable = () => setEnableDialogOpen(true);
|
||||
if (isLoading) return <Page>Loading telemetry settings...</Page>;
|
||||
if (isLoading) return <Page>{t("telemetry.page.loading")}</Page>;
|
||||
if (settings === undefined)
|
||||
return <Page className="text-destructive">Could not load telemetry settings: {error?.message}</Page>;
|
||||
return (
|
||||
<Page className="text-destructive">
|
||||
{t("telemetry.page.loadFailed")}
|
||||
{error?.message}
|
||||
</Page>
|
||||
);
|
||||
return (
|
||||
<Page className="text-sm">
|
||||
<PageTabs value={tab} onValueChange={(value) => setTab(value as (typeof TABS)[number])}>
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>
|
||||
<Activity />
|
||||
Telemetry
|
||||
{t("telemetry.page.title")}
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
Choose what privacy-safe, aggregated usage data this proxy shares
|
||||
</PageHeaderDescription>
|
||||
<PageHeaderDescription>{t("telemetry.page.description")}</PageHeaderDescription>
|
||||
<PageHeaderControls>
|
||||
<TelemetrySwitch settings={settings} update={update} requestEnable={requestEnable} />
|
||||
<PageTabsList>
|
||||
<PageTabsTrigger value="proxy">Proxy requests</PageTabsTrigger>
|
||||
<PageTabsTrigger value="ui">Admin UI</PageTabsTrigger>
|
||||
<PageTabsTrigger value="proxy">{t("telemetry.page.tabs.proxy")}</PageTabsTrigger>
|
||||
<PageTabsTrigger value="ui">{t("telemetry.page.tabs.ui")}</PageTabsTrigger>
|
||||
</PageTabsList>
|
||||
</PageHeaderControls>
|
||||
</PageHeader>
|
||||
|
|
@ -227,20 +242,16 @@ export default function TelemetrySettingsPage() {
|
|||
</PageTabsContent>
|
||||
<PageTabsContent value="ui" className="gap-4">
|
||||
<GroupList groups={UI_GROUPS} settings={settings} update={update} requestEnable={requestEnable} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sent only while Page navigation is on, to this proxy at POST /telemetry/ui_events, and counted into the same
|
||||
report as proxy traffic
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("telemetry.page.uiNote")}</p>
|
||||
</PageTabsContent>
|
||||
</PageTabs>
|
||||
<DestinationPicker settings={settings} update={update} requestEnable={requestEnable} />
|
||||
<section className="space-y-1">
|
||||
<h3 className="font-medium">How and when it is sent</h3>
|
||||
<p className="text-xs text-muted-foreground">{destinationText(settings)}. Changes apply from the next window</p>
|
||||
<h3 className="font-medium">{t("telemetry.howSent.title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Off by default. Each switch adds aggregated counts, never prompts, responses, keys, ids or header values.
|
||||
LITELLM_TELEMETRY_DISABLED=true turns everything off
|
||||
{destinationText(settings, t)}. {t("telemetry.howSent.changesApply")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("telemetry.howSent.offByDefault")}</p>
|
||||
</section>
|
||||
<ReportPreview settings={settings} />
|
||||
<TelemetryEnterpriseDialog
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import type { TelemetryGroup } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
|
||||
|
||||
export interface GroupCopy {
|
||||
readonly title: string;
|
||||
readonly slices?: string;
|
||||
readonly counts: string;
|
||||
readonly helps: string;
|
||||
readonly title: ParseKeys;
|
||||
readonly slices?: ParseKeys;
|
||||
readonly counts: ParseKeys;
|
||||
readonly helps: ParseKeys;
|
||||
}
|
||||
|
||||
export const PROXY_GROUPS: readonly TelemetryGroup[] = [
|
||||
|
|
@ -20,42 +21,42 @@ export const UI_GROUPS: readonly TelemetryGroup[] = ["page_navigation"];
|
|||
|
||||
export const GROUP_COPY: Readonly<Record<TelemetryGroup, GroupCopy>> = {
|
||||
heartbeat: {
|
||||
title: "Heartbeat",
|
||||
counts: "Random instance id, LiteLLM version, report window, which groups are on",
|
||||
helps: "Which versions are running",
|
||||
title: "telemetry.groups.heartbeat.title",
|
||||
counts: "telemetry.groups.heartbeat.counts",
|
||||
helps: "telemetry.groups.heartbeat.helps",
|
||||
},
|
||||
request_success: {
|
||||
title: "Request success",
|
||||
slices: "endpoint, LiteLLM status, provider status, stream, handled by Rust, LiteLLM cache hit",
|
||||
counts: "requests, provider attempts, latency to headers and to first byte",
|
||||
helps: "Error-rate and latency regressions, Rust vs Python",
|
||||
title: "telemetry.groups.requestSuccess.title",
|
||||
slices: "telemetry.groups.requestSuccess.slices",
|
||||
counts: "telemetry.groups.requestSuccess.counts",
|
||||
helps: "telemetry.groups.requestSuccess.helps",
|
||||
},
|
||||
token_info: {
|
||||
title: "Token info",
|
||||
slices: "provider prompt-cache hit",
|
||||
counts: "input, output and cache-read token sums",
|
||||
helps: "Broken prompt caching or token counting",
|
||||
title: "telemetry.groups.tokenInfo.title",
|
||||
slices: "telemetry.groups.tokenInfo.slices",
|
||||
counts: "telemetry.groups.tokenInfo.counts",
|
||||
helps: "telemetry.groups.tokenInfo.helps",
|
||||
},
|
||||
request_taxonomy: {
|
||||
title: "Request taxonomy",
|
||||
slices: "provider, salted deployment hash",
|
||||
counts: "one row per provider attempt with its status",
|
||||
helps: "Which provider is failing, retries and fallbacks",
|
||||
title: "telemetry.groups.requestTaxonomy.title",
|
||||
slices: "telemetry.groups.requestTaxonomy.slices",
|
||||
counts: "telemetry.groups.requestTaxonomy.counts",
|
||||
helps: "telemetry.groups.requestTaxonomy.helps",
|
||||
},
|
||||
event_details: {
|
||||
title: "Event details",
|
||||
counts: "message block counts and types, allowlisted header names (no values), provider time to first token",
|
||||
helps: "Which request shapes and clients fail",
|
||||
title: "telemetry.groups.eventDetails.title",
|
||||
counts: "telemetry.groups.eventDetails.counts",
|
||||
helps: "telemetry.groups.eventDetails.helps",
|
||||
},
|
||||
instance_configuration: {
|
||||
title: "Instance configuration",
|
||||
counts: "names of allowlisted config keys that are set (no values)",
|
||||
helps: "Which features an upgrade must keep working",
|
||||
title: "telemetry.groups.instanceConfiguration.title",
|
||||
counts: "telemetry.groups.instanceConfiguration.counts",
|
||||
helps: "telemetry.groups.instanceConfiguration.helps",
|
||||
},
|
||||
page_navigation: {
|
||||
title: "Page navigation",
|
||||
counts: "Admin UI page views and tab switches by route name (no ids or typed text)",
|
||||
helps: "Which pages and tabs get used",
|
||||
title: "telemetry.groups.pageNavigation.title",
|
||||
counts: "telemetry.groups.pageNavigation.counts",
|
||||
helps: "telemetry.groups.pageNavigation.helps",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, Coins } from "lucide-react";
|
||||
import { useTagSummary } from "@/app/(dashboard)/hooks/tags/useTagSummary";
|
||||
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||
|
|
@ -12,6 +14,7 @@ import { stackedUsageColor } from "@/components/shared/charts";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA, toDailyData } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import claudeLogo from "../../../../../../public/assets/logos/claude.svg";
|
||||
|
|
@ -53,7 +56,8 @@ interface ClientUsagePanelProps {
|
|||
}
|
||||
|
||||
const dateLabel = (date: Date | null) => date?.toLocaleDateString("en-US", { month: "short", day: "numeric" }) ?? "—";
|
||||
const countLabel = (value: number | null) => (value === null ? "Unavailable" : value.toLocaleString());
|
||||
const countLabel = (value: number | null, t: TFunction) =>
|
||||
value === null ? t("usage.clientPanel.tokenBreakdown.unavailable") : value.toLocaleString();
|
||||
|
||||
export default function ClientUsagePanel({
|
||||
client,
|
||||
|
|
@ -63,7 +67,7 @@ export default function ClientUsagePanel({
|
|||
scopeError: accessError = null,
|
||||
teams = [],
|
||||
members = [],
|
||||
defaultScopeLabel = "All users",
|
||||
defaultScopeLabel = i18n.t("usage.clientPanel.scope.allUsers"),
|
||||
onTeamChange,
|
||||
onUserChange,
|
||||
onClose,
|
||||
|
|
@ -80,6 +84,7 @@ export default function ClientUsagePanel({
|
|||
summaryFailed = false,
|
||||
onRetry,
|
||||
}: ClientUsagePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = client === "claude-desktop" ? "Claude Desktop" : "Claude Code";
|
||||
const scopedRequest = Boolean(userId || teamId) && !accessError;
|
||||
const personRequest = {
|
||||
|
|
@ -93,11 +98,12 @@ export default function ClientUsagePanel({
|
|||
const personUsage = useClientUsage(personRequest);
|
||||
const allUsersEnabled = canChooseUser && !scopedRequest && !accessError;
|
||||
const teamLabel = teams.find((team) => team.value === teamId)?.label ?? teamId;
|
||||
const personLabel = members.find((member) => member.value === userId)?.label ?? "Selected user";
|
||||
const personLabel =
|
||||
members.find((member) => member.value === userId)?.label ?? t("usage.clientPanel.scope.selectedUser");
|
||||
const selectedScopeLabel = (() => {
|
||||
if (teamId) return `${teamLabel} · ${userId ? personLabel : "All members"}`;
|
||||
if (!canChooseUser) return "My usage";
|
||||
return userId ? "Selected user" : "All users";
|
||||
if (teamId) return `${teamLabel} · ${userId ? personLabel : t("usage.clientPanel.scope.allMembers")}`;
|
||||
if (!canChooseUser) return t("usage.clientPanel.scope.myUsage");
|
||||
return userId ? t("usage.clientPanel.scope.selectedUser") : t("usage.clientPanel.scope.allUsers");
|
||||
})();
|
||||
const summaryQuery = useTagSummary(startTime, endTime, allUsersEnabled && agent === null);
|
||||
const activeAgent = useMemo(
|
||||
|
|
@ -115,9 +121,9 @@ export default function ClientUsagePanel({
|
|||
const personTotals = overviewTotals(personUsage.data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, null);
|
||||
const pending =
|
||||
!accessError && (scopedRequest ? personUsage.loading : (loading || summaryQuery.isLoading) && activeAgent === null);
|
||||
const summaryError = summaryQuery.isError || summaryFailed ? "Could not load client totals" : null;
|
||||
const summaryError = summaryQuery.isError || summaryFailed ? t("usage.clientPanel.errors.totalsFailed") : null;
|
||||
const scopeError =
|
||||
accessError ?? (!canChooseUser && !scopedRequest ? "Sign in to view your client usage" : summaryError);
|
||||
accessError ?? (!canChooseUser && !scopedRequest ? t("usage.clientPanel.errors.signIn") : summaryError);
|
||||
const error = accessError ?? (scopedRequest ? personUsage.error : scopeError);
|
||||
const summary = scopedRequest ? personTotals : agentDetail?.summary;
|
||||
const rawBreakdown = scopedRequest
|
||||
|
|
@ -152,7 +158,7 @@ export default function ClientUsagePanel({
|
|||
<SheetContent className="gap-0 data-[side=right]:w-[calc(100%-16px)] data-[side=right]:sm:max-w-[600px]">
|
||||
<SheetHeader className="shrink-0 border-b px-6 pt-5 pb-4">
|
||||
<Button variant="ghost" size="sm" className="mb-3 w-fit -ml-2 text-muted-foreground" onClick={onClose}>
|
||||
<ArrowLeft className="size-4" /> Back to Usage
|
||||
<ArrowLeft className="size-4" /> {t("usage.clientPanel.back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<AgentMark agent={{ id: client, label, logo: claudeLogo.src }} />
|
||||
|
|
@ -176,11 +182,11 @@ export default function ClientUsagePanel({
|
|||
{teams.length > 0 && onTeamChange && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="client-usage-team" className="text-xs font-medium text-muted-foreground">
|
||||
Usage scope
|
||||
{t("usage.clientPanel.scope.label")}
|
||||
</label>
|
||||
<SearchSelect
|
||||
id="client-usage-team"
|
||||
aria-label="Usage scope"
|
||||
aria-label={t("usage.clientPanel.scope.ariaLabel")}
|
||||
value={teamId ? `team:${teamId}` : "default"}
|
||||
options={[
|
||||
{ value: "default", label: defaultScopeLabel },
|
||||
|
|
@ -194,14 +200,14 @@ export default function ClientUsagePanel({
|
|||
{teamId && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="client-usage-member" className="text-xs font-medium text-muted-foreground">
|
||||
Team member
|
||||
{t("usage.clientPanel.member.label")}
|
||||
</label>
|
||||
<SearchSelect
|
||||
id="client-usage-member"
|
||||
aria-label="Team member"
|
||||
aria-label={t("usage.clientPanel.member.ariaLabel")}
|
||||
value={userId ? `user:${userId}` : "all"}
|
||||
options={[
|
||||
{ value: "all", label: "All members" },
|
||||
{ value: "all", label: t("usage.clientPanel.member.allMembers") },
|
||||
...members.map((member) => ({ ...member, value: `user:${member.value}` })),
|
||||
]}
|
||||
allowClear={false}
|
||||
|
|
@ -213,11 +219,11 @@ export default function ClientUsagePanel({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label htmlFor="client-usage-user" className="text-xs font-medium text-muted-foreground">
|
||||
Filter by person
|
||||
{t("usage.clientPanel.person.label")}
|
||||
</label>
|
||||
{userId && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onUserChange(null)}>
|
||||
All users
|
||||
{t("usage.clientPanel.person.allUsers")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -225,13 +231,13 @@ export default function ClientUsagePanel({
|
|||
</div>
|
||||
)}
|
||||
{pending && (
|
||||
<div role="status" aria-label="Loading client usage">
|
||||
<div role="status" aria-label={t("usage.clientPanel.loading")}>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" className="rounded-lg border p-4">
|
||||
<p className="font-medium">Could not load client usage</p>
|
||||
<p className="font-medium">{t("usage.clientPanel.errors.loadFailed")}</p>
|
||||
<p className="mt-1 text-muted-foreground">{error}</p>
|
||||
{canRetry && (
|
||||
<Button
|
||||
|
|
@ -246,25 +252,30 @@ export default function ClientUsagePanel({
|
|||
}
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
{t("usage.clientPanel.tryAgain")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ready && !summary && (
|
||||
<p className="rounded-lg border p-4 text-muted-foreground">No {label} usage recorded in this date range.</p>
|
||||
<p className="rounded-lg border p-4 text-muted-foreground">
|
||||
{t("usage.clientPanel.noUsage", { client: label })}
|
||||
</p>
|
||||
)}
|
||||
{ready && summary && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 divide-x overflow-hidden rounded-xl border bg-card py-4">
|
||||
<SummaryStat label="Spend" value={formatUsd(summary.spend)} />
|
||||
<SummaryStat label="Total tokens" value={formatCompact(summary.tokens)} />
|
||||
<SummaryStat label="Requests" value={summary.requests.toLocaleString()} />
|
||||
<SummaryStat label={t("usage.clientPanel.summary.spend")} value={formatUsd(summary.spend)} />
|
||||
<SummaryStat label={t("usage.clientPanel.summary.totalTokens")} value={formatCompact(summary.tokens)} />
|
||||
<SummaryStat
|
||||
label={t("usage.clientPanel.summary.requests")}
|
||||
value={summary.requests.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{summary.requests === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No {label} requests in the selected range. Try another date range.
|
||||
{(canChooseUser || teamId) && " You can also choose another person."}
|
||||
{t("usage.clientPanel.noRequests", { client: label })}
|
||||
{(canChooseUser || teamId) && t("usage.clientPanel.chooseAnotherPerson")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -283,7 +294,7 @@ export default function ClientUsagePanel({
|
|||
{chartError}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={retryModels}>
|
||||
Retry model breakdown
|
||||
{t("usage.clientPanel.retryModels")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -291,10 +302,8 @@ export default function ClientUsagePanel({
|
|||
</>
|
||||
)}
|
||||
<p className="border-t pt-3 text-xs leading-5 text-muted-foreground">
|
||||
{scopedRequest
|
||||
? "Based on retained request logs within the exact selected time range."
|
||||
: "All-user totals use UTC daily aggregates for the selected dates. Filtering by person uses the exact selected times. Cache values are reported counts."}{" "}
|
||||
Spend is the cost recorded by LiteLLM.
|
||||
{scopedRequest ? t("usage.clientPanel.footnote.scoped") : t("usage.clientPanel.footnote.allUsers")}{" "}
|
||||
{t("usage.clientPanel.footnote.spend")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -313,20 +322,26 @@ function SummaryStat({ label, value }: { label: string; value: string }) {
|
|||
}
|
||||
|
||||
function TokenExplanation({ total, breakdown }: { total: number; breakdown: ClientAgentDetail["tokenBreakdown"] }) {
|
||||
const { t } = useTranslation();
|
||||
const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } = breakdown;
|
||||
const freshInput =
|
||||
inputTokens !== null && cacheReadTokens !== null && cacheWriteTokens !== null
|
||||
? inputTokens - cacheReadTokens - cacheWriteTokens
|
||||
: null;
|
||||
const rows = [
|
||||
{ label: "Cache reads", value: cacheReadTokens, color: stackedUsageColor(0) },
|
||||
{ label: "Cache writes", value: cacheWriteTokens, color: stackedUsageColor(1) },
|
||||
{ label: "Uncached input", value: freshInput, color: stackedUsageColor(2) },
|
||||
{ label: "Output", value: outputTokens, color: stackedUsageColor(3) },
|
||||
{ label: t("usage.clientPanel.tokenBreakdown.cacheReads"), value: cacheReadTokens, color: stackedUsageColor(0) },
|
||||
{ label: t("usage.clientPanel.tokenBreakdown.cacheWrites"), value: cacheWriteTokens, color: stackedUsageColor(1) },
|
||||
{ label: t("usage.clientPanel.tokenBreakdown.uncachedInput"), value: freshInput, color: stackedUsageColor(2) },
|
||||
{ label: t("usage.clientPanel.tokenBreakdown.output"), value: outputTokens, color: stackedUsageColor(3) },
|
||||
];
|
||||
const complete = rows.every((row) => row.value !== null);
|
||||
return (
|
||||
<Panel icon={Coins} title="Token breakdown" subtitle={`${formatCompact(total)} total tokens`} bodyClassName="gap-3">
|
||||
<Panel
|
||||
icon={Coins}
|
||||
title={t("usage.clientPanel.tokenBreakdown.title")}
|
||||
subtitle={t("usage.clientPanel.tokenBreakdown.subtitle", { total: formatCompact(total) })}
|
||||
bodyClassName="gap-3"
|
||||
>
|
||||
{complete && total > 0 && (
|
||||
<div aria-hidden="true" className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||
{rows.map((row) => (
|
||||
|
|
@ -339,7 +354,7 @@ function TokenExplanation({ total, breakdown }: { total: number; breakdown: Clie
|
|||
)}
|
||||
{cacheReadTokens !== null && total > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{((cacheReadTokens / total) * 100).toFixed(1)}% of these tokens are cache reads.
|
||||
{t("usage.clientPanel.tokenBreakdown.cacheShare", { percent: ((cacheReadTokens / total) * 100).toFixed(1) })}
|
||||
</p>
|
||||
)}
|
||||
<dl className="divide-y rounded-lg border px-3 text-xs">
|
||||
|
|
@ -349,14 +364,11 @@ function TokenExplanation({ total, breakdown }: { total: number; breakdown: Clie
|
|||
<span className="size-2 rounded-[2px]" style={{ backgroundColor: row.color }} />
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className="font-medium tabular-nums">{countLabel(row.value)}</dd>
|
||||
<dd className="font-medium tabular-nums">{countLabel(row.value, t)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Total tokens include input, cached input and output. Cached input is priced differently, so token count alone
|
||||
does not explain spend. Compare the same person and dates when checking the count shown by a client.
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t("usage.clientPanel.tokenBreakdown.explanation")}</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { parseUsageClient, UsageClient } from "./overview/agentCatalog";
|
||||
|
||||
|
|
@ -13,10 +14,11 @@ export default function ClientUsageSelect({
|
|||
value: UsageClient | null;
|
||||
onChange: (client: UsageClient | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SearchSelect
|
||||
aria-label="View client usage"
|
||||
placeholder="View client usage"
|
||||
aria-label={t("usage.clientSelect.label")}
|
||||
placeholder={t("usage.clientSelect.placeholder")}
|
||||
value={value}
|
||||
options={CLIENT_OPTIONS}
|
||||
onValueChange={(client) => onChange(parseUsageClient(client))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartColumnStacked } from "lucide-react";
|
||||
import { BarChart, CustomTooltip } from "@/components/shared/charts";
|
||||
import { MetricWithMetadata } from "@/components/UsagePage/types";
|
||||
|
|
@ -16,6 +17,7 @@ const COLORS = [SUCCESS_COLOR, FAILED_COLOR] as const;
|
|||
const valueFormatter = (value: number) => value.toLocaleString();
|
||||
|
||||
const EndpointUsageBarChart: React.FC<EndpointUsageBarChartProps> = ({ endpointData }) => {
|
||||
const { t } = useTranslation();
|
||||
// Transform endpoint data into chart format
|
||||
const chartData = React.useMemo(() => {
|
||||
return Object.entries(endpointData || {}).map(([endpoint, data]) => ({
|
||||
|
|
@ -37,7 +39,7 @@ const EndpointUsageBarChart: React.FC<EndpointUsageBarChartProps> = ({ endpointD
|
|||
return (
|
||||
<Panel
|
||||
icon={ChartColumnStacked}
|
||||
title="Success vs Failed Requests by Endpoint"
|
||||
title={t("usage.endpointUsage.barChart.title")}
|
||||
action={
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||
{CATEGORIES.map((category, i) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartLine } from "lucide-react";
|
||||
import { LineChart, stackedUsageColor } from "@/components/shared/charts";
|
||||
import { DailyData } from "@/components/UsagePage/types";
|
||||
|
|
@ -44,6 +45,7 @@ function transformDailyDataToChart(dailyData: DailyData[]): Array<Record<string,
|
|||
}
|
||||
|
||||
export function EndpointUsageLineChart({ dailyData }: EndpointUsageLineChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const chartData = useMemo(() => {
|
||||
if (!dailyData?.results || dailyData.results.length === 0) {
|
||||
return [];
|
||||
|
|
@ -62,7 +64,7 @@ export function EndpointUsageLineChart({ dailyData }: EndpointUsageLineChartProp
|
|||
const colors = useMemo(() => categories.map((_, i) => stackedUsageColor(i)), [categories]);
|
||||
|
||||
return (
|
||||
<Panel icon={ChartLine} title="Endpoint Usage Trends">
|
||||
<Panel icon={ChartLine} title={t("usage.endpointUsage.lineChart.title")}>
|
||||
<LineChart
|
||||
className="h-64"
|
||||
data={chartData}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Route } from "lucide-react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
|
@ -24,6 +25,7 @@ interface EndpointRow {
|
|||
}
|
||||
|
||||
const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData }) => {
|
||||
const { t } = useTranslation();
|
||||
const calculateSuccessRate = (successful: number, total: number): number => {
|
||||
if (total === 0) return 0;
|
||||
return (successful / total) * 100;
|
||||
|
|
@ -42,12 +44,12 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
|
||||
const columns: ColumnDef<EndpointRow>[] = [
|
||||
{
|
||||
header: "Endpoint",
|
||||
header: t("usage.endpointUsage.table.columns.endpoint"),
|
||||
accessorKey: "endpoint",
|
||||
cell: ({ row }) => <span className="font-mono text-xs text-foreground">{row.original.endpoint}</span>,
|
||||
},
|
||||
{
|
||||
header: "Successful / Failed",
|
||||
header: t("usage.endpointUsage.table.columns.successfulFailed"),
|
||||
id: "requests",
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
|
|
@ -59,7 +61,11 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative min-w-16 flex-1">
|
||||
<Meter value={successPercentage} max={totalPercentage || 100} aria-label="Successful requests">
|
||||
<Meter
|
||||
value={successPercentage}
|
||||
max={totalPercentage || 100}
|
||||
aria-label={t("usage.endpointUsage.table.successfulRequests")}
|
||||
>
|
||||
<MeterTrack className={failurePercentage > 0 ? "h-1 bg-destructive/70" : "h-1"}>
|
||||
<MeterIndicator className="bg-[#2b3fd6]" />
|
||||
</MeterTrack>
|
||||
|
|
@ -77,13 +83,13 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
},
|
||||
},
|
||||
{
|
||||
header: "Total Request",
|
||||
header: t("usage.endpointUsage.table.columns.totalRequest"),
|
||||
accessorKey: "api_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <span className="tabular-nums">{row.original.api_requests.toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
header: "Success Rate",
|
||||
header: t("usage.endpointUsage.table.columns.successRate"),
|
||||
accessorKey: "successRate",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -102,13 +108,13 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
},
|
||||
},
|
||||
{
|
||||
header: "Total Tokens",
|
||||
header: t("usage.endpointUsage.table.columns.totalTokens"),
|
||||
accessorKey: "total_tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <span className="tabular-nums">{row.original.total_tokens.toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
header: t("usage.endpointUsage.table.columns.spend"),
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
|
|
@ -118,10 +124,13 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
return (
|
||||
<Panel
|
||||
icon={Route}
|
||||
title="Endpoints"
|
||||
title={t("usage.endpointUsage.table.title")}
|
||||
action={
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{dataSource.length.toLocaleString()} {dataSource.length === 1 ? "endpoint" : "endpoints"}
|
||||
{dataSource.length.toLocaleString()}{" "}
|
||||
{dataSource.length === 1
|
||||
? t("usage.endpointUsage.table.endpointSingular")
|
||||
: t("usage.endpointUsage.table.endpointPlural")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
|
@ -129,7 +138,7 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
|
|||
columns={columns}
|
||||
data={dataSource}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No endpoint usage data"
|
||||
noDataMessage={t("usage.endpointUsage.table.noData")}
|
||||
size="compact"
|
||||
/>
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import React, { type ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import CustomerMultiSelect from "@/components/common_components/CustomerMultiSelect";
|
||||
|
|
@ -57,9 +59,13 @@ import TeamUserSpendCard from "./TeamUserSpendCard";
|
|||
import { ProviderSpendBreakdown } from "./SpendByProvider";
|
||||
|
||||
const BRAND = "#2b3fd6";
|
||||
const FLAT_COST_SERIES = "Flat cost";
|
||||
const FLAT_COST_SERIES: ParseKeys = "usage.entityUsage.chart.flatCostSeries";
|
||||
const FLAT_COST_COLOR = "#8b5cf6";
|
||||
const METRIC_TITLE: Record<UsageMetric, string> = { spend: "Spend", tokens: "Tokens", requests: "Requests" };
|
||||
const METRIC_TITLE: Record<UsageMetric, ParseKeys> = {
|
||||
spend: "usage.entityUsage.chart.metricTitle.spend",
|
||||
tokens: "usage.entityUsage.chart.metricTitle.tokens",
|
||||
requests: "usage.entityUsage.chart.metricTitle.requests",
|
||||
};
|
||||
/** Summary tiles that carry a sparkline, keyed by tile title, valued by the dailyTotals field. */
|
||||
const TILE_TRENDS: Readonly<Record<string, string>> = {
|
||||
"Total Spend": "spend",
|
||||
|
|
@ -79,12 +85,12 @@ const QUIET_HEADER = { headerClassName: "font-normal" };
|
|||
const FLAT_COST_KEY = "flat_cost";
|
||||
|
||||
/** Stacks reserved-capacity flat cost on top of the per-model spend, so each bar is the day's full cost. */
|
||||
const withFlatCost = (series: Series, results: readonly DailyData[]): Series => {
|
||||
const withFlatCost = (series: Series, results: readonly DailyData[], t: TFunction): Series => {
|
||||
const flatByDate = new Map(results.map((day) => [day.date, day.metrics.flat_cost ?? 0]));
|
||||
return {
|
||||
data: series.data.map((day) => ({ ...day, [FLAT_COST_KEY]: flatByDate.get(day.date) ?? 0 })),
|
||||
keys: [...series.keys, FLAT_COST_KEY],
|
||||
labels: [...series.labels, FLAT_COST_SERIES],
|
||||
labels: [...series.labels, t(FLAT_COST_SERIES)],
|
||||
colors: [...series.colors, FLAT_COST_COLOR],
|
||||
};
|
||||
};
|
||||
|
|
@ -144,6 +150,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
selectedEntities: selectedTags,
|
||||
onSelectedEntitiesChange: setSelectedTags,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { teams } = useTeams();
|
||||
const teamList = useMemo(() => teams ?? [], [teams]);
|
||||
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
|
||||
|
|
@ -341,11 +348,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
);
|
||||
|
||||
const getFilterLabel = (entityType: string) => {
|
||||
return `Filter by ${entityType}`;
|
||||
return t("usage.entityUsage.filterLabel", { entity: entityType });
|
||||
};
|
||||
|
||||
const getFilterPlaceholder = (entityType: string) => {
|
||||
return `Select ${entityType} to filter...`;
|
||||
return t("usage.entityUsage.filterPlaceholder", { entity: entityType });
|
||||
};
|
||||
|
||||
const entityFilterSlots: Partial<Record<EntityType, ReactNode>> = {
|
||||
|
|
@ -374,9 +381,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
const dailyTrend = useMemo(() => dailyTotals(spendData.results), [spendData.results]);
|
||||
const stackFlatCost = showFlatCost && breakdown.metric === "spend";
|
||||
const chartSeries = useMemo(() => {
|
||||
const series = stackFlatCost ? withFlatCost(dailySeries, spendData.results) : dailySeries;
|
||||
const series = stackFlatCost ? withFlatCost(dailySeries, spendData.results, t) : dailySeries;
|
||||
return bucketSeries(series, granularity);
|
||||
}, [stackFlatCost, dailySeries, spendData.results, granularity]);
|
||||
}, [stackFlatCost, dailySeries, spendData.results, granularity, t]);
|
||||
const chartTotals = useMemo(() => bucketTotals(chartSeries), [chartSeries]);
|
||||
|
||||
const entityBreakdownColumns = useMemo<ColumnDef<EntityMetricWithMetadata>[]>(
|
||||
|
|
@ -388,37 +395,37 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.metadata.alias}</span>,
|
||||
},
|
||||
{
|
||||
header: "Share",
|
||||
header: t("usage.entityUsage.breakdownTable.share"),
|
||||
id: "share",
|
||||
meta: { ...QUIET_HEADER, className: "w-40" },
|
||||
cell: ({ row }) => <ShareBar value={row.original.metrics.spend} max={maxEntitySpend} />,
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
header: t("usage.entityUsage.breakdownTable.spend"),
|
||||
accessorKey: "metrics.spend",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.metrics.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
header: t("usage.entityUsage.breakdownTable.successful"),
|
||||
accessorKey: "metrics.successful_requests",
|
||||
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.metrics.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
header: t("usage.entityUsage.breakdownTable.failed"),
|
||||
accessorKey: "metrics.failed_requests",
|
||||
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.metrics.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
header: t("usage.entityUsage.breakdownTable.tokens"),
|
||||
accessorKey: "metrics.total_tokens",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.metrics.total_tokens.toLocaleString(),
|
||||
},
|
||||
],
|
||||
[capitalizedEntityLabel, maxEntitySpend],
|
||||
[capitalizedEntityLabel, maxEntitySpend, t],
|
||||
);
|
||||
|
||||
const chev = "size-3 text-muted-foreground";
|
||||
|
|
@ -471,10 +478,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : [];
|
||||
const summaryTiles = buildSummaryTiles(spendData.metadata, showFlatCost, showPtuHours);
|
||||
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata, t) : [];
|
||||
const summaryTiles = buildSummaryTiles(spendData.metadata, showFlatCost, showPtuHours, t);
|
||||
|
||||
const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models";
|
||||
const modelViewTitle =
|
||||
modelViewType === "groups" ? t("usage.entityUsage.topPublicModelNames") : t("usage.entityUsage.topLitellmModels");
|
||||
const chartFormat = (value: number) => formatMetricValue(value, breakdown.metric);
|
||||
|
||||
const costPanel = loading ? (
|
||||
|
|
@ -482,7 +490,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
) : (
|
||||
<div className="grid gap-3">
|
||||
<section className="grid gap-2">
|
||||
<h2 className="text-sm font-medium text-foreground">{capitalizedEntityLabel} Spend Overview</h2>
|
||||
<h2 className="text-sm font-medium text-foreground">
|
||||
{t("usage.entityUsage.spendOverview", { entity: capitalizedEntityLabel })}
|
||||
</h2>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-px overflow-hidden rounded-xl border bg-border",
|
||||
|
|
@ -506,22 +516,39 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
<header className="flex flex-wrap items-start justify-between gap-3 px-5 pt-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{METRIC_TITLE[breakdown.metric]} by {modelViewType === "groups" ? "model" : "deployment"}
|
||||
{t("usage.entityUsage.chart.title", {
|
||||
metric: t(METRIC_TITLE[breakdown.metric]),
|
||||
dimension:
|
||||
modelViewType === "groups"
|
||||
? t("usage.entityUsage.chart.dimension.model")
|
||||
: t("usage.entityUsage.chart.dimension.deployment"),
|
||||
})}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{granularity === "day" ? "Daily" : "Weekly"} {breakdown.metric}, top 8 stacked
|
||||
{stackFlatCost ? ", flat cost on top" : ""}
|
||||
{t("usage.entityUsage.chart.subtitle", {
|
||||
granularity:
|
||||
granularity === "day"
|
||||
? t("usage.entityUsage.chart.granularity.daily")
|
||||
: t("usage.entityUsage.chart.granularity.weekly"),
|
||||
metric: breakdown.metric,
|
||||
})}
|
||||
{stackFlatCost ? t("usage.entityUsage.chart.flatCostOnTop") : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BreakdownControls state={breakdown} onChange={setBreakdown} showDimension={false} />
|
||||
<Segmented
|
||||
label="Bucket size"
|
||||
label={t("usage.entityUsage.chart.bucketSize")}
|
||||
value={granularity}
|
||||
options={GRANULARITY_OPTIONS}
|
||||
onChange={setGranularity}
|
||||
/>
|
||||
<Segmented label="Scale" value={scale} options={SCALE_OPTIONS} onChange={setScale} />
|
||||
<Segmented
|
||||
label={t("usage.entityUsage.chart.scaleLabel")}
|
||||
value={scale}
|
||||
options={SCALE_OPTIONS}
|
||||
onChange={setScale}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="px-3 pt-4 pb-2">
|
||||
|
|
@ -552,13 +579,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
|
||||
<Panel
|
||||
icon={Layers}
|
||||
title={`Spend Per ${capitalizedEntityLabel}`}
|
||||
title={t("usage.entityUsage.spendPerEntity", { entity: capitalizedEntityLabel })}
|
||||
action={
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/cost_tracking"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Track cost per {entityType}
|
||||
{t("usage.entityUsage.trackCostPer", { entity: entityType })}
|
||||
<ExternalLink aria-hidden="true" className="size-3" />
|
||||
</a>
|
||||
}
|
||||
|
|
@ -568,7 +595,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
data={entityRows}
|
||||
getRowId={(row) => row.metadata.id}
|
||||
maxBodyHeight={280}
|
||||
noDataMessage={`No ${entityType} spend data`}
|
||||
noDataMessage={t("usage.entityUsage.noSpendData", { entity: entityType })}
|
||||
size="compact"
|
||||
/>
|
||||
</Panel>
|
||||
|
|
@ -583,7 +610,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
)}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<Panel icon={KeyRound} title="Top Virtual Keys">
|
||||
<Panel icon={KeyRound} title={t("usage.entityUsage.topVirtualKeys")}>
|
||||
<TopKeyView
|
||||
topKeys={getTopAPIKeys(spendData.results, topKeysLimit)}
|
||||
teams={null}
|
||||
|
|
@ -594,7 +621,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
</Panel>
|
||||
<Panel
|
||||
icon={Boxes}
|
||||
title={entityType === "agent" ? "Top Agents" : modelViewTitle}
|
||||
title={entityType === "agent" ? t("usage.entityUsage.topAgents") : modelViewTitle}
|
||||
action={<ModelViewToggle value={modelViewType} onChange={setModelViewType} />}
|
||||
>
|
||||
<TopModelView
|
||||
|
|
@ -606,7 +633,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
</div>
|
||||
|
||||
{showAgentBreakdown && (
|
||||
<Panel icon={Bot} title="Top Agents Driving Spend">
|
||||
<Panel icon={Bot} title={t("usage.entityUsage.topAgentsDrivingSpend")}>
|
||||
{agentLoading ? (
|
||||
<ChartLoader />
|
||||
) : (
|
||||
|
|
@ -619,17 +646,20 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel icon={Server} title="Provider Usage">
|
||||
<Panel icon={Server} title={t("usage.entityUsage.providerUsage")}>
|
||||
<ProviderSpendBreakdown rows={providerSpend} />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabs: readonly { key: string; label: string; content: ReactNode }[] = [
|
||||
{ key: "cost", label: "Cost", content: costPanel },
|
||||
{ key: "cost", label: t("usage.entityUsage.tabs.cost"), content: costPanel },
|
||||
{
|
||||
key: "models",
|
||||
label: entityType === "agent" ? "Request / Token Consumption" : "Model Activity",
|
||||
label:
|
||||
entityType === "agent"
|
||||
? t("usage.entityUsage.tabs.requestTokenConsumption")
|
||||
: t("usage.entityUsage.tabs.modelActivity"),
|
||||
content: (
|
||||
<>
|
||||
<div className="mb-3 flex justify-end">
|
||||
|
|
@ -647,14 +677,14 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
? [
|
||||
{
|
||||
key: "agents",
|
||||
label: "Agent Activity",
|
||||
label: t("usage.entityUsage.tabs.agentActivity"),
|
||||
content: <ActivityMetrics modelMetrics={agentMetrics} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "keys",
|
||||
label: "Key Activity",
|
||||
label: t("usage.entityUsage.tabs.keyActivity"),
|
||||
content: (
|
||||
<KeyActivityPanel
|
||||
summary={summaryMetrics}
|
||||
|
|
@ -667,14 +697,18 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
|
||||
{
|
||||
key: "endpoints",
|
||||
label: t("usage.entityUsage.tabs.endpointActivity"),
|
||||
content: <EndpointUsage userSpendData={spendData} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative grid w-full gap-3">
|
||||
{failed && <ErrorState title="Failed to load spend data" message={errorMessage} onRetry={reload} />}
|
||||
{failed && <ErrorState title={t("usage.entityUsage.spendLoadFailed")} message={errorMessage} onRetry={reload} />}
|
||||
{showAgentBreakdown && agentFailed && (
|
||||
<ErrorState title="Failed to load agent data" message={agentErrorMessage} onRetry={reloadAgent} />
|
||||
<ErrorState title={t("usage.entityUsage.agentLoadFailed")} message={agentErrorMessage} onRetry={reloadAgent} />
|
||||
)}
|
||||
<div className="max-w-full [&>div:first-child]:mb-0 [&>div:first-child_label]:sr-only">
|
||||
<UsageExportHeader
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import React, { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { Panel } from "../overview/Primitives";
|
||||
|
|
@ -32,7 +34,7 @@ const QUIET_HEADER = { headerClassName: "font-normal" };
|
|||
|
||||
const columns: ColumnDef<ProviderSpendData>[] = [
|
||||
{
|
||||
header: "Provider",
|
||||
header: () => i18n.t("usage.providerSpend.columns.provider"),
|
||||
accessorKey: "provider",
|
||||
meta: QUIET_HEADER,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -47,33 +49,33 @@ const columns: ColumnDef<ProviderSpendData>[] = [
|
|||
),
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
header: () => i18n.t("usage.providerSpend.columns.spend"),
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: ({ row }) =>
|
||||
row.original.subscription_covered ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{row.original.spend > 0 && <MoneyCell value={row.original.spend} decimals={2} />}
|
||||
<Badge variant="secondary">Included in subscription</Badge>
|
||||
<Badge variant="secondary">{i18n.t("usage.providerSpend.includedInSubscription")}</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<MoneyCell value={row.original.spend} decimals={2} />
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
header: () => i18n.t("usage.providerSpend.columns.successful"),
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
header: () => i18n.t("usage.providerSpend.columns.failed"),
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
header: () => i18n.t("usage.providerSpend.columns.tokens"),
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.tokens.toLocaleString(),
|
||||
|
|
@ -86,6 +88,7 @@ const columns: ColumnDef<ProviderSpendData>[] = [
|
|||
* usage views.
|
||||
*/
|
||||
export function ProviderSpendBreakdown({ rows }: { rows: readonly ProviderSpendData[] }) {
|
||||
const { t } = useTranslation();
|
||||
const sorted = [...rows].sort((a, b) => b.spend - a.spend);
|
||||
const total = sorted.reduce((sum, row) => sum + row.spend, 0);
|
||||
const shareOf = (spend: number) => (total > 0 ? (spend / total) * 100 : 0);
|
||||
|
|
@ -96,7 +99,7 @@ export function ProviderSpendBreakdown({ rows }: { rows: readonly ProviderSpendD
|
|||
{visible.length > 0 && (
|
||||
<div className="grid gap-2.5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">Total provider spend</span>
|
||||
<span className="text-xs text-muted-foreground">{t("usage.providerSpend.totalProviderSpend")}</span>
|
||||
<span data-testid="provider-spend-total" className="text-sm font-semibold tabular-nums">
|
||||
${formatNumberWithCommas(total, 2)}
|
||||
</span>
|
||||
|
|
@ -127,7 +130,7 @@ export function ProviderSpendBreakdown({ rows }: { rows: readonly ProviderSpendD
|
|||
columns={columns}
|
||||
data={sorted}
|
||||
getRowId={(row) => row.provider}
|
||||
noDataMessage="No provider usage data"
|
||||
noDataMessage={t("usage.providerSpend.noData")}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -163,6 +166,7 @@ function ToggleField({
|
|||
}
|
||||
|
||||
const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChanging, providerSpend }) => {
|
||||
const { t } = useTranslation();
|
||||
const [includeZeroSpend, setIncludeZeroSpend] = useState(false);
|
||||
const [includeUnknown, setIncludeUnknown] = useState(false);
|
||||
|
||||
|
|
@ -190,16 +194,20 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
return (
|
||||
<Panel
|
||||
icon={Server}
|
||||
title="Spend by Provider"
|
||||
title={t("usage.providerSpend.title")}
|
||||
className="h-full"
|
||||
action={
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ToggleField label="Show Zero Spend" checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
|
||||
<ToggleField
|
||||
label="Show Unknown"
|
||||
label={t("usage.providerSpend.showZeroSpend")}
|
||||
checked={includeZeroSpend}
|
||||
onCheckedChange={setIncludeZeroSpend}
|
||||
/>
|
||||
<ToggleField
|
||||
label={t("usage.providerSpend.showUnknown")}
|
||||
checked={includeUnknown}
|
||||
onCheckedChange={setIncludeUnknown}
|
||||
hint="Requests that failed to route to a provider"
|
||||
hint={t("usage.providerSpend.showUnknownHint")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Download, Users } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { teamSpendByUserCall } from "@/components/networking";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import { Panel } from "../overview/Primitives";
|
||||
|
||||
|
|
@ -31,40 +33,46 @@ interface TeamUserSpendCardProps {
|
|||
const QUIET_HEADER = { headerClassName: "font-normal" };
|
||||
|
||||
const columns: ColumnDef<TeamUserSpendRow>[] = [
|
||||
{ header: "Team", accessorFn: teamLabel, id: "team", meta: QUIET_HEADER, cell: ({ row }) => teamLabel(row.original) },
|
||||
{
|
||||
header: "User",
|
||||
header: () => i18n.t("usage.teamUserSpend.columns.team"),
|
||||
accessorFn: teamLabel,
|
||||
id: "team",
|
||||
meta: QUIET_HEADER,
|
||||
cell: ({ row }) => teamLabel(row.original),
|
||||
},
|
||||
{
|
||||
header: () => i18n.t("usage.teamUserSpend.columns.user"),
|
||||
accessorFn: userLabel,
|
||||
id: "user",
|
||||
meta: QUIET_HEADER,
|
||||
cell: ({ row }) => <span className="font-medium text-foreground">{userLabel(row.original)}</span>,
|
||||
},
|
||||
{
|
||||
header: "Spend",
|
||||
header: () => i18n.t("usage.teamUserSpend.columns.spend"),
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
header: "Requests",
|
||||
header: () => i18n.t("usage.teamUserSpend.columns.requests"),
|
||||
accessorKey: "api_requests",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.api_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
header: () => i18n.t("usage.teamUserSpend.columns.successful"),
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
header: () => i18n.t("usage.teamUserSpend.columns.failed"),
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
header: () => i18n.t("usage.teamUserSpend.columns.tokens"),
|
||||
accessorKey: "total_tokens",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: ({ row }) => row.original.total_tokens.toLocaleString(),
|
||||
|
|
@ -72,6 +80,7 @@ const columns: ColumnDef<TeamUserSpendRow>[] = [
|
|||
];
|
||||
|
||||
const TeamUserSpendCard: React.FC<TeamUserSpendCardProps> = ({ accessToken, startTime, endTime, teamIds }) => {
|
||||
const { t } = useTranslation();
|
||||
const hasTeams = teamIds.length > 0;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["teamSpendByUser", startTime?.toISOString(), endTime?.toISOString(), teamIds],
|
||||
|
|
@ -84,7 +93,7 @@ const TeamUserSpendCard: React.FC<TeamUserSpendCardProps> = ({ accessToken, star
|
|||
return (
|
||||
<Panel
|
||||
icon={Users}
|
||||
title="Spend Per User Within Team"
|
||||
title={t("usage.teamUserSpend.title")}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -93,22 +102,22 @@ const TeamUserSpendCard: React.FC<TeamUserSpendCardProps> = ({ accessToken, star
|
|||
onClick={() => data && downloadCsv(buildTeamUserSpendCsv(data), teamUserSpendCsvFileName(data))}
|
||||
>
|
||||
<Download />
|
||||
Download CSV
|
||||
{t("usage.teamUserSpend.downloadCsv")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Attributed per request from spend logs, so it includes JWT/SSO traffic that does not use a virtual key
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t("usage.teamUserSpend.attributionNote")}</p>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={teamUserSpendRowId}
|
||||
isLoading={isLoading}
|
||||
isError={error !== null}
|
||||
errorMessage={error instanceof Error ? error.message : "Spend per user failed to load"}
|
||||
errorMessage={error instanceof Error ? error.message : t("usage.teamUserSpend.loadFailed")}
|
||||
maxBodyHeight={320}
|
||||
noDataMessage={teamIds.length === 0 ? "Select a team to see spend per user" : "No user spend in this range"}
|
||||
noDataMessage={
|
||||
teamIds.length === 0 ? t("usage.teamUserSpend.selectTeam") : t("usage.teamUserSpend.noUserSpend")
|
||||
}
|
||||
size="compact"
|
||||
/>
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { DataTable } from "@/components/shared/DataTable";
|
|||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { Segmented } from "../overview/Primitives";
|
||||
import { providerForModel } from "../overview/modelProvider";
|
||||
|
|
@ -50,35 +51,36 @@ function ModelName({ name }: { name: string | undefined }) {
|
|||
}
|
||||
|
||||
export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modelViewMode, setModelViewMode] = useState<ViewMode>("table");
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Model",
|
||||
header: t("usage.topModelView.columns.model"),
|
||||
accessorKey: "key",
|
||||
meta: QUIET_HEADER,
|
||||
cell: (info: any) => <ModelName name={info.getValue()} />,
|
||||
},
|
||||
{
|
||||
header: "Spend (USD)",
|
||||
header: t("usage.topModelView.columns.spendUsd"),
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
header: t("usage.topModelView.columns.successful"),
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: (info: any) => <span className="text-success">{info.getValue()?.toLocaleString() || 0}</span>,
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
header: t("usage.topModelView.columns.failed"),
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: (info: any) => <span className="text-destructive">{info.getValue()?.toLocaleString() || 0}</span>,
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
header: t("usage.topModelView.columns.tokens"),
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true, ...QUIET_HEADER },
|
||||
cell: (info: any) => info.getValue()?.toLocaleString() || 0,
|
||||
|
|
@ -90,13 +92,13 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
|
|||
<>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented
|
||||
label="Number of models to show"
|
||||
label={t("usage.topModelView.limitLabel")}
|
||||
value={String(topModelsLimit)}
|
||||
options={LIMIT_OPTIONS}
|
||||
onChange={(limit) => setTopModelsLimit(Number(limit))}
|
||||
/>
|
||||
<Segmented
|
||||
label="Top model view mode"
|
||||
label={t("usage.topModelView.viewModeLabel")}
|
||||
value={modelViewMode}
|
||||
options={VIEW_OPTIONS}
|
||||
onChange={setModelViewMode}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
export interface SummaryTile {
|
||||
|
|
@ -18,17 +20,13 @@ interface SpendSummaryMetadata {
|
|||
total_tokens: number;
|
||||
}
|
||||
|
||||
export const TOTAL_COST_TOOLTIP =
|
||||
"Request cost plus flat cost for reserved capacity. Select this tile to see the breakdown.";
|
||||
export const TOTAL_COST_TOOLTIP: ParseKeys = "usage.entitySummary.tooltips.totalCost";
|
||||
|
||||
export const REQUEST_COST_TOOLTIP =
|
||||
"Usage-based cost of the requests this entity sent during the selected period, priced per token.";
|
||||
export const REQUEST_COST_TOOLTIP: ParseKeys = "usage.entitySummary.tooltips.requestCost";
|
||||
|
||||
export const FLAT_COST_TOOLTIP =
|
||||
"Reserved provisioned throughput, billed per hour whether or not requests are sent. Reported here only; it does not count toward team, key, user, or organization budgets.";
|
||||
export const FLAT_COST_TOOLTIP: ParseKeys = "usage.entitySummary.tooltips.flatCost";
|
||||
|
||||
export const PTU_HOURS_TOOLTIP =
|
||||
"Provisioned throughput consumed, in PTU-hours: the tokens sent to PTU deployments converted through the Azure sizing table for each model. Divide it by the PTUs reserved times the hours in the period to see how much of the reservation was used.";
|
||||
export const PTU_HOURS_TOOLTIP: ParseKeys = "usage.entitySummary.tooltips.ptuHours";
|
||||
|
||||
export const hasFlatCost = (metadata: SpendSummaryMetadata): boolean => (metadata.total_flat_cost ?? 0) > 0;
|
||||
|
||||
|
|
@ -38,6 +36,7 @@ export const buildSummaryTiles = (
|
|||
metadata: SpendSummaryMetadata,
|
||||
showFlatCost: boolean,
|
||||
showPtuHours: boolean = false,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): SummaryTile[] => {
|
||||
const flatCost = metadata.total_flat_cost ?? 0;
|
||||
const ptuHoursTile: SummaryTile[] = showPtuHours
|
||||
|
|
@ -45,7 +44,7 @@ export const buildSummaryTiles = (
|
|||
{
|
||||
title: "PTU Hours",
|
||||
value: formatNumberWithCommas(metadata.total_ptu_hours ?? 0, 2),
|
||||
tooltip: PTU_HOURS_TOOLTIP,
|
||||
tooltip: t(PTU_HOURS_TOOLTIP),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
|
@ -54,7 +53,7 @@ export const buildSummaryTiles = (
|
|||
? {
|
||||
title: "Total Cost",
|
||||
value: `$${formatNumberWithCommas(metadata.total_spend + flatCost, 2)}`,
|
||||
tooltip: TOTAL_COST_TOOLTIP,
|
||||
tooltip: t(TOTAL_COST_TOOLTIP),
|
||||
expandable: true,
|
||||
}
|
||||
: { title: "Total Spend", value: `$${formatNumberWithCommas(metadata.total_spend, 2)}` },
|
||||
|
|
@ -70,17 +69,20 @@ export const buildSummaryTiles = (
|
|||
];
|
||||
};
|
||||
|
||||
export const buildCostBreakdownTiles = (metadata: SpendSummaryMetadata): SummaryTile[] => [
|
||||
export const buildCostBreakdownTiles = (
|
||||
metadata: SpendSummaryMetadata,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): SummaryTile[] => [
|
||||
{
|
||||
title: "Request Cost",
|
||||
value: `$${formatNumberWithCommas(metadata.total_spend, 2)}`,
|
||||
className: "text-info",
|
||||
tooltip: REQUEST_COST_TOOLTIP,
|
||||
tooltip: t(REQUEST_COST_TOOLTIP),
|
||||
},
|
||||
{
|
||||
title: "Flat Cost",
|
||||
value: `$${formatNumberWithCommas(metadata.total_flat_cost ?? 0, 2)}`,
|
||||
className: "text-violet-600",
|
||||
tooltip: FLAT_COST_TOOLTIP,
|
||||
tooltip: t(FLAT_COST_TOOLTIP),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import Papa from "papaparse";
|
||||
|
||||
import type { TeamUserSpendResponse } from "@/components/networking";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export type TeamUserSpendRow = TeamUserSpendResponse["results"][number];
|
||||
|
||||
export const NO_USER_LABEL = "(no user)";
|
||||
export const NO_USER_LABEL: ParseKeys = "usage.teamUserSpend.noUser";
|
||||
|
||||
export const userLabel = (row: TeamUserSpendRow): string => {
|
||||
const identity = row.user_email || row.user_alias;
|
||||
return identity || row.user_id || NO_USER_LABEL;
|
||||
return identity || row.user_id || i18n.t(NO_USER_LABEL);
|
||||
};
|
||||
|
||||
export const teamLabel = (row: TeamUserSpendRow): string => row.team_alias || row.team_id;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type ModelViewType = "groups" | "individual";
|
||||
|
||||
const MODEL_VIEW_OPTIONS: readonly { value: ModelViewType; label: string }[] = [
|
||||
{ value: "groups", label: "Public Model Name" },
|
||||
{ value: "individual", label: "Litellm Model Name" },
|
||||
const MODEL_VIEW_OPTIONS: readonly { value: ModelViewType; label: ParseKeys }[] = [
|
||||
{ value: "groups", label: "usage.modelViewToggle.publicModelName" },
|
||||
{ value: "individual", label: "usage.modelViewToggle.litellmModelName" },
|
||||
];
|
||||
|
||||
interface ModelViewToggleProps {
|
||||
|
|
@ -11,6 +14,7 @@ interface ModelViewToggleProps {
|
|||
}
|
||||
|
||||
export default function ModelViewToggle({ value, onChange }: ModelViewToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex bg-muted rounded-lg p-1">
|
||||
{MODEL_VIEW_OPTIONS.map((option) => (
|
||||
|
|
@ -21,7 +25,7 @@ export default function ModelViewToggle({ value, onChange }: ModelViewToggleProp
|
|||
}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
{t(option.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Info } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
|
@ -19,6 +20,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { ATTRIBUTION_RULE, fixedCostByCurrency, formatBillingPeriod, formatFee } from "./formatting";
|
||||
import type { SubscriptionAccountUsage, SubscriptionUsageResponse } from "./types";
|
||||
|
|
@ -41,8 +43,9 @@ const accountRowId = (account: SubscriptionAccountUsage): string =>
|
|||
`${account.custom_llm_provider}:${account.account_id ?? "unresolved"}`;
|
||||
|
||||
const AccountCell = ({ account }: { account: SubscriptionAccountUsage }) => {
|
||||
const { t } = useTranslation();
|
||||
if (account.account_id === null) {
|
||||
return <span className="text-muted-foreground">Account not signed in on the proxy</span>;
|
||||
return <span className="text-muted-foreground">{t("usage.subscriptionCost.card.accountNotSignedIn")}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -58,33 +61,38 @@ const ActionsCell = ({
|
|||
onEditFee,
|
||||
onRemoveFee,
|
||||
}: { account: SubscriptionAccountUsage } & FeeActions) => {
|
||||
const { t } = useTranslation();
|
||||
if (account.account_id === null) return null;
|
||||
if (account.fee === null) {
|
||||
return (
|
||||
<Button variant="outline" size="xs" onClick={() => onSetFee(account)}>
|
||||
Set monthly fee
|
||||
{t("usage.subscriptionCost.card.setFee")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="xs" onClick={() => onEditFee(account)}>
|
||||
Edit
|
||||
{t("usage.subscriptionCost.card.edit")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button variant="outline" size="xs" />}>Remove</AlertDialogTrigger>
|
||||
<AlertDialogTrigger render={<Button variant="outline" size="xs" />}>
|
||||
{t("usage.subscriptionCost.card.remove")}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove this monthly fee?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("usage.subscriptionCost.removeDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{account.account_id} goes back to "Fixed cost not tracked". Its requests stay included in the
|
||||
subscription
|
||||
<Trans
|
||||
i18nKey="usage.subscriptionCost.removeDialog.description"
|
||||
components={{ account: <>{account.account_id}</> }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("usage.subscriptionCost.removeDialog.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => onRemoveFee(account)}>
|
||||
Remove fee
|
||||
{t("usage.subscriptionCost.removeDialog.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -95,7 +103,7 @@ const ActionsCell = ({
|
|||
|
||||
const baseColumns: ColumnDef<SubscriptionAccountUsage>[] = [
|
||||
{
|
||||
header: "Provider",
|
||||
header: () => i18n.t("usage.subscriptionCost.columns.provider"),
|
||||
accessorKey: "custom_llm_provider",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
|
|
@ -105,32 +113,35 @@ const baseColumns: ColumnDef<SubscriptionAccountUsage>[] = [
|
|||
),
|
||||
},
|
||||
{
|
||||
header: "Account",
|
||||
header: () => i18n.t("usage.subscriptionCost.columns.account"),
|
||||
accessorKey: "account_id",
|
||||
cell: ({ row }) => <AccountCell account={row.original} />,
|
||||
},
|
||||
{
|
||||
header: "Deployments",
|
||||
header: () => i18n.t("usage.subscriptionCost.columns.deployments"),
|
||||
id: "deployments",
|
||||
cell: ({ row }) => (row.original.deployments.length === 0 ? "None" : row.original.deployments.join(", ")),
|
||||
cell: ({ row }) =>
|
||||
row.original.deployments.length === 0
|
||||
? i18n.t("usage.subscriptionCost.card.noDeployments")
|
||||
: row.original.deployments.join(", "),
|
||||
},
|
||||
{
|
||||
header: "Monthly fee",
|
||||
header: () => i18n.t("usage.subscriptionCost.columns.monthlyFee"),
|
||||
id: "monthly_fee",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) =>
|
||||
row.original.fee === null ? (
|
||||
<Badge variant="outline">Fixed cost not tracked</Badge>
|
||||
<Badge variant="outline">{i18n.t("usage.subscriptionCost.card.notTracked")}</Badge>
|
||||
) : (
|
||||
formatFee(row.original.fee.monthly_fee, row.original.fee.currency)
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Billing periods in range",
|
||||
header: () => i18n.t("usage.subscriptionCost.columns.billingPeriods"),
|
||||
id: "billing_periods",
|
||||
cell: ({ row }) =>
|
||||
row.original.billing_periods.length === 0 ? (
|
||||
<span className="text-muted-foreground">None in range</span>
|
||||
<span className="text-muted-foreground">{i18n.t("usage.subscriptionCost.card.noPeriodsInRange")}</span>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{row.original.billing_periods.map((period) => (
|
||||
|
|
@ -140,7 +151,7 @@ const baseColumns: ColumnDef<SubscriptionAccountUsage>[] = [
|
|||
),
|
||||
},
|
||||
{
|
||||
header: "Fixed cost in range",
|
||||
header: () => i18n.t("usage.subscriptionCost.columns.fixedCost"),
|
||||
id: "fixed_cost",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) =>
|
||||
|
|
@ -168,6 +179,7 @@ const SubscriptionCostCard: React.FC<SubscriptionCostCardProps> = ({
|
|||
onEditFee,
|
||||
onRemoveFee,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!failed && (usage === null || usage.accounts.length === 0)) return null;
|
||||
const columns = canEdit ? [...baseColumns, actionsColumn({ onSetFee, onEditFee, onRemoveFee })] : baseColumns;
|
||||
const accounts = usage?.accounts ?? [];
|
||||
|
|
@ -177,15 +189,15 @@ const SubscriptionCostCard: React.FC<SubscriptionCostCardProps> = ({
|
|||
<Card className="col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1">
|
||||
Subscription fixed costs
|
||||
{t("usage.subscriptionCost.card.title")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>{ATTRIBUTION_RULE}</TooltipContent>
|
||||
<TooltipContent>{t(ATTRIBUTION_RULE)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{failed && <p className="text-sm text-destructive">Could not load subscription accounts for this range.</p>}
|
||||
{failed && <p className="text-sm text-destructive">{t("usage.subscriptionCost.card.loadFailed")}</p>}
|
||||
{!failed && loading && <ChartLoader isDateChanging={isDateChanging} />}
|
||||
{!failed && !loading && (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -193,24 +205,24 @@ const SubscriptionCostCard: React.FC<SubscriptionCostCardProps> = ({
|
|||
columns={columns}
|
||||
data={accounts}
|
||||
getRowId={accountRowId}
|
||||
noDataMessage="No subscription accounts"
|
||||
noDataMessage={t("usage.subscriptionCost.card.noAccounts")}
|
||||
size="compact"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-sm">
|
||||
{totals.length === 0 ? (
|
||||
<span data-testid="fixed-cost-total">Fixed cost in range: none</span>
|
||||
<span data-testid="fixed-cost-total">{t("usage.subscriptionCost.card.fixedCostNone")}</span>
|
||||
) : (
|
||||
totals.map(({ currency, total }) => (
|
||||
<span key={currency} data-testid="fixed-cost-total">
|
||||
Fixed cost in range: {formatFee(total, currency)}
|
||||
{t("usage.subscriptionCost.card.fixedCostTotal", { total: formatFee(total, currency) })}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
<span data-testid="metered-spend">
|
||||
Usage-billed spend in range, all providers: ${formatNumberWithCommas(meteredSpend, 2)}
|
||||
{t("usage.subscriptionCost.card.meteredSpend", { spend: formatNumberWithCommas(meteredSpend, 2) })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{ATTRIBUTION_RULE}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(ATTRIBUTION_RULE)}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { formatDate } from "@/components/networking";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -15,20 +16,25 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import type { SubscriptionAccountUsage, SubscriptionFeeFormValues } from "./types";
|
||||
|
||||
const feeShape = {
|
||||
monthly_fee: z
|
||||
.string()
|
||||
.min(1, "Enter a monthly fee")
|
||||
.refine((value) => Number(value) > 0, "Monthly fee must be greater than 0"),
|
||||
.min(1, { error: () => i18n.t("usage.subscriptionCost.feeModal.errors.feeRequired") })
|
||||
.refine((value) => Number(value) > 0, {
|
||||
error: () => i18n.t("usage.subscriptionCost.feeModal.errors.feePositive"),
|
||||
}),
|
||||
currency: z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.regex(/^[A-Z]{3}$/, "Currency must be a three-letter code"),
|
||||
billing_period_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Pick a billing period start date"),
|
||||
.regex(/^[A-Z]{3}$/, { error: () => i18n.t("usage.subscriptionCost.feeModal.errors.currencyCode") }),
|
||||
billing_period_start: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, { error: () => i18n.t("usage.subscriptionCost.feeModal.errors.periodStart") }),
|
||||
label: z.string().trim(),
|
||||
};
|
||||
|
||||
|
|
@ -51,6 +57,7 @@ interface SubscriptionFeeModalProps {
|
|||
}
|
||||
|
||||
const SubscriptionFeeModal: React.FC<SubscriptionFeeModalProps> = ({ visible, account, onCancel, onSubmit }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(feeSchema, { defaultValues: formInputFor(account) });
|
||||
const { reset } = form;
|
||||
|
||||
|
|
@ -77,32 +84,53 @@ const SubscriptionFeeModal: React.FC<SubscriptionFeeModalProps> = ({ visible, ac
|
|||
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{account?.fee ? "Edit monthly fee" : "Set monthly fee"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{account?.fee
|
||||
? t("usage.subscriptionCost.feeModal.editTitle")
|
||||
: t("usage.subscriptionCost.feeModal.setTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{account ? `${account.custom_llm_provider} account ${account.account_id ?? ""}` : ""}
|
||||
{account
|
||||
? t("usage.subscriptionCost.feeModal.description", {
|
||||
provider: account.custom_llm_provider,
|
||||
accountId: account.account_id ?? "",
|
||||
})
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(handleFinish)} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="monthly_fee" label="Monthly fee">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="monthly_fee"
|
||||
label={t("usage.subscriptionCost.feeModal.fields.monthlyFee")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} type="number" step={0.01} min={0.01} />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="currency" label="Currency">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
label={t("usage.subscriptionCost.feeModal.fields.currency")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} maxLength={3} />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="billing_period_start" label="Billing period start">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_period_start"
|
||||
label={t("usage.subscriptionCost.feeModal.fields.billingPeriodStart")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} type="date" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="label" label="Label (optional)">
|
||||
<FormField control={form.control} name="label" label={t("usage.subscriptionCost.feeModal.fields.label")}>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("usage.subscriptionCost.feeModal.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
Save
|
||||
{t("usage.subscriptionCost.feeModal.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import type { BillingPeriod } from "./types";
|
||||
|
||||
export const ATTRIBUTION_RULE =
|
||||
"A subscription fee is counted once for each monthly billing period whose start date falls inside the selected dates. Fixed costs sit beside metered spend and are never added to it, to key or team spend, or to budgets.";
|
||||
export const ATTRIBUTION_RULE: ParseKeys = "usage.subscriptionCost.attributionRule";
|
||||
|
||||
export const formatFee = (amount: number, currency: string): string => `${amount.toFixed(2)} ${currency}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
subscriptionAccountDeleteCall,
|
||||
subscriptionAccountUpdateCall,
|
||||
} from "@/components/networking";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type {
|
||||
SubscriptionAccountUpdateRequest,
|
||||
|
|
@ -62,7 +63,7 @@ export function useSubscriptionFeeActions({ accessToken, refetch }: Options): Re
|
|||
if (request === null) return;
|
||||
try {
|
||||
await request;
|
||||
toast.success("Monthly fee saved");
|
||||
toast.success(i18n.t("usage.subscriptionCost.toasts.feeSaved"));
|
||||
setFeeModalAccount(null);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
|
|
@ -77,7 +78,7 @@ export function useSubscriptionFeeActions({ accessToken, refetch }: Options): Re
|
|||
if (!accessToken || account.fee === null) return;
|
||||
try {
|
||||
await subscriptionAccountDeleteCall(accessToken, account.fee.subscription_account_id);
|
||||
toast.success("Monthly fee removed");
|
||||
toast.success(i18n.t("usage.subscriptionCost.toasts.feeRemoved"));
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Download, Link2, Search, X } from "lucide-react";
|
|||
import { useQueryStates } from "nuqs";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { StackedUsageChart } from "@/components/shared/charts";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
|
|
@ -23,6 +24,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
|
@ -113,6 +115,7 @@ interface UsagePageProps {
|
|||
}
|
||||
|
||||
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoading = false, defaultTimeRange }) => {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken, userRole, userId: userID, premiumUser, isViewOnly, isLoading: authLoading } = useAuthorized();
|
||||
const [gatewayActivityData, setGatewayActivityData] = useState<FetchedGatewayActivity | null>(null);
|
||||
|
||||
|
|
@ -194,8 +197,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
if (!accessSettled) return;
|
||||
const cleanup = cleanUsageUrl(urlParams, { allowedViews, isAdmin, canViewTeamClients });
|
||||
if (!cleanup) return;
|
||||
if (cleanup.deniedAccess)
|
||||
toast.warning("You don't have access to that usage view", { id: "usage-url-access-denied" });
|
||||
if (cleanup.deniedAccess) toast.warning(i18n.t("usage.pageView.accessDenied"), { id: "usage-url-access-denied" });
|
||||
void setUrlParams(cleanup.patch);
|
||||
}, [accessSettled, urlParams, allowedViews, isAdmin, canViewTeamClients, setUrlParams]);
|
||||
|
||||
|
|
@ -482,15 +484,18 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
onValueChange={handleDateChange}
|
||||
presetShortLabel={presetShortLabel}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void copyToClipboard(window.location.href, t("usage.pageView.linkCopied"))}
|
||||
>
|
||||
<Link2 />
|
||||
Copy Share Link
|
||||
{t("usage.pageView.copyShareLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{aggregatedFailed && (
|
||||
<ErrorState title="Failed to load spend data" message={aggregatedError} onRetry={reloadAggregated} />
|
||||
<ErrorState title={t("usage.pageView.spendLoadFailed")} message={aggregatedError} onRetry={reloadAggregated} />
|
||||
)}
|
||||
<div>
|
||||
<div>
|
||||
|
|
@ -505,26 +510,26 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="cost" className="flex-none px-3">
|
||||
Overview
|
||||
{t("usage.pageView.tabs.overview")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="flex-none px-3">
|
||||
Model Activity
|
||||
{t("usage.pageView.tabs.modelActivity")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="keys" className="flex-none px-3">
|
||||
Key Activity
|
||||
{t("usage.pageView.tabs.keyActivity")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex-none px-3">
|
||||
User Activity
|
||||
{t("usage.pageView.tabs.userActivity")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="flex-none px-3">
|
||||
MCP Server Activity
|
||||
{t("usage.pageView.tabs.mcpServerActivity")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" className="flex-none px-3">
|
||||
Endpoint Activity
|
||||
{t("usage.pageView.tabs.endpointActivity")}
|
||||
</TabsTrigger>
|
||||
{isAdmin && (
|
||||
<TabsTrigger value="errors" className="flex-none px-3">
|
||||
Errors
|
||||
{t("usage.pageView.tabs.errors")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
|
@ -532,7 +537,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
<AriaUsageTrigger />
|
||||
<Button variant="outline" size="sm" onClick={() => setIsGlobalExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
{t("usage.pageView.exportData")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -566,17 +571,20 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
gatewayActivity && gatewayActivity.by_route.length > 0 ? (
|
||||
<Panel
|
||||
testId="gateway-requests-by-endpoint"
|
||||
title="Gateway Requests by Endpoint"
|
||||
subtitle="Successful and failed requests per route"
|
||||
title={t("usage.pageView.gatewayByEndpoint.title")}
|
||||
subtitle={t("usage.pageView.gatewayByEndpoint.subtitle")}
|
||||
bodyClassName="px-2 pt-4"
|
||||
>
|
||||
<StackedUsageChart
|
||||
data={gatewayRequestsByRoute.map((row) => ({
|
||||
route: row.route,
|
||||
Successful: row.successful_requests,
|
||||
Failed: row.failed_requests,
|
||||
[t("usage.pageView.gatewayByEndpoint.successful")]: row.successful_requests,
|
||||
[t("usage.pageView.gatewayByEndpoint.failed")]: row.failed_requests,
|
||||
}))}
|
||||
series={["Successful", "Failed"]}
|
||||
series={[
|
||||
t("usage.pageView.gatewayByEndpoint.successful"),
|
||||
t("usage.pageView.gatewayByEndpoint.failed"),
|
||||
]}
|
||||
colors={["#2b3fd6", "#ef4444"]}
|
||||
xKey="route"
|
||||
format={(value) => value.toLocaleString()}
|
||||
|
|
@ -610,8 +618,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label="Search models"
|
||||
placeholder="Search by model name"
|
||||
aria-label={t("usage.pageView.modelSearch.label")}
|
||||
placeholder={t("usage.pageView.modelSearch.placeholder")}
|
||||
value={modelQuery}
|
||||
onChange={(event) => setModelQuery(event.target.value)}
|
||||
/>
|
||||
|
|
@ -619,7 +627,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Clear model search"
|
||||
aria-label={t("usage.pageView.modelSearch.clear")}
|
||||
onClick={() => setModelQuery("")}
|
||||
>
|
||||
<X />
|
||||
|
|
@ -633,7 +641,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
Object.keys(modelMetrics).length > 0 &&
|
||||
Object.keys(filteredModelMetrics).length === 0 ? (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
No models match "{trimmedModelQuery}" in this date range
|
||||
{t("usage.pageView.modelSearch.noMatch", { query: trimmedModelQuery })}
|
||||
</p>
|
||||
) : (
|
||||
<ActivityMetrics
|
||||
|
|
@ -732,17 +740,20 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
<>
|
||||
{showCredentialBanner && (
|
||||
<Alert variant="info" className="mb-5">
|
||||
<AlertTitle>Reusable credentials are automatically tracked as tags</AlertTitle>
|
||||
<AlertTitle>{t("usage.pageView.credentialBanner.title")}</AlertTitle>
|
||||
<AlertDescription className="text-inherit">
|
||||
When a reusable credential is used, it will appear as a tag prefixed with{" "}
|
||||
<code className="rounded bg-black/5 px-1 py-0.5 font-mono text-xs">Credential: </code>
|
||||
in this view.
|
||||
<Trans
|
||||
i18nKey="usage.pageView.credentialBanner.description"
|
||||
components={{
|
||||
prefix: <code className="rounded bg-black/5 px-1 py-0.5 font-mono text-xs">Credential: </code>,
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Close"
|
||||
aria-label={t("usage.pageView.credentialBanner.close")}
|
||||
onClick={() => setShowCredentialBanner(false)}
|
||||
>
|
||||
<X />
|
||||
|
|
@ -811,7 +822,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
scopeError={clientScope.error}
|
||||
teams={usageView === "my-usage" ? [] : clientScope.administeredTeams}
|
||||
members={clientScope.members}
|
||||
defaultScopeLabel={isAdmin ? "All users" : "My usage"}
|
||||
defaultScopeLabel={
|
||||
isAdmin ? t("usage.pageView.clientScope.allUsers") : t("usage.pageView.clientScope.myUsage")
|
||||
}
|
||||
onTeamChange={(teamId) => void setUrlParams({ client_team: teamId, client_user: "" }, { scroll: false })}
|
||||
onUserChange={(userId) => void setUrlParams({ client_user: userId ?? "" }, { scroll: false })}
|
||||
onClose={() => void setUrlParams(clientPanelPatch(null), { scroll: false })}
|
||||
|
|
@ -849,7 +862,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations, teamsLoadin
|
|||
}
|
||||
dateRange={dateValue}
|
||||
selectedFilters={[]}
|
||||
customTitle="Export Usage Data"
|
||||
customTitle={t("usage.pageView.exportTitle")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { hasCapability, type Capability } from "@/utils/capabilities";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
export type UsageOption =
|
||||
|
|
@ -27,80 +30,80 @@ export interface UsageViewSelectProps {
|
|||
}
|
||||
interface OptionConfig {
|
||||
value: UsageOption;
|
||||
label: string;
|
||||
description: string;
|
||||
label: ParseKeys;
|
||||
description: ParseKeys;
|
||||
icon: React.ReactNode;
|
||||
capability?: Capability;
|
||||
adminOnly?: boolean;
|
||||
showForAdmin?: string;
|
||||
showForNonAdmin?: string;
|
||||
descriptionForAdmin?: string;
|
||||
descriptionForNonAdmin?: string;
|
||||
showForAdmin?: ParseKeys;
|
||||
showForNonAdmin?: ParseKeys;
|
||||
descriptionForAdmin?: ParseKeys;
|
||||
descriptionForNonAdmin?: ParseKeys;
|
||||
badgeText?: string;
|
||||
}
|
||||
const OPTIONS: OptionConfig[] = [
|
||||
{
|
||||
value: "global",
|
||||
label: "Global Usage",
|
||||
showForAdmin: "Global Usage",
|
||||
showForNonAdmin: "Your Usage",
|
||||
description: "View usage across all resources",
|
||||
descriptionForAdmin: "View usage across all resources",
|
||||
descriptionForNonAdmin: "View your usage",
|
||||
label: "usage.viewSelect.options.global.label",
|
||||
showForAdmin: "usage.viewSelect.options.global.adminLabel",
|
||||
showForNonAdmin: "usage.viewSelect.options.global.nonAdminLabel",
|
||||
description: "usage.viewSelect.options.global.description",
|
||||
descriptionForAdmin: "usage.viewSelect.options.global.adminDescription",
|
||||
descriptionForNonAdmin: "usage.viewSelect.options.global.nonAdminDescription",
|
||||
icon: <Globe className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: "my-usage",
|
||||
label: "Your Usage",
|
||||
description: "View your own usage",
|
||||
label: "usage.viewSelect.options.myUsage.label",
|
||||
description: "usage.viewSelect.options.myUsage.description",
|
||||
icon: <User className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "organization",
|
||||
label: "Organization Usage",
|
||||
description: "View usage across all organizations",
|
||||
label: "usage.viewSelect.options.organization.label",
|
||||
description: "usage.viewSelect.options.organization.description",
|
||||
icon: <Building2 className="size-4" />,
|
||||
capability: "viewOrganizationUsage",
|
||||
},
|
||||
{
|
||||
value: "team",
|
||||
label: "Team Usage",
|
||||
description: "View usage by team",
|
||||
label: "usage.viewSelect.options.team.label",
|
||||
description: "usage.viewSelect.options.team.description",
|
||||
icon: <Users className="size-4" />,
|
||||
},
|
||||
{
|
||||
value: "customer",
|
||||
label: "Customer Usage",
|
||||
description: "View usage by customer accounts",
|
||||
label: "usage.viewSelect.options.customer.label",
|
||||
description: "usage.viewSelect.options.customer.description",
|
||||
icon: <ShoppingCart className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "tag",
|
||||
label: "Tag Usage",
|
||||
description: "View usage grouped by tags",
|
||||
label: "usage.viewSelect.options.tag.label",
|
||||
description: "usage.viewSelect.options.tag.description",
|
||||
icon: <Tags className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "agent",
|
||||
label: "Agent Usage (A2A)",
|
||||
description: "View usage by AI agents",
|
||||
label: "usage.viewSelect.options.agent.label",
|
||||
description: "usage.viewSelect.options.agent.description",
|
||||
icon: <Bot className="size-4" />,
|
||||
capability: "viewAgentUsage",
|
||||
},
|
||||
{
|
||||
value: "user",
|
||||
label: "User Usage",
|
||||
description: "View usage by individual users",
|
||||
label: "usage.viewSelect.options.user.label",
|
||||
description: "usage.viewSelect.options.user.description",
|
||||
icon: <User className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "user-agent-activity",
|
||||
label: "User Agent Activity",
|
||||
description: "View detailed user agent activity logs",
|
||||
label: "usage.viewSelect.options.userAgentActivity.label",
|
||||
description: "usage.viewSelect.options.userAgentActivity.description",
|
||||
icon: <LineChart className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
|
|
@ -132,10 +135,11 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
userRole,
|
||||
canViewTagUsage = false,
|
||||
isOrgAdmin = false,
|
||||
title = "Usage",
|
||||
description = "Select the usage data you want to view",
|
||||
title = i18n.t("usage.viewSelect.title"),
|
||||
description = i18n.t("usage.viewSelect.description"),
|
||||
"data-id": dataId,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const term = useTerminology();
|
||||
const isAdmin = all_admin_roles.includes(userRole ?? "");
|
||||
const getFilteredOptions = () => {
|
||||
|
|
@ -150,8 +154,8 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
|||
}
|
||||
return {
|
||||
value: option.value,
|
||||
label,
|
||||
description: term(desc),
|
||||
label: t(label),
|
||||
description: term(t(desc)),
|
||||
icon: option.icon,
|
||||
badgeText: option.badgeText,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { userRecordHref } from "../usageUrlState";
|
||||
|
||||
export default function UserRecordLink({ userId }: { userId: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Link
|
||||
href={userRecordHref(userId)}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-sm text-primary underline underline-offset-2"
|
||||
>
|
||||
View user record
|
||||
{t("usage.userRecordLink.view")}
|
||||
<SquareArrowOutUpRight className="size-3.5" />
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, ArrowUpRight } from "lucide-react";
|
||||
import { LineChart, STACKED_USAGE_PALETTE, StackedUsageChart } from "@/components/shared/charts";
|
||||
|
|
@ -76,13 +78,14 @@ function PanelBody({
|
|||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function rateChartEmpty(rateView: ErrorRateView, series: ErrorSeries, noTraffic: boolean): string | null {
|
||||
if (noTraffic || series.rows.length === 0) return "No requests in this range";
|
||||
if (rateView === "status" && series.codes.length === 0) return "No failed requests in this range";
|
||||
function rateChartEmpty(rateView: ErrorRateView, series: ErrorSeries, noTraffic: boolean, t: TFunction): string | null {
|
||||
if (noTraffic || series.rows.length === 0) return t("usage.errors.empty.noRequests");
|
||||
if (rateView === "status" && series.codes.length === 0) return t("usage.errors.empty.noFailedRequests");
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ErrorsTab({ activity, loading, failed, userScoped = false }: ErrorsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [kind, setKind] = useState<ErrorEntityKind>("key");
|
||||
const [rateView, setRateView] = useState<ErrorRateView>("total");
|
||||
const summary = useMemo(() => (activity ? errorSummary(activity) : null), [activity]);
|
||||
|
|
@ -99,14 +102,14 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
[series, rateView],
|
||||
);
|
||||
const noTraffic = summary !== null && summary.requests === 0;
|
||||
const rateEmpty = rateChartEmpty(rateView, series, noTraffic);
|
||||
const rateEmpty = rateChartEmpty(rateView, series, noTraffic, t);
|
||||
|
||||
if (userScoped) {
|
||||
return (
|
||||
<Alert role="status">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Errors are deployment-wide</AlertTitle>
|
||||
<AlertDescription>Clear the user filter in Global Usage to see failure analytics</AlertDescription>
|
||||
<AlertTitle>{t("usage.errors.userScoped.title")}</AlertTitle>
|
||||
<AlertDescription>{t("usage.errors.userScoped.description")}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -114,10 +117,8 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
return (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertCircle />
|
||||
<AlertTitle>Error activity could not be loaded</AlertTitle>
|
||||
<AlertDescription>
|
||||
The proxy did not answer the failed-request query. Reload the page to try again.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("usage.errors.failed.title")}</AlertTitle>
|
||||
<AlertDescription>{t("usage.errors.failed.description")}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,30 +126,53 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
return (
|
||||
<div className="grid gap-3" data-testid="usage-errors-tab">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCell label="Error rate">
|
||||
<StatCell label={t("usage.errors.stats.errorRate")}>
|
||||
<Stat
|
||||
label="Error rate"
|
||||
label={t("usage.errors.stats.errorRate")}
|
||||
value={summary ? formatRate(summary.rate) : null}
|
||||
pending={loading}
|
||||
tone={summary && summary.failed > 0 ? "destructive" : undefined}
|
||||
hint={summary ? `${count(summary.failed)} of ${count(summary.requests)} requests` : undefined}
|
||||
hint={
|
||||
summary
|
||||
? t("usage.errors.stats.failedOfTotal", {
|
||||
failed: count(summary.failed),
|
||||
total: count(summary.requests),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell label="Failed requests">
|
||||
<Stat label="Failed requests" value={summary ? count(summary.failed) : null} pending={loading} />
|
||||
</StatCell>
|
||||
<StatCell label="Client errors (4xx)">
|
||||
<Stat label="Client errors (4xx)" value={summary ? count(summary.clientErrors) : null} pending={loading} />
|
||||
</StatCell>
|
||||
<StatCell label="Server errors (5xx)">
|
||||
<Stat label="Server errors (5xx)" value={summary ? count(summary.serverErrors) : null} pending={loading} />
|
||||
</StatCell>
|
||||
<StatCell label="Rate limited (429)">
|
||||
<Stat label="Rate limited (429)" value={summary ? count(summary.rateLimited) : null} pending={loading} />
|
||||
</StatCell>
|
||||
<StatCell label="Auth failures (401/403)">
|
||||
<StatCell label={t("usage.errors.stats.failedRequests")}>
|
||||
<Stat
|
||||
label="Auth failures (401/403)"
|
||||
label={t("usage.errors.stats.failedRequests")}
|
||||
value={summary ? count(summary.failed) : null}
|
||||
pending={loading}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell label={t("usage.errors.stats.clientErrors")}>
|
||||
<Stat
|
||||
label={t("usage.errors.stats.clientErrors")}
|
||||
value={summary ? count(summary.clientErrors) : null}
|
||||
pending={loading}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell label={t("usage.errors.stats.serverErrors")}>
|
||||
<Stat
|
||||
label={t("usage.errors.stats.serverErrors")}
|
||||
value={summary ? count(summary.serverErrors) : null}
|
||||
pending={loading}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell label={t("usage.errors.stats.rateLimited")}>
|
||||
<Stat
|
||||
label={t("usage.errors.stats.rateLimited")}
|
||||
value={summary ? count(summary.rateLimited) : null}
|
||||
pending={loading}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell label={t("usage.errors.stats.authFailures")}>
|
||||
<Stat
|
||||
label={t("usage.errors.stats.authFailures")}
|
||||
value={summary ? count(summary.authFailures) : null}
|
||||
pending={loading}
|
||||
/>
|
||||
|
|
@ -157,9 +181,16 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<Panel
|
||||
title="Error rate over time"
|
||||
subtitle="Failed requests as a share of all requests, per day"
|
||||
action={<Segmented label="Show" value={rateView} options={ERROR_RATE_VIEW_OPTIONS} onChange={setRateView} />}
|
||||
title={t("usage.errors.rateChart.title")}
|
||||
subtitle={t("usage.errors.rateChart.subtitle")}
|
||||
action={
|
||||
<Segmented
|
||||
label={t("usage.errors.rateChart.viewLabel")}
|
||||
value={rateView}
|
||||
options={ERROR_RATE_VIEW_OPTIONS}
|
||||
onChange={setRateView}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PanelBody loading={loading} height="h-64" empty={rateEmpty}>
|
||||
<LineChart
|
||||
|
|
@ -178,11 +209,11 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
/>
|
||||
</PanelBody>
|
||||
</Panel>
|
||||
<Panel title="Failed requests by status code" subtitle="Per day, stacked by HTTP status">
|
||||
<Panel title={t("usage.errors.statusChart.title")} subtitle={t("usage.errors.statusChart.subtitle")}>
|
||||
<PanelBody
|
||||
loading={loading}
|
||||
height="h-64"
|
||||
empty={series.rows.every((row) => row.failed === 0) ? "No failed requests in this range" : null}
|
||||
empty={series.rows.every((row) => row.failed === 0) ? t("usage.errors.empty.noFailedRequests") : null}
|
||||
>
|
||||
<StackedUsageChart
|
||||
data={series.rows}
|
||||
|
|
@ -191,12 +222,12 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
xLabel={shortDate}
|
||||
format={count}
|
||||
totalFor={(date) => failedByDate.get(date)}
|
||||
totalLabel="failed"
|
||||
totalLabel={t("usage.errors.statusChart.totalLabel")}
|
||||
className="h-64"
|
||||
/>
|
||||
<ul
|
||||
className={cn("flex flex-wrap gap-x-4 gap-y-1 pb-4 text-xs text-muted-foreground", PANEL_INSET_X)}
|
||||
aria-label="Status codes in the chart"
|
||||
aria-label={t("usage.errors.statusChart.legendLabel")}
|
||||
>
|
||||
{series.codes.map((code, index) => (
|
||||
<li key={code} className="flex items-center gap-1.5">
|
||||
|
|
@ -214,13 +245,13 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
||||
<Panel title="By status code" subtitle="Share of all failed requests">
|
||||
<Panel title={t("usage.errors.statusList.title")} subtitle={t("usage.errors.statusList.subtitle")}>
|
||||
<PanelBody
|
||||
loading={loading}
|
||||
height="h-48"
|
||||
empty={statusRows.length === 0 ? "No failed requests in this range" : null}
|
||||
empty={statusRows.length === 0 ? t("usage.errors.empty.noFailedRequests") : null}
|
||||
>
|
||||
<ul className={cn("grid gap-2 pb-4", PANEL_INSET_X)} aria-label="Failed requests by status code">
|
||||
<ul className={cn("grid gap-2 pb-4", PANEL_INSET_X)} aria-label={t("usage.errors.statusList.listLabel")}>
|
||||
{statusRows.map((row) => (
|
||||
<li
|
||||
key={row.status_code}
|
||||
|
|
@ -243,27 +274,34 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Failures by identity"
|
||||
subtitle="Keys, teams, users and models ranked by failed requests"
|
||||
action={<Segmented label="Group by" value={kind} options={ENTITY_OPTIONS} onChange={setKind} />}
|
||||
title={t("usage.errors.entities.title")}
|
||||
subtitle={t("usage.errors.entities.subtitle")}
|
||||
action={
|
||||
<Segmented
|
||||
label={t("usage.errors.entities.groupByLabel")}
|
||||
value={kind}
|
||||
options={ENTITY_OPTIONS}
|
||||
onChange={setKind}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PanelBody
|
||||
loading={loading}
|
||||
height="h-48"
|
||||
empty={
|
||||
rows.length === 0
|
||||
? `No ${ERROR_ENTITY_LABEL[kind].plural.toLowerCase()} with failed requests in this range`
|
||||
? t("usage.errors.entities.empty", { entities: ERROR_ENTITY_LABEL[kind].plural.toLowerCase() })
|
||||
: null
|
||||
}
|
||||
>
|
||||
<Table aria-label={`${ERROR_ENTITY_LABEL[kind].plural} ranked by failed requests`}>
|
||||
<Table aria-label={t("usage.errors.entities.tableLabel", { entities: ERROR_ENTITY_LABEL[kind].plural })}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{ERROR_ENTITY_LABEL[kind].singular}</TableHead>
|
||||
<TableHead className="text-right">Requests</TableHead>
|
||||
<TableHead className="text-right">Failed</TableHead>
|
||||
<TableHead>Error rate</TableHead>
|
||||
<TableHead>Top status</TableHead>
|
||||
<TableHead className="text-right">{t("usage.errors.entities.columns.requests")}</TableHead>
|
||||
<TableHead className="text-right">{t("usage.errors.entities.columns.failed")}</TableHead>
|
||||
<TableHead>{t("usage.errors.entities.columns.errorRate")}</TableHead>
|
||||
<TableHead>{t("usage.errors.entities.columns.topStatus")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -292,7 +330,7 @@ export default function ErrorsTab({ activity, loading, failed, userScoped = fals
|
|||
</PanelBody>
|
||||
<div className={cn("flex justify-end pb-4 pt-2", PANEL_INSET_X)}>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={uiHref("logs")} />}>
|
||||
Inspect failed requests in Logs
|
||||
{t("usage.errors.inspectInLogs")}
|
||||
<ArrowUpRight />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export interface RequestErrorDailyEntry {
|
||||
date: string;
|
||||
successful_requests: number;
|
||||
|
|
@ -37,30 +40,30 @@ export const ERROR_ENTITY_LABEL: Record<ErrorEntityKind, { singular: string; plu
|
|||
model: { singular: "Model", plural: "Models" },
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<number, string> = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
402: "Payment Required",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
408: "Request Timeout",
|
||||
413: "Payload Too Large",
|
||||
422: "Unprocessable Entity",
|
||||
429: "Rate Limited",
|
||||
500: "Internal Server Error",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
504: "Gateway Timeout",
|
||||
529: "Overloaded",
|
||||
const STATUS_LABELS: Record<number, ParseKeys> = {
|
||||
400: "usage.errors.statusLabels.badRequest",
|
||||
401: "usage.errors.statusLabels.unauthorized",
|
||||
402: "usage.errors.statusLabels.paymentRequired",
|
||||
403: "usage.errors.statusLabels.forbidden",
|
||||
404: "usage.errors.statusLabels.notFound",
|
||||
408: "usage.errors.statusLabels.requestTimeout",
|
||||
413: "usage.errors.statusLabels.payloadTooLarge",
|
||||
422: "usage.errors.statusLabels.unprocessableEntity",
|
||||
429: "usage.errors.statusLabels.rateLimited",
|
||||
500: "usage.errors.statusLabels.internalServerError",
|
||||
502: "usage.errors.statusLabels.badGateway",
|
||||
503: "usage.errors.statusLabels.serviceUnavailable",
|
||||
504: "usage.errors.statusLabels.gatewayTimeout",
|
||||
529: "usage.errors.statusLabels.overloaded",
|
||||
};
|
||||
|
||||
export const statusCodeLabel = (code: number): string => {
|
||||
if (code === 0) return "Not recorded";
|
||||
if (code === 0) return i18n.t("usage.errors.statusLabels.notRecorded");
|
||||
const known = STATUS_LABELS[code];
|
||||
if (known) return known;
|
||||
if (code >= 500) return "Server Error";
|
||||
if (code >= 400) return "Client Error";
|
||||
return "Error";
|
||||
if (known && Object.hasOwn(STATUS_LABELS, code)) return i18n.t(known);
|
||||
if (code >= 500) return i18n.t("usage.errors.statusLabels.serverError");
|
||||
if (code >= 400) return i18n.t("usage.errors.statusLabels.clientError");
|
||||
return i18n.t("usage.errors.statusLabels.error");
|
||||
};
|
||||
|
||||
export const errorRate = (failed: number, total: number): number | null => (total > 0 ? (failed / total) * 100 : null);
|
||||
|
|
@ -111,9 +114,9 @@ export interface ErrorSeries {
|
|||
}
|
||||
|
||||
export const ERROR_SERIES_MAX_CODES = 6;
|
||||
export const ERROR_SERIES_OTHER = "Other";
|
||||
export const ERROR_SERIES_OTHER: ParseKeys = "usage.errors.series.other";
|
||||
|
||||
const seriesKey = (code: number): string => (code === 0 ? "No status" : String(code));
|
||||
const seriesKey = (code: number): string => (code === 0 ? i18n.t("usage.errors.series.noStatus") : String(code));
|
||||
|
||||
export const errorSeries = (activity: RequestErrorActivity): ErrorSeries => {
|
||||
const ranked = [...activity.by_status_code]
|
||||
|
|
@ -134,7 +137,7 @@ export const errorSeries = (activity: RequestErrorActivity): ErrorSeries => {
|
|||
);
|
||||
const recorded = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
||||
const other: Record<string, number> = needsOther
|
||||
? { [ERROR_SERIES_OTHER]: Math.max(0, day.failed_requests - recorded) }
|
||||
? { [i18n.t(ERROR_SERIES_OTHER)]: Math.max(0, day.failed_requests - recorded) }
|
||||
: {};
|
||||
return {
|
||||
date: day.date,
|
||||
|
|
@ -145,7 +148,7 @@ export const errorSeries = (activity: RequestErrorActivity): ErrorSeries => {
|
|||
...other,
|
||||
};
|
||||
});
|
||||
return { rows, codes: needsOther ? [...codes, ERROR_SERIES_OTHER] : codes };
|
||||
return { rows, codes: needsOther ? [...codes, i18n.t(ERROR_SERIES_OTHER)] : codes };
|
||||
};
|
||||
|
||||
export type ErrorRateView = "total" | "status";
|
||||
|
|
@ -214,6 +217,6 @@ export const entityRows = (activity: RequestErrorActivity, kind: ErrorEntityKind
|
|||
topStatus:
|
||||
entry.top_status_code === null
|
||||
? null
|
||||
: `${entry.top_status_code === 0 ? "No status" : entry.top_status_code} · ${entry.top_status_code_requests.toLocaleString()}`,
|
||||
: `${entry.top_status_code === 0 ? i18n.t("usage.errors.series.noStatus") : entry.top_status_code} · ${entry.top_status_code_requests.toLocaleString()}`,
|
||||
}));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
|
|
@ -60,17 +61,18 @@ export function BreakdownControls({
|
|||
onChange: (next: BreakdownState) => void;
|
||||
showDimension?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Segmented
|
||||
label="Metric"
|
||||
label={t("usage.breakdown.metricLabel")}
|
||||
value={state.metric}
|
||||
options={METRIC_OPTIONS}
|
||||
onChange={(metric) => onChange({ ...state, metric })}
|
||||
/>
|
||||
{showDimension && (
|
||||
<Segmented
|
||||
label="Group by"
|
||||
label={t("usage.breakdown.groupByLabel")}
|
||||
value={state.dimension}
|
||||
options={DIMENSION_OPTIONS}
|
||||
onChange={(dimension) => onChange({ ...state, dimension })}
|
||||
|
|
@ -134,6 +136,7 @@ export function Leaderboard({
|
|||
dense?: boolean;
|
||||
colorByKey?: ReadonlyMap<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const colorOf = (name: string) => {
|
||||
if (colorByKey) return colorForModel(name, colorByKey);
|
||||
const index = series.labels.indexOf(name);
|
||||
|
|
@ -142,7 +145,7 @@ export function Leaderboard({
|
|||
const value = (row: LeaderRow) => rankValue(row, metric);
|
||||
const rows = ranking.slice(0, limit);
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-8 text-center text-xs text-muted-foreground">No usage in this range</p>;
|
||||
return <p className="py-8 text-center text-xs text-muted-foreground">{t("usage.breakdown.empty")}</p>;
|
||||
}
|
||||
const half = columns === 2 ? Math.ceil(rows.length / 2) : rows.length;
|
||||
const groups = columns === 2 ? [rows.slice(0, half), rows.slice(half)] : [rows];
|
||||
|
|
@ -168,7 +171,9 @@ export function Leaderboard({
|
|||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{row.key}</span>
|
||||
{providerForModel(row.key) && (
|
||||
<span className="block truncate text-xs text-muted-foreground">by {providerForModel(row.key)}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{t("usage.breakdown.byProvider", { provider: providerForModel(row.key) })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowRight, Bot, Monitor, Terminal } from "lucide-react";
|
||||
import { StackedUsageChart } from "@/components/shared/charts";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
|
|
@ -85,6 +86,7 @@ function AgentItem({
|
|||
color: string | undefined;
|
||||
onOpen?: (agent: AgentRow) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const term = useTerminology();
|
||||
const openRow = onOpen && agent.tags.length > 0 ? () => onOpen(agent) : undefined;
|
||||
const content = (
|
||||
|
|
@ -104,7 +106,7 @@ function AgentItem({
|
|||
<span className="block truncate text-sm font-medium text-foreground">{agent.label}</span>
|
||||
{openRow ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground">
|
||||
View activity
|
||||
{t("usage.topAgents.viewActivity")}
|
||||
<ArrowRight aria-hidden="true" className="size-3 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -112,7 +114,9 @@ function AgentItem({
|
|||
)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
<span className="block text-sm tabular-nums text-foreground">{formatCompact(agent.tokens)} tokens</span>
|
||||
<span className="block text-sm tabular-nums text-foreground">
|
||||
{t("usage.topAgents.tokens", { tokens: formatCompact(agent.tokens) })}
|
||||
</span>
|
||||
<span className="block text-xs tabular-nums text-muted-foreground">
|
||||
{formatCompactUsd(agent.spend)} · {share.toFixed(1)}%
|
||||
</span>
|
||||
|
|
@ -126,7 +130,7 @@ function AgentItem({
|
|||
{openRow ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`View ${agent.label} usage`}
|
||||
aria-label={t("usage.topAgents.viewAgentUsage", { agent: agent.label })}
|
||||
onClick={openRow}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
|
|
@ -143,8 +147,9 @@ function AgentItem({
|
|||
}
|
||||
|
||||
function TopAgentsBody({ loading, empty, children }: { loading: boolean; empty: boolean; children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) return <ChartSkeleton className="mx-5 h-64 w-auto" />;
|
||||
if (empty) return <p className="py-10 text-center text-xs text-muted-foreground">No agent traffic in this range</p>;
|
||||
if (empty) return <p className="py-10 text-center text-xs text-muted-foreground">{t("usage.topAgents.empty")}</p>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +174,7 @@ export default function TopAgents({
|
|||
totalTokens: number;
|
||||
onOpenAgent?: (agent: AgentRow) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [metric, setMetric] = useState<UsageMetric>("tokens");
|
||||
const allAgents = useMemo(() => topAgents(rows ?? []), [rows]);
|
||||
const agents = useMemo(
|
||||
|
|
@ -191,17 +197,19 @@ export default function TopAgents({
|
|||
const coverage = totalTokens > 0 ? Math.min(attributedTokens / totalTokens, 1) : 1;
|
||||
const subtitle =
|
||||
coverage < COVERAGE_NOTE_BELOW
|
||||
? `Daily usage by agent. ${Math.round(coverage * 100)}% of tokens came with a User-Agent; the rest is unattributed`
|
||||
: "Daily usage by agent, from each request's User-Agent";
|
||||
? t("usage.topAgents.subtitleLowCoverage", { percent: Math.round(coverage * 100) })
|
||||
: t("usage.topAgents.subtitle");
|
||||
const half = Math.ceil(agents.length / 2);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
icon={Bot}
|
||||
title="Top agents"
|
||||
title={t("usage.topAgents.title")}
|
||||
subtitle={subtitle}
|
||||
testId="top-agents"
|
||||
action={<Segmented label="Agent metric" value={metric} options={METRICS} onChange={setMetric} />}
|
||||
action={
|
||||
<Segmented label={t("usage.topAgents.metricLabel")} value={metric} options={METRICS} onChange={setMetric} />
|
||||
}
|
||||
bodyClassName="px-0 pt-4 pb-0"
|
||||
>
|
||||
<TopAgentsBody loading={loading} empty={agents.length === 0}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
|
@ -17,7 +19,11 @@ import {
|
|||
} from "./overviewData";
|
||||
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives";
|
||||
|
||||
const METRIC_NOUN: Record<UsageMetric, string> = { spend: "spend", tokens: "tokens", requests: "requests" };
|
||||
const METRIC_NOUN: Record<UsageMetric, ParseKeys> = {
|
||||
spend: "usage.breakdownChart.metricNoun.spend",
|
||||
tokens: "usage.breakdownChart.metricNoun.tokens",
|
||||
requests: "usage.breakdownChart.metricNoun.requests",
|
||||
};
|
||||
const GRANULARITY_OPTIONS = [
|
||||
{ value: "day", label: "Daily" },
|
||||
{ value: "week", label: "Weekly" },
|
||||
|
|
@ -42,6 +48,7 @@ export default function UsageBreakdownChart({
|
|||
colorByModel,
|
||||
dimension = "model_groups",
|
||||
}: UsageBreakdownChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [metric, setMetric] = useState<UsageMetric>("spend");
|
||||
const state: BreakdownState = { metric, dimension };
|
||||
const [granularity, setGranularity] = useState<Granularity>("day");
|
||||
|
|
@ -54,13 +61,34 @@ export default function UsageBreakdownChart({
|
|||
|
||||
return (
|
||||
<Panel
|
||||
title={`${granularity === "day" ? "Daily" : "Weekly"} usage`}
|
||||
subtitle={`${granularity === "day" ? "Daily" : "Weekly"} ${METRIC_NOUN[state.metric]} by model (top 8, rest grouped as Other)`}
|
||||
title={t("usage.breakdownChart.title", {
|
||||
granularity:
|
||||
granularity === "day"
|
||||
? t("usage.breakdownChart.granularity.daily")
|
||||
: t("usage.breakdownChart.granularity.weekly"),
|
||||
})}
|
||||
subtitle={t("usage.breakdownChart.subtitle", {
|
||||
granularity:
|
||||
granularity === "day"
|
||||
? t("usage.breakdownChart.granularity.daily")
|
||||
: t("usage.breakdownChart.granularity.weekly"),
|
||||
metric: t(METRIC_NOUN[state.metric]),
|
||||
})}
|
||||
action={
|
||||
<>
|
||||
<BreakdownControls state={state} onChange={(next) => setMetric(next.metric)} showDimension={false} />
|
||||
<Segmented label="Bucket size" value={granularity} options={GRANULARITY_OPTIONS} onChange={setGranularity} />
|
||||
<Segmented label="Scale" value={scale} options={SCALE_OPTIONS} onChange={setScale} />
|
||||
<Segmented
|
||||
label={t("usage.breakdownChart.bucketSize")}
|
||||
value={granularity}
|
||||
options={GRANULARITY_OPTIONS}
|
||||
onChange={setGranularity}
|
||||
/>
|
||||
<Segmented
|
||||
label={t("usage.breakdownChart.scaleLabel")}
|
||||
value={scale}
|
||||
options={SCALE_OPTIONS}
|
||||
onChange={setScale}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
bodyClassName="px-0 pt-4 pb-0"
|
||||
|
|
@ -84,9 +112,9 @@ export default function UsageBreakdownChart({
|
|||
)}
|
||||
</div>
|
||||
<div className={cn("border-t pt-3 pb-2", PANEL_INSET_X)}>
|
||||
<h4 className="text-sm leading-5 font-medium text-foreground">Top models</h4>
|
||||
<h4 className="text-sm leading-5 font-medium text-foreground">{t("usage.breakdownChart.topModels")}</h4>
|
||||
{loading ? (
|
||||
<div role="status" aria-label="Loading top models">
|
||||
<div role="status" aria-label={t("usage.breakdownChart.loadingTopModels")}>
|
||||
<ChartSkeleton className="my-2 h-20" />
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import { KeyRound, Users } from "lucide-react";
|
||||
import type { OverviewTotals } from "./overviewData";
|
||||
|
|
@ -41,6 +42,7 @@ export default function UsageOverview({
|
|||
providerBreakdown,
|
||||
budget,
|
||||
}: UsageOverviewProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<UsageStatStrip
|
||||
|
|
@ -58,10 +60,10 @@ export default function UsageOverview({
|
|||
{topAgents}
|
||||
{providerBreakdown}
|
||||
|
||||
<Panel icon={KeyRound} title="Top Virtual Keys">
|
||||
<Panel icon={KeyRound} title={t("usage.overview.topVirtualKeys")}>
|
||||
{topKeys}
|
||||
</Panel>
|
||||
<Panel icon={Users} title="Top Users by Spend">
|
||||
<Panel icon={Users} title={t("usage.overview.topUsersBySpend")}>
|
||||
{topUsers}
|
||||
</Panel>
|
||||
{gatewayByEndpoint}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -25,90 +26,110 @@ export default function UsageStatStrip({
|
|||
requestCountsPending,
|
||||
budget,
|
||||
}: UsageStatStripProps) {
|
||||
const { t } = useTranslation();
|
||||
const daily = useMemo(() => dailyTotals(results), [results]);
|
||||
return (
|
||||
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
|
||||
<Stat
|
||||
label="Spend"
|
||||
label={t("usage.statStrip.spend")}
|
||||
value={formatUsd(totals.spend)}
|
||||
pending={loading}
|
||||
hint={budget ? `of ${formatUsd(budget)} budget` : "No budget set"}
|
||||
hint={budget ? t("usage.statStrip.budgetHint", { budget: formatUsd(budget) }) : t("usage.statStrip.noBudget")}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>
|
||||
<Stat label="Total Requests" value={totals.requests.toLocaleString()} pending={requestCountsPending} />
|
||||
<Stat
|
||||
label={t("usage.statStrip.totalRequests")}
|
||||
value={totals.requests.toLocaleString()}
|
||||
pending={requestCountsPending}
|
||||
/>
|
||||
{!requestCountsPending && (
|
||||
<div className="mt-0.5 flex gap-3 text-xs leading-4 text-muted-foreground">
|
||||
<span>
|
||||
<span className="text-success tabular-nums">{totals.successful.toLocaleString()}</span> ok
|
||||
<Trans
|
||||
i18nKey="usage.statStrip.successfulCount"
|
||||
components={{
|
||||
count: <span className="text-success tabular-nums">{totals.successful.toLocaleString()}</span>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<span className={totals.failed > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
|
||||
{totals.failed.toLocaleString()}
|
||||
</span>{" "}
|
||||
failed
|
||||
<Trans
|
||||
i18nKey="usage.statStrip.failedCount"
|
||||
components={{
|
||||
count: (
|
||||
<span className={totals.failed > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
|
||||
{totals.failed.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</StatCell>
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="tokens" color={BRAND} className="h-14" />}>
|
||||
<Stat
|
||||
label="Total Tokens"
|
||||
label={t("usage.statStrip.totalTokens")}
|
||||
value={loading ? null : formatCompact(totals.tokens)}
|
||||
exact={`${totals.tokens.toLocaleString()} tokens`}
|
||||
exact={t("usage.statStrip.exactTokens", { tokens: totals.tokens.toLocaleString() })}
|
||||
pending={loading}
|
||||
hint={loading ? undefined : `${formatCompact(totals.cacheReadTokens)} cache read`}
|
||||
hint={
|
||||
loading ? undefined : t("usage.statStrip.cacheReadHint", { tokens: formatCompact(totals.cacheReadTokens) })
|
||||
}
|
||||
/>
|
||||
{!loading && (
|
||||
<Popover>
|
||||
<PopoverTrigger className="mt-1 text-xs font-medium text-primary underline-offset-4 hover:underline">
|
||||
View token breakdown
|
||||
{t("usage.statStrip.viewTokenBreakdown")}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" align="start" className="w-80 gap-3">
|
||||
<PopoverTitle>What counts toward total tokens?</PopoverTitle>
|
||||
<PopoverTitle>{t("usage.statStrip.tokenPopover.title")}</PopoverTitle>
|
||||
<dl className="space-y-2 text-sm tabular-nums">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Input tokens</dt>
|
||||
<dt>{t("usage.statStrip.tokenPopover.inputTokens")}</dt>
|
||||
<dd>{totals.inputTokens.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 pl-3 text-xs text-muted-foreground">
|
||||
<dt>Cache reads</dt>
|
||||
<dt>{t("usage.statStrip.tokenPopover.cacheReads")}</dt>
|
||||
<dd>{totals.cacheReadTokens.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 pl-3 text-xs text-muted-foreground">
|
||||
<dt>Cache writes</dt>
|
||||
<dt>{t("usage.statStrip.tokenPopover.cacheWrites")}</dt>
|
||||
<dd>{totals.cacheWriteTokens.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Output tokens</dt>
|
||||
<dt>{t("usage.statStrip.tokenPopover.outputTokens")}</dt>
|
||||
<dd>{totals.outputTokens.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-t pt-2 font-medium">
|
||||
<dt>Total tokens</dt>
|
||||
<dt>{t("usage.statStrip.tokenPopover.totalTokens")}</dt>
|
||||
<dd>{totals.tokens.toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Input includes cache reads and writes. Cached input has separate pricing, so total tokens alone cannot
|
||||
explain the cost.
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
A client may show a different subset of tokens. Compare the same person, client and time range before
|
||||
comparing counts.
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t("usage.statStrip.tokenPopover.pricingNote")}</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t("usage.statStrip.tokenPopover.clientNote")}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<div className="grid gap-3">
|
||||
<Stat label="Avg cost / request" value={formatUsd(totals.avgCostPerRequest, 4)} pending={loading} />
|
||||
<Stat
|
||||
label="Avg latency"
|
||||
label={t("usage.statStrip.avgCostPerRequest")}
|
||||
value={formatUsd(totals.avgCostPerRequest, 4)}
|
||||
pending={loading}
|
||||
/>
|
||||
<Stat
|
||||
label={t("usage.statStrip.avgLatency")}
|
||||
value={totals.avgLatencyMs === null ? "—" : formatLatency(totals.avgLatencyMs)}
|
||||
pending={loading}
|
||||
hint={totals.successRate === null ? undefined : `${totals.successRate.toFixed(1)}% success rate`}
|
||||
hint={
|
||||
totals.successRate === null
|
||||
? undefined
|
||||
: t("usage.statStrip.successRateHint", { rate: totals.successRate.toFixed(1) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</StatCell>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import openaiAgentsLogo from "../../../../../../../public/assets/logos/openai-ag
|
|||
import opencodeLogo from "../../../../../../../public/assets/moyai/logos/opencode.svg";
|
||||
import pydanticAiLogo from "../../../../../../../public/assets/logos/pydantic-ai-color.svg";
|
||||
import xaiLogo from "../../../../../../../public/assets/logos/xai.svg";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
|
||||
/** One row of `GET /tag/summary`. */
|
||||
|
|
@ -47,7 +49,7 @@ export interface AgentRow {
|
|||
interface AgentFamily {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
description: ParseKeys;
|
||||
kind: AgentKind;
|
||||
logo: string | null;
|
||||
/** Matched against the user-agent with its `User-Agent: ` prefix removed, case-insensitively. */
|
||||
|
|
@ -63,7 +65,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: UsageClient.ClaudeDesktop,
|
||||
label: "Claude Desktop",
|
||||
description: "Anthropic's desktop assistant",
|
||||
description: "usage.agentCatalog.descriptions.claudeDesktop",
|
||||
kind: "agent",
|
||||
logo: claudeLogo.src,
|
||||
match: /^claude[-_ ]desktop\b|^claude-cli\/[^\s]+\s+\((?:[^)]*,\s*)?claude-desktop-3p\s*[,)]/i,
|
||||
|
|
@ -71,7 +73,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: UsageClient.ClaudeCode,
|
||||
label: "Claude Code",
|
||||
description: "Anthropic's coding agent",
|
||||
description: "usage.agentCatalog.descriptions.claudeCode",
|
||||
kind: "agent",
|
||||
logo: claudeLogo.src,
|
||||
match: /^(claude-code|claude_code|claude-user)\b|^claude-cli\/[^\s]+\s+\((?:[^)]*,\s*)?cli\s*[,)]/i,
|
||||
|
|
@ -79,7 +81,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "claude-unspecified",
|
||||
label: "Claude (unspecified)",
|
||||
description: "Client not identified",
|
||||
description: "usage.agentCatalog.descriptions.claudeUnspecified",
|
||||
kind: "agent",
|
||||
logo: claudeLogo.src,
|
||||
match: /^claude-cli\b/i,
|
||||
|
|
@ -87,7 +89,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI's coding agent",
|
||||
description: "usage.agentCatalog.descriptions.codex",
|
||||
kind: "agent",
|
||||
logo: openaiLogo.src,
|
||||
match: /^(codex[ _-]|codex$)/i,
|
||||
|
|
@ -95,7 +97,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
description: "Open-source terminal coding agent",
|
||||
description: "usage.agentCatalog.descriptions.opencode",
|
||||
kind: "agent",
|
||||
logo: opencodeLogo.src,
|
||||
match: /^opencode\b/i,
|
||||
|
|
@ -103,7 +105,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "cursor",
|
||||
label: "Cursor",
|
||||
description: "AI code editor",
|
||||
description: "usage.agentCatalog.descriptions.cursor",
|
||||
kind: "agent",
|
||||
logo: cursorLogo.src,
|
||||
match: /^cursor\b/i,
|
||||
|
|
@ -111,7 +113,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "copilot",
|
||||
label: "GitHub Copilot",
|
||||
description: "GitHub's coding assistant",
|
||||
description: "usage.agentCatalog.descriptions.copilot",
|
||||
kind: "agent",
|
||||
logo: copilotLogo.src,
|
||||
match: /^(github-?copilot|copilot)\b/i,
|
||||
|
|
@ -119,16 +121,23 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "hermes",
|
||||
label: "Hermes Agent",
|
||||
description: "Open-source agent",
|
||||
description: "usage.agentCatalog.descriptions.hermes",
|
||||
kind: "agent",
|
||||
logo: hermesLogo.src,
|
||||
match: /^hermes\b/i,
|
||||
},
|
||||
{ id: "grok", label: "Grok", description: "xAI's assistant", kind: "agent", logo: xaiLogo.src, match: /^grok\b/i },
|
||||
{
|
||||
id: "grok",
|
||||
label: "Grok",
|
||||
description: "usage.agentCatalog.descriptions.grok",
|
||||
kind: "agent",
|
||||
logo: xaiLogo.src,
|
||||
match: /^grok\b/i,
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
label: "Cline",
|
||||
description: "Open-source coding agent",
|
||||
description: "usage.agentCatalog.descriptions.cline",
|
||||
kind: "agent",
|
||||
logo: null,
|
||||
match: /^cline\b/i,
|
||||
|
|
@ -136,7 +145,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "kilo-code",
|
||||
label: "Kilo Code",
|
||||
description: "Open-source coding agent",
|
||||
description: "usage.agentCatalog.descriptions.kiloCode",
|
||||
kind: "agent",
|
||||
logo: null,
|
||||
match: /^kilo[- ]?code\b/i,
|
||||
|
|
@ -144,7 +153,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "aider",
|
||||
label: "Aider",
|
||||
description: "AI pair programming in the terminal",
|
||||
description: "usage.agentCatalog.descriptions.aider",
|
||||
kind: "agent",
|
||||
logo: null,
|
||||
match: /^aider\b/i,
|
||||
|
|
@ -152,7 +161,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "openai-agents",
|
||||
label: "OpenAI Agents",
|
||||
description: "OpenAI Agents SDK",
|
||||
description: "usage.agentCatalog.descriptions.openaiAgents",
|
||||
kind: "sdk",
|
||||
logo: openaiAgentsLogo.src,
|
||||
match: /^agents$/i,
|
||||
|
|
@ -160,7 +169,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "langchain",
|
||||
label: "LangChain",
|
||||
description: "Agent framework",
|
||||
description: "usage.agentCatalog.descriptions.langchain",
|
||||
kind: "sdk",
|
||||
logo: langchainLogo.src,
|
||||
match: /^(langchain|langgraph|deepagents)/i,
|
||||
|
|
@ -168,7 +177,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "pydantic-ai",
|
||||
label: "Pydantic AI",
|
||||
description: "Agent framework",
|
||||
description: "usage.agentCatalog.descriptions.pydanticAi",
|
||||
kind: "sdk",
|
||||
logo: pydanticAiLogo.src,
|
||||
match: /^pydantic-ai\b/i,
|
||||
|
|
@ -176,7 +185,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "github-actions",
|
||||
label: "GitHub Actions",
|
||||
description: "CI workflows",
|
||||
description: "usage.agentCatalog.descriptions.githubActions",
|
||||
kind: "sdk",
|
||||
logo: githubLogo.src,
|
||||
match: /^github-actions\b/i,
|
||||
|
|
@ -184,7 +193,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "litellm",
|
||||
label: "LiteLLM SDK",
|
||||
description: "litellm Python SDK",
|
||||
description: "usage.agentCatalog.descriptions.litellmSdk",
|
||||
kind: "sdk",
|
||||
logo: litellmLogo.src,
|
||||
match: /^litellm\b/i,
|
||||
|
|
@ -192,7 +201,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "openai-sdk",
|
||||
label: "OpenAI SDK",
|
||||
description: "OpenAI client library",
|
||||
description: "usage.agentCatalog.descriptions.openaiSdk",
|
||||
kind: "sdk",
|
||||
logo: openaiLogo.src,
|
||||
match: /^(openai|asyncopenai)\b/i,
|
||||
|
|
@ -200,7 +209,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "anthropic-sdk",
|
||||
label: "Anthropic SDK",
|
||||
description: "Anthropic client library",
|
||||
description: "usage.agentCatalog.descriptions.anthropicSdk",
|
||||
kind: "sdk",
|
||||
logo: anthropicLogo.src,
|
||||
match: /^anthropic\b/i,
|
||||
|
|
@ -208,7 +217,7 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "python",
|
||||
label: "Python",
|
||||
description: "Scripts and HTTP clients",
|
||||
description: "usage.agentCatalog.descriptions.python",
|
||||
kind: "sdk",
|
||||
logo: null,
|
||||
match: /^(python|httpx|aiohttp|urllib|requests)/i,
|
||||
|
|
@ -216,12 +225,19 @@ const FAMILIES: readonly AgentFamily[] = [
|
|||
{
|
||||
id: "node",
|
||||
label: "Node.js",
|
||||
description: "Scripts and HTTP clients",
|
||||
description: "usage.agentCatalog.descriptions.node",
|
||||
kind: "sdk",
|
||||
logo: null,
|
||||
match: /^(node|bun|deno|axios|undici)\b/i,
|
||||
},
|
||||
{ id: "curl", label: "curl", description: "Command line", kind: "sdk", logo: null, match: /^curl\b/i },
|
||||
{
|
||||
id: "curl",
|
||||
label: "curl",
|
||||
description: "usage.agentCatalog.descriptions.curl",
|
||||
kind: "sdk",
|
||||
logo: null,
|
||||
match: /^curl\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const USER_AGENT_PREFIX = /^user[- ]agent:\s*/i;
|
||||
|
|
@ -258,7 +274,7 @@ const agentFor = (product: string, rows: readonly TagSummaryRow[]): AgentRow =>
|
|||
return {
|
||||
id: family?.id ?? `ua:${product.toLowerCase()}`,
|
||||
label: family?.label ?? product,
|
||||
description: family?.description ?? "Custom client",
|
||||
description: i18n.t(family?.description ?? "usage.agentCatalog.descriptions.customClient"),
|
||||
kind: family?.kind ?? "sdk",
|
||||
logo: family?.logo ?? null,
|
||||
spend: rows.reduce((sum, row) => sum + (row.total_spend || 0), 0),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { stackedUsageColor } from "@/components/shared/charts";
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityMetadata } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { GatewayActivity } from "../gatewayActivity";
|
||||
|
|
@ -65,7 +67,7 @@ export const colorForModel = (model: string, colorByModel: ReadonlyMap<string, s
|
|||
};
|
||||
|
||||
export const OTHER_COLOR = "#94a3b8";
|
||||
const OTHER_SERIES = "Other";
|
||||
const OTHER_SERIES: ParseKeys = "usage.overview.otherSeries";
|
||||
|
||||
export interface SeriesDay extends Record<string, unknown> {
|
||||
/** ISO date, the chart's category key: unique across years, unlike its display label. */
|
||||
|
|
@ -136,7 +138,7 @@ export const seriesBy = (
|
|||
return { day, values, other: remainder > 1e-6 ? remainder : 0 };
|
||||
});
|
||||
const hasOther = days.some(({ other }) => other > 0) || leaders.length === 0;
|
||||
const labels = hasOther ? [...leaders, OTHER_SERIES] : leaders;
|
||||
const labels = hasOther ? [...leaders, i18n.t(OTHER_SERIES)] : leaders;
|
||||
const keys = labels.map((_, index) => seriesKey(index));
|
||||
const data = days.map(({ day, values, other }) => ({
|
||||
date: day.date,
|
||||
|
|
@ -167,7 +169,7 @@ export const bucketSeries = (series: Series, granularity: Granularity): Series =
|
|||
const members = series.data.filter((day) => bucketStart(day.date) === start);
|
||||
return {
|
||||
date: start,
|
||||
label: `Wk of ${formatDayLabel(start)}`,
|
||||
label: i18n.t("usage.overview.weekOf", { date: formatDayLabel(start) }),
|
||||
...Object.fromEntries(
|
||||
series.keys.map((key) => [key, members.reduce((sum, day) => sum + Number(day[key] ?? 0), 0)]),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
|
||||
import type { AgentRow } from "../components/overview/agentCatalog";
|
||||
import { clientAgentDetail } from "../components/overview/clientAgentDetail";
|
||||
|
|
@ -61,18 +62,18 @@ const breakdownFor = (
|
|||
|
||||
export const clientAgentModelsFromExport = (payload: unknown, agent: AgentRow): ClientAgentModelsResult => {
|
||||
const parsed = z.array(exportRow).safeParse(payload);
|
||||
if (!parsed.success) return { results: [], error: "The proxy returned incomplete model usage data" };
|
||||
if (!parsed.success) return { results: [], error: i18n.t("usage.clientModels.errors.incompleteData") };
|
||||
const tags = new Set(agent.tags);
|
||||
const rows = parsed.data.filter((row) => tags.has(row.entity_id));
|
||||
const identities = rows.map((row) => JSON.stringify([row.date, row.entity_id, row.model]));
|
||||
if (new Set(identities).size !== identities.length) {
|
||||
return { results: [], error: "The proxy returned duplicate model usage data. Try again." };
|
||||
return { results: [], error: i18n.t("usage.clientModels.errors.duplicateData") };
|
||||
}
|
||||
const results = grouped(rows, (row) => row.date).map(([date, members]) => ({
|
||||
date,
|
||||
metrics: metricsFor(members),
|
||||
breakdown: {
|
||||
models: breakdownFor(members, (row) => row.model || "Unknown model"),
|
||||
models: breakdownFor(members, (row) => row.model || i18n.t("usage.clientUsage.unknownModel")),
|
||||
model_groups: {},
|
||||
entities: breakdownFor(members, (row) => row.entity_id),
|
||||
providers: {},
|
||||
|
|
@ -83,7 +84,7 @@ export const clientAgentModelsFromExport = (payload: unknown, agent: AgentRow):
|
|||
if (!clientAgentDetail(agent, results).dailyMatchesSummary) {
|
||||
return {
|
||||
results: [],
|
||||
error: "Model usage does not match these client totals yet. Refresh or choose a shorter range.",
|
||||
error: i18n.t("usage.clientModels.errors.totalsMismatch"),
|
||||
};
|
||||
}
|
||||
return { results, error: null };
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
EMPTY_DAILY_ACTIVITY_RESPONSE,
|
||||
type DailyActivityAggregatedResponse,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
|
||||
import { isUserAgentTag, usageClientFromUserAgent, UsageClient } from "../components/overview/agentCatalog";
|
||||
|
|
@ -165,7 +166,7 @@ const breakdownFor = (
|
|||
|
||||
const localDay = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const modelName = (row: ClientLog) => row.model || "Unknown model";
|
||||
const modelName = (row: ClientLog) => row.model || i18n.t("usage.clientUsage.unknownModel");
|
||||
const dailyData = (rows: readonly ClientLog[]): DailyData[] =>
|
||||
[...new Set(rows.map((row) => localDay(row.startTime)))].sort().map((date) => {
|
||||
const day = rows.filter((row) => localDay(row.startTime) === date);
|
||||
|
|
@ -175,7 +176,7 @@ const dailyData = (rows: readonly ClientLog[]): DailyData[] =>
|
|||
breakdown: {
|
||||
models: breakdownFor(day, modelName),
|
||||
model_groups: breakdownFor(day, (row) => row.model_group || modelName(row)),
|
||||
providers: breakdownFor(day, (row) => row.custom_llm_provider || "Unknown provider"),
|
||||
providers: breakdownFor(day, (row) => row.custom_llm_provider || i18n.t("usage.clientUsage.unknownProvider")),
|
||||
api_keys: {},
|
||||
mcp_servers: {},
|
||||
entities: {},
|
||||
|
|
@ -185,19 +186,19 @@ const dailyData = (rows: readonly ClientLog[]): DailyData[] =>
|
|||
});
|
||||
|
||||
export function clientUsageFromLogs(response: unknown, scope: ClientUsageScope): ClientUsageResult {
|
||||
if (!scope.userId && !scope.teamId) return emptyClientUsage("Select a user or team to view client usage");
|
||||
if (!scope.userId && !scope.teamId) return emptyClientUsage(i18n.t("usage.clientUsage.errors.selectUserOrTeam"));
|
||||
const parsed = pageSchema.safeParse(response);
|
||||
if (!parsed.success) return emptyClientUsage("The proxy returned incomplete client usage data");
|
||||
if (!parsed.success) return emptyClientUsage(i18n.t("usage.clientUsage.errors.incompleteData"));
|
||||
const page = parsed.data;
|
||||
if (page.total_is_capped || page.total_pages > 1 || page.total > CLIENT_REQUEST_LIMIT) {
|
||||
return emptyClientUsage(INCOMPLETE_CLIENT_USAGE);
|
||||
}
|
||||
if (page.data.length !== page.total) return emptyClientUsage("The proxy returned incomplete client usage data");
|
||||
if (page.data.length !== page.total) return emptyClientUsage(i18n.t("usage.clientUsage.errors.incompleteData"));
|
||||
if (scope.userId && page.data.some((row) => row.user !== scope.userId)) {
|
||||
return emptyClientUsage("The proxy returned usage outside the selected user");
|
||||
return emptyClientUsage(i18n.t("usage.clientUsage.errors.outsideUser"));
|
||||
}
|
||||
if (scope.teamId && page.data.some((row) => row.team_id !== scope.teamId)) {
|
||||
return emptyClientUsage("The proxy returned usage outside the selected team");
|
||||
return emptyClientUsage(i18n.t("usage.clientUsage.errors.outsideTeam"));
|
||||
}
|
||||
const uniqueRows = [...new Map(page.data.map((row) => [row.request_id, row])).values()];
|
||||
const rows = uniqueRows.filter(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Member } from "@/components/networking";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { hasProxyWideSpendView, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
|
||||
export interface ClientUsageTeam {
|
||||
|
|
@ -76,23 +77,23 @@ export const resolveClientUsageScope = ({
|
|||
canChooseGlobalUser: proxyAdmin && !ownUsage && !requestedTeamId,
|
||||
error: null,
|
||||
};
|
||||
if (!userId) return { ...base, error: "Sign in to view client usage" };
|
||||
if (!userId) return { ...base, error: i18n.t("usage.clientUsage.errors.signIn") };
|
||||
if (ownUsage) return { ...base, scope: { userId, teamId: null } };
|
||||
|
||||
if (requestedTeamId) {
|
||||
const selectedTeam = availableTeams.find((team) => team.team_id === requestedTeamId);
|
||||
if (!selectedTeam) return { ...base, error: "Choose a team you administer" };
|
||||
if (!selectedTeam) return { ...base, error: i18n.t("usage.clientUsage.errors.chooseAdministeredTeam") };
|
||||
const members = memberOptions(selectedTeam);
|
||||
const memberId = requestedUserId || null;
|
||||
if (memberId && !members.some((member) => member.value === memberId)) {
|
||||
return { ...base, members, error: "Choose a member of the selected team" };
|
||||
return { ...base, members, error: i18n.t("usage.clientUsage.errors.chooseTeamMember") };
|
||||
}
|
||||
return { ...base, members, scope: { teamId: selectedTeam.team_id, userId: memberId } };
|
||||
}
|
||||
|
||||
if (proxyAdmin) return { ...base, scope: { teamId: null, userId: requestedUserId || null } };
|
||||
if (requestedUserId && requestedUserId !== userId) {
|
||||
return { ...base, error: "Choose an administered team before viewing another member's usage" };
|
||||
return { ...base, error: i18n.t("usage.clientUsage.errors.chooseTeamBeforeMember") };
|
||||
}
|
||||
return { ...base, scope: { teamId: null, userId } };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { dailyActivityExportCall } from "@/components/networking";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { AgentRow } from "../components/overview/agentCatalog";
|
||||
import { clientAgentModelsFromExport, type ClientAgentModelsResult } from "./clientAgentModelsData";
|
||||
|
|
@ -18,15 +19,18 @@ const loadModels = async (request: DailyActivityRequest, agent: AgentRow): Promi
|
|||
try {
|
||||
const blob = await dailyActivityExportCall("tag", request, "daily_with_models", "json");
|
||||
if (blob.size > MAX_CLIENT_MODEL_EXPORT_BYTES) {
|
||||
return { results: [], error: "Choose a shorter date range to load the model breakdown" };
|
||||
return { results: [], error: i18n.t("usage.clientModels.errors.tooLarge") };
|
||||
}
|
||||
const payload: unknown = JSON.parse(await blob.text());
|
||||
return clientAgentModelsFromExport(payload, agent);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return { results: [], error: "Could not load complete model usage. Try again." };
|
||||
return { results: [], error: i18n.t("usage.clientModels.errors.invalidJson") };
|
||||
}
|
||||
return { results: [], error: error instanceof Error ? error.message : "Could not load the model breakdown" };
|
||||
return {
|
||||
results: [],
|
||||
error: error instanceof Error ? error.message : i18n.t("usage.clientModels.errors.loadFailed"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -61,7 +65,7 @@ export function useClientAgentModels({ accessToken, startTime, endTime, agent, e
|
|||
return {
|
||||
results: current?.results ?? [],
|
||||
loading: active && validRange && current === null,
|
||||
error: active && !validRange ? "Select a valid date range" : current?.error ?? null,
|
||||
error: active && !validRange ? i18n.t("usage.clientUsage.errors.invalidRange") : current?.error ?? null,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { uiSpendLogsCall } from "@/components/networking";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import {
|
||||
CLIENT_REQUEST_LIMIT,
|
||||
clientUsageFromLogs,
|
||||
|
|
@ -24,9 +25,10 @@ export function useClientUsage({ accessToken, startTime, endTime, userId, teamId
|
|||
const end = endTime?.getTime() ?? Number.NaN;
|
||||
const scopeError = (() => {
|
||||
if (client === null) return null;
|
||||
if (!userId && !teamId) return "Select a user or team to view client usage";
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return "Select a valid date range";
|
||||
if (!accessToken) return "Sign in to view client usage";
|
||||
if (!userId && !teamId) return i18n.t("usage.clientUsage.errors.selectUserOrTeam");
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end)
|
||||
return i18n.t("usage.clientUsage.errors.invalidRange");
|
||||
if (!accessToken) return i18n.t("usage.clientUsage.errors.signIn");
|
||||
return null;
|
||||
})();
|
||||
const enabled = client !== null && scopeError === null;
|
||||
|
|
@ -63,7 +65,9 @@ export function useClientUsage({ accessToken, startTime, endTime, userId, teamId
|
|||
if (!cancelled) {
|
||||
setSettled({
|
||||
key,
|
||||
result: emptyClientUsage(error instanceof Error ? error.message : "Could not load client usage"),
|
||||
result: emptyClientUsage(
|
||||
error instanceof Error ? error.message : i18n.t("usage.clientUsage.errors.loadFailed"),
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
|||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
|
||||
import { useQueryStates } from "nuqs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { dateRangeFromParams, usageUrlParsers } from "./_components/usageUrlState";
|
||||
|
||||
export default function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
useAuthorized();
|
||||
const { data: teams, isLoading: teamsLoading } = useTeams();
|
||||
const { data: organizations } = useOrganizations();
|
||||
|
|
@ -18,7 +20,7 @@ export default function UsagePage() {
|
|||
const [params] = useQueryStates(usageUrlParsers);
|
||||
if (settingsLoading && !dateRangeFromParams(params)) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading usage settings" className="p-6">
|
||||
<div role="status" aria-label={t("usage.page.loadingSettings")} className="p-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { PageContent } from "@/components/shared/Page";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaginationState } from "@tanstack/react-table";
|
||||
import { Info } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
|
|
@ -9,6 +10,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { DeletedKeysTable } from "./DeletedKeysTable/DeletedKeysTable";
|
||||
|
||||
export default function DeletedKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { premiumUser } = useAuthorized();
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
|
||||
|
||||
|
|
@ -19,10 +21,8 @@ export default function DeletedKeysPage() {
|
|||
{!premiumUser && (
|
||||
<Alert className="shrink-0">
|
||||
<Info />
|
||||
<AlertTitle>Coming soon to Enterprise</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deleted key auditing is graduating from beta into our Enterprise audit & compliance suite.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("logs.deletedKeys.page.comingSoonTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("logs.deletedKeys.page.comingSoonDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<DeletedKeysTable
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
|
|
@ -20,13 +21,14 @@ interface DeletedKeysTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "deleted_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No deleted keys found</div>
|
||||
<div className="text-sm text-muted-foreground">Keys deleted from this proxy will show up here.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("logs.deletedKeys.table.emptyTitle")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("logs.deletedKeys.table.emptyDescription")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,9 +40,10 @@ export function DeletedKeysTable({
|
|||
pagination,
|
||||
onPaginationChange,
|
||||
}: DeletedKeysTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => getDeletedKeysTableColumns(), []);
|
||||
const columns = useMemo(() => getDeletedKeysTableColumns(t), [t]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -56,7 +59,7 @@ export function DeletedKeysTable({
|
|||
onPaginationChange={onPaginationChange}
|
||||
rowCount={totalCount}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading deleted keys…"
|
||||
loadingMessage={t("logs.deletedKeys.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, IdentityCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -29,12 +30,12 @@ function UserLinkCell({ userId }: { userId: string | null | undefined }) {
|
|||
);
|
||||
}
|
||||
|
||||
export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] => [
|
||||
export const getDeletedKeysTableColumns = (t: TFunction): ColumnDef<DeletedKeyResponse>[] => [
|
||||
{
|
||||
id: "token",
|
||||
accessorKey: "token",
|
||||
meta: { title: "Key ID" },
|
||||
header: "Key ID",
|
||||
meta: { title: t("logs.deletedKeys.columns.keyId") },
|
||||
header: t("logs.deletedKeys.columns.keyId"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.token} variant="plain" />,
|
||||
|
|
@ -42,8 +43,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "key_alias",
|
||||
accessorKey: "key_alias",
|
||||
meta: { title: "Key Alias" },
|
||||
header: "Key Alias",
|
||||
meta: { title: t("logs.deletedKeys.columns.keyAlias") },
|
||||
header: t("logs.deletedKeys.columns.keyAlias"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -61,8 +62,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
meta: { title: "Team Alias" },
|
||||
header: "Team Alias",
|
||||
meta: { title: t("logs.deletedKeys.columns.teamAlias") },
|
||||
header: t("logs.deletedKeys.columns.teamAlias"),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedTextCell value={row.original.team_alias} />,
|
||||
|
|
@ -70,8 +71,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
meta: { title: t("logs.deletedKeys.columns.spendUsd"), numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("logs.deletedKeys.columns.spendUsd")} />,
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
|
|
@ -79,17 +80,24 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: "Budget (USD)",
|
||||
meta: { title: t("logs.deletedKeys.columns.budgetUsd"), numeric: true },
|
||||
header: t("logs.deletedKeys.columns.budgetUsd"),
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={0} emptyText="Unlimited" showZero />,
|
||||
cell: ({ row }) => (
|
||||
<MoneyCell
|
||||
value={row.original.max_budget}
|
||||
decimals={0}
|
||||
emptyText={t("logs.deletedKeys.columns.unlimited")}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorKey: "user_email",
|
||||
meta: { title: "User Email" },
|
||||
header: "User Email",
|
||||
meta: { title: t("logs.deletedKeys.columns.userEmail") },
|
||||
header: t("logs.deletedKeys.columns.userEmail"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedTextCell value={row.original.user_email} />,
|
||||
|
|
@ -97,8 +105,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
meta: { title: "User ID" },
|
||||
header: "User ID",
|
||||
meta: { title: t("logs.deletedKeys.columns.userId") },
|
||||
header: t("logs.deletedKeys.columns.userId"),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <UserLinkCell userId={row.original.user_id} />,
|
||||
|
|
@ -106,8 +114,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
meta: { title: t("logs.deletedKeys.columns.createdAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("logs.deletedKeys.columns.createdAt")} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
|
|
@ -115,8 +123,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
meta: { title: "Created By" },
|
||||
header: "Created By",
|
||||
meta: { title: t("logs.deletedKeys.columns.createdBy") },
|
||||
header: t("logs.deletedKeys.columns.createdBy"),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <UserLinkCell userId={row.original.created_by} />,
|
||||
|
|
@ -124,8 +132,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
meta: { title: "Deleted At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Deleted At" />,
|
||||
meta: { title: t("logs.deletedKeys.columns.deletedAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("logs.deletedKeys.columns.deletedAt")} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.deleted_at} precision="date" />,
|
||||
|
|
@ -133,8 +141,8 @@ export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] =>
|
|||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
meta: { title: "Deleted By" },
|
||||
header: "Deleted By",
|
||||
meta: { title: t("logs.deletedKeys.columns.deletedBy") },
|
||||
header: t("logs.deletedKeys.columns.deletedBy"),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <UserLinkCell userId={row.original.deleted_by} />,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { PaginationState } from "@tanstack/react-table";
|
|||
import { Info } from "lucide-react";
|
||||
import { PageContent } from "@/components/shared/Page";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable";
|
||||
import { useDeletedTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
|
@ -10,6 +11,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { DeletedTeamsTable } from "./DeletedTeamsTable/DeletedTeamsTable";
|
||||
|
||||
export default function DeletedTeamsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { premiumUser } = useAuthorized();
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
|
|
@ -22,10 +24,8 @@ export default function DeletedTeamsPage() {
|
|||
{!premiumUser && (
|
||||
<Alert className="shrink-0">
|
||||
<Info />
|
||||
<AlertTitle>Coming soon to Enterprise</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deleted team auditing is graduating from beta into our Enterprise audit & compliance suite.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("logs.deletedTeams.page.comingSoonTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("logs.deletedTeams.page.comingSoonDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<DeletedTeamsTable
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
|
@ -20,13 +21,14 @@ interface DeletedTeamsTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "deleted_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No deleted teams found</div>
|
||||
<div className="text-sm text-muted-foreground">Teams deleted from this proxy will show up here.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("logs.deletedTeams.table.emptyTitle")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("logs.deletedTeams.table.emptyDescription")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,9 +40,10 @@ export function DeletedTeamsTable({
|
|||
onPaginationChange,
|
||||
rowCount,
|
||||
}: DeletedTeamsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => getDeletedTeamsTableColumns(), []);
|
||||
const columns = useMemo(() => getDeletedTeamsTableColumns(t), [t]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -56,7 +59,7 @@ export function DeletedTeamsTable({
|
|||
onPaginationChange={onPaginationChange}
|
||||
rowCount={rowCount}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading deleted teams…"
|
||||
loadingMessage={t("logs.deletedTeams.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -18,12 +19,12 @@ function EntityCell({ value, href }: { value: string | null | undefined; href: s
|
|||
);
|
||||
}
|
||||
|
||||
export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
||||
export const getDeletedTeamsTableColumns = (t: TFunction): ColumnDef<DeletedTeam>[] => [
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
meta: { title: "Team Name" },
|
||||
header: "Team Name",
|
||||
meta: { title: t("logs.deletedTeams.columns.teamName") },
|
||||
header: t("logs.deletedTeams.columns.teamName"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -41,8 +42,8 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
{
|
||||
id: "team_id",
|
||||
accessorKey: "team_id",
|
||||
meta: { title: "Team ID" },
|
||||
header: "Team ID",
|
||||
meta: { title: t("logs.deletedTeams.columns.teamId") },
|
||||
header: t("logs.deletedTeams.columns.teamId"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.team_id} variant="plain" />,
|
||||
|
|
@ -50,8 +51,8 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
meta: { title: t("logs.deletedTeams.columns.created") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("logs.deletedTeams.columns.created")} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
|
|
@ -59,8 +60,8 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
meta: { title: t("logs.deletedTeams.columns.spendUsd"), numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("logs.deletedTeams.columns.spendUsd")} />,
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
|
|
@ -68,17 +69,24 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: "Budget (USD)",
|
||||
meta: { title: t("logs.deletedTeams.columns.budgetUsd"), numeric: true },
|
||||
header: t("logs.deletedTeams.columns.budgetUsd"),
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={0} emptyText="Unlimited" showZero />,
|
||||
cell: ({ row }) => (
|
||||
<MoneyCell
|
||||
value={row.original.max_budget}
|
||||
decimals={0}
|
||||
emptyText={t("logs.deletedTeams.columns.unlimited")}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorKey: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
meta: { title: t("logs.deletedTeams.columns.models"), skeleton: "chips" },
|
||||
header: t("logs.deletedTeams.columns.models"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} />,
|
||||
|
|
@ -86,8 +94,8 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
{
|
||||
id: "organization_id",
|
||||
accessorKey: "organization_id",
|
||||
meta: { title: "Organization" },
|
||||
header: "Organization",
|
||||
meta: { title: t("logs.deletedTeams.columns.organization") },
|
||||
header: t("logs.deletedTeams.columns.organization"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -98,8 +106,8 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
meta: { title: "Deleted At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Deleted At" />,
|
||||
meta: { title: t("logs.deletedTeams.columns.deletedAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("logs.deletedTeams.columns.deletedAt")} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.deleted_at} precision="date" />,
|
||||
|
|
@ -107,8 +115,8 @@ export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
|||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
meta: { title: "Deleted By" },
|
||||
header: "Deleted By",
|
||||
meta: { title: t("logs.deletedTeams.columns.deletedBy") },
|
||||
header: t("logs.deletedTeams.columns.deletedBy"),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -18,23 +19,24 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
|
|||
selectedFilters = [],
|
||||
customTitle,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("csv");
|
||||
const [exportType, setExportType] = useState<ExportType>("daily");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const entityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
|
||||
const modalTitle = customTitle || `Export ${entityLabel} Usage`;
|
||||
const modalTitle = customTitle || t("usage.export.modal.title", { entity: entityLabel });
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const blob = await onExport(exportType, exportFormat);
|
||||
downloadBlob(blob, exportFilename(entityType, exportType, exportFormat, dateRange));
|
||||
toast.success(`${entityLabel} usage data exported successfully as ${exportFormat.toUpperCase()}`);
|
||||
toast.success(t("usage.export.modal.success", { entity: entityLabel, format: exportFormat.toUpperCase() }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error exporting data:", error);
|
||||
toast.fromError("Failed to export data");
|
||||
toast.fromError(t("usage.export.modal.failed"));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
|
|
@ -57,11 +59,13 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
|
|||
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={onClose} disabled={isExporting}>
|
||||
Cancel
|
||||
{t("usage.export.modal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={isExporting}>
|
||||
{isExporting && <Loader2 className="animate-spin" />}
|
||||
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
|
||||
{isExporting
|
||||
? t("usage.export.modal.exporting")
|
||||
: t("usage.export.modal.exportFormat", { format: exportFormat.toUpperCase() })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { ExportFormat } from "./types";
|
||||
|
||||
|
|
@ -7,23 +9,24 @@ interface ExportFormatSelectorProps {
|
|||
onChange: (value: ExportFormat) => void;
|
||||
}
|
||||
|
||||
const FORMAT_LABELS: Record<ExportFormat, string> = {
|
||||
csv: "CSV (Excel, Google Sheets)",
|
||||
json: "JSON (includes metadata)",
|
||||
const FORMAT_LABELS: Record<ExportFormat, ParseKeys> = {
|
||||
csv: "usage.export.format.csv",
|
||||
json: "usage.export.format.json",
|
||||
};
|
||||
|
||||
const ExportFormatSelector: React.FC<ExportFormatSelectorProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground block mb-2">Format</label>
|
||||
<label className="text-sm font-medium text-foreground block mb-2">{t("usage.export.format.label")}</label>
|
||||
<Select value={value} onValueChange={(next: ExportFormat | null) => next && onChange(next)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{FORMAT_LABELS[value]}</SelectValue>
|
||||
<SelectValue>{t(FORMAT_LABELS[value])}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((format) => (
|
||||
<SelectItem key={format} value={format}>
|
||||
{FORMAT_LABELS[format]}
|
||||
{t(FORMAT_LABELS[format])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
|
||||
interface ExportSummaryProps {
|
||||
|
|
@ -7,10 +8,11 @@ interface ExportSummaryProps {
|
|||
}
|
||||
|
||||
const ExportSummary: React.FC<ExportSummaryProps> = ({ dateRange, selectedFilters }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{dateRange.from?.toLocaleDateString()} - {dateRange.to?.toLocaleDateString()}
|
||||
{selectedFilters.length > 0 && ` · ${selectedFilters.length} filter${selectedFilters.length > 1 ? "s" : ""}`}
|
||||
{selectedFilters.length > 0 && ` · ${t("usage.export.summary.filterCount", { count: selectedFilters.length })}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import type { ExportType, EntityType } from "./types";
|
||||
|
||||
|
|
@ -9,33 +10,34 @@ interface ExportTypeSelectorProps {
|
|||
}
|
||||
|
||||
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
|
||||
const { t } = useTranslation();
|
||||
const allScopes: { value: ExportType; title: string; description: string }[] = [
|
||||
{
|
||||
value: "daily",
|
||||
title: `Day-by-day breakdown by ${entityType}`,
|
||||
description: `Daily metrics for each ${entityType}`,
|
||||
title: t("usage.export.type.daily.title", { entityType }),
|
||||
description: t("usage.export.type.daily.description", { entityType }),
|
||||
},
|
||||
{
|
||||
value: "daily_with_keys",
|
||||
title: `Day-by-day breakdown by ${entityType} and key`,
|
||||
description: `Daily metrics for each ${entityType}, split by API key`,
|
||||
title: t("usage.export.type.dailyWithKeys.title", { entityType }),
|
||||
description: t("usage.export.type.dailyWithKeys.description", { entityType }),
|
||||
},
|
||||
{
|
||||
value: "daily_with_models",
|
||||
title: `Day-by-day by ${entityType} and model`,
|
||||
description: "Daily metrics split by model",
|
||||
title: t("usage.export.type.dailyWithModels.title", { entityType }),
|
||||
description: t("usage.export.type.dailyWithModels.description"),
|
||||
},
|
||||
{
|
||||
value: "daily_with_users",
|
||||
title: `Day-by-day breakdown by ${entityType} and user`,
|
||||
description: `Daily metrics for each ${entityType}, split by key owner`,
|
||||
title: t("usage.export.type.dailyWithUsers.title", { entityType }),
|
||||
description: t("usage.export.type.dailyWithUsers.description", { entityType }),
|
||||
},
|
||||
];
|
||||
const scopes = allScopes.filter((scope) => scope.value !== "daily_with_users" || entityType !== "user");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground block mb-2">Export type</label>
|
||||
<label className="text-sm font-medium text-foreground block mb-2">{t("usage.export.type.label")}</label>
|
||||
<RadioGroup value={value} onValueChange={(next) => onChange(next as ExportType)} className="gap-2">
|
||||
{scopes.map((scope) => (
|
||||
<label
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import { Download } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
|
|
@ -51,6 +52,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
compactLayout = false,
|
||||
teams = [],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const anchor = useComboboxAnchor();
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
|
||||
|
|
@ -58,14 +60,14 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
const optionValues = filterOptions.map((option) => option.value);
|
||||
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
|
||||
const hasNoOptions = filterOptions.length === 0;
|
||||
const emptyPlaceholder = `No ${entityType}s with usage in this range`;
|
||||
const emptyPlaceholder = t("usage.export.header.noOptionsPlaceholder", { entityType });
|
||||
// A selection carried over from a range that did have options still scopes
|
||||
// the data below, so the control has to stay usable long enough to clear it.
|
||||
const isFilterDisabled = hasNoOptions && selectedFilters.length === 0;
|
||||
|
||||
const filterList = (
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No options found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("usage.export.header.noOptionsFound")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(value: string) => (
|
||||
<ComboboxItem key={value} value={value}>
|
||||
|
|
@ -98,7 +100,11 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
placeholder={hasNoOptions ? emptyPlaceholder : filterPlaceholder}
|
||||
aria-label={hasNoOptions ? emptyPlaceholder : filterPlaceholder}
|
||||
/>
|
||||
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
|
||||
{selectedFilters.length > 0 && (
|
||||
<ComboboxClear
|
||||
aria-label={t("usage.export.header.clear", { label: filterLabel ?? t("usage.export.header.filters") })}
|
||||
/>
|
||||
)}
|
||||
</ComboboxChips>
|
||||
{filterList}
|
||||
</Combobox>
|
||||
|
|
@ -123,7 +129,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
|
|||
<div className="justify-self-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsExportModalOpen(true)}>
|
||||
<Download />
|
||||
Export Data
|
||||
{t("usage.export.header.exportData")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import { IdCell, MoneyCell } from "@/components/shared/table_cells";
|
|||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
|
||||
import { Segmented } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
|
||||
import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info";
|
||||
import { keyInfoV1Call } from "../../../networking";
|
||||
|
|
@ -34,8 +36,7 @@ export interface TopKeyItem {
|
|||
spend: number;
|
||||
}
|
||||
|
||||
const KEY_NOT_IN_DATABASE_TOOLTIP =
|
||||
"This key is no longer in the database (deleted, or a CLI/SSO session key), so its details can't be opened";
|
||||
const KEY_NOT_IN_DATABASE_TOOLTIP: ParseKeys = "usage.topKeys.keyNotInDatabase";
|
||||
|
||||
const canOpenKeyInfo = (item: TopKeyItem) => item.key_exists !== false;
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ interface TopKeyViewProps {
|
|||
}
|
||||
|
||||
const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
|
@ -111,18 +113,18 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
// Define columns for the table view
|
||||
const baseColumns = [
|
||||
{
|
||||
header: "Key ID",
|
||||
header: t("usage.topKeys.columns.keyId"),
|
||||
accessorKey: "api_key",
|
||||
meta: QUIET_HEADER,
|
||||
cell: (info: any) =>
|
||||
canOpenKeyInfo(info.row.original) ? (
|
||||
<IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />
|
||||
) : (
|
||||
<IdCell value={info.getValue()} variant="plain" tooltip={KEY_NOT_IN_DATABASE_TOOLTIP} />
|
||||
<IdCell value={info.getValue()} variant="plain" tooltip={t(KEY_NOT_IN_DATABASE_TOOLTIP)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Key Alias",
|
||||
header: t("usage.topKeys.columns.keyAlias"),
|
||||
accessorKey: "key_alias",
|
||||
meta: QUIET_HEADER,
|
||||
cell: (info: any) => info.getValue() || "-",
|
||||
|
|
@ -130,7 +132,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
...(topKeys.some((k) => k.user)
|
||||
? [
|
||||
{
|
||||
header: "User",
|
||||
header: t("usage.topKeys.columns.user"),
|
||||
accessorKey: "user",
|
||||
meta: QUIET_HEADER,
|
||||
cell: (info: any) => info.getValue() || "-",
|
||||
|
|
@ -140,7 +142,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
];
|
||||
|
||||
const tagsColumn = {
|
||||
header: "Tags",
|
||||
header: t("usage.topKeys.columns.tags"),
|
||||
accessorKey: "tags",
|
||||
meta: QUIET_HEADER,
|
||||
cell: (info: any) => {
|
||||
|
|
@ -165,10 +167,10 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
content={
|
||||
<div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tag Name:</span> {tag.tag}
|
||||
<span className="text-muted-foreground">{t("usage.topKeys.tagTooltip.name")}</span> {tag.tag}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Spend:</span>{" "}
|
||||
<span className="text-muted-foreground">{t("usage.topKeys.tagTooltip.spend")}</span>{" "}
|
||||
{tag.usage > 0 && tag.usage < 0.01 ? "<$0.01" : `$${formatNumberWithCommas(tag.usage, 2)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -183,7 +185,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
<button
|
||||
onClick={() => toggleTagsExpansion(apiKey)}
|
||||
className="ml-0.5 rounded-md p-1 transition-colors hover:bg-accent"
|
||||
title={isExpanded ? "Show fewer tags" : "Show all tags"}
|
||||
title={isExpanded ? t("usage.topKeys.showFewerTags") : t("usage.topKeys.showAllTags")}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUpIcon className="h-3 w-3 text-muted-foreground" />
|
||||
|
|
@ -199,7 +201,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
};
|
||||
|
||||
const spendColumn = {
|
||||
header: "Spend (USD)",
|
||||
header: t("usage.topKeys.columns.spend"),
|
||||
accessorKey: "spend",
|
||||
meta: { ...QUIET_HEADER, numeric: true },
|
||||
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
|
||||
|
|
@ -216,12 +218,17 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
<>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented
|
||||
label="Number of top keys to show"
|
||||
label={t("usage.topKeys.limitLabel")}
|
||||
value={String(topKeysLimit)}
|
||||
options={LIMIT_OPTIONS}
|
||||
onChange={(limit) => setTopKeysLimit(Number(limit))}
|
||||
/>
|
||||
<Segmented label="View" value={viewMode} options={VIEW_OPTIONS} onChange={setViewMode} />
|
||||
<Segmented
|
||||
label={t("usage.topKeys.viewLabel")}
|
||||
value={viewMode}
|
||||
options={VIEW_OPTIONS}
|
||||
onChange={setViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{viewMode === "chart" ? (
|
||||
|
|
@ -246,15 +253,15 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
<div className="relative z-floating max-w-xs rounded-lg border bg-popover px-3 py-2 text-xs shadow-lg">
|
||||
<div className="grid gap-1">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Key Alias: </span>
|
||||
<span className="text-muted-foreground">{t("usage.topKeys.chartTooltip.keyAlias")}</span>
|
||||
<span className="break-all font-mono text-foreground">{item?.key_alias}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Key ID: </span>
|
||||
<span className="text-muted-foreground">{t("usage.topKeys.chartTooltip.keyId")}</span>
|
||||
<span className="break-all font-mono text-foreground">{item?.api_key}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Spend: </span>
|
||||
<span className="text-muted-foreground">{t("usage.topKeys.chartTooltip.spend")}</span>
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
${formatNumberWithCommas(item?.spend, 2)}
|
||||
</span>
|
||||
|
|
@ -279,7 +286,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 text-muted-foreground hover:text-foreground focus:outline-hidden"
|
||||
aria-label="Close"
|
||||
aria-label={t("usage.topKeys.close")}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { KeyRound, Search, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatCompact,
|
||||
|
|
@ -10,6 +11,7 @@ import { Panel, Sparkline, Stat } from "@/app/(dashboard)/usage/_components/comp
|
|||
import { ModelCollapsible, ModelSection } from "@/components/activity_metrics";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import type {
|
||||
DailyActivityKeyPageResponse,
|
||||
|
|
@ -59,9 +61,10 @@ type KeyDetailState =
|
|||
| { status: "loaded"; metrics: ModelActivityData | undefined };
|
||||
|
||||
const keyCountText = (searching: boolean, isFiltering: boolean, shownKeys: number, total: number): string => {
|
||||
if (searching) return "Searching...";
|
||||
if (isFiltering) return `${shownKeys.toLocaleString()} matching keys`;
|
||||
return `${total.toLocaleString()} keys`;
|
||||
if (searching) return i18n.t("usage.keyActivity.searching");
|
||||
if (isFiltering)
|
||||
return i18n.t("usage.keyActivity.matchingKeys", { count: shownKeys, value: shownKeys.toLocaleString() });
|
||||
return i18n.t("usage.keyActivity.keyCount", { count: total, value: total.toLocaleString() });
|
||||
};
|
||||
|
||||
interface KeyDetailContentProps {
|
||||
|
|
@ -72,21 +75,22 @@ interface KeyDetailContentProps {
|
|||
}
|
||||
|
||||
const KeyDetailContent: React.FC<KeyDetailContentProps> = ({ apiKey, detail, hidePromptCachingMetrics, onRetry }) => {
|
||||
const { t } = useTranslation();
|
||||
if (detail?.status === "loading") {
|
||||
return <p className="py-4 text-sm text-muted-foreground">Loading key details...</p>;
|
||||
return <p className="py-4 text-sm text-muted-foreground">{t("usage.keyActivity.loadingDetails")}</p>;
|
||||
}
|
||||
if (detail?.status === "failed") {
|
||||
return (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
Could not load key details.{" "}
|
||||
{t("usage.keyActivity.detailFailed")}{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={onRetry}>
|
||||
Retry
|
||||
{t("usage.keyActivity.retry")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (detail?.status !== "loaded" || detail.metrics === undefined) {
|
||||
return <p className="py-4 text-sm text-muted-foreground">No daily details available</p>;
|
||||
return <p className="py-4 text-sm text-muted-foreground">{t("usage.keyActivity.noDailyDetails")}</p>;
|
||||
}
|
||||
return (
|
||||
<ModelSection modelName={apiKey} metrics={detail.metrics} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
|
|
@ -118,6 +122,7 @@ interface KeySummaryStripProps {
|
|||
}
|
||||
|
||||
const KeySummaryStrip: React.FC<KeySummaryStripProps> = ({ summary, loading, hidePromptCachingMetrics }) => {
|
||||
const { t } = useTranslation();
|
||||
const daily = useMemo(() => summarySeries(summary), [summary]);
|
||||
const trend = (dataKey: string) =>
|
||||
loading || daily.length < 2 ? undefined : (
|
||||
|
|
@ -131,51 +136,61 @@ const KeySummaryStrip: React.FC<KeySummaryStripProps> = ({ summary, loading, hid
|
|||
>
|
||||
{loading ? (
|
||||
<span role="status" className="sr-only">
|
||||
Loading chart data...
|
||||
{t("usage.keyActivity.loadingChartData")}
|
||||
</span>
|
||||
) : (
|
||||
<h3 className="sr-only">Overall Usage</h3>
|
||||
<h3 className="sr-only">{t("usage.keyActivity.overallUsage")}</h3>
|
||||
)}
|
||||
<StatCell trend={trend("spend")}>
|
||||
<Stat label="Spend" value={formatUsd(summary.total_spend)} pending={loading} />
|
||||
<Stat label={t("usage.keyActivity.stats.spend")} value={formatUsd(summary.total_spend)} pending={loading} />
|
||||
</StatCell>
|
||||
<StatCell trend={trend("requests")}>
|
||||
<Stat label="Requests" value={summary.total_requests.toLocaleString()} pending={loading} />
|
||||
<Stat
|
||||
label={t("usage.keyActivity.stats.requests")}
|
||||
value={summary.total_requests.toLocaleString()}
|
||||
pending={loading}
|
||||
/>
|
||||
{!loading && (
|
||||
<div className="mt-0.5 flex gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="text-success tabular-nums">{summary.total_successful_requests.toLocaleString()}</span> ok
|
||||
<span className="text-success tabular-nums">{summary.total_successful_requests.toLocaleString()}</span>{" "}
|
||||
{t("usage.keyActivity.stats.ok")}
|
||||
</span>
|
||||
<span>
|
||||
<span className={summary.total_failed_requests > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
|
||||
{summary.total_failed_requests.toLocaleString()}
|
||||
</span>{" "}
|
||||
failed
|
||||
{t("usage.keyActivity.stats.failed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</StatCell>
|
||||
<StatCell trend={trend("tokens")}>
|
||||
<Stat
|
||||
label="Tokens"
|
||||
label={t("usage.keyActivity.stats.tokens")}
|
||||
value={summary.total_tokens.toLocaleString()}
|
||||
pending={loading}
|
||||
hint={
|
||||
loading
|
||||
? undefined
|
||||
: `${formatCompact(summary.prompt_tokens)} in · ${formatCompact(summary.completion_tokens)} out`
|
||||
: t("usage.keyActivity.stats.tokensHint", {
|
||||
input: formatCompact(summary.prompt_tokens),
|
||||
output: formatCompact(summary.completion_tokens),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat
|
||||
label="Avg cost / request"
|
||||
label={t("usage.keyActivity.stats.avgCost")}
|
||||
value={formatUsd(avgCost, 4)}
|
||||
pending={loading}
|
||||
hint={
|
||||
loading || hidePromptCachingMetrics
|
||||
? undefined
|
||||
: `${formatCompact(summary.total_cache_read_input_tokens)} cache read tokens`
|
||||
: t("usage.keyActivity.stats.cacheReadHint", {
|
||||
value: formatCompact(summary.total_cache_read_input_tokens),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</StatCell>
|
||||
|
|
@ -192,6 +207,7 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
teams,
|
||||
hidePromptCachingMetrics = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [queryState, setQueryState] = useState<{ scope: FetchKeyPage; value: string } | null>(null);
|
||||
const query = queryState?.scope === fetchKeyPage ? queryState.value : "";
|
||||
const updateQuery = useCallback((value: string) => setQueryState({ scope: fetchKeyPage, value }), [fetchKeyPage]);
|
||||
|
|
@ -357,7 +373,7 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
const visibleDetails = detailState?.scope === fetchKeyPage ? detailState.details : {};
|
||||
const keyCountLabel =
|
||||
searchSettled && searchFailed && shownKeys === 0
|
||||
? "Search failed"
|
||||
? t("usage.keyActivity.searchFailed")
|
||||
: keyCountText(searching, isFiltering, shownKeys, total);
|
||||
|
||||
const retryFirstPage = () => {
|
||||
|
|
@ -383,16 +399,16 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
};
|
||||
const firstPageError = (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Could not load keys for this range.{" "}
|
||||
{t("usage.keyActivity.firstPageFailed")}{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={retryFirstPage}>
|
||||
Retry
|
||||
{t("usage.keyActivity.retry")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
const emptyListBody = (() => {
|
||||
if (failed) return firstPageError;
|
||||
if (!loading) return null;
|
||||
return <p className="px-4 py-8 text-center text-sm text-muted-foreground">Loading keys...</p>;
|
||||
return <p className="px-4 py-8 text-center text-sm text-muted-foreground">{t("usage.keyActivity.loadingKeys")}</p>;
|
||||
})();
|
||||
const maxSpend = Math.max(0, ...Object.values(filteredMetrics).map((metrics) => metrics.total_spend));
|
||||
const keyList = Object.entries(filteredMetrics).map(([apiKey, metrics]) => {
|
||||
|
|
@ -416,7 +432,8 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
</div>
|
||||
<span className="text-right text-sm tabular-nums text-foreground">{formatUsd(metrics.total_spend)}</span>
|
||||
<span className="text-right text-sm tabular-nums text-muted-foreground">
|
||||
{metrics.total_requests.toLocaleString()} <span className="sr-only">requests</span>
|
||||
{metrics.total_requests.toLocaleString()}{" "}
|
||||
<span className="sr-only">{t("usage.keyActivity.requestsSrOnly")}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -440,14 +457,17 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
const showSearchErrorNote = !noMatches && searchFailed && !searching;
|
||||
const searchRetryButton = (
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={retrySearch}>
|
||||
Retry search
|
||||
{t("usage.keyActivity.retrySearch")}
|
||||
</button>
|
||||
);
|
||||
const emptyFilterBody = searchFailed ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">Could not search keys. {searchRetryButton}</p>
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("usage.keyActivity.searchKeysFailed")}
|
||||
{searchRetryButton}
|
||||
</p>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No keys match "{trimmedQuery}" in this date range
|
||||
<Trans i18nKey="usage.keyActivity.noMatches" components={{ query: <>{trimmedQuery}</> }} />
|
||||
</p>
|
||||
);
|
||||
|
||||
|
|
@ -489,14 +509,18 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
<Search className="size-3.5 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label="Search keys"
|
||||
placeholder="Search alias, hash, user ID, or email"
|
||||
aria-label={t("usage.keyActivity.searchLabel")}
|
||||
placeholder={t("usage.keyActivity.searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
/>
|
||||
{isFiltering && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => updateQuery("")}>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={t("usage.keyActivity.clearSearch")}
|
||||
onClick={() => updateQuery("")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
|
|
@ -515,27 +539,37 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<KeySummaryStrip summary={summary} loading={summaryLoading} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
<Panel icon={KeyRound} title="Virtual keys" className="overflow-hidden" bodyClassName="px-0 pt-3 pb-0">
|
||||
<Panel
|
||||
icon={KeyRound}
|
||||
title={t("usage.keyActivity.panelTitle")}
|
||||
className="overflow-hidden"
|
||||
bodyClassName="px-0 pt-3 pb-0"
|
||||
>
|
||||
{showColumnHeaders && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`${KEY_ROW_GRID} border-y bg-muted/40 py-2 pr-4 pl-10 text-xs font-medium text-muted-foreground`}
|
||||
>
|
||||
<span>Key</span>
|
||||
<span className="text-right">Spend</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span>{t("usage.keyActivity.columns.key")}</span>
|
||||
<span className="text-right">{t("usage.keyActivity.columns.spend")}</span>
|
||||
<span className="text-right">{t("usage.keyActivity.columns.requests")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={showColumnHeaders ? undefined : "border-t"}>{noMatches ? emptyFilterBody : listBody}</div>
|
||||
{showFooter && (
|
||||
<div className="border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{showSearchErrorNote && <p>Could not search all keys. {searchRetryButton}</p>}
|
||||
{loadingMore && <p>Loading more keys...</p>}
|
||||
{showSearchErrorNote && (
|
||||
<p>
|
||||
{t("usage.keyActivity.searchAllFailed")}
|
||||
{searchRetryButton}
|
||||
</p>
|
||||
)}
|
||||
{loadingMore && <p>{t("usage.keyActivity.loadingMore")}</p>}
|
||||
{showNextPageRetry && (
|
||||
<p>
|
||||
Could not load more keys.{" "}
|
||||
{t("usage.keyActivity.loadMoreFailed")}{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={retryNextPage}>
|
||||
Retry
|
||||
{t("usage.keyActivity.retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { BarChart } from "@/components/shared/charts";
|
|||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TopModelData } from "../types";
|
||||
|
||||
interface KeyModelUsageViewProps {
|
||||
|
|
@ -17,30 +19,30 @@ const COMPACT_TABLE_ROW_HEIGHT = 32;
|
|||
|
||||
const columns: ColumnDef<TopModelData>[] = [
|
||||
{
|
||||
header: "Model",
|
||||
header: () => i18n.t("usage.keyModelUsage.columns.model"),
|
||||
accessorKey: "model",
|
||||
cell: ({ row }) => row.original.model || "-",
|
||||
},
|
||||
{
|
||||
header: "Spend (USD)",
|
||||
header: () => i18n.t("usage.keyModelUsage.columns.spend"),
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
header: () => i18n.t("usage.keyModelUsage.columns.successful"),
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <span className="text-success">{row.original.successful_requests?.toLocaleString() || 0}</span>,
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
header: () => i18n.t("usage.keyModelUsage.columns.failed"),
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <span className="text-destructive">{row.original.failed_requests?.toLocaleString() || 0}</span>,
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
header: () => i18n.t("usage.keyModelUsage.columns.tokens"),
|
||||
accessorKey: "tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.tokens?.toLocaleString() || 0,
|
||||
|
|
@ -48,6 +50,7 @@ const columns: ColumnDef<TopModelData>[] = [
|
|||
];
|
||||
|
||||
const KeyModelUsageView: React.FC<KeyModelUsageViewProps> = ({ topModels }) => {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<"chart" | "table">("table");
|
||||
|
||||
if (topModels.length === 0) {
|
||||
|
|
@ -57,20 +60,20 @@ const KeyModelUsageView: React.FC<KeyModelUsageViewProps> = ({ topModels }) => {
|
|||
return (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Model Usage</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">{t("usage.keyModelUsage.title")}</CardTitle>
|
||||
<CardAction>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "table" ? "bg-info/15 text-info" : "bg-muted text-foreground"}`}
|
||||
>
|
||||
Table
|
||||
{t("usage.keyModelUsage.table")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("chart")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "chart" ? "bg-info/15 text-info" : "bg-muted text-foreground"}`}
|
||||
>
|
||||
Chart
|
||||
{t("usage.keyModelUsage.chart")}
|
||||
</button>
|
||||
</div>
|
||||
</CardAction>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { FetchUserPage } from "./userActivityData";
|
||||
import { formatUserSpend, userPrimaryLabel, userRowKey, userSecondaryLabel } from "./userActivityData";
|
||||
|
|
@ -12,6 +13,7 @@ interface TopUsersViewProps {
|
|||
}
|
||||
|
||||
const TopUsersView: React.FC<TopUsersViewProps> = ({ fetchUserPage }) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<{
|
||||
scope: FetchUserPage;
|
||||
rows: UserActivityRow[];
|
||||
|
|
@ -49,22 +51,22 @@ const TopUsersView: React.FC<TopUsersViewProps> = ({ fetchUserPage }) => {
|
|||
if (current?.failed) {
|
||||
return (
|
||||
<p className="px-1 py-6 text-center text-sm text-muted-foreground">
|
||||
Could not load top users.{" "}
|
||||
{t("usage.topUsers.failed")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-foreground underline"
|
||||
onClick={() => setRetryToken((token) => token + 1)}
|
||||
>
|
||||
Retry
|
||||
{t("usage.topUsers.retry")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return <p className="px-1 py-6 text-center text-sm text-muted-foreground">Loading users...</p>;
|
||||
return <p className="px-1 py-6 text-center text-sm text-muted-foreground">{t("usage.topUsers.loading")}</p>;
|
||||
}
|
||||
if (current.rows.length === 0) {
|
||||
return <p className="px-1 py-6 text-center text-sm text-muted-foreground">No user activity in this date range</p>;
|
||||
return <p className="px-1 py-6 text-center text-sm text-muted-foreground">{t("usage.topUsers.empty")}</p>;
|
||||
}
|
||||
const maxSpend = Math.max(0, ...current.rows.map((row) => row.spend));
|
||||
return (
|
||||
|
|
@ -73,9 +75,9 @@ const TopUsersView: React.FC<TopUsersViewProps> = ({ fetchUserPage }) => {
|
|||
aria-hidden="true"
|
||||
className={`${TOP_USER_ROW_GRID} border-b pb-1.5 text-xs font-medium text-muted-foreground`}
|
||||
>
|
||||
<span>User</span>
|
||||
<span className="text-right">Spend</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span>{t("usage.topUsers.columns.user")}</span>
|
||||
<span className="text-right">{t("usage.topUsers.columns.spend")}</span>
|
||||
<span className="text-right">{t("usage.topUsers.columns.requests")}</span>
|
||||
</li>
|
||||
{current.rows.map((row) => {
|
||||
const secondary = userSecondaryLabel(row);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Users } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
|
||||
|
||||
|
|
@ -14,9 +15,15 @@ interface UserActivityPanelProps {
|
|||
}
|
||||
|
||||
const UserActivityPanel: React.FC<UserActivityPanelProps> = ({ fetchUserPage }) => {
|
||||
const { t } = useTranslation();
|
||||
const page = useUserActivityPage(fetchUserPage, PAGE_SIZE);
|
||||
return (
|
||||
<Panel icon={Users} title="User activity" className="overflow-hidden" bodyClassName="px-0 pt-3 pb-0">
|
||||
<Panel
|
||||
icon={Users}
|
||||
title={t("usage.userActivity.panelTitle")}
|
||||
className="overflow-hidden"
|
||||
bodyClassName="px-0 pt-3 pb-0"
|
||||
>
|
||||
<div className="px-4 pb-2">
|
||||
<UserActivityTable
|
||||
rows={page.rows}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { formatCompact } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
|
||||
|
||||
|
|
@ -40,23 +41,24 @@ const UserActivityTable: React.FC<UserActivityTableProps> = ({
|
|||
onRetryFirstPage,
|
||||
onRetryNextPage,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const firstPageFailed = failed && rows.length === 0;
|
||||
const body = (() => {
|
||||
if (firstPageFailed) {
|
||||
return (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Could not load users for this range.{" "}
|
||||
{t("usage.userActivity.firstPageFailed")}{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={onRetryFirstPage}>
|
||||
Retry
|
||||
{t("usage.userActivity.retry")}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return <p className="px-4 py-8 text-center text-sm text-muted-foreground">Loading users...</p>;
|
||||
return <p className="px-4 py-8 text-center text-sm text-muted-foreground">{t("usage.userActivity.loading")}</p>;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return <p className="px-4 py-8 text-center text-sm text-muted-foreground">No user activity in this date range</p>;
|
||||
return <p className="px-4 py-8 text-center text-sm text-muted-foreground">{t("usage.userActivity.empty")}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
|
|
@ -65,12 +67,12 @@ const UserActivityTable: React.FC<UserActivityTableProps> = ({
|
|||
aria-hidden="true"
|
||||
className={`${USER_ROW_GRID} border-y bg-muted/40 py-2 pl-10 pr-4 text-xs font-medium text-muted-foreground`}
|
||||
>
|
||||
<span>User</span>
|
||||
<span className="text-right">Spend</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span className="text-right">Successful</span>
|
||||
<span className="text-right">Failed</span>
|
||||
<span className="text-right">Total tokens</span>
|
||||
<span>{t("usage.userActivity.columns.user")}</span>
|
||||
<span className="text-right">{t("usage.userActivity.columns.spend")}</span>
|
||||
<span className="text-right">{t("usage.userActivity.columns.requests")}</span>
|
||||
<span className="text-right">{t("usage.userActivity.columns.successful")}</span>
|
||||
<span className="text-right">{t("usage.userActivity.columns.failed")}</span>
|
||||
<span className="text-right">{t("usage.userActivity.columns.totalTokens")}</span>
|
||||
</div>
|
||||
<ul>
|
||||
{rows.map((row) => (
|
||||
|
|
@ -107,24 +109,28 @@ const UserActivityTable: React.FC<UserActivityTableProps> = ({
|
|||
<div className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
|
||||
{total.toLocaleString()} users
|
||||
<Trans
|
||||
i18nKey="usage.userActivity.userCount"
|
||||
count={total}
|
||||
components={{ value: <>{total.toLocaleString()}</> }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div>{body}</div>
|
||||
{showFooter && (
|
||||
<div className="border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{loadingMore && <p>Loading more users...</p>}
|
||||
{loadingMore && <p>{t("usage.userActivity.loadingMore")}</p>}
|
||||
{failed && !firstPageFailed && (
|
||||
<p>
|
||||
Could not load more users.{" "}
|
||||
{t("usage.userActivity.loadMoreFailed")}{" "}
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={onRetryNextPage}>
|
||||
Retry
|
||||
{t("usage.userActivity.retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{canLoadMore && (
|
||||
<button type="button" className="font-medium text-foreground underline" onClick={onLoadMore}>
|
||||
Load more
|
||||
{t("usage.userActivity.loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import i18n from "@/i18n/i18n";
|
||||
import { getSpendString } from "@/utils/dataUtils";
|
||||
|
||||
import type { DailyActivityUserPageResponse, UserActivityRow } from "../dailyActivityApi";
|
||||
|
|
@ -9,7 +10,7 @@ export const userRowKey = (row: UserActivityRow): string =>
|
|||
|
||||
export const userPrimaryLabel = (row: UserActivityRow): string => {
|
||||
const friendlyName = row.user_email || row.user_alias;
|
||||
return friendlyName || row.user_id || "(no user)";
|
||||
return friendlyName || row.user_id || i18n.t("usage.userActivity.noUser");
|
||||
};
|
||||
|
||||
export const userSecondaryLabel = (row: UserActivityRow): string | null => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { processActivityData } from "@/components/activity_metrics";
|
||||
import type { DailyActivityAggregatedResponse, DailyActivityMetadata, KeySpendActivityRow } from "./dailyActivityApi";
|
||||
import { toDailyData } from "./dailyActivityApi";
|
||||
|
|
@ -18,7 +19,7 @@ export const overallUsageMetrics = (
|
|||
results: ReturnType<typeof toDailyData>,
|
||||
metadata: DailyActivityMetadata,
|
||||
): ModelActivityData => ({
|
||||
label: "Overall Usage",
|
||||
label: i18n.t("usage.keyActivity.overallUsage"),
|
||||
total_requests: metadata.total_api_requests,
|
||||
total_successful_requests: metadata.total_successful_requests,
|
||||
total_failed_requests: metadata.total_failed_requests,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export function valueFormatter(number: number) {
|
||||
if (number >= 1_000_000_000) {
|
||||
return (number / 1_000_000_000).toFixed(2) + "B";
|
||||
|
|
@ -18,8 +20,8 @@ export function averageResponseTimeMs(totalResponseTimeMs: number, timedRequests
|
|||
|
||||
export function formatResponseTime(ms: number | null | undefined) {
|
||||
if (ms == null) return "-";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
if (ms < 1000) return i18n.t("usage.valueFormatters.milliseconds", { value: Math.round(ms) });
|
||||
return i18n.t("usage.valueFormatters.seconds", { value: (ms / 1000).toFixed(2) });
|
||||
}
|
||||
|
||||
export function valueFormatterSpend(number: number) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { providerForModel } from "@/app/(dashboard)/usage/_components/components
|
|||
import { cn } from "@/lib/cva.config";
|
||||
import { Activity, ChevronDown, Coins, Database, KeyRound, Timer } from "lucide-react";
|
||||
import React, { type ReactNode, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { Team } from "./key_team_helpers/key_list";
|
||||
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
|
||||
import { keyActivityLabel } from "./UsagePage/keyActivityLabel";
|
||||
|
|
@ -114,11 +116,14 @@ const ItemMark = ({ label, color }: { label: string; color: string }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const TopKeysPanel = ({ children }: { children: ReactNode }) => (
|
||||
<Panel icon={KeyRound} title="Top Virtual Keys by Spend">
|
||||
{children}
|
||||
</Panel>
|
||||
);
|
||||
const TopKeysPanel = ({ children }: { children: ReactNode }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Panel icon={KeyRound} title={t("activityMetrics.topKeys.title")}>
|
||||
{children}
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const ModelTopKeys = ({
|
||||
modelName,
|
||||
|
|
@ -127,6 +132,7 @@ const ModelTopKeys = ({
|
|||
modelName: string;
|
||||
fetchTopApiKeys: (model: string) => Promise<ModelTopKeysResponse>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
interface ModelTopKeyRow {
|
||||
api_key: string;
|
||||
key_alias: string | null;
|
||||
|
|
@ -179,7 +185,7 @@ const ModelTopKeys = ({
|
|||
if (current === null) {
|
||||
return (
|
||||
<TopKeysPanel>
|
||||
<p className="py-2 text-xs text-muted-foreground">Loading top keys...</p>
|
||||
<p className="py-2 text-xs text-muted-foreground">{t("activityMetrics.topKeys.loading")}</p>
|
||||
</TopKeysPanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -188,7 +194,7 @@ const ModelTopKeys = ({
|
|||
return (
|
||||
<TopKeysPanel>
|
||||
<p className="py-2 text-xs text-muted-foreground">
|
||||
Could not load top keys.{" "}
|
||||
{t("activityMetrics.topKeys.failed")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-foreground underline"
|
||||
|
|
@ -197,7 +203,7 @@ const ModelTopKeys = ({
|
|||
setRetryToken((token) => token + 1);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
{t("activityMetrics.topKeys.retry")}
|
||||
</button>
|
||||
</p>
|
||||
</TopKeysPanel>
|
||||
|
|
@ -216,15 +222,24 @@ const ModelTopKeys = ({
|
|||
<li key={keyData.api_key} className="flex items-center justify-between gap-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">{keyLabel}</p>
|
||||
{keyData.team_id && <p className="truncate text-xs text-muted-foreground">Team: {keyData.team_id}</p>}
|
||||
{keyData.team_id && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{t("activityMetrics.topKeys.team", { teamId: keyData.team_id })}
|
||||
</p>
|
||||
)}
|
||||
{keyData.user && keyData.user !== keyLabel && (
|
||||
<p className="truncate text-xs text-muted-foreground">User: {keyData.user}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{t("activityMetrics.topKeys.user", { user: keyData.user })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm tabular-nums text-foreground">${formatNumberWithCommas(keyData.spend, 2)}</p>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
|
||||
{t("activityMetrics.topKeys.usage", {
|
||||
requests: keyData.requests.toLocaleString(),
|
||||
tokens: keyData.tokens.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -246,37 +261,47 @@ export const ModelSection = ({
|
|||
hidePromptCachingMetrics?: boolean;
|
||||
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<StatStrip columns={5}>
|
||||
<StatCell>
|
||||
<Stat label="Total Requests" value={<StatValue>{metrics.total_requests.toLocaleString()}</StatValue>} />
|
||||
<Stat
|
||||
label={t("activityMetrics.stats.totalRequests")}
|
||||
value={<StatValue>{metrics.total_requests.toLocaleString()}</StatValue>}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat
|
||||
label="Total Successful Requests"
|
||||
label={t("activityMetrics.stats.totalSuccessfulRequests")}
|
||||
value={<StatValue>{metrics.total_successful_requests.toLocaleString()}</StatValue>}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat
|
||||
label="Total Tokens"
|
||||
label={t("activityMetrics.stats.totalTokens")}
|
||||
value={<StatValue>{metrics.total_tokens.toLocaleString()}</StatValue>}
|
||||
hint={`${Math.round(metrics.total_tokens / metrics.total_successful_requests)} avg per successful request`}
|
||||
hint={t("activityMetrics.stats.avgTokensPerRequest", {
|
||||
value: Math.round(metrics.total_tokens / metrics.total_successful_requests),
|
||||
})}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat
|
||||
label="Total Spend"
|
||||
label={t("activityMetrics.stats.totalSpend")}
|
||||
value={<StatValue>${formatNumberWithCommas(metrics.total_spend, 2)}</StatValue>}
|
||||
hint={`$${formatNumberWithCommas(metrics.total_spend / metrics.total_successful_requests, 3)} per successful request`}
|
||||
hint={t("activityMetrics.stats.spendPerRequest", {
|
||||
value: formatNumberWithCommas(metrics.total_spend / metrics.total_successful_requests, 3),
|
||||
})}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat
|
||||
label="Avg Response Time"
|
||||
label={t("activityMetrics.stats.avgResponseTime")}
|
||||
value={<StatValue>{formatResponseTime(modelAverageResponseTimeMs(metrics))}</StatValue>}
|
||||
hint={`over ${(metrics.total_timed_requests ?? 0).toLocaleString()} timed successful requests`}
|
||||
hint={t("activityMetrics.stats.timedRequests", {
|
||||
value: (metrics.total_timed_requests ?? 0).toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
</StatCell>
|
||||
</StatStrip>
|
||||
|
|
@ -285,7 +310,11 @@ export const ModelSection = ({
|
|||
|
||||
{metrics.top_models && metrics.top_models.length > 0 && <KeyModelUsageView topModels={metrics.top_models} />}
|
||||
|
||||
<Panel icon={Coins} title="Spend per day" action={<SeriesKey categories={["metrics.spend"]} colors={[BRAND]} />}>
|
||||
<Panel
|
||||
icon={Coins}
|
||||
title={t("activityMetrics.panels.spendPerDay")}
|
||||
action={<SeriesKey categories={["metrics.spend"]} colors={[BRAND]} />}
|
||||
>
|
||||
<BarChart
|
||||
className={CHART_CLASS}
|
||||
data={metrics.daily_data}
|
||||
|
|
@ -300,7 +329,10 @@ export const ModelSection = ({
|
|||
</Panel>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="Total Tokens" action={<SeriesKey categories={TOKEN_CATEGORIES} colors={TOKEN_COLORS} />}>
|
||||
<Panel
|
||||
title={t("activityMetrics.panels.totalTokens")}
|
||||
action={<SeriesKey categories={TOKEN_CATEGORIES} colors={TOKEN_COLORS} />}
|
||||
>
|
||||
<AreaChart
|
||||
className={CHART_CLASS}
|
||||
data={metrics.daily_data}
|
||||
|
|
@ -313,7 +345,10 @@ export const ModelSection = ({
|
|||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Requests per day" action={<SeriesKey categories={["metrics.api_requests"]} colors={[BRAND]} />}>
|
||||
<Panel
|
||||
title={t("activityMetrics.panels.requestsPerDay")}
|
||||
action={<SeriesKey categories={["metrics.api_requests"]} colors={[BRAND]} />}
|
||||
>
|
||||
<BarChart
|
||||
className={CHART_CLASS}
|
||||
data={metrics.daily_data}
|
||||
|
|
@ -330,7 +365,7 @@ export const ModelSection = ({
|
|||
{(metrics.total_timed_requests ?? 0) > 0 && (
|
||||
<Panel
|
||||
icon={Timer}
|
||||
title="Avg Response Time per day"
|
||||
title={t("activityMetrics.panels.avgResponseTimePerDay")}
|
||||
action={<SeriesKey categories={["metrics.avg_response_time_ms"]} colors={[BRAND]} />}
|
||||
>
|
||||
<LineChart
|
||||
|
|
@ -348,7 +383,7 @@ export const ModelSection = ({
|
|||
)}
|
||||
|
||||
<Panel
|
||||
title="Success vs Failed Requests"
|
||||
title={t("activityMetrics.panels.successVsFailed")}
|
||||
action={<SeriesKey categories={REQUEST_CATEGORIES} colors={["green", "red"]} />}
|
||||
>
|
||||
<AreaChart
|
||||
|
|
@ -366,12 +401,20 @@ export const ModelSection = ({
|
|||
{!hidePromptCachingMetrics && (
|
||||
<Panel
|
||||
icon={Database}
|
||||
title="Prompt Caching Metrics"
|
||||
title={t("activityMetrics.panels.promptCaching")}
|
||||
action={<SeriesKey categories={CACHE_CATEGORIES} colors={["cyan", "purple"]} />}
|
||||
>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs tabular-nums text-muted-foreground">
|
||||
<p>Cache Read: {metrics.total_cache_read_input_tokens?.toLocaleString() || 0} tokens</p>
|
||||
<p>Cache Creation: {metrics.total_cache_creation_input_tokens?.toLocaleString() || 0} tokens</p>
|
||||
<p>
|
||||
{t("activityMetrics.panels.cacheRead", {
|
||||
tokens: metrics.total_cache_read_input_tokens?.toLocaleString() || 0,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("activityMetrics.panels.cacheCreation", {
|
||||
tokens: metrics.total_cache_creation_input_tokens?.toLocaleString() || 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<AreaChart
|
||||
className={cn(CHART_CLASS, "mt-3")}
|
||||
|
|
@ -436,10 +479,11 @@ export const ModelCollapsible = ({
|
|||
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
|
||||
modelMetrics,
|
||||
summaryMetrics,
|
||||
summaryTitle = "Overall Usage",
|
||||
summaryTitle = i18n.t("activityMetrics.summary.overallUsage"),
|
||||
hidePromptCachingMetrics = false,
|
||||
fetchTopApiKeys,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const modelNames = Object.keys(modelMetrics).sort((a, b) => {
|
||||
if (a === "") return 1;
|
||||
if (b === "") return -1;
|
||||
|
|
@ -528,25 +572,34 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
|
|||
</h3>
|
||||
<StatStrip columns={4}>
|
||||
<StatCell>
|
||||
<Stat label="Total Requests" value={<StatValue>{totalRequests.toLocaleString()}</StatValue>} />
|
||||
<Stat
|
||||
label={t("activityMetrics.stats.totalRequests")}
|
||||
value={<StatValue>{totalRequests.toLocaleString()}</StatValue>}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat
|
||||
label="Total Successful Requests"
|
||||
label={t("activityMetrics.stats.totalSuccessfulRequests")}
|
||||
value={<StatValue>{totalSuccessfulRequests.toLocaleString()}</StatValue>}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat label="Total Tokens" value={<StatValue>{totalTokens.toLocaleString()}</StatValue>} />
|
||||
<Stat
|
||||
label={t("activityMetrics.stats.totalTokens")}
|
||||
value={<StatValue>{totalTokens.toLocaleString()}</StatValue>}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<Stat label="Total Spend" value={<StatValue>${formatNumberWithCommas(totalSpend, 2)}</StatValue>} />
|
||||
<Stat
|
||||
label={t("activityMetrics.stats.totalSpend")}
|
||||
value={<StatValue>${formatNumberWithCommas(totalSpend, 2)}</StatValue>}
|
||||
/>
|
||||
</StatCell>
|
||||
</StatStrip>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<Panel
|
||||
title="Total Tokens Over Time"
|
||||
title={t("activityMetrics.panels.totalTokensOverTime")}
|
||||
action={<SeriesKey categories={TOKEN_CATEGORIES} colors={TOKEN_COLORS} />}
|
||||
>
|
||||
<AreaChart
|
||||
|
|
@ -562,7 +615,7 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
|
|||
/>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Total Requests Over Time"
|
||||
title={t("activityMetrics.panels.totalRequestsOverTime")}
|
||||
action={<SeriesKey categories={REQUEST_CATEGORIES} colors={["emerald", "red"]} />}
|
||||
>
|
||||
<AreaChart
|
||||
|
|
@ -584,7 +637,7 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
|
|||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
{modelNames.map((modelName, index) => {
|
||||
const metrics = modelMetrics[modelName];
|
||||
const label = metrics.label || "Unknown Item";
|
||||
const label = metrics.label || t("activityMetrics.summary.unknownItem");
|
||||
const provider = providerForModel(metrics.label || "");
|
||||
const avgResponse = modelAverageResponseTimeMs(metrics);
|
||||
const share = maxSpend > 0 ? (metrics.total_spend / maxSpend) * 100 : 0;
|
||||
|
|
@ -614,9 +667,13 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
|
|||
</div>
|
||||
<div className="flex shrink-0 items-center gap-5 text-sm tabular-nums text-muted-foreground">
|
||||
<span className="text-foreground">${formatNumberWithCommas(metrics.total_spend, 2)}</span>
|
||||
<span>{metrics.total_requests.toLocaleString()} requests</span>
|
||||
<span>
|
||||
{t("activityMetrics.summary.requests", { requests: metrics.total_requests.toLocaleString() })}
|
||||
</span>
|
||||
{avgResponse != null && (
|
||||
<span className="hidden md:inline">{formatResponseTime(avgResponse)} avg response</span>
|
||||
<span className="hidden md:inline">
|
||||
{t("activityMetrics.summary.avgResponse", { value: formatResponseTime(avgResponse) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleCheck, FileDown } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
|
|
@ -13,10 +14,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const cloudZeroSettingsSchema = z.object({
|
||||
api_key: z.string().min(1, "Please enter your CloudZero API key"),
|
||||
connection_id: z.string().min(1, "Please enter the CloudZero connection ID"),
|
||||
api_key: z.string().min(1, { error: () => i18n.t("usage.cloudzero.validation.apiKeyRequired") }),
|
||||
connection_id: z.string().min(1, { error: () => i18n.t("usage.cloudzero.validation.connectionIdRequired") }),
|
||||
});
|
||||
|
||||
interface CloudZeroExportModalProps {
|
||||
|
|
@ -36,6 +38,7 @@ interface CloudZeroSettingsView {
|
|||
type ExportType = "cloudzero" | "csv";
|
||||
|
||||
const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onClose, accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(cloudZeroSettingsSchema, {
|
||||
defaultValues: { api_key: "", connection_id: "" },
|
||||
});
|
||||
|
|
@ -71,11 +74,15 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
} else if (response.status !== 404) {
|
||||
// 404 means no settings configured yet, which is fine
|
||||
const errorData = await response.json();
|
||||
toast.fromError(`Failed to load existing settings: ${errorData.error || "Unknown error"}`);
|
||||
toast.fromError(
|
||||
t("usage.cloudzero.toasts.loadFailedWithReason", {
|
||||
error: errorData.error || t("usage.cloudzero.toasts.unknownError"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading CloudZero settings:", error);
|
||||
toast.fromError("Failed to load existing settings");
|
||||
toast.fromError(t("usage.cloudzero.toasts.loadFailed"));
|
||||
} finally {
|
||||
setSettingsLoading(false);
|
||||
}
|
||||
|
|
@ -83,7 +90,7 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
|
||||
const handleSaveCloudZeroSettings = async (values: CloudZeroSettings) => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("No access token available");
|
||||
toast.fromError(t("usage.cloudzero.toasts.noAccessToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +117,7 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(data.message || "CloudZero settings saved successfully");
|
||||
toast.success(data.message || t("usage.cloudzero.toasts.saved"));
|
||||
setExistingSettings({
|
||||
api_key_masked: values.api_key.substring(0, 4) + "****" + values.api_key.slice(-4),
|
||||
connection_id: values.connection_id,
|
||||
|
|
@ -118,12 +125,12 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
});
|
||||
return true;
|
||||
} else {
|
||||
toast.fromError(data.error || "Failed to save CloudZero settings");
|
||||
toast.fromError(data.error || t("usage.cloudzero.toasts.saveFailed"));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving CloudZero settings:", error);
|
||||
toast.fromError("Failed to save CloudZero settings");
|
||||
toast.fromError(t("usage.cloudzero.toasts.saveFailed"));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -132,7 +139,7 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
|
||||
const handleExportCloudZero = async () => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("No access token available");
|
||||
toast.fromError(t("usage.cloudzero.toasts.noAccessToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -153,14 +160,14 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(data.message || "Export to CloudZero completed successfully");
|
||||
toast.success(data.message || t("usage.cloudzero.toasts.exported"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.fromError(data.error || "Failed to export to CloudZero");
|
||||
toast.fromError(data.error || t("usage.cloudzero.toasts.exportFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error exporting to CloudZero:", error);
|
||||
toast.fromError("Failed to export to CloudZero");
|
||||
toast.fromError(t("usage.cloudzero.toasts.exportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
|
|
@ -170,11 +177,11 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
setExportLoading(true);
|
||||
try {
|
||||
// TODO: Implement CSV export functionality
|
||||
toast.info("CSV export functionality coming soon!");
|
||||
toast.info(t("usage.cloudzero.toasts.csvComingSoon"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error exporting CSV:", error);
|
||||
toast.fromError("Failed to export CSV");
|
||||
toast.fromError(t("usage.cloudzero.toasts.csvFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
|
|
@ -219,7 +226,7 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span>Export to CloudZero</span>
|
||||
<span>{t("usage.cloudzero.destinations.cloudzero")}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -235,7 +242,7 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Export to CSV</span>
|
||||
<span>{t("usage.cloudzero.destinations.csv")}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -245,14 +252,14 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleModalClose()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Data</DialogTitle>
|
||||
<DialogTitle>{t("usage.cloudzero.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Export Type Selection */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2 block">Export Destination</p>
|
||||
<p className="text-sm font-medium mb-2 block">{t("usage.cloudzero.destinationLabel")}</p>
|
||||
<Select items={exportOptions} value={exportType} onValueChange={(value) => value && setExportType(value)}>
|
||||
<SelectTrigger className="w-full" aria-label="Export Destination">
|
||||
<SelectTrigger className="w-full" aria-label={t("usage.cloudzero.destinationLabel")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -277,11 +284,13 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
{existingSettings && (
|
||||
<Alert className="mb-4">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Existing CloudZero Configuration</AlertTitle>
|
||||
<AlertTitle>{t("usage.cloudzero.existing.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
API Key: {existingSettings.api_key_masked}
|
||||
{t("usage.cloudzero.existing.apiKey")}
|
||||
{existingSettings.api_key_masked}
|
||||
<br />
|
||||
Connection ID: {existingSettings.connection_id}
|
||||
{t("usage.cloudzero.existing.connectionId")}
|
||||
{existingSettings.connection_id}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -289,15 +298,27 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
{!existingSettings && (
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="api_key" label="CloudZero API Key">
|
||||
<FormField control={form.control} name="api_key" label={t("usage.cloudzero.form.apiKeyLabel")}>
|
||||
{({ ref, ...field }) => (
|
||||
<PasswordInput {...field} ref={ref} placeholder="Enter your CloudZero API key" />
|
||||
<PasswordInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("usage.cloudzero.form.apiKeyPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="connection_id" label="Connection ID">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="connection_id"
|
||||
label={t("usage.cloudzero.form.connectionIdLabel")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="Enter CloudZero connection ID" />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("usage.cloudzero.form.connectionIdPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
|
@ -312,17 +333,15 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
{exportType === "csv" && (
|
||||
<Alert variant="info">
|
||||
<FileDown />
|
||||
<AlertTitle>CSV Export</AlertTitle>
|
||||
<AlertDescription>
|
||||
Export your usage data as a CSV file for analysis in spreadsheet applications.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("usage.cloudzero.csv.title")}</AlertTitle>
|
||||
<AlertDescription>{t("usage.cloudzero.csv.description")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={handleModalClose}>
|
||||
Cancel
|
||||
{t("usage.cloudzero.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -331,7 +350,7 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
aria-busy={loading || exportLoading}
|
||||
>
|
||||
{(loading || exportLoading) && <UiLoadingSpinner className="size-4" />}
|
||||
{exportType === "cloudzero" ? "Export to CloudZero" : "Export CSV"}
|
||||
{exportType === "cloudzero" ? t("usage.cloudzero.exportToCloudZero") : t("usage.cloudzero.exportCsv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
import { RefreshIcon, ClockIcon } from "@heroicons/react/outline";
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
variant = "card",
|
||||
className = "",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const formatTimestamp = (timestamp: string | Date) => {
|
||||
const date = new Date(timestamp);
|
||||
const dateStr = date.toLocaleDateString("en-US", {
|
||||
|
|
@ -33,7 +35,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
return t("autoRotationView.timestamp", { date: dateStr, time: timeStr });
|
||||
};
|
||||
|
||||
const content = (
|
||||
|
|
@ -41,12 +43,17 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshIcon className="h-4 w-4 text-info" />
|
||||
<p className="text-sm font-semibold text-foreground">Auto-Rotation</p>
|
||||
<StatusBadge tone={autoRotate ? "success" : "neutral"} label={autoRotate ? "Enabled" : "Disabled"} />
|
||||
<p className="text-sm font-semibold text-foreground">{t("autoRotationView.title")}</p>
|
||||
<StatusBadge
|
||||
tone={autoRotate ? "success" : "neutral"}
|
||||
label={autoRotate ? t("autoRotationView.enabled") : t("autoRotationView.disabled")}
|
||||
/>
|
||||
{autoRotate && rotationInterval && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">•</p>
|
||||
<p className="text-sm text-muted-foreground">Every {rotationInterval}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("autoRotationView.every", { interval: rotationInterval })}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -58,7 +65,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<ClockIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Last Rotation</p>
|
||||
<p className="text-sm font-medium text-foreground">{t("autoRotationView.lastRotation")}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatTimestamp(lastRotationAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,7 +75,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<ClockIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Next Scheduled Rotation</p>
|
||||
<p className="text-sm font-medium text-foreground">{t("autoRotationView.nextScheduledRotation")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatTimestamp(nextRotationAt || keyRotationAt || "")}
|
||||
</p>
|
||||
|
|
@ -79,7 +86,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
{autoRotate && !lastRotationAt && !keyRotationAt && !nextRotationAt && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<ClockIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No rotation history available</p>
|
||||
<p className="text-sm text-muted-foreground">{t("autoRotationView.noHistory")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -88,7 +95,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
{!autoRotate && !lastRotationAt && !keyRotationAt && !nextRotationAt && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<RefreshIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Auto-rotation is not enabled for this key</p>
|
||||
<p className="text-sm text-muted-foreground">{t("autoRotationView.notEnabled")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -99,8 +106,8 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
<div className={`rounded-lg border border-border bg-card p-6 ${className}`}>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Auto-Rotation</p>
|
||||
<p className="text-xs text-muted-foreground">Automatic key rotation settings and status for this key</p>
|
||||
<p className="text-sm font-semibold text-foreground">{t("autoRotationView.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("autoRotationView.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
|
|
@ -110,7 +117,7 @@ const AutoRotationView: React.FC<AutoRotationViewProps> = ({
|
|||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<p className="mb-3 text-sm font-medium text-foreground">Auto-Rotation</p>
|
||||
<p className="mb-3 text-sm font-medium text-foreground">{t("autoRotationView.title")}</p>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCustomerLookups, useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
|
||||
import { PaginatedMultiSelect } from "@/components/shared/PaginatedMultiSelect";
|
||||
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
|
|
@ -10,6 +11,7 @@ interface CustomerMultiSelectProps {
|
|||
}
|
||||
|
||||
export default function CustomerMultiSelect({ value, onChange, disabled }: CustomerMultiSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = useCustomers(20, search);
|
||||
const loadedCustomers = useMemo(() => (data?.pages ?? []).flat(), [data]);
|
||||
|
|
@ -27,9 +29,9 @@ export default function CustomerMultiSelect({ value, onChange, disabled }: Custo
|
|||
).values(),
|
||||
);
|
||||
const lookupError = selectedCustomers.some((result) => result.isError)
|
||||
? "Unable to load selected customers"
|
||||
? t("usage.customerSelect.lookupError")
|
||||
: undefined;
|
||||
const errorText = isError ? "Unable to load customers" : lookupError;
|
||||
const errorText = isError ? t("usage.customerSelect.loadError") : lookupError;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -42,11 +44,11 @@ export default function CustomerMultiSelect({ value, onChange, disabled }: Custo
|
|||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
placeholder="Search customers by ID or alias..."
|
||||
emptyText="No customers found"
|
||||
placeholder={t("usage.customerSelect.placeholder")}
|
||||
emptyText={t("usage.customerSelect.empty")}
|
||||
errorText={errorText}
|
||||
loadingText="Loading customers..."
|
||||
clearAllLabel="Clear all customers"
|
||||
loadingText={t("usage.customerSelect.loading")}
|
||||
clearAllLabel={t("usage.customerSelect.clearAll")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{errorText && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { EntityLink } from "@/components/shared/EntityLink";
|
||||
import { cx } from "@/lib/cva.config";
|
||||
|
|
@ -24,6 +25,7 @@ export default function LabeledField({
|
|||
copyable = false,
|
||||
defaultUserIdCheck = false,
|
||||
}: LabeledFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = !value;
|
||||
const isDefaultUser = defaultUserIdCheck && value === DEFAULT_PROXY_ADMIN_USER_ID;
|
||||
const displayValue = isEmpty ? "-" : value;
|
||||
|
|
@ -43,7 +45,7 @@ export default function LabeledField({
|
|||
{displayValue}
|
||||
</strong>
|
||||
)}
|
||||
{isCopyable && <CopyButton value={value} label={`Copy ${label}`} />}
|
||||
{isCopyable && <CopyButton value={value} label={t("labeledField.copy", { label })} />}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { hasRouterSettings } from "./routerSettingsPayload";
|
||||
|
||||
interface RouterSettingsSummaryProps {
|
||||
|
|
@ -15,8 +17,9 @@ const fallbackEntries = (fallbacks: unknown): Array<[string, string[]]> => {
|
|||
|
||||
export default function RouterSettingsSummary({
|
||||
routerSettings,
|
||||
emptyText = "No router settings configured",
|
||||
emptyText = i18n.t("routerSettingsSummary.empty"),
|
||||
}: RouterSettingsSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!hasRouterSettings(routerSettings)) {
|
||||
return <div className="text-muted-foreground">{emptyText}</div>;
|
||||
}
|
||||
|
|
@ -28,18 +31,27 @@ export default function RouterSettingsSummary({
|
|||
<div className="space-y-1 text-sm">
|
||||
{settings.routing_strategy != null && (
|
||||
<div>
|
||||
Routing Strategy: <Badge variant="secondary">{String(settings.routing_strategy)}</Badge>
|
||||
{t("routerSettingsSummary.routingStrategy")}{" "}
|
||||
<Badge variant="secondary">{String(settings.routing_strategy)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{settings.num_retries != null && <div>Number of Retries: {String(settings.num_retries)}</div>}
|
||||
{settings.allowed_fails != null && <div>Allowed Failures: {String(settings.allowed_fails)}</div>}
|
||||
{settings.cooldown_time != null && <div>Cooldown Time: {String(settings.cooldown_time)}s</div>}
|
||||
{settings.timeout != null && <div>Timeout: {String(settings.timeout)}s</div>}
|
||||
{settings.retry_after != null && <div>Retry After: {String(settings.retry_after)}s</div>}
|
||||
{Boolean(settings.enable_tag_filtering) && <div>Tag Filtering: Enabled</div>}
|
||||
{settings.num_retries != null && (
|
||||
<div>{t("routerSettingsSummary.numRetries", { value: String(settings.num_retries) })}</div>
|
||||
)}
|
||||
{settings.allowed_fails != null && (
|
||||
<div>{t("routerSettingsSummary.allowedFailures", { value: String(settings.allowed_fails) })}</div>
|
||||
)}
|
||||
{settings.cooldown_time != null && (
|
||||
<div>{t("routerSettingsSummary.cooldownTime", { value: String(settings.cooldown_time) })}</div>
|
||||
)}
|
||||
{settings.timeout != null && <div>{t("routerSettingsSummary.timeout", { value: String(settings.timeout) })}</div>}
|
||||
{settings.retry_after != null && (
|
||||
<div>{t("routerSettingsSummary.retryAfter", { value: String(settings.retry_after) })}</div>
|
||||
)}
|
||||
{Boolean(settings.enable_tag_filtering) && <div>{t("routerSettingsSummary.tagFilteringEnabled")}</div>}
|
||||
{fallbacks.length > 0 && (
|
||||
<div>
|
||||
<div>Fallbacks:</div>
|
||||
<div>{t("routerSettingsSummary.fallbacks")}</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{fallbacks.map(([model, targets]) => (
|
||||
<div key={model} className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Guardrail } from "./types";
|
||||
import { getGuardrailsList } from "../networking";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
|
|
@ -12,6 +13,7 @@ interface GuardrailSelectorProps {
|
|||
}
|
||||
|
||||
const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({ onChange, value, className, accessToken, disabled }) => {
|
||||
const { t } = useTranslation();
|
||||
const [guardrails, setGuardrails] = useState<Guardrail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({ onChange, value,
|
|||
<div className="min-w-0">
|
||||
<MultiSelect
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Setting guardrails is a premium feature." : "Select guardrails"}
|
||||
placeholder={disabled ? t("guardrailSelector.premiumOnly") : t("guardrailSelector.placeholder")}
|
||||
onValueChange={handleGuardrailChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CogIcon, BanIcon } from "@heroicons/react/outline";
|
||||
import { callbackInfo, callback_map, reverse_callback_map } from "./callback_info_helpers";
|
||||
|
|
@ -23,6 +24,7 @@ export function LoggingSettingsView({
|
|||
variant = "card",
|
||||
className = "",
|
||||
}: LoggingSettingsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const getLoggingDisplayName = (callbackName: string) => {
|
||||
// Find the display name for the callback
|
||||
const callbackDisplayName = Object.entries(callback_map).find(([_, value]) => value === callbackName)?.[0];
|
||||
|
|
@ -45,11 +47,11 @@ export function LoggingSettingsView({
|
|||
const getEventTypeLabel = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "success":
|
||||
return "Success Only";
|
||||
return t("loggingSettingsView.eventTypes.success");
|
||||
case "failure":
|
||||
return "Failure Only";
|
||||
return t("loggingSettingsView.eventTypes.failure");
|
||||
case "success_and_failure":
|
||||
return "Success & Failure";
|
||||
return t("loggingSettingsView.eventTypes.successAndFailure");
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
|
|
@ -61,7 +63,7 @@ export function LoggingSettingsView({
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CogIcon className="h-4 w-4 text-info" />
|
||||
<span className="font-semibold text-foreground">Logging Integrations</span>
|
||||
<span className="font-semibold text-foreground">{t("loggingSettingsView.loggingIntegrations")}</span>
|
||||
<Badge variant="secondary">{loggingConfigs.length}</Badge>
|
||||
</div>
|
||||
|
||||
|
|
@ -84,7 +86,9 @@ export function LoggingSettingsView({
|
|||
<div>
|
||||
<span className="block font-medium text-info">{displayName}</span>
|
||||
<span className="block text-xs text-info">
|
||||
{Object.keys(config.callback_vars).length} parameters configured
|
||||
{t("loggingSettingsView.parametersConfigured", {
|
||||
count: Object.keys(config.callback_vars).length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -98,7 +102,7 @@ export function LoggingSettingsView({
|
|||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted border border-border">
|
||||
<CogIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">No logging integrations configured</span>
|
||||
<span className="text-muted-foreground text-sm">{t("loggingSettingsView.noLoggingIntegrations")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -107,7 +111,7 @@ export function LoggingSettingsView({
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BanIcon className="h-4 w-4 text-destructive" />
|
||||
<span className="font-semibold text-foreground">Disabled Callbacks</span>
|
||||
<span className="font-semibold text-foreground">{t("loggingSettingsView.disabledCallbacks")}</span>
|
||||
<Badge variant="destructive">{disabledCallbacks.length}</Badge>
|
||||
</div>
|
||||
|
||||
|
|
@ -130,10 +134,10 @@ export function LoggingSettingsView({
|
|||
/>
|
||||
<div>
|
||||
<span className="block font-medium text-destructive">{displayName}</span>
|
||||
<span className="block text-xs text-destructive">Disabled for this key</span>
|
||||
<span className="block text-xs text-destructive">{t("loggingSettingsView.disabledForKey")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="destructive">Disabled</Badge>
|
||||
<Badge variant="destructive">{t("loggingSettingsView.disabled")}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -141,7 +145,7 @@ export function LoggingSettingsView({
|
|||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted border border-border">
|
||||
<BanIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">No callbacks disabled</span>
|
||||
<span className="text-muted-foreground text-sm">{t("loggingSettingsView.noCallbacksDisabled")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -153,10 +157,8 @@ export function LoggingSettingsView({
|
|||
<div className={`bg-card border border-border rounded-lg p-6 ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div>
|
||||
<span className="block font-semibold text-foreground">Logging Settings</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Active logging integrations and disabled callbacks for this key
|
||||
</span>
|
||||
<span className="block font-semibold text-foreground">{t("loggingSettingsView.title")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("loggingSettingsView.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
|
|
@ -166,7 +168,7 @@ export function LoggingSettingsView({
|
|||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<span className="block font-medium text-foreground mb-3">Logging Settings</span>
|
||||
<span className="block font-medium text-foreground mb-3">{t("loggingSettingsView.title")}</span>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import moment from "moment";
|
||||
import { AuditLogEntry, AUDIT_TABLE_NAME_DISPLAY } from "../AuditLogsTableColumns";
|
||||
import DefaultProxyAdminTag from "../../../common_components/DefaultProxyAdminTag";
|
||||
|
|
@ -25,6 +26,7 @@ const ACTION_TONE: Record<string, StatusTone> = {
|
|||
};
|
||||
|
||||
function CopyableJsonBlock({ label, value }: { label: string; value: Record<string, any> }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
|
|
@ -38,7 +40,13 @@ function CopyableJsonBlock({ label, value }: { label: string; value: Record<stri
|
|||
<div className="overflow-hidden rounded-sm border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-3 py-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">{label}</span>
|
||||
<Button variant="ghost" size="icon-xs" onClick={handleCopy} title="Copy JSON" aria-label="Copy JSON">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleCopy}
|
||||
title={t("logs.audit.drawer.copyJson")}
|
||||
aria-label={t("logs.audit.drawer.copyJson")}
|
||||
>
|
||||
{copied ? <Check className="text-success" /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -59,6 +67,7 @@ function MetadataRow({ label, value }: { label: string; value: React.ReactNode }
|
|||
}
|
||||
|
||||
function DiffSection({ log }: { log: AuditLogEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const { action, table_name, before_value, updated_values } = log;
|
||||
const isKeyTable = table_name === "LiteLLM_VerificationToken";
|
||||
const isUpdateAction = action === "updated" || action === "rotated";
|
||||
|
|
@ -96,8 +105,10 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
|
|||
}
|
||||
});
|
||||
|
||||
displayBefore = Object.keys(changedBefore).length > 0 ? changedBefore : { note: "No differing fields detected" };
|
||||
displayAfter = Object.keys(changedAfter).length > 0 ? changedAfter : { note: "No differing fields detected" };
|
||||
displayBefore =
|
||||
Object.keys(changedBefore).length > 0 ? changedBefore : { note: t("logs.audit.drawer.noDifferingFields") };
|
||||
displayAfter =
|
||||
Object.keys(changedAfter).length > 0 ? changedAfter : { note: t("logs.audit.drawer.noDifferingFields") };
|
||||
}
|
||||
|
||||
const renderValue = (label: string, value: Record<string, any> | null | undefined) => {
|
||||
|
|
@ -125,17 +136,19 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
|
|||
<div className="space-y-1 px-3 py-3 text-xs">
|
||||
{value.token !== undefined && (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Token:</span> {value.token ?? "N/A"}
|
||||
<span className="text-muted-foreground">{t("logs.audit.drawer.token")}</span> {value.token ?? "N/A"}
|
||||
</p>
|
||||
)}
|
||||
{value.spend !== undefined && (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Spend:</span> ${Number(value.spend).toFixed(6)}
|
||||
<span className="text-muted-foreground">{t("logs.audit.drawer.spend")}</span> $
|
||||
{Number(value.spend).toFixed(6)}
|
||||
</p>
|
||||
)}
|
||||
{value.max_budget !== undefined && (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Max Budget:</span> ${Number(value.max_budget).toFixed(6)}
|
||||
<span className="text-muted-foreground">{t("logs.audit.drawer.maxBudget")}</span> $
|
||||
{Number(value.max_budget).toFixed(6)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -149,21 +162,24 @@ function DiffSection({ log }: { log: AuditLogEntry }) {
|
|||
|
||||
return (
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{renderValue("Before", displayBefore)}
|
||||
{renderValue("After", displayAfter)}
|
||||
{renderValue(t("logs.audit.drawer.before"), displayBefore)}
|
||||
{renderValue(t("logs.audit.drawer.after"), displayAfter)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!log) return null;
|
||||
|
||||
const tableDisplay = AUDIT_TABLE_NAME_DISPLAY[log.table_name] ?? log.table_name;
|
||||
const tableDisplay = Object.hasOwn(AUDIT_TABLE_NAME_DISPLAY, log.table_name)
|
||||
? t(AUDIT_TABLE_NAME_DISPLAY[log.table_name])
|
||||
: log.table_name;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<ResizableSheetContent storageKey="litellm:auditLogDrawerWidth" className="gap-0 p-0">
|
||||
<SheetTitle className="sr-only">Audit log details</SheetTitle>
|
||||
<SheetTitle className="sr-only">{t("logs.audit.drawer.title")}</SheetTitle>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border bg-card px-6 py-4">
|
||||
<StatusBadge tone={ACTION_TONE[log.action] ?? "neutral"} label={log.action} />
|
||||
|
|
@ -174,25 +190,30 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
|
|||
|
||||
<div className="px-6 py-5">
|
||||
<div className="mb-5 rounded-lg border border-border bg-muted p-4">
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide text-foreground uppercase">Details</p>
|
||||
<MetadataRow label="Table" value={tableDisplay} />
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide text-foreground uppercase">
|
||||
{t("logs.audit.drawer.details")}
|
||||
</p>
|
||||
<MetadataRow label={t("logs.audit.drawer.table")} value={tableDisplay} />
|
||||
<MetadataRow
|
||||
label="Object ID"
|
||||
label={t("logs.audit.drawer.objectId")}
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1 font-mono text-xs">
|
||||
{log.object_id}
|
||||
<CopyButton value={log.object_id} label="Copy object ID" />
|
||||
<CopyButton value={log.object_id} label={t("logs.audit.drawer.copyObjectId")} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<MetadataRow label="Changed By" value={<DefaultProxyAdminTag userId={log.changed_by} />} />
|
||||
<MetadataRow
|
||||
label="API Key (Hash)"
|
||||
label={t("logs.audit.drawer.changedBy")}
|
||||
value={<DefaultProxyAdminTag userId={log.changed_by} />}
|
||||
/>
|
||||
<MetadataRow
|
||||
label={t("logs.audit.drawer.apiKeyHash")}
|
||||
value={
|
||||
log.changed_by_api_key ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-xs break-all">
|
||||
{log.changed_by_api_key}
|
||||
<CopyButton value={log.changed_by_api_key} label="Copy API key hash" />
|
||||
<CopyButton value={log.changed_by_api_key} label={t("logs.audit.drawer.copyApiKeyHash")} />
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
|
|
@ -39,6 +40,7 @@ export default function AuditLogsPanel({
|
|||
isActive,
|
||||
premiumUser,
|
||||
}: AuditLogsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
|
|
@ -101,16 +103,14 @@ export default function AuditLogsPanel({
|
|||
if (!premiumUser) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto text-center">
|
||||
<h1 style={{ display: "block", marginBottom: "10px" }}>✨ Enterprise Feature.</h1>
|
||||
<p style={{ display: "block", marginBottom: "10px" }}>
|
||||
This is a LiteLLM Enterprise feature, and requires a valid key to use.
|
||||
</p>
|
||||
<h1 style={{ display: "block", marginBottom: "10px" }}>{t("logs.audit.panel.enterpriseTitle")}</h1>
|
||||
<p style={{ display: "block", marginBottom: "10px" }}>{t("logs.audit.panel.enterpriseDescription")}</p>
|
||||
<p style={{ display: "block", marginBottom: "20px", fontStyle: "italic" }}>
|
||||
Here's a preview of what Audit Logs offer:
|
||||
{t("logs.audit.panel.previewIntro")}
|
||||
</p>
|
||||
<img
|
||||
src={resolveLogoSrc(auditLogsPreviewImg)}
|
||||
alt="Audit Logs Preview"
|
||||
alt={t("logs.audit.panel.previewAlt")}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "700px",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
|
|
@ -12,6 +14,7 @@ import {
|
|||
} from "@/components/shared/DataTable";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import { AUDIT_TABLE_NAME_DISPLAY, AuditLogEntry, getAuditLogsTableColumns } from "./AuditLogsTableColumns";
|
||||
|
||||
|
|
@ -68,30 +71,29 @@ const FILTER_LABELS: Record<string, string> = {
|
|||
table_name: "Table",
|
||||
};
|
||||
|
||||
const formatFilterValue = (columnId: string, value: unknown): string => {
|
||||
const formatFilterValue = (columnId: string, value: unknown, t: TFunction = i18n.t.bind(i18n)): string => {
|
||||
const raw = String(value);
|
||||
if (columnId === "action") {
|
||||
return ACTION_OPTIONS.find((option) => option.value === raw)?.label ?? raw;
|
||||
}
|
||||
if (columnId === "table_name") {
|
||||
return AUDIT_TABLE_NAME_DISPLAY[raw] ?? raw;
|
||||
return Object.hasOwn(AUDIT_TABLE_NAME_DISPLAY, raw) ? t(AUDIT_TABLE_NAME_DISPLAY[raw]) : raw;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
function AuditLogsEmptyState({ filtered }: { filtered: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ScrollText className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{filtered ? "No matching audit logs" : "No audit logs yet"}
|
||||
{filtered ? t("logs.audit.table.emptyFilteredTitle") : t("logs.audit.table.emptyTitle")}
|
||||
</div>
|
||||
<div className="max-w-xs text-center text-sm text-muted-foreground">
|
||||
{filtered
|
||||
? "No audit log entries match your filters."
|
||||
: "Administrative changes to keys, teams, users, and models will appear here."}
|
||||
{filtered ? t("logs.audit.table.emptyFilteredDescription") : t("logs.audit.table.emptyDescription")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -111,8 +113,9 @@ export function AuditLogsTable({
|
|||
onRefresh,
|
||||
onViewLog,
|
||||
}: AuditLogsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]);
|
||||
const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }, t), [onViewLog, t]);
|
||||
const hasActiveSearch = Boolean(searchValue?.trim());
|
||||
|
||||
return (
|
||||
|
|
@ -129,7 +132,7 @@ export function AuditLogsTable({
|
|||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={onColumnFiltersChange}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading audit logs…"
|
||||
loadingMessage={t("logs.audit.table.loading")}
|
||||
noDataMessage={<AuditLogsEmptyState filtered={columnFilters.length > 0 || hasActiveSearch} />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
|
|
@ -138,7 +141,7 @@ export function AuditLogsTable({
|
|||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Search audit logs by ID…"
|
||||
searchPlaceholder={t("logs.audit.table.searchPlaceholder")}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
|
|
@ -150,50 +153,50 @@ export function AuditLogsTable({
|
|||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down audit log entries"
|
||||
title={t("logs.audit.table.filtersTitle")}
|
||||
description={t("logs.audit.table.filtersDescription")}
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="Object ID">
|
||||
<DataTableFilterField label={t("logs.audit.table.objectId")}>
|
||||
<Input
|
||||
value={(get("object_id") as string) ?? ""}
|
||||
onChange={(event) => set("object_id", event.target.value)}
|
||||
placeholder="Enter object ID…"
|
||||
placeholder={t("logs.audit.table.objectIdPlaceholder")}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Changed By">
|
||||
<DataTableFilterField label={t("logs.audit.table.changedBy")}>
|
||||
<Input
|
||||
value={(get("changed_by") as string) ?? ""}
|
||||
onChange={(event) => set("changed_by", event.target.value)}
|
||||
placeholder="Enter user ID…"
|
||||
placeholder={t("logs.audit.table.changedByPlaceholder")}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Team ID">
|
||||
<DataTableFilterField label={t("logs.audit.table.teamId")}>
|
||||
<Input
|
||||
value={(get("team_id") as string) ?? ""}
|
||||
onChange={(event) => set("team_id", event.target.value)}
|
||||
placeholder="Enter team ID…"
|
||||
placeholder={t("logs.audit.table.teamIdPlaceholder")}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Key Hash">
|
||||
<DataTableFilterField label={t("logs.audit.table.keyHash")}>
|
||||
<Input
|
||||
value={(get("key_hash") as string) ?? ""}
|
||||
onChange={(event) => set("key_hash", event.target.value)}
|
||||
placeholder="Enter key hash…"
|
||||
placeholder={t("logs.audit.table.keyHashPlaceholder")}
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Action">
|
||||
<DataTableFilterField label={t("logs.audit.table.action")}>
|
||||
<Select
|
||||
items={ACTION_FILTER_ITEMS}
|
||||
value={(get("action") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("action", value === ALL_VALUE ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Actions" />
|
||||
<SelectValue placeholder={t("logs.audit.table.allActions")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Actions</SelectItem>
|
||||
<SelectItem value={ALL_VALUE}>{t("logs.audit.table.allActions")}</SelectItem>
|
||||
{ACTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -202,17 +205,17 @@ export function AuditLogsTable({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Table">
|
||||
<DataTableFilterField label={t("logs.audit.table.table")}>
|
||||
<Select
|
||||
items={TABLE_FILTER_ITEMS}
|
||||
value={(get("table_name") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("table_name", value === ALL_VALUE ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Tables" />
|
||||
<SelectValue placeholder={t("logs.audit.table.allTables")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Tables</SelectItem>
|
||||
<SelectItem value={ALL_VALUE}>{t("logs.audit.table.allTables")}</SelectItem>
|
||||
{TABLE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
|
||||
import { DateCell, IdCell, IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
|
||||
|
|
@ -18,13 +19,13 @@ export type AuditLogEntry = {
|
|||
updated_values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const AUDIT_TABLE_NAME_DISPLAY: Record<string, string> = {
|
||||
LiteLLM_VerificationToken: "Keys",
|
||||
LiteLLM_TeamTable: "Teams",
|
||||
LiteLLM_UserTable: "Users",
|
||||
LiteLLM_OrganizationTable: "Organizations",
|
||||
LiteLLM_ProxyModelTable: "Models",
|
||||
LiteLLM_AgentsTable: "Agents",
|
||||
export const AUDIT_TABLE_NAME_DISPLAY: Record<string, ParseKeys> = {
|
||||
LiteLLM_VerificationToken: "logs.audit.tableNames.keys",
|
||||
LiteLLM_TeamTable: "logs.audit.tableNames.teams",
|
||||
LiteLLM_UserTable: "logs.audit.tableNames.users",
|
||||
LiteLLM_OrganizationTable: "logs.audit.tableNames.organizations",
|
||||
LiteLLM_ProxyModelTable: "logs.audit.tableNames.models",
|
||||
LiteLLM_AgentsTable: "logs.audit.tableNames.agents",
|
||||
};
|
||||
|
||||
const ACTION_TONE: Record<string, StatusTone> = {
|
||||
|
|
@ -44,11 +45,14 @@ interface AuditLogsTableColumnsDeps {
|
|||
onViewLog: (log: AuditLogEntry) => void;
|
||||
}
|
||||
|
||||
export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDeps): ColumnDef<AuditLogEntry>[] => [
|
||||
export const getAuditLogsTableColumns = (
|
||||
{ onViewLog }: AuditLogsTableColumnsDeps,
|
||||
t: TFunction,
|
||||
): ColumnDef<AuditLogEntry>[] => [
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
header: "Timestamp",
|
||||
header: t("logs.audit.columns.timestamp"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
|
|
@ -56,7 +60,7 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
{
|
||||
id: "action",
|
||||
accessorKey: "action",
|
||||
header: "Action",
|
||||
header: t("logs.audit.columns.action"),
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -66,17 +70,21 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
{
|
||||
id: "table_name",
|
||||
accessorKey: "table_name",
|
||||
header: "Table",
|
||||
header: t("logs.audit.columns.table"),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">{AUDIT_TABLE_NAME_DISPLAY[row.original.table_name] ?? row.original.table_name}</span>
|
||||
<span className="text-sm">
|
||||
{Object.hasOwn(AUDIT_TABLE_NAME_DISPLAY, row.original.table_name)
|
||||
? t(AUDIT_TABLE_NAME_DISPLAY[row.original.table_name])
|
||||
: row.original.table_name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "object_id",
|
||||
accessorKey: "object_id",
|
||||
header: "Object ID",
|
||||
header: t("logs.audit.columns.objectId"),
|
||||
minSize: 220,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -91,7 +99,7 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
{
|
||||
id: "changed_by",
|
||||
accessorKey: "changed_by",
|
||||
header: "Changed By",
|
||||
header: t("logs.audit.columns.changedBy"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DefaultProxyAdminTag userId={row.original.changed_by} />,
|
||||
|
|
@ -99,7 +107,7 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
{
|
||||
id: "changed_by_api_key",
|
||||
accessorKey: "changed_by_api_key",
|
||||
header: "API Key (Hash)",
|
||||
header: t("logs.audit.columns.apiKeyHash"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.changed_by_api_key} variant="plain" />,
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ export const CREDENTIAL_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
export const KUBERNETES_POD_ROUTING_LABELS = {
|
||||
round_robin: "Round robin",
|
||||
session_affinity: "Session affinity",
|
||||
session_affinity_retry: "Session affinity (retry)",
|
||||
round_robin: "logs.common.podRouting.roundRobin",
|
||||
session_affinity: "logs.common.podRouting.sessionAffinity",
|
||||
session_affinity_retry: "logs.common.podRouting.sessionAffinityRetry",
|
||||
} as const;
|
||||
|
||||
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
|
||||
interface ClassifierAuditViewProps {
|
||||
|
|
@ -9,22 +10,24 @@ interface ClassifierAuditViewProps {
|
|||
}
|
||||
|
||||
export function ClassifierAuditView({ request, response }: ClassifierAuditViewProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-6 space-y-4">
|
||||
<AuditField title="Classifier input" value={request.classifier_input}>
|
||||
Provider request payload. A cached call or disabled message logging may have no capture.
|
||||
<AuditField title={t("logs.detail.classifierAudit.input.title")} value={request.classifier_input}>
|
||||
{t("logs.detail.classifierAudit.input.description")}
|
||||
</AuditField>
|
||||
<AuditField title="Originating request, credentials masked" value={request.originating_request_masked}>
|
||||
Comparison only. This source request was not appended to the classifier input.
|
||||
<AuditField title={t("logs.detail.classifierAudit.originating.title")} value={request.originating_request_masked}>
|
||||
{t("logs.detail.classifierAudit.originating.description")}
|
||||
</AuditField>
|
||||
<AuditField title="Classifier response" value={response}>
|
||||
The returned verdict and any explanation supplied by the classifier. Later routing rules may change the tier.
|
||||
<AuditField title={t("logs.detail.classifierAudit.response.title")} value={response}>
|
||||
{t("logs.detail.classifierAudit.response.description")}
|
||||
</AuditField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditField({ title, value, children }: { title: string; value: unknown; children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const serialized = JSON.stringify(value);
|
||||
const truncated = serialized?.includes("litellm_truncated") ?? false;
|
||||
|
||||
|
|
@ -32,17 +35,19 @@ function AuditField({ title, value, children }: { title: string; value: unknown;
|
|||
<Card size="sm" role="region" aria-label={title}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{value != null && <CopyButton value={JSON.stringify(value, null, 2)} label={`Copy ${title}`} />}
|
||||
{value != null && (
|
||||
<CopyButton value={JSON.stringify(value, null, 2)} label={t("logs.detail.classifierAudit.copy", { title })} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{children}</p>
|
||||
{truncated && (
|
||||
<p role="status" className="mb-3 text-sm text-warning">
|
||||
This stored copy is truncated. The complete payload is unavailable from the configured log storage.
|
||||
{t("logs.detail.classifierAudit.truncated")}
|
||||
</p>
|
||||
)}
|
||||
{value == null ? (
|
||||
<p className="text-sm text-muted-foreground">Not captured or message logging disabled</p>
|
||||
<p className="text-sm text-muted-foreground">{t("logs.detail.classifierAudit.notCaptured")}</p>
|
||||
) : (
|
||||
<JsonViewer data={value} mode="formatted" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export const AUTOROUTER_CLASSIFIER_ORIGIN = "autorouter_classifier";
|
||||
|
||||
export function ClassifyTag({ origin, className }: { origin?: string | null; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (origin !== AUTOROUTER_CLASSIFIER_ORIGIN) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
title="Tier classification call made by the auto-router, not a request the caller sent"
|
||||
title={t("logs.detail.classifyTag.title")}
|
||||
className={cn("px-2 py-0 text-[10px] font-normal", className)}
|
||||
>
|
||||
Classify
|
||||
{t("logs.detail.classifyTag.label")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
interface CollapsibleMessageProps {
|
||||
|
|
@ -15,6 +16,7 @@ interface CollapsibleMessageProps {
|
|||
|
||||
export function CollapsibleMessage({ label, content, defaultExpanded = false }: CollapsibleMessageProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const { t } = useTranslation();
|
||||
const charCount = content?.length || 0;
|
||||
|
||||
if (!content || charCount === 0) {
|
||||
|
|
@ -30,7 +32,9 @@ export function CollapsibleMessage({ label, content, defaultExpanded = false }:
|
|||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[10px] uppercase tracking-[0.5px] text-muted-foreground">{label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({charCount.toLocaleString()} chars)</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("logs.detail.collapsibleMessage.charCount", { count: charCount, value: charCount.toLocaleString() })}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { copyToClipboard } from "@/utils/dataUtils";
|
|||
import { type ReactNode, useState } from "react";
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronUp, Copy, X } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -54,6 +55,7 @@ export function DrawerHeader({
|
|||
onToggleSidebar,
|
||||
backTo,
|
||||
}: DrawerHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const provider = log.custom_llm_provider || "";
|
||||
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
|
||||
const showToggleWithProvider = isSidebarCollapsed && Boolean(providerInfo || log.model);
|
||||
|
|
@ -76,7 +78,7 @@ export function DrawerHeader({
|
|||
<Button variant="outline" size="sm" onClick={onClose} className="mb-3 max-w-full">
|
||||
<ArrowLeft data-icon="inline-start" />
|
||||
{backTo.icon}
|
||||
<span className="truncate">Back to {backTo.label}</span>
|
||||
<span className="truncate">{t("logs.detail.header.backTo", { label: backTo.label })}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Row 0: Model + Provider with Logo */}
|
||||
|
|
@ -103,7 +105,7 @@ export function DrawerHeader({
|
|||
>
|
||||
{showToggleWithRequestId && <SidebarToggle isCollapsed onToggle={onToggleSidebar} />}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CopyableId value={log.request_id} label="Request ID" fontSize={FONT_SIZE_HEADER} />
|
||||
<CopyableId value={log.request_id} label={t("logs.detail.header.requestId")} fontSize={FONT_SIZE_HEADER} />
|
||||
{callId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
|
|
@ -141,12 +143,13 @@ function ModelProviderSection({
|
|||
providerLogo?: string;
|
||||
providerName?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{providerLogo && (
|
||||
<img
|
||||
src={providerLogo}
|
||||
alt={providerName || "Provider"}
|
||||
alt={providerName || t("logs.detail.header.provider")}
|
||||
style={{ width: 24, height: 24 }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
|
|
@ -182,6 +185,7 @@ function CopyableId({
|
|||
muted?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (await copyToClipboard(value)) {
|
||||
|
|
@ -211,7 +215,7 @@ function CopyableId({
|
|||
{value}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copied ? "Copied!" : `Copy ${label}`}
|
||||
aria-label={copied ? t("logs.detail.header.copied") : t("logs.detail.header.copyLabel", { label })}
|
||||
onClick={handleCopy}
|
||||
className="ml-1 align-middle text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
|
|
@ -237,6 +241,7 @@ function NavigationSection({
|
|||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const keyboardShortcutStyle = {
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 4,
|
||||
|
|
@ -265,7 +270,7 @@ function NavigationSection({
|
|||
<TooltipTrigger render={<Button variant="ghost" size="icon-sm" onClick={onClose} />}>
|
||||
<X className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>ESC to close</TooltipContent>
|
||||
<TooltipContent>{t("logs.detail.header.escToClose")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
@ -286,10 +291,11 @@ function StatusBar({
|
|||
statusColor: "error" | "success";
|
||||
environment: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={statusColor === "error" ? "destructive" : "secondary"}>{statusLabel}</Badge>
|
||||
<Badge variant="outline">Env: {environment}</Badge>
|
||||
<Badge variant="outline">{t("logs.detail.header.environment", { environment })}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ParsedMessage } from "./prettyMessagesTypes";
|
||||
import { SimpleMessageBlock } from "./SimpleMessageBlock";
|
||||
|
|
@ -15,6 +16,7 @@ interface HistoryTreeProps {
|
|||
|
||||
export function HistoryTree({ messages }: HistoryTreeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
|
|
@ -29,7 +31,7 @@ export function HistoryTree({ messages }: HistoryTreeProps) {
|
|||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[10px] uppercase tracking-[0.5px] text-muted-foreground">
|
||||
HISTORY ({messages.length} message{messages.length !== 1 ? "s" : ""})
|
||||
{t("logs.detail.historyTree.label", { count: messages.length })}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { ParsedMessage } from "./prettyMessagesTypes";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
|
@ -19,6 +20,7 @@ interface InputCardProps {
|
|||
|
||||
export function InputCard({ messages, promptTokens, inputCost }: InputCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
|
|
@ -32,7 +34,7 @@ export function InputCard({ messages, promptTokens, inputCost }: InputCardProps)
|
|||
|
||||
const handleCopy = () => {
|
||||
const content = lastMessage?.content || "";
|
||||
void copyToClipboard(content, "Input copied");
|
||||
void copyToClipboard(content, t("logs.detail.inputCard.copied"));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useTheme } from "next-themes";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { JsonView, darkStyles, defaultStyles } from "react-json-view-lite";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
import { JSON_MAX_HEIGHT, SPACING_LARGE } from "./constants";
|
||||
|
|
@ -14,8 +15,9 @@ interface JsonViewerProps {
|
|||
*/
|
||||
export function JsonViewer({ data }: JsonViewerProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!data) return <span className="text-muted-foreground">No data</span>;
|
||||
if (!data) return <span className="text-muted-foreground">{t("logs.detail.jsonViewer.noData")}</span>;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { copyToClipboard } from "@/utils/dataUtils";
|
|||
import { useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, CircleAlert, Copy, Info } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -75,6 +77,7 @@ export function LogDetailContent({
|
|||
accessToken,
|
||||
userEmail,
|
||||
}: LogDetailContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const metadata = logEntry.metadata || {};
|
||||
const podRouting = metadata.kubernetes_pod_routing;
|
||||
const hasError = metadata.status === "failure";
|
||||
|
|
@ -96,7 +99,7 @@ export function LogDetailContent({
|
|||
const guardrailEntries = normalizeGuardrailEntries(guardrailInfo);
|
||||
const hasGuardrailData = guardrailEntries.length > 0;
|
||||
const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries);
|
||||
const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries);
|
||||
const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries, t);
|
||||
|
||||
// LLM Judge data
|
||||
const evalInfo = metadata?.eval_information;
|
||||
|
|
@ -109,7 +112,7 @@ export function LogDetailContent({
|
|||
if (hasError && errorInfo) {
|
||||
return {
|
||||
error: {
|
||||
message: errorInfo.error_message || "An error occurred",
|
||||
message: errorInfo.error_message || t("logs.detail.content.errorFallback"),
|
||||
type: errorInfo.error_class || "error",
|
||||
code: errorInfo.error_code || "unknown",
|
||||
param: null,
|
||||
|
|
@ -129,7 +132,7 @@ export function LogDetailContent({
|
|||
>
|
||||
<CircleAlert className="size-4 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<div className="font-medium text-destructive">Request Failed</div>
|
||||
<div className="font-medium text-destructive">{t("logs.detail.content.requestFailed")}</div>
|
||||
<ErrorDescription errorInfo={errorInfo} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,43 +147,48 @@ export function LogDetailContent({
|
|||
<div className="bg-card rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Card size="sm" style={{ marginBottom: 0 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Request Details</CardTitle>
|
||||
<CardTitle>{t("logs.detail.content.requestDetails")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList>
|
||||
<DescriptionItem label="Model">{logEntry.model}</DescriptionItem>
|
||||
<DescriptionItem label="Provider">{logEntry.custom_llm_provider || "-"}</DescriptionItem>
|
||||
<DescriptionItem label="Call Type">{logEntry.call_type}</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.content.model")}>{logEntry.model}</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.content.provider")}>
|
||||
{logEntry.custom_llm_provider || "-"}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.content.callType")}>{logEntry.call_type}</DescriptionItem>
|
||||
{logEntry.user && (
|
||||
<DescriptionItem label="User">
|
||||
<DescriptionItem label={t("logs.detail.content.user")}>
|
||||
<UserIdentity userId={logEntry.user} email={userEmail} />
|
||||
</DescriptionItem>
|
||||
)}
|
||||
<DescriptionItem label="Model ID">
|
||||
<DescriptionItem label={t("logs.detail.content.modelId")}>
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label="API Base">
|
||||
<DescriptionItem label={t("logs.detail.content.apiBase")}>
|
||||
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
|
||||
</DescriptionItem>
|
||||
{podRouting && typeof podRouting === "object" && (
|
||||
<DescriptionItem label="Pod">
|
||||
<DescriptionItem label={t("logs.detail.content.pod")}>
|
||||
{podRouting.pod_ip}
|
||||
{" · "}
|
||||
{KUBERNETES_POD_ROUTING_LABELS[podRouting.selection]}
|
||||
{Object.hasOwn(KUBERNETES_POD_ROUTING_LABELS, podRouting.selection) &&
|
||||
t(KUBERNETES_POD_ROUTING_LABELS[podRouting.selection])}
|
||||
{" · "}
|
||||
{podRouting.pod_count} pods
|
||||
{t("logs.detail.content.podCount", { count: podRouting.pod_count })}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{logEntry.requester_ip_address && (
|
||||
<DescriptionItem label="IP Address">{logEntry.requester_ip_address}</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.content.ipAddress")}>
|
||||
{logEntry.requester_ip_address}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{typeof logEntry.metadata?.used_client_oauth_token === "boolean" && (
|
||||
<DescriptionItem label="Credential">
|
||||
<DescriptionItem label={t("logs.detail.content.credential")}>
|
||||
{CREDENTIAL_LABELS[String(logEntry.metadata.used_client_oauth_token)]}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<DescriptionItem label="Guardrail">
|
||||
<DescriptionItem label={t("logs.detail.content.guardrail")}>
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
</DescriptionItem>
|
||||
)}
|
||||
|
|
@ -225,7 +233,7 @@ export function LogDetailContent({
|
|||
<div className="bg-card rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6 p-8 text-center">
|
||||
<UiLoadingSpinner className="inline-block size-5" />
|
||||
<div style={{ marginTop: 8, color: "var(--color-muted-foreground)" }}>
|
||||
Loading request & response data...
|
||||
{t("logs.detail.content.loadingDetails")}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -303,6 +311,7 @@ function CopyButton({
|
|||
disabled?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (await copyToClipboard(getText())) {
|
||||
|
|
@ -317,7 +326,7 @@ function CopyButton({
|
|||
size="icon-sm"
|
||||
onClick={handleCopy}
|
||||
disabled={disabled}
|
||||
aria-label={copied ? "Copied!" : label}
|
||||
aria-label={copied ? t("logs.detail.content.copied") : label}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
|
|
@ -325,16 +334,17 @@ function CopyButton({
|
|||
}
|
||||
|
||||
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{errorInfo.error_code && (
|
||||
<div>
|
||||
<span className="font-semibold">Error Code:</span> {errorInfo.error_code}
|
||||
<span className="font-semibold">{t("logs.detail.content.errorCode")}</span> {errorInfo.error_code}
|
||||
</div>
|
||||
)}
|
||||
{errorInfo.error_message && (
|
||||
<div>
|
||||
<span className="font-semibold">Message:</span> {errorInfo.error_message}
|
||||
<span className="font-semibold">{t("logs.detail.content.errorMessage")}</span> {errorInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -342,10 +352,11 @@ function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
|||
}
|
||||
|
||||
function TagsSection({ tags }: { tags: Record<string, any> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="bg-card rounded-lg shadow-sm w-full max-w-full overflow-hidden p-4 mb-6">
|
||||
<span className="font-semibold" style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
|
||||
Tags
|
||||
{t("logs.detail.content.tags")}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.entries(tags).map(([key, value]) => (
|
||||
|
|
@ -369,6 +380,7 @@ function UserIdentity({ userId, email }: { userId: string; email?: string }) {
|
|||
}
|
||||
|
||||
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = () => {
|
||||
const el = document.getElementById("guardrail-section");
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
|
|
@ -379,7 +391,9 @@ function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: nu
|
|||
<a onClick={handleClick} style={{ cursor: "pointer" }}>
|
||||
{label}
|
||||
</a>
|
||||
{maskedCount > 0 && <Badge variant="secondary">{maskedCount} masked</Badge>}
|
||||
{maskedCount > 0 && (
|
||||
<Badge variant="secondary">{t("logs.detail.content.maskedCount", { count: maskedCount })}</Badge>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -397,28 +411,33 @@ function getUncachedInputTextTokens(metadata: Record<string, any>): number | und
|
|||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
const RESPONSE_CACHE_TOOLTIP =
|
||||
"Whether this request was served from LiteLLM's response cache (e.g. Redis / in-memory), skipping the LLM provider call entirely. This is separate from provider prompt caching; a Miss here does not mean prompt caching failed.";
|
||||
const RESPONSE_CACHE_TOOLTIP: ParseKeys = "logs.detail.metrics.responseCacheTooltip";
|
||||
const RESPONSE_CACHE_DOCS_URL = "https://docs.litellm.ai/docs/proxy/caching";
|
||||
const CACHE_KEY_TOOLTIP =
|
||||
"The key LiteLLM computed for this request in the response cache. Requests with the same cache key share a cached response; a different key means the request content did not match any cached entry.";
|
||||
const CACHE_KEY_TOOLTIP: ParseKeys = "logs.detail.metrics.cacheKeyTooltip";
|
||||
const PROMPT_CACHE_DOCS_URL = "https://docs.litellm.ai/docs/completion/prompt_caching";
|
||||
|
||||
function MetricLabel({ label, tooltip, docsUrl }: { label: string; tooltip: string; docsUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span role="img" aria-label={`${label} info`} className="inline-flex text-muted-foreground" />}
|
||||
render={
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t("logs.detail.metrics.infoLabel", { label })}
|
||||
className="inline-flex text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltip}{" "}
|
||||
<a href={docsUrl} target="_blank" rel="noreferrer" className="underline">
|
||||
Docs
|
||||
{t("logs.detail.metrics.docs")}
|
||||
</a>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -432,6 +451,7 @@ function MetricLabel({ label, tooltip, docsUrl }: { label: string; tooltip: stri
|
|||
* from the parsed output and error files, and the models the batch actually ran on.
|
||||
*/
|
||||
function BatchResultsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record<string, unknown> }) {
|
||||
const { t } = useTranslation();
|
||||
const counts = getBatchRequestCounts(metadata);
|
||||
const batchId = getBatchIdFromRequestId(logEntry.request_id);
|
||||
const batchModels = getBatchModels(metadata);
|
||||
|
|
@ -441,21 +461,21 @@ function BatchResultsSection({ logEntry, metadata }: { logEntry: LogEntry; metad
|
|||
<div className="bg-card rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Card size="sm" style={{ marginBottom: 0 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Batch Results</CardTitle>
|
||||
<CardTitle>{t("logs.detail.batch.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList>
|
||||
{batchId && (
|
||||
<DescriptionItem label="Batch ID">
|
||||
<DescriptionItem label={t("logs.detail.batch.batchId")}>
|
||||
<TruncatedValue value={batchId} />
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{counts && (
|
||||
<>
|
||||
<DescriptionItem label="Successful Requests">
|
||||
<DescriptionItem label={t("logs.detail.batch.successful")}>
|
||||
{formatNumberWithCommas(counts.successful)}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label="Failed Requests">
|
||||
<DescriptionItem label={t("logs.detail.batch.failed")}>
|
||||
{counts.failed > 0 ? (
|
||||
<Badge variant="secondary" className="bg-destructive/15 text-destructive">
|
||||
{formatNumberWithCommas(counts.failed)}
|
||||
|
|
@ -466,7 +486,9 @@ function BatchResultsSection({ logEntry, metadata }: { logEntry: LogEntry; metad
|
|||
</DescriptionItem>
|
||||
</>
|
||||
)}
|
||||
{batchModels && <DescriptionItem label="Models">{batchModels.join(", ")}</DescriptionItem>}
|
||||
{batchModels && (
|
||||
<DescriptionItem label={t("logs.detail.batch.models")}>{batchModels.join(", ")}</DescriptionItem>
|
||||
)}
|
||||
</DescriptionList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -475,6 +497,7 @@ function BatchResultsSection({ logEntry, metadata }: { logEntry: LogEntry; metad
|
|||
}
|
||||
|
||||
function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record<string, any> }) {
|
||||
const { t } = useTranslation();
|
||||
const completionStartTime = logEntry.completionStartTime;
|
||||
const ttftMs =
|
||||
completionStartTime && completionStartTime !== logEntry.endTime
|
||||
|
|
@ -497,19 +520,21 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
<div className="bg-card rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Card size="sm" style={{ marginBottom: 0 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics</CardTitle>
|
||||
<CardTitle>{t("logs.detail.metrics.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList>
|
||||
{showAnthropicMessagesInputOutput ? (
|
||||
<>
|
||||
<DescriptionItem label="Input Tokens">{formatNumberWithCommas(uncachedInputTokens)}</DescriptionItem>
|
||||
<DescriptionItem label="Output Tokens">
|
||||
<DescriptionItem label={t("logs.detail.metrics.inputTokens")}>
|
||||
{formatNumberWithCommas(uncachedInputTokens)}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.metrics.outputTokens")}>
|
||||
{formatNumberWithCommas(logEntry.completion_tokens)}
|
||||
</DescriptionItem>
|
||||
</>
|
||||
) : (
|
||||
<DescriptionItem label="Tokens">
|
||||
<DescriptionItem label={t("logs.detail.metrics.tokens")}>
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
|
|
@ -518,34 +543,46 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
</DescriptionItem>
|
||||
)}
|
||||
{reasoningTokens !== undefined && reasoningTokens > 0 && (
|
||||
<DescriptionItem label="Reasoning Tokens">{formatNumberWithCommas(reasoningTokens)}</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.metrics.reasoningTokens")}>
|
||||
{formatNumberWithCommas(reasoningTokens)}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
<DescriptionItem label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</DescriptionItem>
|
||||
<DescriptionItem label="Duration">
|
||||
<DescriptionItem label={t("logs.detail.metrics.cost")}>
|
||||
${formatNumberWithCommas(logEntry.spend || 0, 8)}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.metrics.duration")}>
|
||||
{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s
|
||||
</DescriptionItem>
|
||||
{ttftMs != null && ttftMs > 0 && (
|
||||
<DescriptionItem label="Time to First Token">{(ttftMs / 1000).toFixed(3)} s</DescriptionItem>
|
||||
<DescriptionItem label={t("logs.detail.metrics.timeToFirstToken")}>
|
||||
{(ttftMs / 1000).toFixed(3)} s
|
||||
</DescriptionItem>
|
||||
)}
|
||||
|
||||
{showResponseCache && (
|
||||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Response Cache"
|
||||
tooltip={RESPONSE_CACHE_TOOLTIP}
|
||||
label={t("logs.detail.metrics.responseCache")}
|
||||
tooltip={t(RESPONSE_CACHE_TOOLTIP)}
|
||||
docsUrl={RESPONSE_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge variant="secondary" className={isResponseCacheHit ? "bg-success/15 text-success" : undefined}>
|
||||
{isResponseCacheHit ? "Hit" : "Miss"}
|
||||
{isResponseCacheHit ? t("logs.detail.metrics.hit") : t("logs.detail.metrics.miss")}
|
||||
</Badge>
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{responseCacheKey && (
|
||||
<DescriptionItem
|
||||
label={<MetricLabel label="Cache Key" tooltip={CACHE_KEY_TOOLTIP} docsUrl={RESPONSE_CACHE_DOCS_URL} />}
|
||||
label={
|
||||
<MetricLabel
|
||||
label={t("logs.detail.metrics.cacheKey")}
|
||||
tooltip={t(CACHE_KEY_TOOLTIP)}
|
||||
docsUrl={RESPONSE_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TruncatedValue value={responseCacheKey} />
|
||||
</DescriptionItem>
|
||||
|
|
@ -554,7 +591,7 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Read Tokens"
|
||||
label={t("logs.detail.metrics.promptCacheReadTokens")}
|
||||
tooltip={PROMPT_CACHE_READ_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
|
|
@ -567,7 +604,7 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Creation Tokens"
|
||||
label={t("logs.detail.metrics.promptCacheCreationTokens")}
|
||||
tooltip={PROMPT_CACHE_CREATION_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
|
|
@ -578,12 +615,12 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
)}
|
||||
|
||||
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
|
||||
<DescriptionItem label="LiteLLM Overhead">
|
||||
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
|
||||
<DescriptionItem label={t("logs.detail.metrics.overhead")}>
|
||||
{t("logs.detail.metrics.overheadValue", { value: metadata.litellm_overhead_time_ms.toFixed(2) })}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
|
||||
<DescriptionItem label="Retries">
|
||||
<DescriptionItem label={t("logs.detail.metrics.retries")}>
|
||||
{metadata?.attempted_retries != null && metadata.attempted_retries > 0 && (
|
||||
<>
|
||||
{metadata.attempted_retries}
|
||||
|
|
@ -594,16 +631,16 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
)}
|
||||
{metadata?.attempted_retries != null && metadata.attempted_retries <= 0 && (
|
||||
<Badge variant="secondary" className="bg-success/15 text-success">
|
||||
None
|
||||
{t("logs.detail.metrics.noRetries")}
|
||||
</Badge>
|
||||
)}
|
||||
{metadata?.attempted_retries == null && "-"}
|
||||
</DescriptionItem>
|
||||
|
||||
<DescriptionItem label="Start Time">
|
||||
<DescriptionItem label={t("logs.detail.metrics.startTime")}>
|
||||
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label="End Time">
|
||||
<DescriptionItem label={t("logs.detail.metrics.endTime")}>
|
||||
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</DescriptionItem>
|
||||
</DescriptionList>
|
||||
|
|
@ -628,6 +665,7 @@ function RequestResponseSection({
|
|||
getFormattedResponse,
|
||||
logEntry,
|
||||
}: RequestResponseSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||
const [viewMode, setViewMode] = useState<"pretty" | "json">("pretty");
|
||||
|
|
@ -660,12 +698,12 @@ function RequestResponseSection({
|
|||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-foreground" style={{ margin: 0 }}>
|
||||
Request & Response
|
||||
{t("logs.detail.requestResponse.title")}
|
||||
</h3>
|
||||
</CollapsibleTrigger>
|
||||
<TabsList className="mr-4">
|
||||
<TabsTrigger value="pretty">Pretty</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
<TabsTrigger value="pretty">{t("logs.detail.requestResponse.pretty")}</TabsTrigger>
|
||||
<TabsTrigger value="json">{t("logs.detail.requestResponse.json")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
|
|
@ -689,12 +727,12 @@ function RequestResponseSection({
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value={TAB_REQUEST}>Request</TabsTrigger>
|
||||
<TabsTrigger value={TAB_RESPONSE}>Response</TabsTrigger>
|
||||
<TabsTrigger value={TAB_REQUEST}>{t("logs.detail.requestResponse.request")}</TabsTrigger>
|
||||
<TabsTrigger value={TAB_RESPONSE}>{t("logs.detail.requestResponse.response")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<CopyButton
|
||||
getText={getCopyText}
|
||||
label="Copy JSON"
|
||||
label={t("logs.detail.requestResponse.copyJson")}
|
||||
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -716,7 +754,7 @@ function RequestResponseSection({
|
|||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
Response data not available
|
||||
{t("logs.detail.requestResponse.responseUnavailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -750,6 +788,7 @@ const guardrailJumpLinkOutcome = (evaluated: unknown[]): keyof typeof GUARDRAIL_
|
|||
};
|
||||
|
||||
export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[] }) {
|
||||
const { t } = useTranslation();
|
||||
const statuses = guardrailEntries.map((e) => e?.guardrail_status || e?.status);
|
||||
const evaluated = statuses.filter((s) => !isNotRunStatus(s));
|
||||
const notRunCount = statuses.length - evaluated.length;
|
||||
|
|
@ -776,9 +815,8 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
|
|||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{glyph} {evaluated.length} guardrail
|
||||
{evaluated.length !== 1 ? "s" : ""} evaluated
|
||||
{notRunCount > 0 ? `, ${notRunCount} not run` : ""}
|
||||
{glyph} {t("logs.detail.guardrailJumpLink.evaluated", { count: evaluated.length })}
|
||||
{notRunCount > 0 ? t("logs.detail.guardrailJumpLink.notRun", { count: notRunCount }) : ""}
|
||||
<span style={{ fontSize: 11, opacity: 0.7 }}>{"\u2193"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -786,6 +824,7 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
|
|||
}
|
||||
|
||||
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
|
|
@ -797,12 +836,12 @@ function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
|
|||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-foreground">Metadata</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">{t("logs.detail.metadata.title")}</h3>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
|
||||
<CopyButton getText={() => JSON.stringify(metadata, null, 2)} label="Copy Metadata" />
|
||||
<CopyButton getText={() => JSON.stringify(metadata, null, 2)} label={t("logs.detail.metadata.copy")} />
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bot, Check, Copy, Sparkles, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LogEntry } from "../types";
|
||||
|
|
@ -62,6 +63,7 @@ function TraceEventIcon({ callType, isAutoRouted }: { callType: string; isAutoRo
|
|||
}
|
||||
|
||||
function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const isAutoRouted = useIsAutoRoutedModelGroup(row.model_group);
|
||||
const durationValue =
|
||||
row.request_duration_ms != null
|
||||
|
|
@ -94,7 +96,7 @@ function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
|
|||
{row.total_tokens ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{row.total_tokens} tok</span>
|
||||
<span>{t("logs.detail.drawer.tokens", { count: row.total_tokens })}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -123,6 +125,7 @@ export function LogDetailsDrawer({
|
|||
startTime,
|
||||
backTo,
|
||||
}: LogDetailsDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const isSessionMode = Boolean(sessionId);
|
||||
const [selectedSessionRequestId, setSelectedSessionRequestId] = useState<string | null>(null);
|
||||
const [sessionSortMode, setSessionSortMode] = useState<SessionLogSortMode>("duration");
|
||||
|
|
@ -267,7 +270,7 @@ export function LogDetailsDrawer({
|
|||
const metadata = currentLog?.metadata || {};
|
||||
|
||||
// Status display values
|
||||
const statusLabel = metadata.status === "failure" ? "Failure" : "Success";
|
||||
const statusLabel = metadata.status === "failure" ? t("logs.detail.drawer.failure") : t("logs.detail.drawer.success");
|
||||
const statusColor = metadata.status === "failure" ? ("error" as const) : ("success" as const);
|
||||
const environment = metadata?.user_api_key_team_alias || "default";
|
||||
|
||||
|
|
@ -312,7 +315,9 @@ export function LogDetailsDrawer({
|
|||
style={{ width: DRAWER_WIDTH }}
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
{logEntry?.request_id ? `Request ${logEntry.request_id} details` : "Request details"}
|
||||
{logEntry?.request_id
|
||||
? t("logs.detail.drawer.titleWithId", { id: logEntry.request_id })
|
||||
: t("logs.detail.drawer.title")}
|
||||
</SheetTitle>
|
||||
<div style={{ height: "100%" }} className="flex relative">
|
||||
{!isSidebarCollapsed && (
|
||||
|
|
@ -328,7 +333,7 @@ export function LogDetailsDrawer({
|
|||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{isSessionMode ? "Session" : "Trace"}
|
||||
{isSessionMode ? t("logs.detail.drawer.session") : t("logs.detail.drawer.trace")}
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-foreground leading-tight flex items-center gap-1">
|
||||
<span className="truncate">{leftPanelDisplayId}</span>
|
||||
|
|
@ -336,7 +341,7 @@ export function LogDetailsDrawer({
|
|||
type="button"
|
||||
onClick={handleCopyLeftPanelId}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Copy trace id"
|
||||
aria-label={t("logs.detail.drawer.copyTraceId")}
|
||||
>
|
||||
{copiedLeftPanelId ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
|
|
@ -344,7 +349,7 @@ export function LogDetailsDrawer({
|
|||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
|
||||
{logsForList.length} req
|
||||
{t("logs.detail.drawer.requestCount", { count: logsForList.length })}
|
||||
{[
|
||||
isSessionMode
|
||||
? llmCount
|
||||
|
|
@ -378,12 +383,12 @@ export function LogDetailsDrawer({
|
|||
</div>
|
||||
{isSessionMode && (
|
||||
<div className="text-[11px] text-muted-foreground font-mono whitespace-nowrap">
|
||||
{cacheHitCount}/{logsForList.length} cached
|
||||
{t("logs.detail.drawer.cachedCount", { hits: cacheHitCount, total: logsForList.length })}
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && sessionTruncated && (
|
||||
<div className="mt-1 text-[11px] text-warning font-mono">
|
||||
Showing most recent {logsForList.length} of {sessionTotalCount}
|
||||
{t("logs.detail.drawer.truncated", { shown: logsForList.length, total: sessionTotalCount })}
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && (
|
||||
|
|
@ -394,10 +399,10 @@ export function LogDetailsDrawer({
|
|||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="duration" className="text-[11px]">
|
||||
Duration
|
||||
{t("logs.detail.drawer.sortDuration")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="start_time" className="text-[11px]">
|
||||
Start time
|
||||
{t("logs.detail.drawer.sortStartTime")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { COLOR_BORDER } from "./constants";
|
||||
import { ParsedMessage } from "./prettyMessagesTypes";
|
||||
|
|
@ -18,11 +19,12 @@ interface OutputCardProps {
|
|||
|
||||
export function OutputCard({ message, completionTokens, outputCost }: OutputCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!message) return;
|
||||
|
||||
void copyToClipboard(message.content || "", "Output copied");
|
||||
void copyToClipboard(message.content || "", t("logs.detail.outputCard.copied"));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -44,7 +46,7 @@ export function OutputCard({ message, completionTokens, outputCost }: OutputCard
|
|||
{message ? (
|
||||
<SimpleMessageBlock label="ASSISTANT" content={message.content} toolCalls={message.toolCalls} />
|
||||
) : (
|
||||
<span className="text-[13px] text-muted-foreground italic">No response data available</span>
|
||||
<span className="text-[13px] text-muted-foreground italic">{t("logs.detail.outputCard.noResponse")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { copyToClipboard } from "@/utils/dataUtils";
|
|||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, MessageSquare, Mic, Settings, Volume2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
|
@ -83,6 +84,7 @@ export function isRealtimeResponse(response: any): boolean {
|
|||
}
|
||||
|
||||
export function RealtimePrettyView({ response, metrics }: RealtimePrettyViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const events: RealtimeEvent[] = response?.results || [];
|
||||
const usage = response?.usage;
|
||||
|
||||
|
|
@ -115,7 +117,7 @@ export function RealtimePrettyView({ response, metrics }: RealtimePrettyViewProp
|
|||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No recognized realtime events found
|
||||
{t("logs.detail.realtime.noEvents")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -123,6 +125,7 @@ export function RealtimePrettyView({ response, metrics }: RealtimePrettyViewProp
|
|||
}
|
||||
|
||||
function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCount: number }) {
|
||||
const { t } = useTranslation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
|
|
@ -163,14 +166,14 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Settings className="size-3.5 text-muted-foreground" />
|
||||
<span style={{ fontWeight: 500, fontSize: 14 }}>Session</span>
|
||||
<span style={{ fontWeight: 500, fontSize: 14 }}>{t("logs.detail.realtime.session")}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{session.model}
|
||||
</span>
|
||||
{turnCount > 0 && (
|
||||
<Badge variant="secondary" style={{ margin: 0, fontWeight: 500 }}>
|
||||
{turnCount} {turnCount === 1 ? "turn" : "turns"}
|
||||
{t("logs.detail.realtime.turnCount", { count: turnCount })}
|
||||
</Badge>
|
||||
)}
|
||||
{session.voice && (
|
||||
|
|
@ -207,15 +210,23 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<ConfigRow label="Model" value={session.model} />
|
||||
<ConfigRow label="Voice" value={session.voice} />
|
||||
<ConfigRow label="Temperature" value={session.temperature} />
|
||||
<ConfigRow label="Max Output Tokens" value={session.max_response_output_tokens} />
|
||||
<ConfigRow label="Input Audio Format" value={session.input_audio_format} />
|
||||
<ConfigRow label="Output Audio Format" value={session.output_audio_format} />
|
||||
{session.turn_detection && <ConfigRow label="Turn Detection" value={session.turn_detection.type} />}
|
||||
<ConfigRow label={t("logs.detail.realtime.config.model")} value={session.model} />
|
||||
<ConfigRow label={t("logs.detail.realtime.config.voice")} value={session.voice} />
|
||||
<ConfigRow label={t("logs.detail.realtime.config.temperature")} value={session.temperature} />
|
||||
<ConfigRow
|
||||
label={t("logs.detail.realtime.config.maxOutputTokens")}
|
||||
value={session.max_response_output_tokens}
|
||||
/>
|
||||
<ConfigRow label={t("logs.detail.realtime.config.inputAudioFormat")} value={session.input_audio_format} />
|
||||
<ConfigRow label={t("logs.detail.realtime.config.outputAudioFormat")} value={session.output_audio_format} />
|
||||
{session.turn_detection && (
|
||||
<ConfigRow label={t("logs.detail.realtime.config.turnDetection")} value={session.turn_detection.type} />
|
||||
)}
|
||||
{session.tools && session.tools.length > 0 && (
|
||||
<ConfigRow label="Tools" value={`${session.tools.length} tool(s)`} />
|
||||
<ConfigRow
|
||||
label={t("logs.detail.realtime.config.tools")}
|
||||
value={t("logs.detail.realtime.config.toolCount", { count: session.tools.length })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -231,7 +242,7 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Instructions
|
||||
{t("logs.detail.realtime.instructions")}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -317,6 +328,7 @@ function ConversationCard({
|
|||
}
|
||||
|
||||
function ResponseTurn({ response, index }: { response: RealtimeResponse; index: number }) {
|
||||
const { t } = useTranslation();
|
||||
const outputs = response.output || [];
|
||||
const usage = response.usage;
|
||||
|
||||
|
|
@ -342,7 +354,7 @@ function ResponseTurn({ response, index }: { response: RealtimeResponse; index:
|
|||
</Badge>
|
||||
{usage && (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{usage.input_tokens ?? 0} in / {usage.output_tokens ?? 0} out tokens
|
||||
{t("logs.detail.realtime.turnTokens", { input: usage.input_tokens ?? 0, output: usage.output_tokens ?? 0 })}
|
||||
</span>
|
||||
)}
|
||||
{response.conversation_id && (
|
||||
|
|
@ -351,7 +363,7 @@ function ResponseTurn({ response, index }: { response: RealtimeResponse; index:
|
|||
<TooltipTrigger
|
||||
render={<span className="text-muted-foreground" style={{ fontSize: 11, cursor: "help" }} />}
|
||||
>
|
||||
conv: {response.conversation_id.slice(0, 12)}...
|
||||
{t("logs.detail.realtime.conversation", { id: response.conversation_id.slice(0, 12) })}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{response.conversation_id}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -365,8 +377,12 @@ function ResponseTurn({ response, index }: { response: RealtimeResponse; index:
|
|||
))}
|
||||
|
||||
{/* Token breakdown if available */}
|
||||
{usage?.input_token_details && <TokenBreakdown label="Input" details={usage.input_token_details} />}
|
||||
{usage?.output_token_details && <TokenBreakdown label="Output" details={usage.output_token_details} />}
|
||||
{usage?.input_token_details && (
|
||||
<TokenBreakdown label={t("logs.detail.realtime.input")} details={usage.input_token_details} />
|
||||
)}
|
||||
{usage?.output_token_details && (
|
||||
<TokenBreakdown label={t("logs.detail.realtime.output")} details={usage.output_token_details} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -441,6 +457,7 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
|
|||
}
|
||||
|
||||
function TokenBreakdown({ label, details }: { label: string; details: Record<string, any> }) {
|
||||
const { t } = useTranslation();
|
||||
const entries = Object.entries(details).filter(
|
||||
([, v]) => typeof v === "number" || (typeof v === "object" && v !== null),
|
||||
);
|
||||
|
|
@ -453,7 +470,7 @@ function TokenBreakdown({ label, details }: { label: string; details: Record<str
|
|||
className="text-muted-foreground"
|
||||
style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}
|
||||
>
|
||||
{label} Token Breakdown
|
||||
{t("logs.detail.realtime.tokenBreakdown", { label })}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { Waypoints } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export interface RoutingDecisionTierBoundaries {
|
||||
|
|
@ -49,10 +52,10 @@ export interface RoutingDecision {
|
|||
};
|
||||
}
|
||||
|
||||
const ROUTER_TYPE_LABELS: Record<string, string> = {
|
||||
complexity: "Auto-Router v2",
|
||||
adaptive: "Adaptive router",
|
||||
quality: "Quality router",
|
||||
const ROUTER_TYPE_LABELS: Record<string, ParseKeys> = {
|
||||
complexity: "logs.detail.routing.routerType.complexity",
|
||||
adaptive: "logs.detail.routing.routerType.adaptive",
|
||||
quality: "logs.detail.routing.routerType.quality",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -64,6 +67,7 @@ function describeScoreAgainstBoundaries(
|
|||
score: number,
|
||||
boundaries?: RoutingDecisionTierBoundaries,
|
||||
renamed?: boolean,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null {
|
||||
if (!boundaries) return null;
|
||||
const {
|
||||
|
|
@ -74,16 +78,18 @@ function describeScoreAgainstBoundaries(
|
|||
if (simpleMedium === undefined || mediumComplex === undefined || complexReasoning === undefined) return null;
|
||||
|
||||
const named = (range: string, tier: string): string => (renamed ? range : `${range}, ${tier}`);
|
||||
if (score < simpleMedium) return named(`below ${simpleMedium}`, "SIMPLE");
|
||||
if (score < mediumComplex) return named(`${simpleMedium} to ${mediumComplex}`, "MEDIUM");
|
||||
if (score < complexReasoning) return named(`${mediumComplex} to ${complexReasoning}`, "COMPLEX");
|
||||
return named(`at or above ${complexReasoning}`, "REASONING");
|
||||
if (score < simpleMedium) return named(t("logs.detail.routing.scoreRange.below", { value: simpleMedium }), "SIMPLE");
|
||||
if (score < mediumComplex)
|
||||
return named(t("logs.detail.routing.scoreRange.between", { from: simpleMedium, to: mediumComplex }), "MEDIUM");
|
||||
if (score < complexReasoning)
|
||||
return named(t("logs.detail.routing.scoreRange.between", { from: mediumComplex, to: complexReasoning }), "COMPLEX");
|
||||
return named(t("logs.detail.routing.scoreRange.atOrAbove", { value: complexReasoning }), "REASONING");
|
||||
}
|
||||
|
||||
function describePlanModeFloor(matchedKeyword: string | undefined): string {
|
||||
if (matchedKeyword === "exit_plan_mode") return "Plan-mode floor (exit_plan_mode tool)";
|
||||
if (matchedKeyword) return `Plan-mode floor: "${matchedKeyword}"`;
|
||||
return "Plan-mode floor";
|
||||
function describePlanModeFloor(matchedKeyword: string | undefined, t: TFunction): string {
|
||||
if (matchedKeyword === "exit_plan_mode") return t("logs.detail.routing.cause.planModeExitTool");
|
||||
if (matchedKeyword) return t("logs.detail.routing.cause.planModeKeyword", { keyword: matchedKeyword });
|
||||
return t("logs.detail.routing.cause.planMode");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,41 +97,41 @@ function describePlanModeFloor(matchedKeyword: string | undefined): string {
|
|||
* would add to housekeeping_patterns to cover another client, so naming it turns the row into
|
||||
* the instruction. Without it the drawer says only that the classifier was skipped.
|
||||
*/
|
||||
function describeHousekeeping(matchedKeyword: string | undefined): string {
|
||||
if (matchedKeyword) return `Client housekeeping call: "${matchedKeyword}"`;
|
||||
return "Client housekeeping call, classifier skipped";
|
||||
function describeHousekeeping(matchedKeyword: string | undefined, t: TFunction): string {
|
||||
if (matchedKeyword) return t("logs.detail.routing.cause.housekeepingKeyword", { keyword: matchedKeyword });
|
||||
return t("logs.detail.routing.cause.housekeeping");
|
||||
}
|
||||
|
||||
/** Rows logged before the floor was recorded name what it tracked back then instead of a number. */
|
||||
function describeReasoningOverride(tierLabel: string | undefined, floor: number | undefined): string {
|
||||
const stated = floor === undefined ? "the Simple to Medium boundary" : String(floor);
|
||||
return `Heuristic, ${tierLabel ?? "REASONING"} override (2 or more reasoning markers, score of at least ${stated})`;
|
||||
function describeReasoningOverride(tierLabel: string | undefined, floor: number | undefined, t: TFunction): string {
|
||||
const stated = floor === undefined ? t("logs.detail.routing.cause.simpleMediumBoundary") : String(floor);
|
||||
return t("logs.detail.routing.cause.reasoningOverride", { tier: tierLabel ?? "REASONING", floor: stated });
|
||||
}
|
||||
|
||||
const CONSTANT_CAUSE_LABELS: Record<string, string> = {
|
||||
heuristic_scorer: "Heuristic scorer",
|
||||
heuristic_v2: "Heuristic v2",
|
||||
capability_classifier: "Capability",
|
||||
capability_classifier_fallback: "Capable tier, Capability classifier failed",
|
||||
llm_v2_classifier: "FUSE v2",
|
||||
llm_v2_fallback: "Capable tier, FUSE v2 classifier failed",
|
||||
heuristic_first_short_circuit: "Heuristic scorer, classifier skipped",
|
||||
hybrid_short_circuit: "Heuristic scorer, score clear of every boundary",
|
||||
classifier_plugin: "Custom classifier plugin",
|
||||
semantic_keyword_match: "Semantic keyword match",
|
||||
session_affinity_pin: "Pinned to session",
|
||||
session_affinity_escalation: "Escalated from session pin",
|
||||
user_turn_continuation: "Continuation turn, classifier skipped",
|
||||
modality_escalation: "Escalated for image input",
|
||||
modality_pin_override: "Overrode session pin for image input",
|
||||
quality_tier: "Quality tier mapping",
|
||||
bandit: "Adaptive bandit",
|
||||
default_fallback: "Default model, no route matched",
|
||||
classifier_fallback: "Fallback tier, classifier failed",
|
||||
default_model_fallback: "Default model, classifier failed",
|
||||
const CONSTANT_CAUSE_LABELS: Record<string, ParseKeys> = {
|
||||
heuristic_scorer: "logs.detail.routing.cause.heuristicScorer",
|
||||
heuristic_v2: "logs.detail.routing.cause.heuristicV2",
|
||||
capability_classifier: "logs.detail.routing.cause.capabilityClassifier",
|
||||
capability_classifier_fallback: "logs.detail.routing.cause.capabilityClassifierFallback",
|
||||
llm_v2_classifier: "logs.detail.routing.cause.llmV2Classifier",
|
||||
llm_v2_fallback: "logs.detail.routing.cause.llmV2Fallback",
|
||||
heuristic_first_short_circuit: "logs.detail.routing.cause.heuristicFirstShortCircuit",
|
||||
hybrid_short_circuit: "logs.detail.routing.cause.hybridShortCircuit",
|
||||
classifier_plugin: "logs.detail.routing.cause.classifierPlugin",
|
||||
semantic_keyword_match: "logs.detail.routing.cause.semanticKeywordMatch",
|
||||
session_affinity_pin: "logs.detail.routing.cause.sessionAffinityPin",
|
||||
session_affinity_escalation: "logs.detail.routing.cause.sessionAffinityEscalation",
|
||||
user_turn_continuation: "logs.detail.routing.cause.userTurnContinuation",
|
||||
modality_escalation: "logs.detail.routing.cause.modalityEscalation",
|
||||
modality_pin_override: "logs.detail.routing.cause.modalityPinOverride",
|
||||
quality_tier: "logs.detail.routing.cause.qualityTier",
|
||||
bandit: "logs.detail.routing.cause.bandit",
|
||||
default_fallback: "logs.detail.routing.cause.defaultFallback",
|
||||
classifier_fallback: "logs.detail.routing.cause.classifierFallback",
|
||||
default_model_fallback: "logs.detail.routing.cause.defaultModelFallback",
|
||||
};
|
||||
|
||||
function describeCause(decision: RoutingDecision): string {
|
||||
function describeCause(decision: RoutingDecision, t: TFunction): string {
|
||||
const {
|
||||
cause,
|
||||
classifier_model: classifierModel,
|
||||
|
|
@ -134,25 +140,29 @@ function describeCause(decision: RoutingDecision): string {
|
|||
reasoning_override_min_score: overrideFloor,
|
||||
} = decision;
|
||||
|
||||
const constant = cause ? CONSTANT_CAUSE_LABELS[cause] : undefined;
|
||||
if (constant) return constant;
|
||||
const constant = cause && Object.hasOwn(CONSTANT_CAUSE_LABELS, cause) ? CONSTANT_CAUSE_LABELS[cause] : undefined;
|
||||
if (constant) return t(constant);
|
||||
|
||||
switch (cause) {
|
||||
case "reasoning_override":
|
||||
return describeReasoningOverride(tierLabel, overrideFloor);
|
||||
return describeReasoningOverride(tierLabel, overrideFloor, t);
|
||||
case "llm_classifier":
|
||||
return classifierModel ? `LLM classifier (${classifierModel})` : "LLM classifier";
|
||||
return classifierModel
|
||||
? t("logs.detail.routing.cause.llmClassifierModel", { model: classifierModel })
|
||||
: t("logs.detail.routing.cause.llmClassifier");
|
||||
case "jev_classifier":
|
||||
return "OSS classifier";
|
||||
return t("logs.detail.routing.cause.ossClassifier");
|
||||
case "literal_keyword_match":
|
||||
case "keyword":
|
||||
return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";
|
||||
return matchedKeyword
|
||||
? t("logs.detail.routing.cause.keywordMatchKeyword", { keyword: matchedKeyword })
|
||||
: t("logs.detail.routing.cause.keywordMatch");
|
||||
case "plan_mode":
|
||||
return describePlanModeFloor(matchedKeyword);
|
||||
return describePlanModeFloor(matchedKeyword, t);
|
||||
case "housekeeping":
|
||||
return describeHousekeeping(matchedKeyword);
|
||||
return describeHousekeeping(matchedKeyword, t);
|
||||
default:
|
||||
return cause ?? "Unknown";
|
||||
return cause ?? t("logs.detail.routing.cause.unknown");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,9 +172,14 @@ function describeCause(decision: RoutingDecision): string {
|
|||
* ordinary route; it just must not claim a bump that did not happen. Only called when
|
||||
* the request escalated or asked to, so there is no "did not escalate" case.
|
||||
*/
|
||||
function describeEscalation(escalated: boolean, keyword: string | undefined): string {
|
||||
if (escalated) return keyword ? `Yes, keyword "${keyword}"` : "Yes";
|
||||
return keyword ? `Requested via "${keyword}"; already at the highest tier` : "Requested; already at the highest tier";
|
||||
function describeEscalation(escalated: boolean, keyword: string | undefined, t: TFunction): string {
|
||||
if (escalated)
|
||||
return keyword
|
||||
? t("logs.detail.routing.escalation.yesKeyword", { keyword })
|
||||
: t("logs.detail.routing.escalation.yes");
|
||||
return keyword
|
||||
? t("logs.detail.routing.escalation.requestedKeyword", { keyword })
|
||||
: t("logs.detail.routing.escalation.requested");
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
|
|
@ -186,6 +201,7 @@ function PercentageRow({ label, value, unit = "%" }: { label: string; value?: nu
|
|||
}
|
||||
|
||||
function CapabilityForecast({ decision }: { decision: RoutingDecision }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
classifier_p_solve: raw,
|
||||
classifier_calibrated_p_solve: calibrated,
|
||||
|
|
@ -198,19 +214,20 @@ function CapabilityForecast({ decision }: { decision: RoutingDecision }) {
|
|||
|
||||
return (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="mb-1 text-sm font-medium">Capability estimates</div>
|
||||
<div className="mb-2 text-xs text-muted-foreground">Efficient model solve chance</div>
|
||||
<PercentageRow label="Raw" value={raw} />
|
||||
<PercentageRow label="Calibrated" value={calibrated} />
|
||||
<PercentageRow label="Threshold" value={threshold} />
|
||||
{boundary && <Row label="Boundary">{boundary}</Row>}
|
||||
{rule && <Row label="Rule">{rule}</Row>}
|
||||
{version && <Row label="Calibration">{version}</Row>}
|
||||
<div className="mb-1 text-sm font-medium">{t("logs.detail.routing.capability.title")}</div>
|
||||
<div className="mb-2 text-xs text-muted-foreground">{t("logs.detail.routing.capability.subtitle")}</div>
|
||||
<PercentageRow label={t("logs.detail.routing.capability.raw")} value={raw} />
|
||||
<PercentageRow label={t("logs.detail.routing.capability.calibrated")} value={calibrated} />
|
||||
<PercentageRow label={t("logs.detail.routing.capability.threshold")} value={threshold} />
|
||||
{boundary && <Row label={t("logs.detail.routing.capability.boundary")}>{boundary}</Row>}
|
||||
{rule && <Row label={t("logs.detail.routing.capability.rule")}>{rule}</Row>}
|
||||
{version && <Row label={t("logs.detail.routing.calibration")}>{version}</Row>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FuseV2Forecast({ decision }: { decision: RoutingDecision }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
classifier_efficient_p_solve: rawEfficient,
|
||||
classifier_capable_p_solve: rawCapable,
|
||||
|
|
@ -229,14 +246,22 @@ function FuseV2Forecast({ decision }: { decision: RoutingDecision }) {
|
|||
|
||||
return (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="mb-1 text-sm font-medium">FUSE v2 estimates</div>
|
||||
<PercentageRow label="Efficient (raw)" value={rawEfficient} />
|
||||
<PercentageRow label="Capable (raw)" value={rawCapable} />
|
||||
<PercentageRow label="Efficient (calibrated)" value={calibratedEfficient} />
|
||||
<PercentageRow label="Capable (calibrated)" value={calibratedCapable} />
|
||||
<PercentageRow label="Applied gap" value={gap} unit=" percentage points" />
|
||||
<PercentageRow label="Allowed gap" value={allowedGap} unit=" percentage points" />
|
||||
{version && <Row label="Calibration">{version}</Row>}
|
||||
<div className="mb-1 text-sm font-medium">{t("logs.detail.routing.fuseV2.title")}</div>
|
||||
<PercentageRow label={t("logs.detail.routing.fuseV2.efficientRaw")} value={rawEfficient} />
|
||||
<PercentageRow label={t("logs.detail.routing.fuseV2.capableRaw")} value={rawCapable} />
|
||||
<PercentageRow label={t("logs.detail.routing.fuseV2.efficientCalibrated")} value={calibratedEfficient} />
|
||||
<PercentageRow label={t("logs.detail.routing.fuseV2.capableCalibrated")} value={calibratedCapable} />
|
||||
<PercentageRow
|
||||
label={t("logs.detail.routing.fuseV2.appliedGap")}
|
||||
value={gap}
|
||||
unit={t("logs.detail.routing.fuseV2.percentagePoints")}
|
||||
/>
|
||||
<PercentageRow
|
||||
label={t("logs.detail.routing.fuseV2.allowedGap")}
|
||||
value={allowedGap}
|
||||
unit={t("logs.detail.routing.fuseV2.percentagePoints")}
|
||||
/>
|
||||
{version && <Row label={t("logs.detail.routing.calibration")}>{version}</Row>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -248,6 +273,7 @@ export function RoutingDecisionCard({
|
|||
decision?: RoutingDecision | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!decision || !decision.cause) return null;
|
||||
|
||||
const {
|
||||
|
|
@ -270,12 +296,12 @@ export function RoutingDecisionCard({
|
|||
// inside `signals`, which redaction can remove.
|
||||
const scoreExplanation =
|
||||
score !== undefined && decision.cause !== "reasoning_override" && decision.cause !== "plan_mode"
|
||||
? describeScoreAgainstBoundaries(score, tierBoundaries, tierLabel !== undefined)
|
||||
? describeScoreAgainstBoundaries(score, tierBoundaries, tierLabel !== undefined, t)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={cn("mb-6 w-full max-w-full overflow-hidden rounded-lg bg-card shadow-sm", className)}>
|
||||
<div className="border-b px-4 py-2.5 text-sm font-medium">Routing</div>
|
||||
<div className="border-b px-4 py-2.5 text-sm font-medium">{t("logs.detail.routing.title")}</div>
|
||||
<div className="px-4 py-3">
|
||||
{routerModelName && (
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
|
|
@ -283,29 +309,31 @@ export function RoutingDecisionCard({
|
|||
<span>{routerModelName}</span>
|
||||
{routerType && (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
({ROUTER_TYPE_LABELS[routerType] ?? routerType})
|
||||
({Object.hasOwn(ROUTER_TYPE_LABELS, routerType) ? t(ROUTER_TYPE_LABELS[routerType]) : routerType})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier && (
|
||||
<Row label="Tier">
|
||||
<Row label={t("logs.detail.routing.tier")}>
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{tierLabel ?? tier}
|
||||
</Badge>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{requestType && <Row label="Request type">{requestType}</Row>}
|
||||
{requestType && <Row label={t("logs.detail.routing.requestType")}>{requestType}</Row>}
|
||||
|
||||
<Row label="Decided by">{describeCause(decision)}</Row>
|
||||
{decision.classifier_model && <Row label="Classifier model">{decision.classifier_model}</Row>}
|
||||
<Row label={t("logs.detail.routing.decidedBy")}>{describeCause(decision, t)}</Row>
|
||||
{decision.classifier_model && (
|
||||
<Row label={t("logs.detail.routing.classifierModel")}>{decision.classifier_model}</Row>
|
||||
)}
|
||||
{decision.classifier_confidence != null && (
|
||||
<Row label="Confidence">{(decision.classifier_confidence * 100).toFixed(1)}%</Row>
|
||||
<Row label={t("logs.detail.routing.confidence")}>{(decision.classifier_confidence * 100).toFixed(1)}%</Row>
|
||||
)}
|
||||
{decision.classifier_probabilities && (
|
||||
<Row label="Probabilities">
|
||||
<Row label={t("logs.detail.routing.probabilities")}>
|
||||
{Object.entries(decision.classifier_probabilities).map(([name, probability]) => (
|
||||
<div key={name}>
|
||||
{name}: {(probability * 100).toFixed(1)}%
|
||||
|
|
@ -313,23 +341,27 @@ export function RoutingDecisionCard({
|
|||
))}
|
||||
</Row>
|
||||
)}
|
||||
{decision.classifier_cost != null && <Row label="Classifier cost">${decision.classifier_cost.toFixed(8)}</Row>}
|
||||
{decision.classifier_cost != null && (
|
||||
<Row label={t("logs.detail.routing.classifierCost")}>${decision.classifier_cost.toFixed(8)}</Row>
|
||||
)}
|
||||
|
||||
{score !== undefined && (
|
||||
<Row label="Score">
|
||||
<Row label={t("logs.detail.routing.score")}>
|
||||
<span className="tabular-nums">{score.toFixed(2)}</span>
|
||||
{scoreExplanation && <span className="ml-2 text-muted-foreground">({scoreExplanation})</span>}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{routedModel && <Row label="Routed to">{routedModel}</Row>}
|
||||
{routedModel && <Row label={t("logs.detail.routing.routedTo")}>{routedModel}</Row>}
|
||||
|
||||
{escalated !== undefined && <Row label="Escalated">{describeEscalation(escalated, escalationKeyword)}</Row>}
|
||||
{escalated !== undefined && (
|
||||
<Row label={t("logs.detail.routing.escalated")}>{describeEscalation(escalated, escalationKeyword, t)}</Row>
|
||||
)}
|
||||
|
||||
{forecast && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="mb-1 text-sm font-medium">Heuristic v2 estimates</div>
|
||||
<Row label="Success by tier">
|
||||
<div className="mb-1 text-sm font-medium">{t("logs.detail.routing.heuristicV2.title")}</div>
|
||||
<Row label={t("logs.detail.routing.heuristicV2.successByTier")}>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"].map((predictedTier) => (
|
||||
<Badge key={predictedTier} variant="outline" className="font-normal tabular-nums">
|
||||
|
|
@ -338,11 +370,11 @@ export function RoutingDecisionCard({
|
|||
))}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="Threshold">
|
||||
<Row label={t("logs.detail.routing.heuristicV2.threshold")}>
|
||||
<span className="tabular-nums">{(forecast.threshold * 100).toFixed(1)}%</span>
|
||||
</Row>
|
||||
<Row label="Predicted tier">{forecast.predicted_tier}</Row>
|
||||
<Row label="Request type">{forecast.request_type}</Row>
|
||||
<Row label={t("logs.detail.routing.heuristicV2.predictedTier")}>{forecast.predicted_tier}</Row>
|
||||
<Row label={t("logs.detail.routing.requestType")}>{forecast.request_type}</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -350,7 +382,7 @@ export function RoutingDecisionCard({
|
|||
<FuseV2Forecast decision={decision} />
|
||||
|
||||
{signals && signals.length > 0 && (
|
||||
<Row label="Signals">
|
||||
<Row label={t("logs.detail.routing.signals")}>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{signals.map((signal) => (
|
||||
<Badge key={signal} variant="outline" className="font-normal">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { ChevronDown, ChevronUp, Copy, MessageSquare } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
|
@ -28,6 +29,7 @@ export function SectionHeader({
|
|||
onToggleCollapse,
|
||||
turnCount,
|
||||
}: SectionHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const summary = (
|
||||
<>
|
||||
{onToggleCollapse &&
|
||||
|
|
@ -43,15 +45,27 @@ export function SectionHeader({
|
|||
) : (
|
||||
<span className="text-sm opacity-60 grayscale">✨</span>
|
||||
)}
|
||||
<span className="text-sm font-medium">{type === "input" ? "Input" : "Output"}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{type === "input" ? t("logs.detail.sectionHeader.input") : t("logs.detail.sectionHeader.output")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tokens !== undefined && <span className="text-xs text-muted-foreground">Tokens: {tokens.toLocaleString()}</span>}
|
||||
{tokens !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("logs.detail.sectionHeader.tokens", { value: tokens.toLocaleString() })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{cost !== undefined && <span className="text-xs text-muted-foreground">Cost: ${cost.toFixed(6)}</span>}
|
||||
{cost !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("logs.detail.sectionHeader.cost", { value: cost.toFixed(6) })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{turnCount !== undefined && turnCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">Turns: {turnCount}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("logs.detail.sectionHeader.turns", { count: turnCount })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -82,7 +96,9 @@ export function SectionHeader({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={type === "input" ? "Copy input" : "Copy output"}
|
||||
aria-label={
|
||||
type === "input" ? t("logs.detail.sectionHeader.copyInput") : t("logs.detail.sectionHeader.copyOutput")
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
|
|
@ -92,7 +108,7 @@ export function SectionHeader({
|
|||
>
|
||||
<Copy />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy</TooltipContent>
|
||||
<TooltipContent>{t("logs.detail.sectionHeader.copy")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
|
|
@ -9,13 +10,14 @@ export interface SidebarToggleProps {
|
|||
}
|
||||
|
||||
export function SidebarToggle({ isCollapsed, onToggle, className }: SidebarToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggle}
|
||||
className={cn("shrink-0 bg-card! border! border-border! rounded-md!", className)}
|
||||
aria-label={isCollapsed ? "Expand trace sidebar" : "Collapse trace sidebar"}
|
||||
aria-label={isCollapsed ? t("logs.detail.sidebarToggle.expand") : t("logs.detail.sidebarToggle.collapse")}
|
||||
>
|
||||
{isCollapsed ? <ChevronLeft className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* Used in compact/tree views
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ToolCall } from "./prettyMessagesTypes";
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ interface SimpleToolCallBlockProps {
|
|||
}
|
||||
|
||||
export function SimpleToolCallBlock({ tool, compact = false }: SimpleToolCallBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -21,7 +23,7 @@ export function SimpleToolCallBlock({ tool, compact = false }: SimpleToolCallBlo
|
|||
>
|
||||
{/* Function badge */}
|
||||
<div className="absolute -top-2 left-3 rounded-[3px] border border-border bg-background px-1.5 text-[10px] text-muted-foreground">
|
||||
function
|
||||
{t("logs.detail.toolCall.badge")}
|
||||
</div>
|
||||
|
||||
<span className="mb-1.5 block text-[13px] font-semibold">{tool.name}</span>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TokenFlowProps {
|
||||
prompt?: number;
|
||||
completion?: number;
|
||||
|
|
@ -9,10 +11,14 @@ interface TokenFlowProps {
|
|||
* Shows total with breakdown of prompt and completion tokens.
|
||||
*/
|
||||
export function TokenFlow({ prompt = 0, completion = 0, total = 0 }: TokenFlowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span>
|
||||
{total.toLocaleString()} ({prompt.toLocaleString()} prompt tokens + {completion.toLocaleString()} completion
|
||||
tokens)
|
||||
{t("logs.detail.tokenFlow.summary", {
|
||||
total: total.toLocaleString(),
|
||||
prompt: prompt.toLocaleString(),
|
||||
completion: completion.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { DEFAULT_MAX_WIDTH, FONT_FAMILY_MONO } from "./constants";
|
||||
|
|
@ -12,6 +13,7 @@ interface TruncatedValueProps {
|
|||
* Useful for displaying long IDs, URLs, or other text that may overflow.
|
||||
*/
|
||||
export function TruncatedValue({ value, maxWidth = DEFAULT_MAX_WIDTH }: TruncatedValueProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
|
||||
return (
|
||||
|
|
@ -23,7 +25,12 @@ export function TruncatedValue({ value, maxWidth = DEFAULT_MAX_WIDTH }: Truncate
|
|||
<span className="truncate text-xs" style={{ maxWidth, fontFamily: FONT_FAMILY_MONO }}>
|
||||
{value}
|
||||
</span>
|
||||
<CopyButton value={value} label="Copy" className="size-4 shrink-0" iconClassName="size-3" />
|
||||
<CopyButton
|
||||
value={value}
|
||||
label={t("logs.detail.truncatedValue.copy")}
|
||||
className="size-4 shrink-0"
|
||||
iconClassName="size-3"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfigInfoMessageProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -27,20 +29,19 @@ export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show }) =>
|
|||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-info">Request/Response Data Not Available</h4>
|
||||
<h4 className="text-sm font-medium text-info">{t("logs.detail.configInfo.title")}</h4>
|
||||
<p className="text-sm text-info mt-1">
|
||||
To view request and response details, enable prompt storage in your LiteLLM configuration by adding the
|
||||
following to your <code className="bg-info/15 px-1 py-0.5 rounded-sm">proxy_config.yaml</code> file, or toggle
|
||||
the setting in <strong>Admin Settings → Logging Settings</strong>.
|
||||
<Trans
|
||||
i18nKey="logs.detail.configInfo.description"
|
||||
components={{ code: <code className="bg-info/15 px-1 py-0.5 rounded-sm" />, strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
<pre className="mt-2 bg-card p-3 rounded-sm border border-info/20 text-xs font-mono overflow-auto">
|
||||
{`general_settings:
|
||||
store_model_in_db: true
|
||||
store_prompts_in_spend_logs: true`}
|
||||
</pre>
|
||||
<p className="text-xs text-info mt-2">
|
||||
Note: This will only affect new requests after the configuration change.
|
||||
</p>
|
||||
<p className="text-xs text-info mt-2">{t("logs.detail.configInfo.note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const isCached = cacheHit?.toLowerCase() === "true";
|
||||
const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined;
|
||||
|
|
@ -100,12 +102,12 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-medium text-foreground">Cost Breakdown</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">{t("logs.detail.costBreakdown.title")}</h3>
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
<span className="text-sm text-muted-foreground">Total:</span>
|
||||
<span className="text-sm text-muted-foreground">{t("logs.detail.costBreakdown.total")}</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatCost(totalSpend)}
|
||||
{isCached && " (Cached)"}
|
||||
{isCached && t("logs.detail.costBreakdown.cached")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -127,24 +129,34 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
return (
|
||||
<>
|
||||
<div className="flex text-sm">
|
||||
<span className="text-muted-foreground font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-muted-foreground font-medium w-1/3">
|
||||
{t("logs.detail.costBreakdown.inputCost")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{formatCost(rawCost)}
|
||||
{rawInputTokens !== undefined && rawInputTokens !== null && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
({rawInputTokens.toLocaleString()} tokens)
|
||||
{t("logs.detail.costBreakdown.tokens", {
|
||||
count: rawInputTokens,
|
||||
value: rawInputTokens.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-muted-foreground font-medium w-1/3">Prompt Cache Read Cost:</span>
|
||||
<span className="text-muted-foreground font-medium w-1/3">
|
||||
{t("logs.detail.costBreakdown.cacheReadCost")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
|
||||
{(cacheReadTokens ?? 0) > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
({(cacheReadTokens ?? 0).toLocaleString()} tokens)
|
||||
{t("logs.detail.costBreakdown.tokens", {
|
||||
count: cacheReadTokens ?? 0,
|
||||
value: (cacheReadTokens ?? 0).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -152,12 +164,17 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
)}
|
||||
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-muted-foreground font-medium w-1/3">Prompt Cache Write Cost:</span>
|
||||
<span className="text-muted-foreground font-medium w-1/3">
|
||||
{t("logs.detail.costBreakdown.cacheWriteCost")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
|
||||
{(cacheCreationTokens ?? 0) > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
({(cacheCreationTokens ?? 0).toLocaleString()} tokens)
|
||||
{t("logs.detail.costBreakdown.tokens", {
|
||||
count: cacheCreationTokens ?? 0,
|
||||
value: (cacheCreationTokens ?? 0).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -168,12 +185,17 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
}
|
||||
return (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-muted-foreground font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-muted-foreground font-medium w-1/3">
|
||||
{t("logs.detail.costBreakdown.inputCost")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{formatCost(inputCost)}
|
||||
{promptTokens !== undefined && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
({promptTokens.toLocaleString()} prompt tokens)
|
||||
{t("logs.detail.costBreakdown.promptTokens", {
|
||||
count: promptTokens,
|
||||
value: promptTokens.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -181,19 +203,26 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
);
|
||||
})()}
|
||||
<div className="flex text-sm">
|
||||
<span className="text-muted-foreground font-medium w-1/3">Output Cost:</span>
|
||||
<span className="text-muted-foreground font-medium w-1/3">
|
||||
{t("logs.detail.costBreakdown.outputCost")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{formatCost(outputCost)}
|
||||
{completionTokens !== undefined && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
({completionTokens.toLocaleString()} completion tokens)
|
||||
{t("logs.detail.costBreakdown.completionTokens", {
|
||||
count: completionTokens,
|
||||
value: completionTokens.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-muted-foreground font-medium w-1/3">Tool Usage Cost:</span>
|
||||
<span className="text-muted-foreground font-medium w-1/3">
|
||||
{t("logs.detail.costBreakdown.toolUsageCost")}
|
||||
</span>
|
||||
<span className="text-foreground">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -212,7 +241,7 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
{!isCached && (
|
||||
<div className="pt-2 border-t border-border max-w-2xl">
|
||||
<div className="flex text-sm font-semibold">
|
||||
<span className="text-foreground w-1/3">Original LLM Cost:</span>
|
||||
<span className="text-foreground w-1/3">{t("logs.detail.costBreakdown.originalCost")}</span>
|
||||
<span className="text-foreground">{formatCost(originalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -227,14 +256,16 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
{costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && (
|
||||
<div className="flex text-sm text-muted-foreground">
|
||||
<span className="font-medium w-1/3">
|
||||
Discount ({formatPercent(costBreakdown.discount_percent)}):
|
||||
{t("logs.detail.costBreakdown.discountPercent", {
|
||||
percent: formatPercent(costBreakdown.discount_percent),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.discount_amount !== undefined && costBreakdown.discount_percent === undefined && (
|
||||
<div className="flex text-sm text-muted-foreground">
|
||||
<span className="font-medium w-1/3">Discount Amount:</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.costBreakdown.discountAmount")}</span>
|
||||
<span className="text-foreground">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -247,7 +278,9 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
{costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && (
|
||||
<div className="flex text-sm text-muted-foreground">
|
||||
<span className="font-medium w-1/3">
|
||||
Margin ({formatPercent(costBreakdown.margin_percent)}):
|
||||
{t("logs.detail.costBreakdown.marginPercent", {
|
||||
percent: formatPercent(costBreakdown.margin_percent),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
+
|
||||
|
|
@ -259,7 +292,7 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
)}
|
||||
{costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && (
|
||||
<div className="flex text-sm text-muted-foreground">
|
||||
<span className="font-medium w-1/3">Margin:</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.costBreakdown.margin")}</span>
|
||||
<span className="text-foreground">+{formatCost(costBreakdown.margin_fixed_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -271,10 +304,12 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
{/* Final Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-border max-w-2xl">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-sm text-foreground w-1/3">Final Calculated Cost:</span>
|
||||
<span className="font-bold text-sm text-foreground w-1/3">
|
||||
{t("logs.detail.costBreakdown.finalCost")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
{formatCost(totalCost)}
|
||||
{isCached && " (Cached)"}
|
||||
{isCached && t("logs.detail.costBreakdown.cached")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { CircleCheck, CircleX, FlaskConical } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
|
@ -30,6 +31,7 @@ interface EvalViewerProps {
|
|||
}
|
||||
|
||||
export default function EvalViewer({ data }: EvalViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const entries: EvalInformation[] = Array.isArray(data) ? data : [data];
|
||||
|
||||
if (!entries.length) return null;
|
||||
|
|
@ -39,7 +41,7 @@ export default function EvalViewer({ data }: EvalViewerProps) {
|
|||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<FlaskConical className="size-4" style={{ color: "#6366f1" }} />
|
||||
<span className="font-semibold" style={{ fontSize: 15 }}>
|
||||
LLM Judge Results
|
||||
{t("logs.detail.eval.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -51,6 +53,7 @@ export default function EvalViewer({ data }: EvalViewerProps) {
|
|||
}
|
||||
|
||||
function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
||||
const { t } = useTranslation();
|
||||
const passed = entry.passed;
|
||||
const scoreColor = passed ? "#52c41a" : "#ff4d4f";
|
||||
|
||||
|
|
@ -71,7 +74,9 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
<CircleX className="size-4" style={{ color: "#ff4d4f" }} />
|
||||
)}
|
||||
<span className="font-semibold">{entry.eval_name}</span>
|
||||
<Badge variant={passed ? "secondary" : "destructive"}>{passed ? "PASSED" : "FAILED"}</Badge>
|
||||
<Badge variant={passed ? "secondary" : "destructive"}>
|
||||
{passed ? t("logs.detail.eval.passed") : t("logs.detail.eval.failed")}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -83,12 +88,9 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
}
|
||||
>
|
||||
{entry.overall_score?.toFixed(0)} / 100
|
||||
{entry.threshold != null && ` (threshold: ${entry.threshold})`}
|
||||
{entry.threshold != null && t("logs.detail.eval.threshold", { value: entry.threshold })}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Weighted average of all criterion scores. Each criterion has a weight (%) set when the eval was
|
||||
created — higher-weight criteria count more toward the final score.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("logs.detail.eval.scoreTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
@ -97,12 +99,12 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
<div className="flex items-center gap-2">
|
||||
{entry.judge_model && (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Judge: {entry.judge_model}
|
||||
{t("logs.detail.eval.judge", { model: entry.judge_model })}
|
||||
</span>
|
||||
)}
|
||||
{entry.iteration != null && (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Iter: {entry.iteration + 1}
|
||||
{t("logs.detail.eval.iteration", { value: entry.iteration + 1 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -112,7 +114,7 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
<CardContent>
|
||||
{entry.eval_error && (
|
||||
<span className="text-warning" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
|
||||
Judge error: {entry.eval_error}
|
||||
{t("logs.detail.eval.judgeError", { error: entry.eval_error })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -120,22 +122,20 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead style={{ width: 160 }}>Criterion</TableHead>
|
||||
<TableHead style={{ width: 65 }}>Weight</TableHead>
|
||||
<TableHead style={{ width: 65 }}>Score</TableHead>
|
||||
<TableHead style={{ width: 160 }}>{t("logs.detail.eval.columns.criterion")}</TableHead>
|
||||
<TableHead style={{ width: 65 }}>{t("logs.detail.eval.columns.weight")}</TableHead>
|
||||
<TableHead style={{ width: 65 }}>{t("logs.detail.eval.columns.score")}</TableHead>
|
||||
<TableHead style={{ width: 75 }}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span style={{ borderBottom: "1px dashed #aaa", cursor: "help" }} />}>
|
||||
Weighted
|
||||
{t("logs.detail.eval.columns.weighted")}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Score × Weight — how much each criterion contributes to the final score
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("logs.detail.eval.columns.weightedTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
<TableHead>{t("logs.detail.eval.columns.comment")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -189,7 +189,7 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="font-semibold" style={{ fontSize: 12 }}>
|
||||
Total
|
||||
{t("logs.detail.eval.total")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
|
|
@ -206,7 +206,7 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
</Table>
|
||||
) : (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available.
|
||||
{t("logs.detail.eval.noBreakdown", { score: entry.overall_score?.toFixed(1) })}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export type BedrockGuardrailAction = "NONE" | "GUARDRAIL_INTERVENED";
|
||||
|
||||
|
|
@ -114,7 +116,8 @@ const chip = (text: React.ReactNode, tone: ChipTone = "slate") => {
|
|||
return <span className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${map[tone]}`}>{text}</span>;
|
||||
};
|
||||
|
||||
const boolPill = (b?: boolean) => (b ? chip("detected", "red") : chip("not detected", "slate"));
|
||||
const boolPill = (b?: boolean) =>
|
||||
b ? chip(i18n.t("logs.detail.bedrock.detected"), "red") : chip(i18n.t("logs.detail.bedrock.notDetected"), "slate");
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
|
|
@ -169,6 +172,7 @@ const Divider: React.FC = () => <div className="my-3 border-t" />;
|
|||
|
||||
/** ====== Main component ====== */
|
||||
export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailResponse }> = ({ response }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!response) return null;
|
||||
|
||||
const outputs: BedrockOutputContent[] = (response.outputs ?? response.output ?? []) as BedrockOutputContent[];
|
||||
|
|
@ -179,12 +183,18 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
<div className="flex flex-wrap gap-2">
|
||||
{response.guardrailCoverage?.textCharacters &&
|
||||
chip(
|
||||
`text guarded ${response.guardrailCoverage.textCharacters.guarded ?? 0}/${response.guardrailCoverage.textCharacters.total ?? 0}`,
|
||||
t("logs.detail.bedrock.textGuarded", {
|
||||
guarded: response.guardrailCoverage.textCharacters.guarded ?? 0,
|
||||
total: response.guardrailCoverage.textCharacters.total ?? 0,
|
||||
}),
|
||||
"blue",
|
||||
)}
|
||||
{response.guardrailCoverage?.images &&
|
||||
chip(
|
||||
`images guarded ${response.guardrailCoverage.images.guarded ?? 0}/${response.guardrailCoverage.images.total ?? 0}`,
|
||||
t("logs.detail.bedrock.imagesGuarded", {
|
||||
guarded: response.guardrailCoverage.images.guarded ?? 0,
|
||||
total: response.guardrailCoverage.images.total ?? 0,
|
||||
}),
|
||||
"blue",
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -208,17 +218,17 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
<div className="border rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<KV label="Action:">{chip(response.action ?? "N/A", actionTone)}</KV>
|
||||
{response.actionReason && <KV label="Action Reason:">{response.actionReason}</KV>}
|
||||
<KV label={t("logs.detail.bedrock.action")}>{chip(response.action ?? "N/A", actionTone)}</KV>
|
||||
{response.actionReason && <KV label={t("logs.detail.bedrock.actionReason")}>{response.actionReason}</KV>}
|
||||
{response.blockedResponse && (
|
||||
<KV label="Blocked Response:">
|
||||
<KV label={t("logs.detail.bedrock.blockedResponse")}>
|
||||
<span className="italic">{response.blockedResponse}</span>
|
||||
</KV>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<KV label="Coverage:">{coverageChips}</KV>
|
||||
<KV label="Usage:">{usagePills}</KV>
|
||||
<KV label={t("logs.detail.bedrock.coverage")}>{coverageChips}</KV>
|
||||
<KV label={t("logs.detail.bedrock.usage")}>{usagePills}</KV>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -226,11 +236,13 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
{outputs.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<h4 className="font-medium mb-2">Outputs</h4>
|
||||
<h4 className="font-medium mb-2">{t("logs.detail.bedrock.outputs")}</h4>
|
||||
<div className="space-y-2">
|
||||
{outputs.map((o, i) => (
|
||||
<div key={i} className="p-3 bg-muted rounded-md">
|
||||
<div className="text-sm whitespace-pre-wrap">{o.text ?? <em>(non-text output)</em>}</div>
|
||||
<div className="text-sm whitespace-pre-wrap">
|
||||
{o.text ?? <em>{t("logs.detail.bedrock.nonTextOutput")}</em>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -256,12 +268,17 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
return (
|
||||
<Section
|
||||
key={idx}
|
||||
title={`Assessment #${idx + 1}`}
|
||||
title={t("logs.detail.bedrock.assessment", { index: idx + 1 })}
|
||||
defaultOpen
|
||||
right={
|
||||
<div className="flex items-center gap-3">
|
||||
{assess.invocationMetrics?.guardrailProcessingLatency != null &&
|
||||
chip(`${assess.invocationMetrics.guardrailProcessingLatency} ms`, "amber")}
|
||||
chip(
|
||||
t("logs.detail.bedrock.latencyMs", {
|
||||
value: assess.invocationMetrics.guardrailProcessingLatency,
|
||||
}),
|
||||
"amber",
|
||||
)}
|
||||
{policyBadges}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -269,9 +286,9 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
{/* Word policy */}
|
||||
{assess.wordPolicy && (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Word Policy</h6>
|
||||
<h6 className="font-medium mb-2">{t("logs.detail.bedrock.wordPolicy")}</h6>
|
||||
{(assess.wordPolicy.customWords?.length ?? 0) > 0 && (
|
||||
<Section title="Custom Words" defaultOpen>
|
||||
<Section title={t("logs.detail.bedrock.customWords")} defaultOpen>
|
||||
<div className="space-y-2">
|
||||
{assess.wordPolicy.customWords!.map((w, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-muted rounded-sm">
|
||||
|
|
@ -286,7 +303,7 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
</Section>
|
||||
)}
|
||||
{(assess.wordPolicy.managedWordLists?.length ?? 0) > 0 && (
|
||||
<Section title="Managed Word Lists" defaultOpen={false}>
|
||||
<Section title={t("logs.detail.bedrock.managedWordLists")} defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{assess.wordPolicy.managedWordLists!.map((w, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-muted rounded-sm">
|
||||
|
|
@ -307,16 +324,16 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
{/* Content policy */}
|
||||
{assess.contentPolicy?.filters?.length ? (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Content Policy</h6>
|
||||
<h6 className="font-medium mb-2">{t("logs.detail.bedrock.contentPolicy")}</h6>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
<th className="py-1 pr-4">Type</th>
|
||||
<th className="py-1 pr-4">Action</th>
|
||||
<th className="py-1 pr-4">Detected</th>
|
||||
<th className="py-1 pr-4">Strength</th>
|
||||
<th className="py-1 pr-4">Confidence</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.type")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.action")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.detected")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.strength")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.confidence")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -338,16 +355,16 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
{/* Contextual grounding */}
|
||||
{assess.contextualGroundingPolicy?.filters?.length ? (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Contextual Grounding</h6>
|
||||
<h6 className="font-medium mb-2">{t("logs.detail.bedrock.contextualGrounding")}</h6>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
<th className="py-1 pr-4">Type</th>
|
||||
<th className="py-1 pr-4">Action</th>
|
||||
<th className="py-1 pr-4">Detected</th>
|
||||
<th className="py-1 pr-4">Score</th>
|
||||
<th className="py-1 pr-4">Threshold</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.type")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.action")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.detected")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.score")}</th>
|
||||
<th className="py-1 pr-4">{t("logs.detail.bedrock.columns.threshold")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -369,9 +386,9 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
{/* Sensitive Information */}
|
||||
{assess.sensitiveInformationPolicy && (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Sensitive Information</h6>
|
||||
<h6 className="font-medium mb-2">{t("logs.detail.bedrock.sensitiveInformation")}</h6>
|
||||
{(assess.sensitiveInformationPolicy.piiEntities?.length ?? 0) > 0 && (
|
||||
<Section title="PII Entities" defaultOpen>
|
||||
<Section title={t("logs.detail.bedrock.piiEntities")} defaultOpen>
|
||||
<div className="space-y-2">
|
||||
{assess.sensitiveInformationPolicy.piiEntities!.map((p, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-muted rounded-sm">
|
||||
|
|
@ -387,7 +404,7 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
</Section>
|
||||
)}
|
||||
{(assess.sensitiveInformationPolicy.regexes?.length ?? 0) > 0 && (
|
||||
<Section title="Custom Regexes" defaultOpen={false}>
|
||||
<Section title={t("logs.detail.bedrock.customRegexes")} defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{assess.sensitiveInformationPolicy.regexes!.map((r, i) => (
|
||||
<div
|
||||
|
|
@ -414,7 +431,7 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
{/* Topic policy */}
|
||||
{assess.topicPolicy?.topics?.length ? (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Topic Policy</h6>
|
||||
<h6 className="font-medium mb-2">{t("logs.detail.bedrock.topicPolicy")}</h6>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assess.topicPolicy.topics!.map((t, i) => (
|
||||
<div key={i} className="px-3 py-1.5 bg-muted rounded-md text-xs">
|
||||
|
|
@ -432,31 +449,35 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
|
||||
{/* Invocation metrics */}
|
||||
{assess.invocationMetrics && (
|
||||
<Section title="Invocation Metrics" defaultOpen={false}>
|
||||
<Section title={t("logs.detail.bedrock.invocationMetrics")} defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<KV label="Latency (ms)">{assess.invocationMetrics.guardrailProcessingLatency ?? "—"}</KV>
|
||||
<KV label="Coverage:">
|
||||
<KV label={t("logs.detail.bedrock.latency")}>
|
||||
{assess.invocationMetrics.guardrailProcessingLatency ?? "—"}
|
||||
</KV>
|
||||
<KV label={t("logs.detail.bedrock.coverage")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assess.invocationMetrics.guardrailCoverage?.textCharacters &&
|
||||
chip(
|
||||
`text ${assess.invocationMetrics.guardrailCoverage.textCharacters.guarded ?? 0}/${
|
||||
assess.invocationMetrics.guardrailCoverage.textCharacters.total ?? 0
|
||||
}`,
|
||||
t("logs.detail.bedrock.textCoverage", {
|
||||
guarded: assess.invocationMetrics.guardrailCoverage.textCharacters.guarded ?? 0,
|
||||
total: assess.invocationMetrics.guardrailCoverage.textCharacters.total ?? 0,
|
||||
}),
|
||||
"blue",
|
||||
)}
|
||||
{assess.invocationMetrics.guardrailCoverage?.images &&
|
||||
chip(
|
||||
`images ${assess.invocationMetrics.guardrailCoverage.images.guarded ?? 0}/${
|
||||
assess.invocationMetrics.guardrailCoverage.images.total ?? 0
|
||||
}`,
|
||||
t("logs.detail.bedrock.imagesCoverage", {
|
||||
guarded: assess.invocationMetrics.guardrailCoverage.images.guarded ?? 0,
|
||||
total: assess.invocationMetrics.guardrailCoverage.images.total ?? 0,
|
||||
}),
|
||||
"blue",
|
||||
)}
|
||||
</div>
|
||||
</KV>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<KV label="Usage:">
|
||||
<KV label={t("logs.detail.bedrock.usage")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assess.invocationMetrics.usage &&
|
||||
Object.entries(assess.invocationMetrics.usage).map(([k, v]) =>
|
||||
|
|
@ -478,7 +499,7 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
|
||||
{/* Automated reasoning (fallback render) */}
|
||||
{assess.automatedReasoningPolicy?.findings?.length ? (
|
||||
<Section title="Automated Reasoning Findings" defaultOpen={false}>
|
||||
<Section title={t("logs.detail.bedrock.automatedReasoningFindings")} defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{assess.automatedReasoningPolicy.findings!.map((f, i) => (
|
||||
<pre key={i} className="bg-muted rounded-sm p-2 text-xs overflow-x-auto">
|
||||
|
|
@ -495,7 +516,7 @@ export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailRespo
|
|||
) : null}
|
||||
|
||||
{/* Raw JSON (for debugging / completeness) */}
|
||||
<Section title="Raw Bedrock Guardrail Response" defaultOpen={false}>
|
||||
<Section title={t("logs.detail.bedrock.rawResponse")} defaultOpen={false}>
|
||||
<pre className="bg-muted rounded-sm p-3 text-xs overflow-x-auto">{JSON.stringify(response, null, 2)}</pre>
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
checkEuAiActCompliance,
|
||||
|
|
@ -54,6 +56,7 @@ const ComplianceCard = ({
|
|||
loading: boolean;
|
||||
error: string | null;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -88,12 +91,12 @@ const ComplianceCard = ({
|
|||
: "bg-destructive/15 text-destructive border border-destructive/20"
|
||||
}`}
|
||||
>
|
||||
{data.compliant ? "COMPLIANT" : "NON-COMPLIANT"}
|
||||
{data.compliant ? t("logs.detail.compliance.compliant") : t("logs.detail.compliance.nonCompliant")}
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="px-2 py-0.5 rounded-sm text-[11px] font-medium bg-muted text-muted-foreground border border-border">
|
||||
UNAVAILABLE
|
||||
{t("logs.detail.compliance.unavailable")}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
|
|
@ -110,7 +113,7 @@ const ComplianceCard = ({
|
|||
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{loading && <p className="text-sm text-muted-foreground">Checking compliance...</p>}
|
||||
{loading && <p className="text-sm text-muted-foreground">{t("logs.detail.compliance.checking")}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{data && (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -137,6 +140,7 @@ const ComplianceCard = ({
|
|||
// -- Main Component --
|
||||
|
||||
const CompliancePanel: React.FC<CompliancePanelProps> = ({ accessToken, logEntry }) => {
|
||||
const { t } = useTranslation();
|
||||
const [euAiActData, setEuAiActData] = useState<ComplianceResponse | null>(null);
|
||||
const [gdprData, setGdprData] = useState<ComplianceResponse | null>(null);
|
||||
const [euAiActLoading, setEuAiActLoading] = useState(false);
|
||||
|
|
@ -159,25 +163,35 @@ const CompliancePanel: React.FC<CompliancePanelProps> = ({ accessToken, logEntry
|
|||
setEuAiActError(null);
|
||||
checkEuAiActCompliance(accessToken, payload)
|
||||
.then(setEuAiActData)
|
||||
.catch((err) => setEuAiActError(err.message || "Failed to check EU AI Act compliance"))
|
||||
.catch((err) => setEuAiActError(err.message || i18n.t("logs.detail.compliance.euAiActFailed")))
|
||||
.finally(() => setEuAiActLoading(false));
|
||||
|
||||
setGdprLoading(true);
|
||||
setGdprError(null);
|
||||
checkGdprCompliance(accessToken, payload)
|
||||
.then(setGdprData)
|
||||
.catch((err) => setGdprError(err.message || "Failed to check GDPR compliance"))
|
||||
.catch((err) => setGdprError(err.message || i18n.t("logs.detail.compliance.gdprFailed")))
|
||||
.finally(() => setGdprLoading(false));
|
||||
}, [accessToken, logEntry]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
Regulatory Compliance
|
||||
{t("logs.detail.compliance.title")}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<ComplianceCard title="EU AI Act" data={euAiActData} loading={euAiActLoading} error={euAiActError} />
|
||||
<ComplianceCard title="GDPR" data={gdprData} loading={gdprLoading} error={gdprError} />
|
||||
<ComplianceCard
|
||||
title={t("logs.detail.compliance.euAiAct")}
|
||||
data={euAiActData}
|
||||
loading={euAiActLoading}
|
||||
error={euAiActError}
|
||||
/>
|
||||
<ComplianceCard
|
||||
title={t("logs.detail.compliance.gdpr")}
|
||||
data={gdprData}
|
||||
loading={gdprLoading}
|
||||
error={gdprError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ContentFilterDetection {
|
||||
type: "pattern" | "blocked_word" | "category_keyword";
|
||||
|
|
@ -74,13 +75,14 @@ const KV: React.FC<KVProps> = ({ label, children, mono }) => (
|
|||
);
|
||||
|
||||
const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response }) => {
|
||||
const { t } = useTranslation();
|
||||
// Handle case where response is a string (error message) or null
|
||||
if (!response || typeof response === "string") {
|
||||
if (typeof response === "string" && response) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-destructive/20 p-4">
|
||||
<div className="text-destructive">
|
||||
<h5 className="font-medium mb-2">Error</h5>
|
||||
<h5 className="font-medium mb-2">{t("logs.detail.contentFilter.error")}</h5>
|
||||
<p className="text-sm">{response}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,7 +97,7 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
|
|||
if (detections.length === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-border p-4">
|
||||
<div className="text-muted-foreground text-sm">No detections found</div>
|
||||
<div className="text-muted-foreground text-sm">{t("logs.detail.contentFilter.noDetections")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -118,23 +120,26 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
|
|||
<div className="bg-card rounded-lg border border-border p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<KV label="Total Detections:">
|
||||
<KV label={t("logs.detail.contentFilter.totalDetections")}>
|
||||
<span className="font-semibold">{totalDetections}</span>
|
||||
</KV>
|
||||
<KV label="Actions:">
|
||||
<KV label={t("logs.detail.contentFilter.actions")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{blockedCount > 0 && chip(`${blockedCount} blocked`, "red")}
|
||||
{maskedCount > 0 && chip(`${maskedCount} masked`, "blue")}
|
||||
{blockedCount === 0 && maskedCount === 0 && chip("passed", "green")}
|
||||
{blockedCount > 0 && chip(t("logs.detail.contentFilter.blockedCount", { count: blockedCount }), "red")}
|
||||
{maskedCount > 0 && chip(t("logs.detail.contentFilter.maskedCount", { count: maskedCount }), "blue")}
|
||||
{blockedCount === 0 && maskedCount === 0 && chip(t("logs.detail.contentFilter.passed"), "green")}
|
||||
</div>
|
||||
</KV>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<KV label="By Type:">
|
||||
<KV label={t("logs.detail.contentFilter.byType")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{patterns.length > 0 && chip(`${patterns.length} patterns`, "slate")}
|
||||
{blockedWords.length > 0 && chip(`${blockedWords.length} keywords`, "slate")}
|
||||
{categoryKeywords.length > 0 && chip(`${categoryKeywords.length} categories`, "slate")}
|
||||
{patterns.length > 0 &&
|
||||
chip(t("logs.detail.contentFilter.patternsCount", { count: patterns.length }), "slate")}
|
||||
{blockedWords.length > 0 &&
|
||||
chip(t("logs.detail.contentFilter.keywordsCount", { count: blockedWords.length }), "slate")}
|
||||
{categoryKeywords.length > 0 &&
|
||||
chip(t("logs.detail.contentFilter.categoriesCount", { count: categoryKeywords.length }), "slate")}
|
||||
</div>
|
||||
</KV>
|
||||
</div>
|
||||
|
|
@ -143,16 +148,18 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
|
|||
|
||||
{/* Patterns Section */}
|
||||
{patterns.length > 0 && (
|
||||
<Section title="Patterns Matched" count={patterns.length} defaultOpen={true}>
|
||||
<Section title={t("logs.detail.contentFilter.patternsMatched")} count={patterns.length} defaultOpen={true}>
|
||||
<div className="space-y-2">
|
||||
{patterns.map((detection, idx) => (
|
||||
<div key={idx} className="p-3 bg-muted rounded-md">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<KV label="Pattern:">{detection.pattern_name || "unknown"}</KV>
|
||||
<KV label={t("logs.detail.contentFilter.pattern")}>{detection.pattern_name || "unknown"}</KV>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<KV label="Action:">{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}</KV>
|
||||
<KV label={t("logs.detail.contentFilter.action")}>
|
||||
{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}
|
||||
</KV>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -163,19 +170,23 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
|
|||
|
||||
{/* Blocked Words Section */}
|
||||
{blockedWords.length > 0 && (
|
||||
<Section title="Blocked Words Detected" count={blockedWords.length} defaultOpen={true}>
|
||||
<Section title={t("logs.detail.contentFilter.blockedWords")} count={blockedWords.length} defaultOpen={true}>
|
||||
<div className="space-y-2">
|
||||
{blockedWords.map((detection, idx) => (
|
||||
<div key={idx} className="p-3 bg-muted rounded-md">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<KV label="Keyword:" mono>
|
||||
<KV label={t("logs.detail.contentFilter.keyword")} mono>
|
||||
{detection.keyword || "unknown"}
|
||||
</KV>
|
||||
{detection.description && <KV label="Description:">{detection.description}</KV>}
|
||||
{detection.description && (
|
||||
<KV label={t("logs.detail.contentFilter.description")}>{detection.description}</KV>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<KV label="Action:">{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}</KV>
|
||||
<KV label={t("logs.detail.contentFilter.action")}>
|
||||
{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}
|
||||
</KV>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -186,18 +197,22 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
|
|||
|
||||
{/* Category Keywords Section */}
|
||||
{categoryKeywords.length > 0 && (
|
||||
<Section title="Category Keywords Detected" count={categoryKeywords.length} defaultOpen={true}>
|
||||
<Section
|
||||
title={t("logs.detail.contentFilter.categoryKeywords")}
|
||||
count={categoryKeywords.length}
|
||||
defaultOpen={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{categoryKeywords.map((detection, idx) => (
|
||||
<div key={idx} className="p-3 bg-muted rounded-md">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<KV label="Category:">{detection.category || "unknown"}</KV>
|
||||
<KV label="Keyword:" mono>
|
||||
<KV label={t("logs.detail.contentFilter.category")}>{detection.category || "unknown"}</KV>
|
||||
<KV label={t("logs.detail.contentFilter.keyword")} mono>
|
||||
{detection.keyword || "unknown"}
|
||||
</KV>
|
||||
{detection.severity && (
|
||||
<KV label="Severity:">
|
||||
<KV label={t("logs.detail.contentFilter.severity")}>
|
||||
{chip(
|
||||
detection.severity,
|
||||
detection.severity === "high" ? "red" : detection.severity === "medium" ? "amber" : "slate",
|
||||
|
|
@ -206,7 +221,9 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
|
|||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<KV label="Action:">{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}</KV>
|
||||
<KV label={t("logs.detail.contentFilter.action")}>
|
||||
{chip(detection.action, detection.action === "BLOCK" ? "red" : "blue")}
|
||||
</KV>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -216,7 +233,7 @@ const ContentFilterDetails: React.FC<ContentFilterDetailsProps> = ({ response })
|
|||
)}
|
||||
|
||||
{/* Raw JSON (for debugging) */}
|
||||
<Section title="Raw Detection Data" defaultOpen={false}>
|
||||
<Section title={t("logs.detail.contentFilter.rawData")} defaultOpen={false}>
|
||||
<pre className="bg-muted rounded-sm p-3 text-xs overflow-x-auto">{JSON.stringify(detections, null, 2)}</pre>
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
|
|
@ -121,10 +123,10 @@ const formatMode = (mode: GuardrailInformation["guardrail_mode"]): string => {
|
|||
return s.replace(/_/g, "-").toUpperCase();
|
||||
};
|
||||
|
||||
const formatDurationMs = (seconds: number | null): string => {
|
||||
const formatDurationMs = (seconds: number | null, t: TFunction): string => {
|
||||
if (seconds == null) return "—";
|
||||
const ms = Math.round(seconds * 1000);
|
||||
return `${ms}ms`;
|
||||
return t("logs.detail.guardrails.durationMs", { ms });
|
||||
};
|
||||
|
||||
const getTotalMasked = (entry: GuardrailInformation): number => {
|
||||
|
|
@ -146,11 +148,11 @@ const getEntryOutcome = (entry: GuardrailInformation): EntryOutcome => {
|
|||
|
||||
const isEntrySuccess = (entry: GuardrailInformation): boolean => getEntryOutcome(entry) === "passed";
|
||||
|
||||
const OUTCOME_LABEL: Record<EntryOutcome, string> = {
|
||||
passed: "PASSED",
|
||||
flagged: "FLAGGED",
|
||||
failed: "FAILED",
|
||||
not_run: "NOT RUN",
|
||||
const OUTCOME_LABEL: Record<EntryOutcome, ParseKeys> = {
|
||||
passed: "logs.detail.guardrails.outcome.passed",
|
||||
flagged: "logs.detail.guardrails.outcome.flagged",
|
||||
failed: "logs.detail.guardrails.outcome.failed",
|
||||
not_run: "logs.detail.guardrails.outcome.notRun",
|
||||
};
|
||||
|
||||
const OUTCOME_BADGE_CLASS: Record<EntryOutcome, string> = {
|
||||
|
|
@ -286,19 +288,22 @@ const DownloadIcon = () => (
|
|||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!matchDetails || matchDetails.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<h5 className="text-sm font-medium mb-2 text-foreground">Match Details ({matchDetails.length})</h5>
|
||||
<h5 className="text-sm font-medium mb-2 text-foreground">
|
||||
{t("logs.detail.guardrails.matchDetails.title", { count: matchDetails.length })}
|
||||
</h5>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4 font-medium">Type</th>
|
||||
<th className="pb-2 pr-4 font-medium">Method</th>
|
||||
<th className="pb-2 pr-4 font-medium">Action</th>
|
||||
<th className="pb-2 font-medium">Detail</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("logs.detail.guardrails.matchDetails.type")}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("logs.detail.guardrails.matchDetails.method")}</th>
|
||||
<th className="pb-2 pr-4 font-medium">{t("logs.detail.guardrails.matchDetails.action")}</th>
|
||||
<th className="pb-2 font-medium">{t("logs.detail.guardrails.matchDetails.detail")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -333,6 +338,7 @@ const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) =>
|
|||
};
|
||||
|
||||
const GenericGuardrailResponse = ({ response }: { response: any }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
return (
|
||||
<div className="mt-3">
|
||||
|
|
@ -343,7 +349,7 @@ const GenericGuardrailResponse = ({ response }: { response: any }) => {
|
|||
>
|
||||
<div className="flex items-center">
|
||||
<ChevronIcon expanded={showRaw} />
|
||||
<h5 className="font-medium text-sm ml-1">Raw Guardrail Response</h5>
|
||||
<h5 className="font-medium text-sm ml-1">{t("logs.detail.guardrails.rawResponse")}</h5>
|
||||
</div>
|
||||
</div>
|
||||
{showRaw && (
|
||||
|
|
@ -382,6 +388,7 @@ const orderWithinPhase = (group: GuardrailInformation[]): GuardrailInformation[]
|
|||
};
|
||||
|
||||
const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const sorted = useMemo(() => entries.filter(belongsOnLifecycle), [entries]);
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
|
|
@ -394,7 +401,11 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
const items: TimelineEntry[] = [];
|
||||
|
||||
// Request received
|
||||
items.push({ type: "request", label: "Request received", offsetMs: baseTime === null ? null : 0 });
|
||||
items.push({
|
||||
type: "request",
|
||||
label: t("logs.detail.guardrails.lifecycle.requestReceived"),
|
||||
offsetMs: baseTime === null ? null : 0,
|
||||
});
|
||||
|
||||
// Pre-call guardrails — use modeMatches so array modes (e.g. ["pre_call", "post_call"])
|
||||
// place the entry in every matching bucket.
|
||||
|
|
@ -407,7 +418,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
for (const e of preCalls) {
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Pre-call guardrail: ${getDisplayName(e)}`,
|
||||
label: t("logs.detail.guardrails.lifecycle.preCall", { name: getDisplayName(e) }),
|
||||
offsetMs: offsetOf(e),
|
||||
outcome: getEntryOutcome(e),
|
||||
});
|
||||
|
|
@ -422,7 +433,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
|
||||
items.push({
|
||||
type: "llm",
|
||||
label: "LLM call",
|
||||
label: t("logs.detail.guardrails.lifecycle.llmCall"),
|
||||
offsetMs: llmEndTime === null || baseTime === null ? null : Math.round((llmEndTime - baseTime) * 1000),
|
||||
});
|
||||
|
||||
|
|
@ -430,7 +441,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
for (const e of duringCalls) {
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `During-call guardrail: ${getDisplayName(e)}`,
|
||||
label: t("logs.detail.guardrails.lifecycle.duringCall", { name: getDisplayName(e) }),
|
||||
offsetMs: offsetOf(e),
|
||||
outcome: getEntryOutcome(e),
|
||||
});
|
||||
|
|
@ -440,7 +451,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
for (const e of postCalls) {
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Post-call guardrail: ${getDisplayName(e)}`,
|
||||
label: t("logs.detail.guardrails.lifecycle.postCall", { name: getDisplayName(e) }),
|
||||
offsetMs: offsetOf(e),
|
||||
outcome: getEntryOutcome(e),
|
||||
});
|
||||
|
|
@ -449,14 +460,20 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
// Response returned
|
||||
const maxEnd = timed.length > 0 ? Math.max(...timed.map((e) => e.end_time)) : null;
|
||||
const responseOffsetMs = maxEnd === null || baseTime === null ? null : Math.round((maxEnd - baseTime) * 1000) + 1;
|
||||
items.push({ type: "response", label: "Response returned", offsetMs: responseOffsetMs });
|
||||
items.push({
|
||||
type: "response",
|
||||
label: t("logs.detail.guardrails.lifecycle.responseReturned"),
|
||||
offsetMs: responseOffsetMs,
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [sorted]);
|
||||
}, [sorted, t]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4">Request Lifecycle</h4>
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
{t("logs.detail.guardrails.lifecycle.title")}
|
||||
</h4>
|
||||
<div className="relative">
|
||||
{timeline.map((item, idx) => (
|
||||
<div key={idx} data-testid="lifecycle-row" className="flex items-start gap-3 relative">
|
||||
|
|
@ -484,11 +501,11 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${OUTCOME_BADGE_CLASS[item.outcome]}`}
|
||||
>
|
||||
{OUTCOME_LABEL[item.outcome]}
|
||||
{t(OUTCOME_LABEL[item.outcome])}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono ml-auto shrink-0">
|
||||
{item.offsetMs === null ? "—" : `T+${item.offsetMs}ms`}
|
||||
{item.offsetMs === null ? "—" : t("logs.detail.guardrails.lifecycle.offset", { ms: item.offsetMs })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -509,11 +526,12 @@ const formatGuardrailCost = (cost: number): string => {
|
|||
};
|
||||
|
||||
const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const outcome = getEntryOutcome(entry);
|
||||
const totalMasked = getTotalMasked(entry);
|
||||
const displayName = getDisplayName(entry);
|
||||
const durationStr = formatDurationMs(entry.duration);
|
||||
const durationStr = formatDurationMs(entry.duration, t);
|
||||
const modeStr = formatMode(entry.guardrail_mode);
|
||||
const riskScore = getRiskScore(entry);
|
||||
const textRecords = entry.guardrail_usage?.["text_records"];
|
||||
|
|
@ -532,9 +550,9 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
// Match count string: "X/Y matched" or "X matched"
|
||||
const matchCountStr =
|
||||
entry.patterns_checked != null
|
||||
? `${totalMasked}/${entry.patterns_checked} matched`
|
||||
? t("logs.detail.guardrails.card.matchedOfChecked", { matched: totalMasked, checked: entry.patterns_checked })
|
||||
: totalMasked > 0
|
||||
? `${totalMasked} matched`
|
||||
? t("logs.detail.guardrails.card.matched", { count: totalMasked })
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
|
@ -560,7 +578,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
<span
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-semibold uppercase shrink-0 ${OUTCOME_BADGE_CLASS[outcome]}`}
|
||||
>
|
||||
{OUTCOME_LABEL[outcome]}
|
||||
{t(OUTCOME_LABEL[outcome])}
|
||||
</span>
|
||||
|
||||
{matchCountStr && (
|
||||
|
|
@ -577,7 +595,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
|
||||
{entry.confidence_score != null && (
|
||||
<span className="px-2 py-0.5 bg-muted text-muted-foreground border border-border rounded-sm text-[11px] font-medium shrink-0">
|
||||
{(entry.confidence_score * 100).toFixed(0)}% conf
|
||||
{t("logs.detail.guardrails.card.confidence", { value: (entry.confidence_score * 100).toFixed(0) })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -591,16 +609,19 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
/>
|
||||
}
|
||||
>
|
||||
Risk {riskScore}/10
|
||||
{t("logs.detail.guardrails.card.risk", { score: riskScore })}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`Risk score: ${riskScore}/10`}</TooltipContent>
|
||||
<TooltipContent>{t("logs.detail.guardrails.card.riskTooltip", { score: riskScore })}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{textRecords != null && (
|
||||
<span className="px-2 py-0.5 bg-muted text-muted-foreground border border-border rounded-sm text-[11px] font-medium shrink-0">
|
||||
{textRecords.toLocaleString()} text record{textRecords === 1 ? "" : "s"}
|
||||
{t("logs.detail.guardrails.card.textRecords", {
|
||||
count: textRecords,
|
||||
value: textRecords.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -616,8 +637,8 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{entry.guardrail_cost_in_spend === false
|
||||
? "Estimated guardrail cost (reported only; not counted against spend or budgets)"
|
||||
: "Guardrail cost"}
|
||||
? t("logs.detail.guardrails.card.costEstimated")
|
||||
: t("logs.detail.guardrails.card.cost")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -642,28 +663,38 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
{/* Classification details for llm-judge */}
|
||||
{entry.classification && (
|
||||
<div className="mb-3 bg-muted rounded-lg p-3 space-y-1">
|
||||
<h5 className="text-sm font-medium text-foreground mb-2">Classification</h5>
|
||||
<h5 className="text-sm font-medium text-foreground mb-2">
|
||||
{t("logs.detail.guardrails.classification.title")}
|
||||
</h5>
|
||||
{entry.classification.category && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-muted-foreground">Category:</span>
|
||||
<span className="font-medium w-1/3 text-muted-foreground">
|
||||
{t("logs.detail.guardrails.classification.category")}
|
||||
</span>
|
||||
<span>{entry.classification.category}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.article_reference && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-muted-foreground">Reference:</span>
|
||||
<span className="font-medium w-1/3 text-muted-foreground">
|
||||
{t("logs.detail.guardrails.classification.reference")}
|
||||
</span>
|
||||
<span className="font-mono">{entry.classification.article_reference}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.confidence != null && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-muted-foreground">Confidence:</span>
|
||||
<span className="font-medium w-1/3 text-muted-foreground">
|
||||
{t("logs.detail.guardrails.classification.confidence")}
|
||||
</span>
|
||||
<span>{(entry.classification.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.reason && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-muted-foreground">Reason:</span>
|
||||
<span className="font-medium w-1/3 text-muted-foreground">
|
||||
{t("logs.detail.guardrails.classification.reason")}
|
||||
</span>
|
||||
<span>{entry.classification.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -678,7 +709,9 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
{/* Masked entity summary */}
|
||||
{totalMasked > 0 && (
|
||||
<div className="mt-3">
|
||||
<h5 className="text-sm font-medium text-foreground mb-2">Masked Entities</h5>
|
||||
<h5 className="text-sm font-medium text-foreground mb-2">
|
||||
{t("logs.detail.guardrails.card.maskedEntities")}
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(entry.masked_entity_count || {}).map(([entityType, count]) => (
|
||||
<span key={entityType} className="px-2 py-1 bg-info/10 text-info rounded-sm text-xs font-medium">
|
||||
|
|
@ -721,6 +754,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const guardrailEntries = useMemo(() => {
|
||||
return Array.isArray(data)
|
||||
? data.filter((entry): entry is GuardrailInformation => Boolean(entry))
|
||||
|
|
@ -763,10 +797,10 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
|
|||
<div className="flex items-center gap-4">
|
||||
<ShieldIcon />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Guardrails & Policy Compliance</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t("logs.detail.guardrails.title")}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{evaluatedCount} guardrail{evaluatedCount !== 1 ? "s" : ""} evaluated
|
||||
{t("logs.detail.guardrails.evaluated", { count: evaluatedCount })}
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span
|
||||
|
|
@ -783,20 +817,20 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
|
|||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
{passedCount} Passed
|
||||
{t("logs.detail.guardrails.passedCount", { count: passedCount })}
|
||||
</span>
|
||||
{flaggedCount > 0 && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${OUTCOME_BADGE_CLASS.flagged}`}
|
||||
>
|
||||
{flaggedCount} Flagged
|
||||
{t("logs.detail.guardrails.flaggedCount", { count: flaggedCount })}
|
||||
</span>
|
||||
)}
|
||||
{notRunCount > 0 && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${OUTCOME_BADGE_CLASS.not_run}`}
|
||||
>
|
||||
{notRunCount} Not run
|
||||
{t("logs.detail.guardrails.notRunCount", { count: notRunCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -805,7 +839,9 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
|
|||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-foreground">Total: {totalOverheadMs}ms overhead</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("logs.detail.guardrails.totalOverhead", { ms: totalOverheadMs })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -813,7 +849,7 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
|
|||
className="inline-flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-foreground bg-card hover:bg-accent transition-colors"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Export Compliance Log
|
||||
{t("logs.detail.guardrails.exportLog")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -835,7 +871,7 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
|
|||
{/* Evaluation Details */}
|
||||
<div className="px-6 py-5">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
Evaluation Details
|
||||
{t("logs.detail.guardrails.evaluationDetails")}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface RecognitionMetadata {
|
||||
recognizer_name: string;
|
||||
|
|
@ -26,6 +27,7 @@ const getScoreColor = (score: number): string => {
|
|||
const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) => {
|
||||
const [entityListExpanded, setEntityListExpanded] = useState(true);
|
||||
const [expandedEntities, setExpandedEntities] = useState<Record<number, boolean>>({});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleEntity = (index: number) => {
|
||||
setExpandedEntities((prev) => ({
|
||||
|
|
@ -47,7 +49,7 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
|
|||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h4 className="font-medium">Detected Entities ({entities.length})</h4>
|
||||
<h4 className="font-medium">{t("logs.detail.presidio.title", { count: entities.length })}</h4>
|
||||
</div>
|
||||
|
||||
{entityListExpanded && (
|
||||
|
|
@ -71,10 +73,12 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="font-medium mr-2">{entity.entity_type}</span>
|
||||
<span className={`font-mono ${getScoreColor(entity.score)}`}>Score: {entity.score.toFixed(2)}</span>
|
||||
<span className={`font-mono ${getScoreColor(entity.score)}`}>
|
||||
{t("logs.detail.presidio.score", { value: entity.score.toFixed(2) })}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Position: {entity.start}-{entity.end}
|
||||
{t("logs.detail.presidio.position", { start: entity.start, end: entity.end })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -83,17 +87,15 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
|
|||
<div className="grid grid-cols-2 gap-4 mb-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Entity Type:</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.presidio.entityType")}</span>
|
||||
<span>{entity.entity_type}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Position:</span>
|
||||
<span>
|
||||
Characters {entity.start}-{entity.end}
|
||||
</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.presidio.positionLabel")}</span>
|
||||
<span>{t("logs.detail.presidio.characters", { start: entity.start, end: entity.end })}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Confidence:</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.presidio.confidence")}</span>
|
||||
<span className={getScoreColor(entity.score)}>{entity.score.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,11 +104,11 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
|
|||
{entity.recognition_metadata && (
|
||||
<>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Recognizer:</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.presidio.recognizer")}</span>
|
||||
<span>{entity.recognition_metadata.recognizer_name}</span>
|
||||
</div>
|
||||
<div className="flex overflow-hidden">
|
||||
<span className="font-medium w-1/3">Identifier:</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.presidio.identifier")}</span>
|
||||
<span className="truncate text-xs font-mono">
|
||||
{entity.recognition_metadata.recognizer_identifier}
|
||||
</span>
|
||||
|
|
@ -115,7 +117,7 @@ const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) =
|
|||
)}
|
||||
{entity.analysis_explanation && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Explanation:</span>
|
||||
<span className="font-medium w-1/3">{t("logs.detail.presidio.explanation")}</span>
|
||||
<span>{entity.analysis_explanation}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Formatted view of tool definition with parameters table and call data
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ParsedTool, ParameterRow } from "./types";
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ interface FormattedToolViewProps {
|
|||
}
|
||||
|
||||
export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
||||
const { t } = useTranslation();
|
||||
// Parse parameters for table display
|
||||
const parameterRows: ParameterRow[] = Object.entries(tool.parameters?.properties || {}).map(
|
||||
([name, schema]: [string, any]) => ({
|
||||
|
|
@ -33,13 +35,13 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
{/* Parameters Table */}
|
||||
{parameterRows.length > 0 && (
|
||||
<div>
|
||||
<span className="mb-2 block text-xs text-muted-foreground">Parameters</span>
|
||||
<span className="mb-2 block text-xs text-muted-foreground">{t("logs.detail.tools.parameters")}</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Parameter</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>{t("logs.detail.tools.columns.parameter")}</TableHead>
|
||||
<TableHead>{t("logs.detail.tools.columns.type")}</TableHead>
|
||||
<TableHead>{t("logs.detail.tools.columns.description")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -67,7 +69,7 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
{/* If tool was called, show the arguments used */}
|
||||
{tool.called && tool.callData && (
|
||||
<div className="mt-4">
|
||||
<span className="mb-2 block text-xs text-muted-foreground">Called With</span>
|
||||
<span className="mb-2 block text-xs text-muted-foreground">{t("logs.detail.tools.calledWith")}</span>
|
||||
<div className="rounded border border-success/30 bg-success/10 p-3">
|
||||
<pre className="m-0 whitespace-pre-wrap break-words text-xs text-foreground">
|
||||
{JSON.stringify(tool.callData.arguments, null, 2)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ParsedTool } from "./types";
|
||||
import { FormattedToolView } from "./FormattedToolView";
|
||||
|
|
@ -15,6 +16,7 @@ interface ToolExpandedContentProps {
|
|||
}
|
||||
|
||||
export function ToolExpandedContent({ tool }: ToolExpandedContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("formatted");
|
||||
|
||||
return (
|
||||
|
|
@ -27,11 +29,11 @@ export function ToolExpandedContent({ tool }: ToolExpandedContentProps) {
|
|||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">Description</span>
|
||||
<span className="text-xs text-muted-foreground">{t("logs.detail.tools.description")}</span>
|
||||
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as ViewMode)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="formatted">Formatted</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
<TabsTrigger value="formatted">{t("logs.detail.tools.formatted")}</TabsTrigger>
|
||||
<TabsTrigger value="json">{t("logs.detail.tools.json")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ParsedTool } from "./types";
|
||||
|
|
@ -14,6 +15,7 @@ interface ToolItemProps {
|
|||
}
|
||||
|
||||
export function ToolItem({ tool }: ToolItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -34,7 +36,9 @@ export function ToolItem({ tool }: ToolItemProps) {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={tool.called ? "default" : "secondary"}>{tool.called ? "called" : "not called"}</Badge>
|
||||
<Badge variant={tool.called ? "default" : "secondary"}>
|
||||
{tool.called ? t("logs.detail.tools.called") : t("logs.detail.tools.notCalled")}
|
||||
</Badge>
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue