@carlonicora/nextjs-jsonapi 1.142.0 → 1.143.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AbstractService-B0T7h8fX.d.mts +124 -0
- package/dist/AbstractService-Cew0PD0L.d.ts +124 -0
- package/dist/{AuthComponent-DdxCFgUZ.d.mts → AuthComponent-Cik96ElA.d.mts} +1 -1
- package/dist/{AuthComponent-Cd7lcYif.d.ts → AuthComponent-DTTKu3VK.d.ts} +1 -1
- package/dist/{BlockNoteEditor-35DDJH3M.mjs → BlockNoteEditor-34QM4UR5.mjs} +4 -4
- package/dist/{BlockNoteEditor-XXTBZ7DD.js → BlockNoteEditor-LMKWUOJG.js} +19 -19
- package/dist/{BlockNoteEditor-XXTBZ7DD.js.map → BlockNoteEditor-LMKWUOJG.js.map} +1 -1
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-KZHHKPQZ.mjs → chunk-BIL2WQEV.mjs} +247 -1
- package/dist/chunk-BIL2WQEV.mjs.map +1 -0
- package/dist/{chunk-5SFFHIEE.js → chunk-DS4IBFWA.js} +252 -6
- package/dist/chunk-DS4IBFWA.js.map +1 -0
- package/dist/{chunk-7CR4YB7Y.js → chunk-F6PZZ4KI.js} +730 -730
- package/dist/{chunk-7CR4YB7Y.js.map → chunk-F6PZZ4KI.js.map} +1 -1
- package/dist/{chunk-ZV2GZKFR.mjs → chunk-HN5ED7OF.mjs} +2 -2
- package/dist/{chunk-FZ5SPJEU.mjs → chunk-UGSOBBZH.mjs} +4 -4
- package/dist/{chunk-C6UXEPNY.js → chunk-UXJAS6C5.js} +7 -7
- package/dist/{chunk-C6UXEPNY.js.map → chunk-UXJAS6C5.js.map} +1 -1
- package/dist/client/index.d.mts +3 -3
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +3 -3
- package/dist/components/index.d.ts +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/{config-BrkU_Ynz.d.ts → config-Cd5xTEfZ.d.ts} +1 -1
- package/dist/{config-snd2ToEB.d.mts → config-YyhFHxkU.d.mts} +1 -1
- package/dist/contexts/index.d.mts +1 -1
- package/dist/contexts/index.d.ts +1 -1
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +13 -49
- package/dist/core/index.d.ts +13 -49
- package/dist/core/index.js +8 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +7 -1
- package/dist/features/help/index.d.mts +2 -2
- package/dist/features/help/index.d.ts +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.css +29 -0
- package/dist/features/tokenusage/index.css.map +1 -0
- package/dist/features/tokenusage/index.d.mts +388 -0
- package/dist/features/tokenusage/index.d.ts +388 -0
- package/dist/features/tokenusage/index.js +1012 -0
- package/dist/features/tokenusage/index.js.map +1 -0
- package/dist/features/tokenusage/index.mjs +1012 -0
- package/dist/features/tokenusage/index.mjs.map +1 -0
- package/dist/index.d.mts +8 -6
- package/dist/index.d.ts +8 -6
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -2
- package/dist/{s3.service-Dmfxpn-k.d.ts → s3.service-CpwA3sl_.d.ts} +2 -123
- package/dist/{s3.service-DYflLXCt.d.mts → s3.service-Dt6MfuHr.d.mts} +2 -123
- package/dist/server/index.d.mts +3 -2
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/tokenusage-admin.module-5wJ_tZTj.d.ts +61 -0
- package/dist/tokenusage-admin.module-DQVT4O4j.d.mts +61 -0
- package/dist/{types-CQSjy7et.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
- package/dist/{types-DHOxe8rc.d.ts → types-bsm8Fp55.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-tjq2mlDV.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-uOnyJ6j2.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
- package/package.json +6 -1
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +189 -0
- package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +138 -0
- package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +177 -0
- package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +160 -0
- package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +99 -0
- package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +225 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +42 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +41 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageTimelineChart.spec.tsx +47 -0
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +252 -0
- package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +80 -0
- package/src/features/tokenusage/data/TokenUsageAdminService.ts +54 -0
- package/src/features/tokenusage/data/__tests__/tokenusage-admin-models.spec.ts +147 -0
- package/src/features/tokenusage/data/index.ts +8 -0
- package/src/features/tokenusage/data/tokenusage-admin-breakdown.interface.ts +18 -0
- package/src/features/tokenusage/data/tokenusage-admin-breakdown.ts +115 -0
- package/src/features/tokenusage/data/tokenusage-admin-summary.interface.ts +14 -0
- package/src/features/tokenusage/data/tokenusage-admin-summary.ts +90 -0
- package/src/features/tokenusage/data/tokenusage-admin-timeline.interface.ts +13 -0
- package/src/features/tokenusage/data/tokenusage-admin-timeline.ts +94 -0
- package/src/features/tokenusage/data/tokenusage-admin.types.ts +56 -0
- package/src/features/tokenusage/i18n-keys.ts +65 -0
- package/src/features/tokenusage/index.ts +30 -0
- package/src/features/tokenusage/lib/metrics.ts +69 -0
- package/src/features/tokenusage/lib/operation-label.ts +41 -0
- package/src/features/tokenusage/lib/palette.ts +197 -0
- package/src/features/tokenusage/tokenusage-admin.module.ts +22 -0
- package/dist/chunk-5SFFHIEE.js.map +0 -1
- package/dist/chunk-KZHHKPQZ.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-35DDJH3M.mjs.map → BlockNoteEditor-34QM4UR5.mjs.map} +0 -0
- /package/dist/{chunk-ZV2GZKFR.mjs.map → chunk-HN5ED7OF.mjs.map} +0 -0
- /package/dist/{chunk-FZ5SPJEU.mjs.map → chunk-UGSOBBZH.mjs.map} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { DateRangeSelector } from "../../../components/forms/DateRangeSelector";
|
|
5
|
+
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../shadcnui";
|
|
6
|
+
import { cn } from "../../../utils";
|
|
7
|
+
import type { Granularity, Metric } from "../data/tokenusage-admin.types";
|
|
8
|
+
|
|
9
|
+
/** Sentinel for "no company filter" — Base UI Select cannot hold `undefined`. */
|
|
10
|
+
const ALL_COMPANIES = "all";
|
|
11
|
+
|
|
12
|
+
type FilterState = {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
granularity: Granularity;
|
|
16
|
+
companyId?: string;
|
|
17
|
+
metric: Metric;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Props = FilterState & {
|
|
21
|
+
companies: { id: string; label: string }[];
|
|
22
|
+
/** Receives ONLY the keys that changed. */
|
|
23
|
+
onChange: (next: Partial<FilterState>) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The single control row above the KPI tiles.
|
|
28
|
+
*
|
|
29
|
+
* It is deliberately stateless: every control reports the one key it changed and
|
|
30
|
+
* the owning context re-fetches. Keeping the whole filter state in one place is
|
|
31
|
+
* what lets the page issue a single coordinated batch of requests instead of one
|
|
32
|
+
* per control.
|
|
33
|
+
*/
|
|
34
|
+
export function TokenUsageAdminFilterBar({ granularity, companyId, metric, companies, onChange }: Props) {
|
|
35
|
+
const t = useTranslations();
|
|
36
|
+
|
|
37
|
+
const companyItems: Record<string, string> = {
|
|
38
|
+
[ALL_COMPANIES]: t("token_usage.admin.all_companies"),
|
|
39
|
+
...Object.fromEntries(companies.map((company) => [company.id, company.label])),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
44
|
+
<DateRangeSelector
|
|
45
|
+
onDateChange={(range) => {
|
|
46
|
+
if (!range?.from || !range?.to) return;
|
|
47
|
+
onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
<Segmented
|
|
52
|
+
ariaLabel={t("token_usage.admin.granularity.label")}
|
|
53
|
+
value={granularity}
|
|
54
|
+
options={[
|
|
55
|
+
{ value: "day", label: t("token_usage.admin.granularity.day") },
|
|
56
|
+
{ value: "week", label: t("token_usage.admin.granularity.week") },
|
|
57
|
+
{ value: "month", label: t("token_usage.admin.granularity.month") },
|
|
58
|
+
]}
|
|
59
|
+
onSelect={(value) => onChange({ granularity: value })}
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<Segmented
|
|
63
|
+
ariaLabel={t("token_usage.admin.metric.label")}
|
|
64
|
+
value={metric}
|
|
65
|
+
options={[
|
|
66
|
+
{ value: "cost", label: t("token_usage.admin.metric.cost") },
|
|
67
|
+
{ value: "credits", label: t("token_usage.admin.metric.credits") },
|
|
68
|
+
{ value: "tokens", label: t("token_usage.admin.metric.tokens") },
|
|
69
|
+
]}
|
|
70
|
+
onSelect={(value) => onChange({ metric: value })}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<Select
|
|
74
|
+
items={companyItems}
|
|
75
|
+
value={companyId ?? ALL_COMPANIES}
|
|
76
|
+
onValueChange={(value) =>
|
|
77
|
+
onChange({ companyId: !value || value === ALL_COMPANIES ? undefined : (value as string) })
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
<SelectTrigger className="w-56">
|
|
81
|
+
<SelectValue placeholder={t("token_usage.admin.all_companies")} />
|
|
82
|
+
</SelectTrigger>
|
|
83
|
+
<SelectContent>
|
|
84
|
+
<SelectItem value={ALL_COMPANIES}>{t("token_usage.admin.all_companies")}</SelectItem>
|
|
85
|
+
{companies.map((company) => (
|
|
86
|
+
<SelectItem key={company.id} value={company.id}>
|
|
87
|
+
{company.label}
|
|
88
|
+
</SelectItem>
|
|
89
|
+
))}
|
|
90
|
+
</SelectContent>
|
|
91
|
+
</Select>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A segmented control built from plain Buttons.
|
|
98
|
+
*
|
|
99
|
+
* The design system has no ToggleGroup primitive, and Base UI triggers may never
|
|
100
|
+
* wrap a Button, so the segmented look is composed from Buttons directly — the
|
|
101
|
+
* selected segment takes the solid variant, the rest stay ghosts.
|
|
102
|
+
*/
|
|
103
|
+
function Segmented<T extends string>({
|
|
104
|
+
ariaLabel,
|
|
105
|
+
value,
|
|
106
|
+
options,
|
|
107
|
+
onSelect,
|
|
108
|
+
}: {
|
|
109
|
+
ariaLabel: string;
|
|
110
|
+
value: T;
|
|
111
|
+
options: { value: T; label: string }[];
|
|
112
|
+
onSelect: (value: T) => void;
|
|
113
|
+
}) {
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
role="group"
|
|
117
|
+
aria-label={ariaLabel}
|
|
118
|
+
className="border-border inline-flex items-center gap-0.5 rounded-md border p-0.5"
|
|
119
|
+
>
|
|
120
|
+
{options.map((option) => {
|
|
121
|
+
const selected = option.value === value;
|
|
122
|
+
return (
|
|
123
|
+
<Button
|
|
124
|
+
key={option.value}
|
|
125
|
+
type="button"
|
|
126
|
+
size="sm"
|
|
127
|
+
variant={selected ? "default" : "ghost"}
|
|
128
|
+
aria-pressed={selected}
|
|
129
|
+
className={cn(!selected && "text-muted-foreground")}
|
|
130
|
+
onClick={() => onSelect(option.value)}
|
|
131
|
+
>
|
|
132
|
+
{option.label}
|
|
133
|
+
</Button>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { Card, CardContent } from "../../../shadcnui";
|
|
6
|
+
import { cn } from "../../../utils";
|
|
7
|
+
import type { TokenUsageAdminSummaryInterface } from "../data/tokenusage-admin-summary.interface";
|
|
8
|
+
import type { Metric } from "../data/tokenusage-admin.types";
|
|
9
|
+
import {
|
|
10
|
+
cacheHitPercentage,
|
|
11
|
+
formatDecimal,
|
|
12
|
+
formatMetricValue,
|
|
13
|
+
formatPercent,
|
|
14
|
+
metricValue,
|
|
15
|
+
percentageDelta,
|
|
16
|
+
type TokenUsageMetrics,
|
|
17
|
+
} from "../lib/metrics";
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
/** The six summary rows: {customer, platform, total} × {current, previous}. */
|
|
21
|
+
summary: TokenUsageAdminSummaryInterface[];
|
|
22
|
+
metric: Metric;
|
|
23
|
+
/** True when a company filter is applied — platform spend is then meaningless. */
|
|
24
|
+
singleCustomerMode: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ZERO: TokenUsageMetrics = { cost: 0, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls: 0 };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The KPI header of the administrative token-usage page.
|
|
31
|
+
*
|
|
32
|
+
* Two lead tiles carry the cost centres — customer spend and platform spend —
|
|
33
|
+
* each with its delta against the equal-length preceding window. Three
|
|
34
|
+
* supporting tiles below carry the totals that give those two numbers context.
|
|
35
|
+
*
|
|
36
|
+
* The backend always returns both windows, which is why no tile has to branch on
|
|
37
|
+
* a missing row: an absent scope is simply zero-filled here.
|
|
38
|
+
*/
|
|
39
|
+
export function TokenUsageAdminTiles({ summary, metric, singleCustomerMode }: Props) {
|
|
40
|
+
const t = useTranslations();
|
|
41
|
+
|
|
42
|
+
const rowFor = (scope: string, window: string): TokenUsageMetrics =>
|
|
43
|
+
summary.find((r) => r.scope === scope && r.window === window) ?? ZERO;
|
|
44
|
+
|
|
45
|
+
const customerCurrent = rowFor("customer", "current");
|
|
46
|
+
const customerPrevious = rowFor("customer", "previous");
|
|
47
|
+
const platformCurrent = rowFor("platform", "current");
|
|
48
|
+
const platformPrevious = rowFor("platform", "previous");
|
|
49
|
+
const totalCurrent = rowFor("total", "current");
|
|
50
|
+
const totalPrevious = rowFor("total", "previous");
|
|
51
|
+
|
|
52
|
+
const averagePerCall = totalCurrent.calls ? totalCurrent.cost / totalCurrent.calls : 0;
|
|
53
|
+
const cacheHit = cacheHitPercentage(totalCurrent.cached, totalCurrent.tokensIn);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="grid gap-3">
|
|
57
|
+
<div className={cn("grid gap-3", singleCustomerMode ? "sm:grid-cols-1" : "sm:grid-cols-2")}>
|
|
58
|
+
<LeadTile
|
|
59
|
+
testId="tile-customer"
|
|
60
|
+
label={t("token_usage.admin.customer_spend")}
|
|
61
|
+
value={formatMetricValue(metricValue(customerCurrent, metric), metric)}
|
|
62
|
+
delta={percentageDelta(metricValue(customerCurrent, metric), metricValue(customerPrevious, metric))}
|
|
63
|
+
previousLabel={t("token_usage.admin.vs_previous")}
|
|
64
|
+
/>
|
|
65
|
+
{!singleCustomerMode && (
|
|
66
|
+
<LeadTile
|
|
67
|
+
testId="tile-platform"
|
|
68
|
+
label={t("token_usage.admin.platform_spend")}
|
|
69
|
+
value={formatMetricValue(metricValue(platformCurrent, metric), metric)}
|
|
70
|
+
delta={percentageDelta(metricValue(platformCurrent, metric), metricValue(platformPrevious, metric))}
|
|
71
|
+
previousLabel={t("token_usage.admin.vs_previous")}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
77
|
+
<SupportingTile
|
|
78
|
+
testId="tile-total"
|
|
79
|
+
label={t("token_usage.admin.total_cost")}
|
|
80
|
+
value={formatMetricValue(metricValue(totalCurrent, metric), metric)}
|
|
81
|
+
delta={percentageDelta(metricValue(totalCurrent, metric), metricValue(totalPrevious, metric))}
|
|
82
|
+
/>
|
|
83
|
+
<SupportingTile
|
|
84
|
+
testId="tile-avg-per-call"
|
|
85
|
+
label={t("token_usage.admin.avg_per_call")}
|
|
86
|
+
value={`€ ${formatDecimal(averagePerCall, 4)}`}
|
|
87
|
+
/>
|
|
88
|
+
<SupportingTile
|
|
89
|
+
testId="tile-cache-hit"
|
|
90
|
+
label={t("token_usage.admin.cache_hit")}
|
|
91
|
+
value={formatPercent(cacheHit)}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function LeadTile({
|
|
99
|
+
testId,
|
|
100
|
+
label,
|
|
101
|
+
value,
|
|
102
|
+
delta,
|
|
103
|
+
previousLabel,
|
|
104
|
+
}: {
|
|
105
|
+
testId: string;
|
|
106
|
+
label: string;
|
|
107
|
+
value: string;
|
|
108
|
+
delta: number | undefined;
|
|
109
|
+
previousLabel: string;
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<Card data-testid={testId}>
|
|
113
|
+
<CardContent className="grid gap-1">
|
|
114
|
+
<span className="text-muted-foreground text-xs">{label}</span>
|
|
115
|
+
<span className="text-primary text-xl font-semibold tabular-nums">{value}</span>
|
|
116
|
+
<span className="flex items-center gap-1">
|
|
117
|
+
<Delta testId={`${testId}-delta`} delta={delta} />
|
|
118
|
+
<span className="text-muted-foreground text-xs">{previousLabel}</span>
|
|
119
|
+
</span>
|
|
120
|
+
</CardContent>
|
|
121
|
+
</Card>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function SupportingTile({
|
|
126
|
+
testId,
|
|
127
|
+
label,
|
|
128
|
+
value,
|
|
129
|
+
delta,
|
|
130
|
+
}: {
|
|
131
|
+
testId: string;
|
|
132
|
+
label: string;
|
|
133
|
+
value: string;
|
|
134
|
+
delta?: number | undefined;
|
|
135
|
+
}) {
|
|
136
|
+
return (
|
|
137
|
+
<Card data-testid={testId} size="sm">
|
|
138
|
+
<CardContent className="grid gap-1">
|
|
139
|
+
<span className="text-muted-foreground text-xs">{label}</span>
|
|
140
|
+
<span className="flex items-center gap-2">
|
|
141
|
+
<span className="text-sm font-medium tabular-nums">{value}</span>
|
|
142
|
+
{delta !== undefined && <Delta testId={`${testId}-delta`} delta={delta} />}
|
|
143
|
+
</span>
|
|
144
|
+
</CardContent>
|
|
145
|
+
</Card>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The delta slot. An undefined delta means the previous window was zero: an
|
|
151
|
+
* em dash says "not comparable" where a percentage would say "infinite growth".
|
|
152
|
+
*/
|
|
153
|
+
function Delta({ testId, delta }: { testId: string; delta: number | undefined }) {
|
|
154
|
+
if (delta === undefined) {
|
|
155
|
+
return (
|
|
156
|
+
<span data-testid={testId} className="text-muted-foreground text-xs">
|
|
157
|
+
—
|
|
158
|
+
</span>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const increased = delta >= 0;
|
|
163
|
+
const Icon = increased ? ArrowUpIcon : ArrowDownIcon;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<span
|
|
167
|
+
data-testid={testId}
|
|
168
|
+
className={cn(
|
|
169
|
+
"inline-flex items-center gap-0.5 text-xs tabular-nums",
|
|
170
|
+
increased ? "text-success" : "text-destructive",
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
<Icon aria-hidden className="size-3" />
|
|
174
|
+
{formatDecimal(Math.abs(delta), 0)} %
|
|
175
|
+
</span>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../shadcnui";
|
|
7
|
+
import { cn } from "../../../utils";
|
|
8
|
+
import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
|
|
9
|
+
import type { Metric } from "../data/tokenusage-admin.types";
|
|
10
|
+
import { cacheHitPercentage, formatDecimal, formatMetricValue, formatPercent, metricValue } from "../lib/metrics";
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
rows: TokenUsageAdminBreakdownInterface[];
|
|
14
|
+
metric: Metric;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type SortKey =
|
|
18
|
+
"label" | "sublabel" | "activeUsers" | "calls" | "tokensIn" | "tokensOut" | "cacheHit" | "cost" | "credits" | "share";
|
|
19
|
+
|
|
20
|
+
type SortState = { key: SortKey; direction: "asc" | "desc" };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The numeric detail behind the ranked bars: every row, every metric column.
|
|
24
|
+
*
|
|
25
|
+
* The bars answer "who is biggest"; this answers "why". Sorting is client-side
|
|
26
|
+
* because the whole ranked set — top-N plus the "other" rollup — is already in
|
|
27
|
+
* memory, so a round trip would buy nothing.
|
|
28
|
+
*/
|
|
29
|
+
export function TokenUsageBreakdownTable({ rows, metric }: Props) {
|
|
30
|
+
const t = useTranslations();
|
|
31
|
+
const [sort, setSort] = useState<SortState | undefined>(undefined);
|
|
32
|
+
|
|
33
|
+
// The company dimension is the only one that reports active users. Rather than
|
|
34
|
+
// render an empty column on user/operation breakdowns, the column disappears.
|
|
35
|
+
const showActiveUsers = rows.some((row) => row.activeUsers !== undefined);
|
|
36
|
+
|
|
37
|
+
const total = useMemo(() => rows.reduce((sum, row) => sum + metricValue(row, metric), 0), [rows, metric]);
|
|
38
|
+
|
|
39
|
+
const sortValue = (row: TokenUsageAdminBreakdownInterface, key: SortKey): number | string => {
|
|
40
|
+
switch (key) {
|
|
41
|
+
case "label":
|
|
42
|
+
return row.label ?? "";
|
|
43
|
+
case "sublabel":
|
|
44
|
+
return row.sublabel ?? "";
|
|
45
|
+
case "activeUsers":
|
|
46
|
+
return row.activeUsers ?? 0;
|
|
47
|
+
case "cacheHit":
|
|
48
|
+
return cacheHitPercentage(row.cached, row.tokensIn);
|
|
49
|
+
case "share":
|
|
50
|
+
return metricValue(row, metric);
|
|
51
|
+
default:
|
|
52
|
+
return row[key];
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const sortedRows = useMemo(() => {
|
|
57
|
+
if (!sort) return rows;
|
|
58
|
+
|
|
59
|
+
const factor = sort.direction === "desc" ? -1 : 1;
|
|
60
|
+
return [...rows].sort((a, b) => {
|
|
61
|
+
const left = sortValue(a, sort.key);
|
|
62
|
+
const right = sortValue(b, sort.key);
|
|
63
|
+
if (typeof left === "string" || typeof right === "string") {
|
|
64
|
+
return String(left).localeCompare(String(right), "it-IT") * factor;
|
|
65
|
+
}
|
|
66
|
+
return (left - right) * factor;
|
|
67
|
+
});
|
|
68
|
+
}, [rows, sort, metric]);
|
|
69
|
+
|
|
70
|
+
// First click on a column sorts descending: on a spend table the interesting
|
|
71
|
+
// end of every numeric column is the top one.
|
|
72
|
+
const toggleSort = (key: SortKey) =>
|
|
73
|
+
setSort((current) =>
|
|
74
|
+
current?.key === key
|
|
75
|
+
? { key, direction: current.direction === "desc" ? "asc" : "desc" }
|
|
76
|
+
: { key, direction: "desc" },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const columns: { key: SortKey; label: string; numeric: boolean }[] = [
|
|
80
|
+
{ key: "label", label: t("token_usage.admin.columns.label"), numeric: false },
|
|
81
|
+
{ key: "sublabel", label: t("token_usage.admin.columns.sublabel"), numeric: false },
|
|
82
|
+
...(showActiveUsers
|
|
83
|
+
? [{ key: "activeUsers" as const, label: t("token_usage.admin.columns.active_users"), numeric: true }]
|
|
84
|
+
: []),
|
|
85
|
+
{ key: "calls", label: t("token_usage.admin.columns.calls"), numeric: true },
|
|
86
|
+
{ key: "tokensIn", label: t("token_usage.admin.columns.tokens_in"), numeric: true },
|
|
87
|
+
{ key: "tokensOut", label: t("token_usage.admin.columns.tokens_out"), numeric: true },
|
|
88
|
+
// Reuses the tile's key rather than minting a column-specific one — same
|
|
89
|
+
// metric, same words.
|
|
90
|
+
{ key: "cacheHit", label: t("token_usage.admin.cache_hit"), numeric: true },
|
|
91
|
+
{ key: "cost", label: t("token_usage.admin.columns.cost"), numeric: true },
|
|
92
|
+
{ key: "credits", label: t("token_usage.admin.columns.credits"), numeric: true },
|
|
93
|
+
{ key: "share", label: t("token_usage.admin.columns.share"), numeric: true },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
// The package's <Table> wraps itself in an `overflow-x-clip` container, which
|
|
98
|
+
// would swallow the overflow before this scroller ever saw it — hence the
|
|
99
|
+
// child override. Wide metric tables must scroll, never widen the page.
|
|
100
|
+
<div className="overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible">
|
|
101
|
+
<Table>
|
|
102
|
+
<TableHeader>
|
|
103
|
+
<TableRow>
|
|
104
|
+
{columns.map((column) => (
|
|
105
|
+
<TableHead key={column.key} className={cn(column.numeric && "text-right")}>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => toggleSort(column.key)}
|
|
109
|
+
className={cn(
|
|
110
|
+
"hover:text-primary inline-flex items-center gap-1 text-xs font-medium",
|
|
111
|
+
column.numeric && "flex-row-reverse",
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
{column.label}
|
|
115
|
+
{sort?.key === column.key &&
|
|
116
|
+
(sort.direction === "desc" ? (
|
|
117
|
+
<ArrowDownIcon aria-hidden className="size-3" />
|
|
118
|
+
) : (
|
|
119
|
+
<ArrowUpIcon aria-hidden className="size-3" />
|
|
120
|
+
))}
|
|
121
|
+
</button>
|
|
122
|
+
</TableHead>
|
|
123
|
+
))}
|
|
124
|
+
</TableRow>
|
|
125
|
+
</TableHeader>
|
|
126
|
+
<TableBody>
|
|
127
|
+
{sortedRows.map((row) => {
|
|
128
|
+
const value = metricValue(row, metric);
|
|
129
|
+
const share = total > 0 ? (value / total) * 100 : 0;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<TableRow key={row.id} data-testid={`breakdown-row-${row.id}`}>
|
|
133
|
+
<TableCell className="text-xs">
|
|
134
|
+
{row.id === "other" ? t("token_usage.admin.other") : row.label}
|
|
135
|
+
</TableCell>
|
|
136
|
+
<TableCell className="text-muted-foreground text-xs">{row.sublabel ?? ""}</TableCell>
|
|
137
|
+
{showActiveUsers && (
|
|
138
|
+
<TableCell className="text-right text-xs tabular-nums">
|
|
139
|
+
{row.activeUsers === undefined ? "" : formatDecimal(row.activeUsers, 0)}
|
|
140
|
+
</TableCell>
|
|
141
|
+
)}
|
|
142
|
+
<TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.calls, 0)}</TableCell>
|
|
143
|
+
<TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.tokensIn, 0)}</TableCell>
|
|
144
|
+
<TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.tokensOut, 0)}</TableCell>
|
|
145
|
+
<TableCell className="text-right text-xs tabular-nums">
|
|
146
|
+
{formatPercent(cacheHitPercentage(row.cached, row.tokensIn))}
|
|
147
|
+
</TableCell>
|
|
148
|
+
<TableCell className="text-right text-xs tabular-nums">{formatMetricValue(row.cost, "cost")}</TableCell>
|
|
149
|
+
<TableCell className="text-right text-xs tabular-nums">
|
|
150
|
+
{formatMetricValue(row.credits, "credits")}
|
|
151
|
+
</TableCell>
|
|
152
|
+
<TableCell className="text-right text-xs tabular-nums">{formatPercent(share)}</TableCell>
|
|
153
|
+
</TableRow>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
</TableBody>
|
|
157
|
+
</Table>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
|
|
5
|
+
import type { Metric } from "../data/tokenusage-admin.types";
|
|
6
|
+
import { formatMetricValue, formatPercent, metricValue } from "../lib/metrics";
|
|
7
|
+
import { operationLabel } from "../lib/operation-label";
|
|
8
|
+
import { SEQUENTIAL_RAMP } from "../lib/palette";
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
/** Ranked rows, already ordered descending by the backend, "other" last. */
|
|
12
|
+
rows: TokenUsageAdminBreakdownInterface[];
|
|
13
|
+
metric: Metric;
|
|
14
|
+
/** Italian copy shown when there is nothing to rank. */
|
|
15
|
+
emptyLabel: string;
|
|
16
|
+
/**
|
|
17
|
+
* Whether `row.label` is an operation TYPE (vocabulary the consuming app
|
|
18
|
+
* translates) rather than an entity NAME (data, rendered verbatim).
|
|
19
|
+
*
|
|
20
|
+
* Company and user rows carry names — translating them would be nonsense — so
|
|
21
|
+
* this defaults to false and only the platform-by-operation panel opts in.
|
|
22
|
+
*/
|
|
23
|
+
labelsAreOperationTypes?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A ranked horizontal bar list — "who spent the most".
|
|
28
|
+
*
|
|
29
|
+
* This does a MAGNITUDE job, not a categorical one, so colour comes from a
|
|
30
|
+
* single sequential ramp indexed by RANK POSITION, never from a categorical
|
|
31
|
+
* palette keyed by entity. That is deliberate: a filter change reorders the
|
|
32
|
+
* rows, and rank-indexed colour simply re-shades them instead of repainting a
|
|
33
|
+
* company into another company's identity hue.
|
|
34
|
+
*
|
|
35
|
+
* There is exactly one series, so there is no legend: every bar is
|
|
36
|
+
* direct-labelled with its value and its share of the total.
|
|
37
|
+
*/
|
|
38
|
+
export function TokenUsageRankedBar({ rows, metric, emptyLabel, labelsAreOperationTypes = false }: Props) {
|
|
39
|
+
const t = useTranslations();
|
|
40
|
+
|
|
41
|
+
const renderLabel = (row: TokenUsageAdminBreakdownInterface): string => {
|
|
42
|
+
if (row.id === "other") return t("token_usage.admin.other");
|
|
43
|
+
if (!labelsAreOperationTypes) return row.label;
|
|
44
|
+
return operationLabel(
|
|
45
|
+
row.label,
|
|
46
|
+
(key) => t(key),
|
|
47
|
+
(key) => t.has(key),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (rows.length === 0) {
|
|
52
|
+
return <p className="text-muted-foreground py-6 text-center text-sm">{emptyLabel}</p>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const values = rows.map((row) => metricValue(row, metric));
|
|
56
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
57
|
+
const max = Math.max(...values, 0);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="grid gap-0.5">
|
|
61
|
+
{rows.map((row, index) => {
|
|
62
|
+
const value = values[index];
|
|
63
|
+
// The backend folds everything past the limit into one "other" row; it
|
|
64
|
+
// gets the most recessive ramp step so it never out-shouts a real entity.
|
|
65
|
+
const isOther = row.id === "other";
|
|
66
|
+
const step = isOther ? SEQUENTIAL_RAMP.length - 1 : Math.min(index, SEQUENTIAL_RAMP.length - 1);
|
|
67
|
+
const width = max > 0 ? (value / max) * 100 : 0;
|
|
68
|
+
// Shares are taken against the supplied rows, which already include
|
|
69
|
+
// "other" — so the column sums to 100 %.
|
|
70
|
+
const share = total > 0 ? (value / total) * 100 : 0;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div key={row.id} className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-0.5 py-0.5">
|
|
74
|
+
<div className="grid min-w-0 gap-0.5">
|
|
75
|
+
<span className="text-muted-foreground truncate text-xs">{renderLabel(row)}</span>
|
|
76
|
+
<div className="bg-muted h-2 w-full overflow-hidden rounded-r-[4px]">
|
|
77
|
+
<div
|
|
78
|
+
data-testid={`ranked-fill-${row.id}`}
|
|
79
|
+
data-ramp-step={String(step)}
|
|
80
|
+
className="h-full rounded-r-[4px]"
|
|
81
|
+
style={{ width: `${width}%`, backgroundColor: SEQUENTIAL_RAMP[step] }}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<span data-testid={`ranked-value-${row.id}`} className="text-right text-xs tabular-nums">
|
|
86
|
+
{formatMetricValue(value, metric)}
|
|
87
|
+
</span>
|
|
88
|
+
<span
|
|
89
|
+
data-testid={`ranked-share-${row.id}`}
|
|
90
|
+
className="text-muted-foreground w-16 text-right text-xs tabular-nums"
|
|
91
|
+
>
|
|
92
|
+
{formatPercent(share)}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|