@carlonicora/nextjs-jsonapi 1.141.3 → 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/{AssistantMessageInterface-DH1QwtC_.d.mts → AssistantMessageInterface-Ce8FcllX.d.mts} +1 -1
- package/dist/{AssistantMessageInterface-giWvsOrX.d.ts → AssistantMessageInterface-Dh0BpbP2.d.ts} +1 -1
- 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-JTKEBS2W.mjs → BlockNoteEditor-34QM4UR5.mjs} +4 -4
- package/dist/{BlockNoteEditor-4OXY6BKF.js → BlockNoteEditor-LMKWUOJG.js} +19 -19
- package/dist/{BlockNoteEditor-4OXY6BKF.js.map → BlockNoteEditor-LMKWUOJG.js.map} +1 -1
- package/dist/{auth.interface-yivTb46w.d.ts → auth.interface-COmBjh89.d.ts} +1 -1
- package/dist/{auth.interface-r3XKYOrQ.d.mts → auth.interface-CkmZqat3.d.mts} +1 -1
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-5GVY2M5X.mjs → chunk-BIL2WQEV.mjs} +264 -18
- package/dist/chunk-BIL2WQEV.mjs.map +1 -0
- package/dist/{chunk-AGKZCRG2.js → chunk-DS4IBFWA.js} +269 -23
- package/dist/chunk-DS4IBFWA.js.map +1 -0
- package/dist/{chunk-4TGGHNWL.js → chunk-F6PZZ4KI.js} +752 -752
- package/dist/chunk-F6PZZ4KI.js.map +1 -0
- package/dist/{chunk-KXTTYXJ6.mjs → chunk-HN5ED7OF.mjs} +2 -2
- package/dist/{chunk-K76QSQTC.mjs → chunk-UGSOBBZH.mjs} +28 -28
- package/dist/chunk-UGSOBBZH.mjs.map +1 -0
- package/dist/{chunk-SE2YSAER.js → chunk-UXJAS6C5.js} +7 -7
- package/dist/{chunk-SE2YSAER.js.map → chunk-UXJAS6C5.js.map} +1 -1
- package/dist/client/index.d.mts +5 -5
- package/dist/client/index.d.ts +5 -5
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +6 -6
- package/dist/components/index.d.ts +6 -6
- 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 +4 -4
- package/dist/contexts/index.d.ts +4 -4
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +24 -60
- package/dist/core/index.d.ts +24 -60
- 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 +11 -9
- package/dist/index.d.ts +11 -9
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -2
- package/dist/{notification.interface-LKFmZndR.d.ts → notification.interface--27exGde.d.ts} +6 -6
- package/dist/{notification.interface-B3kNUTZs.d.mts → notification.interface-CG1vqoxg.d.mts} +6 -6
- package/dist/{s3.service-BrmeMjtk.d.ts → s3.service-CpwA3sl_.d.ts} +4 -125
- package/dist/{s3.service-CXKV_OT-.d.mts → s3.service-Dt6MfuHr.d.mts} +4 -125
- package/dist/server/index.d.mts +5 -4
- package/dist/server/index.d.ts +5 -4
- 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/dist/{useSocket-BsbaGPqE.d.ts → useSocket-DgGAQW6h.d.ts} +1 -1
- package/dist/{useSocket-BSjn1Rlb.d.mts → useSocket-M3YY0aTS.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/company/components/details/TokenStatusIndicator.tsx +9 -9
- package/src/features/company/data/company.interface.ts +6 -6
- package/src/features/company/data/company.ts +17 -17
- 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/src/features/user/contexts/CurrentUserContext.tsx +18 -18
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +21 -21
- package/dist/chunk-4TGGHNWL.js.map +0 -1
- package/dist/chunk-5GVY2M5X.mjs.map +0 -1
- package/dist/chunk-AGKZCRG2.js.map +0 -1
- package/dist/chunk-K76QSQTC.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-JTKEBS2W.mjs.map → BlockNoteEditor-34QM4UR5.mjs.map} +0 -0
- /package/dist/{chunk-KXTTYXJ6.mjs.map → chunk-HN5ED7OF.mjs.map} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useTheme } from "next-themes";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
|
7
|
+
import {
|
|
8
|
+
ChartConfig,
|
|
9
|
+
ChartContainer,
|
|
10
|
+
ChartLegend,
|
|
11
|
+
ChartLegendContent,
|
|
12
|
+
ChartTooltip,
|
|
13
|
+
ChartTooltipContent,
|
|
14
|
+
} from "../../../shadcnui";
|
|
15
|
+
import { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface";
|
|
16
|
+
import { Metric, StackBy } from "../data/tokenusage-admin.types";
|
|
17
|
+
import { operationLabel } from "../lib/operation-label";
|
|
18
|
+
import { CATEGORICAL_CEILING, ChartMode, OTHER_COLOR, seriesColor } from "../lib/palette";
|
|
19
|
+
|
|
20
|
+
/** The rollup series every series past the colour ceiling folds into. */
|
|
21
|
+
const OTHER_SERIES = "other";
|
|
22
|
+
|
|
23
|
+
type TokenUsageTimelineChartProps = {
|
|
24
|
+
rows: TokenUsageAdminTimelineInterface[];
|
|
25
|
+
metric: Metric;
|
|
26
|
+
stackBy: StackBy;
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TimelineBucket = { bucket: string } & Record<string, number | string>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The bucket key, derived with UTC getters.
|
|
34
|
+
*
|
|
35
|
+
* The backend field is `type: "date"` — a calendar day with no time — and the
|
|
36
|
+
* model parses the `YYYY-MM-DD` wire value with `new Date(...)`, which lands on
|
|
37
|
+
* UTC midnight. Reading it back with local getters would shift the bucket a day
|
|
38
|
+
* early west of UTC, so the key (and every label built from it) stays in UTC.
|
|
39
|
+
*/
|
|
40
|
+
const bucketKey = (date: Date): string => {
|
|
41
|
+
const y = date.getUTCFullYear();
|
|
42
|
+
const m = `${date.getUTCMonth() + 1}`.padStart(2, "0");
|
|
43
|
+
const d = `${date.getUTCDate()}`.padStart(2, "0");
|
|
44
|
+
return `${y}-${m}-${d}`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const metricValue = (row: TokenUsageAdminTimelineInterface, metric: Metric): number => {
|
|
48
|
+
if (metric === "cost") return row.cost;
|
|
49
|
+
if (metric === "credits") return row.credits;
|
|
50
|
+
return row.tokensIn + row.tokensOut;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The granularity the data was bucketed at, inferred from the spacing between
|
|
55
|
+
* consecutive buckets. The backend chooses the granularity and the rows carry
|
|
56
|
+
* the consequence, so the axis reads it off the data rather than taking a prop
|
|
57
|
+
* that could disagree with what was actually fetched.
|
|
58
|
+
*/
|
|
59
|
+
const inferGranularity = (buckets: string[]): "day" | "week" | "month" => {
|
|
60
|
+
if (buckets.length < 2) return "day";
|
|
61
|
+
|
|
62
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
63
|
+
let smallestGap = Number.POSITIVE_INFINITY;
|
|
64
|
+
for (let i = 1; i < buckets.length; i += 1) {
|
|
65
|
+
const gap = (Date.parse(buckets[i]) - Date.parse(buckets[i - 1])) / dayMs;
|
|
66
|
+
if (gap > 0 && gap < smallestGap) smallestGap = gap;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (smallestGap >= 28) return "month";
|
|
70
|
+
if (smallestGap >= 7) return "week";
|
|
71
|
+
return "day";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const bucketFormatter = (granularity: "day" | "week" | "month"): Intl.DateTimeFormat =>
|
|
75
|
+
granularity === "month"
|
|
76
|
+
? new Intl.DateTimeFormat("it-IT", { month: "short", year: "numeric", timeZone: "UTC" })
|
|
77
|
+
: new Intl.DateTimeFormat("it-IT", { day: "numeric", month: "short", timeZone: "UTC" });
|
|
78
|
+
|
|
79
|
+
const compactNumber = new Intl.NumberFormat("it-IT", { notation: "compact", maximumFractionDigits: 1 });
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Usage over time as a stacked bar chart.
|
|
83
|
+
*
|
|
84
|
+
* Colour does an IDENTITY job here — each stacked segment is a series, not a
|
|
85
|
+
* magnitude — so it draws the fixed categorical order from `../lib/palette` and
|
|
86
|
+
* never generates or cycles a hue: series past the ceiling are summed into a
|
|
87
|
+
* single "other" segment painted in the palette's neutral.
|
|
88
|
+
*
|
|
89
|
+
* The palette's light-mode validator run carries a sub-3:1 contrast WARN on
|
|
90
|
+
* three slots, which obliges a relief channel. That is why the legend and the
|
|
91
|
+
* value-carrying tooltip below are not optional decoration: they are what makes
|
|
92
|
+
* the low-contrast fills readable.
|
|
93
|
+
*/
|
|
94
|
+
export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: TokenUsageTimelineChartProps) {
|
|
95
|
+
const t = useTranslations();
|
|
96
|
+
const { resolvedTheme } = useTheme();
|
|
97
|
+
const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light";
|
|
98
|
+
|
|
99
|
+
const { chartData, seriesKeys } = useMemo(() => {
|
|
100
|
+
if (rows.length === 0) return { chartData: [] as TimelineBucket[], seriesKeys: [] as string[] };
|
|
101
|
+
|
|
102
|
+
// Totals per raw series decide who keeps an identity colour: the biggest
|
|
103
|
+
// seven do, everything else is summed into "other". Ranking by total (not by
|
|
104
|
+
// first appearance) keeps the assignment stable while a filter changes which
|
|
105
|
+
// series are present.
|
|
106
|
+
const totals = new Map<string, number>();
|
|
107
|
+
for (const row of rows) totals.set(row.series, (totals.get(row.series) ?? 0) + metricValue(row, metric));
|
|
108
|
+
|
|
109
|
+
const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([series]) => series);
|
|
110
|
+
const named = ranked.slice(0, CATEGORICAL_CEILING);
|
|
111
|
+
const folded = new Set(ranked.slice(CATEGORICAL_CEILING));
|
|
112
|
+
|
|
113
|
+
const keys = folded.size > 0 ? [...named, OTHER_SERIES] : named;
|
|
114
|
+
|
|
115
|
+
const byBucket = new Map<string, TimelineBucket>();
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
const key = bucketKey(row.bucket);
|
|
118
|
+
let entry = byBucket.get(key);
|
|
119
|
+
if (!entry) {
|
|
120
|
+
entry = { bucket: key };
|
|
121
|
+
for (const series of keys) entry[series] = 0;
|
|
122
|
+
byBucket.set(key, entry);
|
|
123
|
+
}
|
|
124
|
+
const series = folded.has(row.series) ? OTHER_SERIES : row.series;
|
|
125
|
+
entry[series] = (entry[series] as number) + metricValue(row, metric);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
chartData: [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)),
|
|
130
|
+
seriesKeys: keys,
|
|
131
|
+
};
|
|
132
|
+
}, [rows, metric]);
|
|
133
|
+
|
|
134
|
+
const seriesLabel = (series: string): string => {
|
|
135
|
+
if (series === OTHER_SERIES) {
|
|
136
|
+
const key = "token_usage.series.other";
|
|
137
|
+
return t.has(key) ? t(key) : "Altro";
|
|
138
|
+
}
|
|
139
|
+
// Stacking by company makes the series a company NAME, which is data, not
|
|
140
|
+
// vocabulary — it is never looked up. Scope and type keys are vocabulary the
|
|
141
|
+
// consuming app supplies, with the raw key as the fallback.
|
|
142
|
+
if (stackBy === "company") return series;
|
|
143
|
+
return operationLabel(
|
|
144
|
+
series,
|
|
145
|
+
(key) => t(key),
|
|
146
|
+
(key) => t.has(key),
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const chartConfig = useMemo(
|
|
151
|
+
() => Object.fromEntries(seriesKeys.map((series) => [series, { label: seriesLabel(series) }])) as ChartConfig,
|
|
152
|
+
[seriesKeys, stackBy],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (chartData.length === 0) {
|
|
156
|
+
const key = "token_usage.timeline.empty";
|
|
157
|
+
return (
|
|
158
|
+
<p className={className ? `text-muted-foreground text-sm ${className}` : "text-muted-foreground text-sm"}>
|
|
159
|
+
{t.has(key) ? t(key) : "Nessun dato nel periodo selezionato"}
|
|
160
|
+
</p>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const granularity = inferGranularity(chartData.map((entry) => entry.bucket));
|
|
165
|
+
const formatBucket = bucketFormatter(granularity);
|
|
166
|
+
const seriesColorFor = (series: string, index: number) =>
|
|
167
|
+
series === OTHER_SERIES ? OTHER_COLOR : seriesColor(index, mode);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className={className}>
|
|
171
|
+
{/* The pivot, exposed for assertions and for screen readers that would
|
|
172
|
+
otherwise get nothing from the SVG. */}
|
|
173
|
+
<span className="sr-only" data-testid="timeline-data">
|
|
174
|
+
{JSON.stringify(chartData)}
|
|
175
|
+
</span>
|
|
176
|
+
<span className="sr-only" data-testid="timeline-series">
|
|
177
|
+
{JSON.stringify(seriesKeys)}
|
|
178
|
+
</span>
|
|
179
|
+
|
|
180
|
+
<ChartContainer config={chartConfig} className="aspect-auto h-72 w-full">
|
|
181
|
+
<BarChart accessibilityLayer data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
|
182
|
+
<CartesianGrid vertical={false} />
|
|
183
|
+
<XAxis
|
|
184
|
+
dataKey="bucket"
|
|
185
|
+
tickLine={false}
|
|
186
|
+
axisLine={false}
|
|
187
|
+
tickMargin={8}
|
|
188
|
+
minTickGap={16}
|
|
189
|
+
tickFormatter={(value: string) => formatBucket.format(new Date(`${value}T00:00:00.000Z`))}
|
|
190
|
+
/>
|
|
191
|
+
<YAxis
|
|
192
|
+
tickLine={false}
|
|
193
|
+
axisLine={false}
|
|
194
|
+
tickMargin={8}
|
|
195
|
+
width={48}
|
|
196
|
+
tickFormatter={(value: number) => compactNumber.format(value)}
|
|
197
|
+
/>
|
|
198
|
+
<ChartTooltip
|
|
199
|
+
content={
|
|
200
|
+
<ChartTooltipContent
|
|
201
|
+
labelFormatter={(value) => formatBucket.format(new Date(`${String(value)}T00:00:00.000Z`))}
|
|
202
|
+
/>
|
|
203
|
+
}
|
|
204
|
+
/>
|
|
205
|
+
{seriesKeys.length >= 2 ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
206
|
+
{seriesKeys.map((series, index) => (
|
|
207
|
+
<Bar
|
|
208
|
+
key={series}
|
|
209
|
+
dataKey={series}
|
|
210
|
+
stackId="usage"
|
|
211
|
+
maxBarSize={28}
|
|
212
|
+
fill={seriesColorFor(series, index)}
|
|
213
|
+
// A 2px stroke in the surface colour is the gap between stacked
|
|
214
|
+
// segments and between neighbouring bars — the palette's own
|
|
215
|
+
// secondary-encoding channel, not a border.
|
|
216
|
+
stroke="var(--background)"
|
|
217
|
+
strokeWidth={2}
|
|
218
|
+
radius={index === seriesKeys.length - 1 ? [4, 4, 0, 0] : 0}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</BarChart>
|
|
222
|
+
</ChartContainer>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TokenUsageAdminTiles } from "../TokenUsageAdminTiles";
|
|
4
|
+
|
|
5
|
+
const row = (scope: string, window: string, cost: number, calls = 1) =>
|
|
6
|
+
({ id: `${scope}|${window}`, scope, window, cost, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls }) as any;
|
|
7
|
+
|
|
8
|
+
const summary = [
|
|
9
|
+
row("customer", "current", 9.46, 2956),
|
|
10
|
+
row("platform", "current", 9.47, 7681),
|
|
11
|
+
row("total", "current", 18.93, 10637),
|
|
12
|
+
row("customer", "previous", 8.0, 2000),
|
|
13
|
+
row("platform", "previous", 10.0, 8000),
|
|
14
|
+
row("total", "previous", 18.0, 10000),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
describe("TokenUsageAdminTiles", () => {
|
|
18
|
+
it("shows both cost centres with their deltas against the previous window", () => {
|
|
19
|
+
render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode={false} />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByTestId("tile-customer")).toHaveTextContent("9,46");
|
|
22
|
+
expect(screen.getByTestId("tile-platform")).toHaveTextContent("9,47");
|
|
23
|
+
// customer 9.46 vs 8.00 => +18%
|
|
24
|
+
expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("18");
|
|
25
|
+
// platform 9.47 vs 10.00 => -5%
|
|
26
|
+
expect(screen.getByTestId("tile-platform-delta")).toHaveTextContent("5");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("hides the platform tile in single-customer mode", () => {
|
|
30
|
+
render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode />);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByTestId("tile-customer")).toBeInTheDocument();
|
|
33
|
+
expect(screen.queryByTestId("tile-platform")).not.toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("suppresses the delta rather than showing Infinity when the previous window is zero", () => {
|
|
37
|
+
const zeroPrev = summary.map((r) => (r.window === "previous" ? { ...r, cost: 0 } : r));
|
|
38
|
+
render(<TokenUsageAdminTiles summary={zeroPrev as any} metric="cost" singleCustomerMode={false} />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("—");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TokenUsageRankedBar } from "../TokenUsageRankedBar";
|
|
4
|
+
|
|
5
|
+
const row = (id: string, label: string, cost: number) =>
|
|
6
|
+
({ id, label, cost, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls: 1 }) as any;
|
|
7
|
+
|
|
8
|
+
describe("TokenUsageRankedBar", () => {
|
|
9
|
+
it("direct-labels each row with its value and its share of the total", () => {
|
|
10
|
+
render(
|
|
11
|
+
<TokenUsageRankedBar
|
|
12
|
+
rows={[row("a", "Studio Rossi", 6), row("b", "Studio Bianchi", 4)]}
|
|
13
|
+
metric="cost"
|
|
14
|
+
emptyLabel="nessun dato"
|
|
15
|
+
/>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByTestId("ranked-value-a")).toHaveTextContent("6,00");
|
|
19
|
+
expect(screen.getByTestId("ranked-share-a")).toHaveTextContent("60");
|
|
20
|
+
expect(screen.getByTestId("ranked-share-b")).toHaveTextContent("40");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders the empty label when there are no rows", () => {
|
|
24
|
+
render(<TokenUsageRankedBar rows={[]} metric="cost" emptyLabel="nessun dato" />);
|
|
25
|
+
expect(screen.getByText("nessun dato")).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("assigns colour by rank position from one ramp, so no two rows share an identity hue by accident", () => {
|
|
29
|
+
render(
|
|
30
|
+
<TokenUsageRankedBar
|
|
31
|
+
rows={[row("a", "A", 3), row("b", "B", 2), row("c", "C", 1)]}
|
|
32
|
+
metric="cost"
|
|
33
|
+
emptyLabel="nessun dato"
|
|
34
|
+
/>,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const colours = ["a", "b", "c"].map((id) => screen.getByTestId(`ranked-fill-${id}`).getAttribute("data-ramp-step"));
|
|
38
|
+
expect(new Set(colours).size).toBe(3);
|
|
39
|
+
expect(colours).toEqual(["0", "1", "2"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TokenUsageTimelineChart } from "../TokenUsageTimelineChart";
|
|
4
|
+
|
|
5
|
+
const row = (bucket: string, series: string, cost: number) =>
|
|
6
|
+
({
|
|
7
|
+
id: `${bucket}|${series}`,
|
|
8
|
+
bucket: new Date(bucket),
|
|
9
|
+
series,
|
|
10
|
+
cost,
|
|
11
|
+
credits: 0,
|
|
12
|
+
tokensIn: 0,
|
|
13
|
+
tokensOut: 0,
|
|
14
|
+
cached: 0,
|
|
15
|
+
calls: 1,
|
|
16
|
+
}) as any;
|
|
17
|
+
|
|
18
|
+
describe("TokenUsageTimelineChart", () => {
|
|
19
|
+
it("pivots flat rows into one entry per bucket with a key per series", () => {
|
|
20
|
+
render(
|
|
21
|
+
<TokenUsageTimelineChart
|
|
22
|
+
rows={[row("2026-08-01", "customer", 1), row("2026-08-01", "platform", 2), row("2026-08-02", "customer", 3)]}
|
|
23
|
+
metric="cost"
|
|
24
|
+
stackBy="scope"
|
|
25
|
+
/>,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const data = JSON.parse(screen.getByTestId("timeline-data").textContent!);
|
|
29
|
+
expect(data).toHaveLength(2);
|
|
30
|
+
expect(data[0]).toMatchObject({ customer: 1, platform: 2 });
|
|
31
|
+
expect(data[1]).toMatchObject({ customer: 3 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("folds series beyond the 7-colour ceiling into a single 'other' series", () => {
|
|
35
|
+
const rows = Array.from({ length: 10 }, (_, i) => row("2026-08-01", `type${i}`, 10 - i));
|
|
36
|
+
render(<TokenUsageTimelineChart rows={rows} metric="cost" stackBy="type" />);
|
|
37
|
+
|
|
38
|
+
const series = JSON.parse(screen.getByTestId("timeline-series").textContent!);
|
|
39
|
+
expect(series).toHaveLength(8);
|
|
40
|
+
expect(series[7]).toBe("other");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders the empty state without a chart when there are no rows", () => {
|
|
44
|
+
render(<TokenUsageTimelineChart rows={[]} metric="cost" stackBy="scope" />);
|
|
45
|
+
expect(screen.queryByTestId("timeline-data")).not.toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
});
|