276 lines
9.9 KiB
TypeScript
276 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { useTranslations } from "next-intl";
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
|
|
|
interface DailyUsage {
|
|
date: string;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
spend: number;
|
|
}
|
|
|
|
interface UsageData {
|
|
month: string;
|
|
currentPeriod: {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
totalSpend: number;
|
|
requestCount: number;
|
|
};
|
|
budget: {
|
|
maxBudget: number | null;
|
|
spend: number;
|
|
remaining: number | null;
|
|
/**
|
|
* Feature 7: budget reset cadence as stored on LiteLLM.
|
|
* Strings: "30d" / "1mo" / "1y" / null (no reset). UI maps these
|
|
* to user-friendly labels.
|
|
*/
|
|
budgetDuration: string | null;
|
|
};
|
|
rateLimits: { rpm: number | null; tpm: number | null };
|
|
dailyUsage: DailyUsage[];
|
|
}
|
|
|
|
function fmt(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
|
return n.toString();
|
|
}
|
|
|
|
/**
|
|
* Format a numeric amount as CHF.
|
|
*
|
|
* Note on currency labelling: LiteLLM stores raw cost numbers it
|
|
* receives from upstream (OpenAI/Anthropic), which originate as USD.
|
|
* The PieCed pricing config (Slice 5) converts those numbers to
|
|
* CHF before LiteLLM persists them, so the values flowing through
|
|
* here are already CHF amounts. We label them as such in the UI;
|
|
* "USD" or "$" anywhere in the customer-facing experience would
|
|
* be misleading.
|
|
*
|
|
* Precision is adaptive:
|
|
* - Amounts ≥ 1 CHF: 2 decimals (typical money formatting).
|
|
* - Smaller amounts: 4 decimals — per-request inference costs are
|
|
* routinely sub-rappen, and rounding to 2dp
|
|
* would render CHF 0.0042 as "CHF 0.00",
|
|
* which obscures real costs from customers
|
|
* looking at the daily breakdown.
|
|
*
|
|
* This is a customer-facing display helper; for storage and
|
|
* comparisons keep using the raw number.
|
|
*/
|
|
function chf(n: number): string {
|
|
const decimals = Math.abs(n) >= 1 ? 2 : 4;
|
|
return `CHF ${n.toFixed(decimals)}`;
|
|
}
|
|
|
|
function getCurrentMonth(): string {
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function shiftMonth(month: string, delta: number): string {
|
|
const [y, m] = month.split("-").map(Number);
|
|
const d = new Date(y, m - 1 + delta, 1);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function formatMonth(month: string, locale: string): string {
|
|
const [y, m] = month.split("-").map(Number);
|
|
return new Date(y, m - 1).toLocaleDateString(locale, { year: "numeric", month: "long" });
|
|
}
|
|
|
|
function UsageChart({ data }: { data: DailyUsage[] }) {
|
|
if (!data.length) return null;
|
|
const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1);
|
|
const barW = Math.max(4, Math.floor(600 / data.length) - 2);
|
|
const h = 120;
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<svg
|
|
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
|
className="w-full h-36"
|
|
preserveAspectRatio="xMinYMid meet"
|
|
>
|
|
{data.map((d, i) => {
|
|
const total = d.inputTokens + d.outputTokens;
|
|
const totalH = (total / maxTokens) * h;
|
|
const inputH = (d.inputTokens / maxTokens) * h;
|
|
const x = i * (barW + 2);
|
|
return (
|
|
<g key={d.date}>
|
|
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)}</title>
|
|
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
|
|
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
|
{i % 7 === 0 && (
|
|
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
|
|
<span className="flex items-center gap-1">
|
|
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" /> Input
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" /> Output
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Usage display widget.
|
|
*
|
|
* Pass `tenant=<name>` for the canonical path — works for both
|
|
* customers and admins, the API resolves team+alias from the tenant
|
|
* CR's status. The visibility check on the API ensures users can't
|
|
* query tenants they shouldn't see.
|
|
*
|
|
* `teamId`/`keyAlias` remain available as a platform-admin escape
|
|
* hatch for cross-org debugging, but the tenant-detail and dashboard
|
|
* paths should always use `tenant`.
|
|
*
|
|
* Bug 19 fix: previous version omitted both props for customer
|
|
* sessions, expecting the API to "figure it out". The API's fallback
|
|
* was "first visible tenant", which meant siblings in the same org
|
|
* showed identical numbers regardless of which detail page was open.
|
|
* Now the page passes the tenant name explicitly; no fallback exists.
|
|
*/
|
|
export function UsageDisplay({
|
|
tenant,
|
|
teamId,
|
|
keyAlias,
|
|
canEditBudget = false,
|
|
}: {
|
|
tenant?: string | null;
|
|
teamId?: string | null;
|
|
keyAlias?: string | null;
|
|
/**
|
|
* Feature 7: when true, the Budget StatCard becomes clickable and
|
|
* opens the budget editor. Off by default — owners and platform
|
|
* admins get it on; `user` role customers see the budget read-only.
|
|
* Server component decides this via canMutate(user).
|
|
*/
|
|
canEditBudget?: boolean;
|
|
}) {
|
|
const t = useTranslations("usage");
|
|
const [month, setMonth] = useState(getCurrentMonth);
|
|
const [data, setData] = useState<UsageData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const isCurrentMonth = month === getCurrentMonth();
|
|
|
|
const fetchUsage = useCallback(() => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const params = new URLSearchParams({ month });
|
|
if (tenant) {
|
|
params.set("tenant", tenant);
|
|
} else if (teamId) {
|
|
// Admin escape hatch — only honoured by the API when the
|
|
// viewer is platform-role.
|
|
params.set("teamId", teamId);
|
|
if (keyAlias) params.set("keyAlias", keyAlias);
|
|
}
|
|
|
|
fetch(`/api/usage?${params}`)
|
|
.then((res) => { if (!res.ok) throw new Error(`${res.status}`); return res.json(); })
|
|
.then(setData)
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
}, [tenant, teamId, keyAlias, month]);
|
|
|
|
useEffect(() => { fetchUsage(); }, [fetchUsage]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Month selector */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => setMonth((m) => shiftMonth(m, -1))}
|
|
className="rounded-md px-2 py-1 text-xs text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
|
>
|
|
←
|
|
</button>
|
|
<span className="font-display text-sm font-medium text-text-primary">
|
|
{formatMonth(month, "en")}
|
|
</span>
|
|
<button
|
|
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
|
disabled={isCurrentMonth}
|
|
className="rounded-md px-2 py-1 text-xs text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
→
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-6 animate-pulse">
|
|
<div className="h-4 w-32 bg-surface-3 rounded mb-4" />
|
|
<div className="h-36 bg-surface-2 rounded" />
|
|
</div>
|
|
) : error || !data ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-6">
|
|
<p className="text-sm text-text-secondary">{error || t("noData")}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
|
|
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
|
|
<StatCard label={t("totalSpend")} value={chf(data.currentPeriod.totalSpend)} accent />
|
|
{canEditBudget && tenant ? (
|
|
<BudgetEditableCard
|
|
tenantName={tenant}
|
|
maxBudget={data.budget.maxBudget}
|
|
remaining={data.budget.remaining}
|
|
budgetDuration={data.budget.budgetDuration}
|
|
onSaved={fetchUsage}
|
|
/>
|
|
) : (
|
|
<StatCard
|
|
label={t("budget")}
|
|
value={
|
|
data.budget.remaining !== null
|
|
? chf(data.budget.remaining)
|
|
: t("noLimit")
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-surface-1 border border-border rounded-xl p-5">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{t("dailyBreakdown")}
|
|
</h3>
|
|
<span className="text-xs text-text-muted tabular-nums">
|
|
{data.currentPeriod.requestCount} {t("requests")}
|
|
</span>
|
|
</div>
|
|
<UsageChart data={data.dailyUsage} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
|
return (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
|
<div className="text-xs text-text-muted mb-1">{label}</div>
|
|
<div className={`font-display text-lg font-semibold tabular-nums ${accent ? "text-accent" : "text-text-primary"}`}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|