Files
pieced-portal/src/components/dashboard/usage-display.tsx

384 lines
14 KiB
TypeScript

"use client";
import { useTranslations, useLocale } 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[] }) {
const t = useTranslations("usage");
const locale = useLocale();
// Which day's detail is shown in the readout. Defaults to the most
// recent day; hover (mouse), tap (touch) or focus (keyboard) all
// update it. The previous version put per-day numbers only in SVG
// <title> hover tooltips, which are unreachable on touch devices and
// invisible to keyboard users — this readout fixes both.
const [selected, setSelected] = useState<number | null>(null);
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;
const activeIndex = selected ?? data.length - 1;
const active = data[activeIndex];
const dayLabel = (iso: string) => {
const [y, m, dd] = iso.split("-").map(Number);
return new Date(y, m - 1, dd).toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
};
const barAria = (d: DailyUsage) =>
`${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt(
d.outputTokens
)} ${t("outputTokens")}, ${chf(d.spend)}`;
return (
<div>
{/* Readout — the touch/keyboard-accessible equivalent of the old
hover-only tooltip. Always reflects the active day. */}
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
<span className="font-medium text-text-primary">
{dayLabel(active.date)}
</span>
<span className="text-text-secondary tabular-nums">
{fmt(active.inputTokens)} {t("inputTokens")}
</span>
<span className="text-text-secondary tabular-nums">
{fmt(active.outputTokens)} {t("outputTokens")}
</span>
<span className="text-accent tabular-nums">{chf(active.spend)}</span>
</div>
<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"
role="group"
aria-label={t("dailyBreakdown")}
>
{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);
const isActive = i === activeIndex;
return (
<g
key={d.date}
role="button"
tabIndex={0}
aria-label={barAria(d)}
aria-pressed={isActive}
className="cursor-pointer focus:outline-none"
onClick={() => setSelected(i)}
onMouseEnter={() => setSelected(i)}
onFocus={() => setSelected(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(i);
}
}}
>
<title>{barAria(d)}</title>
{/* Full-height transparent hit area so thin bars stay
easy to tap on touch screens. */}
<rect x={x} y={0} width={barW} height={h} fill="transparent" />
<rect
x={x}
y={h - totalH}
width={barW}
height={Math.max(0, totalH - inputH)}
rx={1}
fill="var(--color-accent)"
opacity={isActive ? 0.5 : 0.3}
/>
<rect
x={x}
y={h - inputH}
width={barW}
height={inputH}
rx={1}
fill="var(--color-accent)"
opacity={isActive ? 1 : 0.7}
/>
{isActive && (
<rect
x={x - 1}
y={Math.max(0, h - totalH) - 1}
width={barW + 2}
height={Math.max(2, totalH) + 1}
rx={1.5}
fill="none"
stroke="var(--color-accent)"
strokeWidth={1}
/>
)}
{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>
<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" />{" "}
{t("legendInput")}
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" />{" "}
{t("legendOutput")}
</span>
<span className="ml-auto text-text-muted/70">{t("chartHint")}</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 locale = useLocale();
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, locale)}
</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>
);
}