@oh-my-pi/omp-stats 18.3.5 → 18.4.1
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/CHANGELOG.md +22 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +143 -87
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { getProviderDashboardStats } from "../api";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
buildSharedPlugins,
|
|
7
|
-
buildSharedScales,
|
|
8
|
-
buildTopNByModelSeries,
|
|
9
|
-
CHART_THEMES,
|
|
10
|
-
MODEL_COLORS,
|
|
11
|
-
styleDatasets,
|
|
12
|
-
} from "../components/chart-shared";
|
|
1
|
+
import { format } from "@oh-my-pi/pi-utils/dates";
|
|
2
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
3
|
+
import { getProviderDashboardStats, getProviderWindowStats } from "../api";
|
|
4
|
+
import { Chart, type ChartSeries, Legend, ShareBar, TimeChart, useHiddenSeries } from "../charts";
|
|
5
|
+
import { buildColorLookup, OTHER_COLOR, SERIES_COLORS } from "../data/colors";
|
|
13
6
|
import {
|
|
14
7
|
formatCompact,
|
|
15
8
|
formatCost,
|
|
@@ -19,206 +12,443 @@ import {
|
|
|
19
12
|
formatRelativeTime,
|
|
20
13
|
formatTokensPerSecond,
|
|
21
14
|
} from "../data/formatters";
|
|
22
|
-
import {
|
|
15
|
+
import { type QueryResult, useQuery } from "../data/query";
|
|
16
|
+
import { bucketAxis, formatBucket, formatTick, rangeMeta } from "../data/range";
|
|
17
|
+
import { densify, pivotSeries } from "../data/series";
|
|
23
18
|
import type {
|
|
24
19
|
ProviderAggregate,
|
|
25
|
-
ProviderDashboardStats,
|
|
26
20
|
ProviderHourlyPoint,
|
|
27
21
|
ProviderWindowInsight,
|
|
22
|
+
ProviderWindowStats,
|
|
28
23
|
TimeRange,
|
|
29
24
|
UsageWindowSeries,
|
|
30
25
|
} from "../types";
|
|
31
|
-
import {
|
|
32
|
-
|
|
26
|
+
import {
|
|
27
|
+
Badge,
|
|
28
|
+
Card,
|
|
29
|
+
ChartSkeleton,
|
|
30
|
+
type Column,
|
|
31
|
+
EmptyState,
|
|
32
|
+
errorRateTone,
|
|
33
|
+
LabelCell,
|
|
34
|
+
MeterCell,
|
|
35
|
+
PageHeader,
|
|
36
|
+
QueryView,
|
|
37
|
+
Segmented,
|
|
38
|
+
Stat,
|
|
39
|
+
StatGrid,
|
|
40
|
+
Swatch,
|
|
41
|
+
Table,
|
|
42
|
+
TableSkeleton,
|
|
43
|
+
} from "../ui";
|
|
44
|
+
import "./providers.css";
|
|
33
45
|
|
|
34
46
|
export interface ProvidersRouteProps {
|
|
35
47
|
active: boolean;
|
|
36
48
|
range: TimeRange;
|
|
37
|
-
refreshTrigger: number;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
|
|
41
|
-
const {
|
|
42
|
-
data: stats,
|
|
43
|
-
error,
|
|
44
|
-
loading,
|
|
45
|
-
} = useResource(["providers", range, refreshTrigger], signal => getProviderDashboardStats(range, signal), {
|
|
46
|
-
pollMs: 30000,
|
|
47
|
-
enabled: active,
|
|
48
|
-
});
|
|
51
|
+
type BurnMetric = "tokens" | "cost" | "requests";
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
const BURN_OPTIONS = [
|
|
54
|
+
{ value: "tokens" as const, label: "Tokens" },
|
|
55
|
+
{ value: "cost" as const, label: "Cost", title: "API-equivalent estimate" },
|
|
56
|
+
{ value: "requests" as const, label: "Requests" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Providers stacked individually in the burn chart; the rest fold into "Other". */
|
|
60
|
+
const TOP_PROVIDERS = 6;
|
|
61
|
+
|
|
62
|
+
const TOKEN_MIX = [
|
|
63
|
+
{ key: "input", label: "Uncached input", color: "#5b8cff" },
|
|
64
|
+
{ key: "cacheRead", label: "Cache read", color: "var(--chart-primary)" },
|
|
65
|
+
{ key: "cacheWrite", label: "Cache write", color: "#f5b54a" },
|
|
66
|
+
{ key: "output", label: "Output", color: "var(--chart-secondary)" },
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
const SNAPSHOT_HINT = "Usage snapshots accumulate whenever usage limits are fetched (TUI footer, /usage, omp usage).";
|
|
70
|
+
|
|
71
|
+
interface WindowRef {
|
|
72
|
+
provider: string;
|
|
73
|
+
windowKey: string;
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
export function ProvidersRoute({ active, range }: ProvidersRouteProps) {
|
|
77
|
+
const stats = useQuery(["providers", range], () => getProviderDashboardStats(range), { enabled: active });
|
|
78
|
+
const [metric, setMetric] = useState<BurnMetric>("tokens");
|
|
79
|
+
const [hidden, toggleHidden] = useHiddenSeries();
|
|
80
|
+
const meta = rangeMeta(range);
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
// Subscription windows load independently: insights for every provider
|
|
83
|
+
// first, then utilization series for the one provider being charted.
|
|
84
|
+
const windows = useQuery(["provider-windows", range], () => getProviderWindowStats(range, null), {
|
|
85
|
+
enabled: active,
|
|
86
|
+
});
|
|
87
|
+
const [picked, setPicked] = useState<WindowRef | null>(null);
|
|
88
|
+
const selected = resolveWindow(windows.data?.windowInsights ?? [], picked);
|
|
89
|
+
const selectedProvider = selected?.provider ?? null;
|
|
90
|
+
const accounts = useQuery(
|
|
91
|
+
["provider-windows", range, selectedProvider],
|
|
92
|
+
() => getProviderWindowStats(range, selectedProvider),
|
|
93
|
+
{ enabled: active && selectedProvider !== null },
|
|
76
94
|
);
|
|
77
95
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
const view = useMemo(() => {
|
|
97
|
+
const providers = stats.data?.providers ?? [];
|
|
98
|
+
const points = stats.data?.series ?? [];
|
|
99
|
+
const colors = buildColorLookup(providers.map(p => ({ key: p.provider, weight: p.totalTokens })));
|
|
100
|
+
const buckets = bucketAxis(
|
|
101
|
+
range,
|
|
102
|
+
points.map(p => p.timestamp),
|
|
103
|
+
);
|
|
104
|
+
const totals = { requests: 0, failed: 0, tokens: 0, cost: 0, unpriced: 0 };
|
|
105
|
+
for (const p of providers) {
|
|
106
|
+
totals.requests += p.totalRequests;
|
|
107
|
+
totals.failed += p.failedRequests;
|
|
108
|
+
totals.tokens += p.totalTokens;
|
|
109
|
+
totals.cost += p.totalCost;
|
|
110
|
+
totals.unpriced += p.unpricedRequests;
|
|
111
|
+
}
|
|
112
|
+
// Zero slots become gaps so the tooltip lists only providers active in that bucket.
|
|
113
|
+
const pivot = (value: (p: (typeof points)[number]) => number) =>
|
|
114
|
+
pivotSeries(points, { buckets, key: p => p.provider, value, limit: TOP_PROVIDERS, colors }).map(s => ({
|
|
115
|
+
...s,
|
|
116
|
+
values: s.values.map(v => v || null),
|
|
117
|
+
}));
|
|
118
|
+
return {
|
|
119
|
+
colors,
|
|
120
|
+
buckets,
|
|
121
|
+
totals,
|
|
122
|
+
burn: {
|
|
123
|
+
tokens: pivot(p => p.totalTokens),
|
|
124
|
+
cost: pivot(p => p.cost),
|
|
125
|
+
requests: pivot(p => p.requests),
|
|
126
|
+
} satisfies Record<BurnMetric, ChartSeries[]>,
|
|
127
|
+
spark: {
|
|
128
|
+
tokens: densify(points, buckets, p => p.totalTokens),
|
|
129
|
+
cost: densify(points, buckets, p => p.cost),
|
|
130
|
+
requests: densify(points, buckets, p => p.requests),
|
|
131
|
+
},
|
|
132
|
+
topProvider: providers.reduce<ProviderAggregate | null>(
|
|
133
|
+
(top, p) => (top === null || p.totalTokens > top.totalTokens ? p : top),
|
|
134
|
+
null,
|
|
98
135
|
),
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
key: "cost",
|
|
108
|
-
header: "API-equivalent estimate",
|
|
109
|
-
numeric: true,
|
|
110
|
-
render: provider => formatEstimatedCost(provider.totalCost, provider.unpricedRequests),
|
|
111
|
-
},
|
|
112
|
-
{ key: "tps", header: "Tok/s", numeric: true, render: p => formatTokensPerSecond(p.avgTokensPerSecond) },
|
|
113
|
-
];
|
|
136
|
+
};
|
|
137
|
+
}, [stats.data, range]);
|
|
138
|
+
|
|
139
|
+
const burnSeries = view.burn[metric];
|
|
140
|
+
const burnFormat = metric === "cost" ? (v: number) => formatCost(v) : formatCompact;
|
|
141
|
+
const t = view.totals;
|
|
114
142
|
|
|
115
143
|
return (
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
? `Token, request, and API-equivalent estimates; excludes ${unpricedRequests.toLocaleString()} unpriced subscription request${unpricedRequests === 1 ? "" : "s"}`
|
|
121
|
-
: "Token, request, and API-equivalent estimates over the active range"
|
|
122
|
-
}
|
|
123
|
-
>
|
|
124
|
-
<DataTable
|
|
125
|
-
columns={columns}
|
|
126
|
-
data={providers}
|
|
127
|
-
keyExtractor={p => p.provider}
|
|
128
|
-
emptyText="No requests recorded in this range"
|
|
144
|
+
<div className="page">
|
|
145
|
+
<PageHeader
|
|
146
|
+
title="Providers"
|
|
147
|
+
description={`Burn, reliability and subscription headroom per provider over ${meta.windowLabel}.`}
|
|
129
148
|
/>
|
|
130
|
-
|
|
149
|
+
|
|
150
|
+
<QueryView query={stats} skeleton={<ChartSkeleton height={112} />}>
|
|
151
|
+
{({ providers }) => (
|
|
152
|
+
<div data-stale={stats.stale}>
|
|
153
|
+
<StatGrid min={190}>
|
|
154
|
+
<Stat
|
|
155
|
+
label="Providers"
|
|
156
|
+
value={formatInteger(providers.length)}
|
|
157
|
+
hint={view.topProvider ? `Most tokens: ${view.topProvider.provider}` : undefined}
|
|
158
|
+
/>
|
|
159
|
+
<Stat
|
|
160
|
+
label="Requests"
|
|
161
|
+
value={formatInteger(t.requests)}
|
|
162
|
+
hint={`${formatInteger(t.failed)} failed`}
|
|
163
|
+
spark={view.spark.requests}
|
|
164
|
+
/>
|
|
165
|
+
<Stat
|
|
166
|
+
label="Tokens"
|
|
167
|
+
title="Uncached input + cache reads + cache writes + output"
|
|
168
|
+
value={formatCompact(t.tokens)}
|
|
169
|
+
spark={view.spark.tokens}
|
|
170
|
+
/>
|
|
171
|
+
<Stat
|
|
172
|
+
label="API-equivalent cost"
|
|
173
|
+
title="What this usage would cost at public API rates"
|
|
174
|
+
value={formatEstimatedCost(t.cost, t.unpriced)}
|
|
175
|
+
hint={t.unpriced > 0 ? `${formatInteger(t.unpriced)} unpriced` : undefined}
|
|
176
|
+
spark={view.spark.cost}
|
|
177
|
+
sparkColor="var(--chart-secondary)"
|
|
178
|
+
/>
|
|
179
|
+
<Stat
|
|
180
|
+
label="Error rate"
|
|
181
|
+
value={formatPercent(t.requests > 0 ? t.failed / t.requests : 0)}
|
|
182
|
+
hint={`${formatInteger(t.requests - t.failed)} succeeded`}
|
|
183
|
+
/>
|
|
184
|
+
</StatGrid>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</QueryView>
|
|
188
|
+
|
|
189
|
+
<Card
|
|
190
|
+
index={1}
|
|
191
|
+
title="Provider totals"
|
|
192
|
+
description={
|
|
193
|
+
t.unpriced > 0
|
|
194
|
+
? `Cost is an API-equivalent estimate and excludes ${formatInteger(t.unpriced)} unpriced subscription request${t.unpriced === 1 ? "" : "s"}. Select a row for its token mix.`
|
|
195
|
+
: "Cost is an API-equivalent estimate. Select a row for its token mix."
|
|
196
|
+
}
|
|
197
|
+
flush
|
|
198
|
+
stale={stats.stale}
|
|
199
|
+
>
|
|
200
|
+
<QueryView query={stats} skeleton={<TableSkeleton rows={6} />}>
|
|
201
|
+
{({ providers }) => <ProviderTotalsTable providers={providers} colors={view.colors} />}
|
|
202
|
+
</QueryView>
|
|
203
|
+
</Card>
|
|
204
|
+
|
|
205
|
+
<div className="grid grid-main-side">
|
|
206
|
+
<Card
|
|
207
|
+
index={2}
|
|
208
|
+
title="Burn by provider"
|
|
209
|
+
description={
|
|
210
|
+
metric === "cost" && t.unpriced > 0
|
|
211
|
+
? `API-equivalent estimate per ${meta.bucketMs < 86_400_000 ? "hour" : "day"}; excludes unpriced subscription requests`
|
|
212
|
+
: `Per ${meta.bucketMs < 3_600_000 ? "5 minutes" : meta.bucketMs < 86_400_000 ? "hour" : "day"}, top ${TOP_PROVIDERS} providers stacked`
|
|
213
|
+
}
|
|
214
|
+
actions={<Segmented size="sm" options={BURN_OPTIONS} value={metric} onChange={setMetric} />}
|
|
215
|
+
stale={stats.stale}
|
|
216
|
+
>
|
|
217
|
+
<QueryView query={stats} skeleton={<ChartSkeleton height={260} />}>
|
|
218
|
+
{() => (
|
|
219
|
+
<div className="stack" style={{ gap: 12 }}>
|
|
220
|
+
<TimeChart
|
|
221
|
+
buckets={view.buckets}
|
|
222
|
+
bucketMs={meta.bucketMs}
|
|
223
|
+
series={burnSeries}
|
|
224
|
+
hidden={hidden}
|
|
225
|
+
height={260}
|
|
226
|
+
format={burnFormat}
|
|
227
|
+
formatTooltip={metric === "cost" ? v => formatCost(v) : formatInteger}
|
|
228
|
+
emptyLabel="No provider activity in this range"
|
|
229
|
+
/>
|
|
230
|
+
<Legend
|
|
231
|
+
items={burnSeries.map(s => ({
|
|
232
|
+
key: s.key,
|
|
233
|
+
label: s.label,
|
|
234
|
+
color: s.color,
|
|
235
|
+
value: burnFormat(s.values.reduce<number>((sum, v) => sum + (v ?? 0), 0)),
|
|
236
|
+
}))}
|
|
237
|
+
hidden={hidden}
|
|
238
|
+
onToggle={toggleHidden}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
</QueryView>
|
|
243
|
+
</Card>
|
|
244
|
+
|
|
245
|
+
<QueryView query={stats} skeleton={<ChartSkeleton height={320} />}>
|
|
246
|
+
{({ hourly, providers }) => <PeakHoursCard hourly={hourly} providers={providers} stale={stats.stale} />}
|
|
247
|
+
</QueryView>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<Card
|
|
251
|
+
index={4}
|
|
252
|
+
title="Subscription windows"
|
|
253
|
+
description="What one usage window buys you and how many accounts peak demand needs. Select a row to chart it."
|
|
254
|
+
flush
|
|
255
|
+
stale={windows.stale}
|
|
256
|
+
>
|
|
257
|
+
<QueryView query={windows} skeleton={<TableSkeleton rows={6} />}>
|
|
258
|
+
{data => <WindowInsightsTable insights={data.windowInsights} selected={selected} onSelect={setPicked} />}
|
|
259
|
+
</QueryView>
|
|
260
|
+
</Card>
|
|
261
|
+
|
|
262
|
+
<WindowUtilizationCard
|
|
263
|
+
insights={windows.data?.windowInsights ?? null}
|
|
264
|
+
accounts={accounts}
|
|
265
|
+
selected={selected}
|
|
266
|
+
onSelect={setPicked}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
131
269
|
);
|
|
132
270
|
}
|
|
133
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The window charted below the insights table: the user's pick when it still
|
|
274
|
+
* exists, else the first window of the picked provider, else the most-burned
|
|
275
|
+
* window (the insights table's first row).
|
|
276
|
+
*/
|
|
277
|
+
function resolveWindow(insights: readonly ProviderWindowInsight[], picked: WindowRef | null): WindowRef | null {
|
|
278
|
+
if (picked && insights.some(i => i.provider === picked.provider && i.windowKey === picked.windowKey)) return picked;
|
|
279
|
+
const fallback =
|
|
280
|
+
(picked && insights.find(i => i.provider === picked.provider)) ||
|
|
281
|
+
insights.reduce<ProviderWindowInsight | undefined>(
|
|
282
|
+
(top, i) => (top === undefined || i.fractionConsumed > top.fractionConsumed ? i : top),
|
|
283
|
+
undefined,
|
|
284
|
+
);
|
|
285
|
+
return fallback ? { provider: fallback.provider, windowKey: fallback.windowKey } : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
134
288
|
// ---------------------------------------------------------------------------
|
|
135
|
-
//
|
|
289
|
+
// Provider totals
|
|
136
290
|
// ---------------------------------------------------------------------------
|
|
137
291
|
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
292
|
+
function ProviderTotalsTable({
|
|
293
|
+
providers,
|
|
294
|
+
colors,
|
|
295
|
+
}: {
|
|
296
|
+
providers: ProviderAggregate[];
|
|
297
|
+
colors: ReadonlyMap<string, string>;
|
|
298
|
+
}) {
|
|
299
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
300
|
+
const columns = useMemo<Column<ProviderAggregate>[]>(() => {
|
|
301
|
+
let maxRequests = 0;
|
|
302
|
+
let maxTokens = 0;
|
|
303
|
+
let grandTokens = 0;
|
|
304
|
+
for (const p of providers) {
|
|
305
|
+
maxRequests = Math.max(maxRequests, p.totalRequests);
|
|
306
|
+
maxTokens = Math.max(maxTokens, p.totalTokens);
|
|
307
|
+
grandTokens += p.totalTokens;
|
|
308
|
+
}
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
key: "provider",
|
|
312
|
+
header: "Provider",
|
|
313
|
+
sort: p => p.provider,
|
|
314
|
+
render: p => (
|
|
315
|
+
<span className="row" style={{ gap: 8 }}>
|
|
316
|
+
<Swatch color={colors.get(p.provider) ?? OTHER_COLOR} />
|
|
317
|
+
<span className="mono cell-primary">{p.provider}</span>
|
|
318
|
+
</span>
|
|
319
|
+
),
|
|
157
320
|
},
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
321
|
+
{
|
|
322
|
+
key: "requests",
|
|
323
|
+
header: "Requests",
|
|
324
|
+
align: "right",
|
|
325
|
+
sort: p => p.totalRequests,
|
|
326
|
+
render: p => (
|
|
327
|
+
<MeterCell value={p.totalRequests} max={maxRequests} display={formatInteger(p.totalRequests)} />
|
|
328
|
+
),
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
key: "errors",
|
|
332
|
+
header: "Error rate",
|
|
333
|
+
align: "right",
|
|
334
|
+
sort: p => (p.totalRequests > 0 ? p.failedRequests / p.totalRequests : 0),
|
|
335
|
+
render: p => {
|
|
336
|
+
const rate = p.totalRequests > 0 ? p.failedRequests / p.totalRequests : 0;
|
|
337
|
+
return (
|
|
338
|
+
<span className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
|
|
339
|
+
<span className="num dim">{formatInteger(p.failedRequests)}</span>
|
|
340
|
+
<Badge tone={p.failedRequests > 0 ? errorRateTone(rate) : "neutral"} mono>
|
|
341
|
+
{formatPercent(rate)}
|
|
342
|
+
</Badge>
|
|
343
|
+
</span>
|
|
344
|
+
);
|
|
178
345
|
},
|
|
179
|
-
}),
|
|
180
|
-
scales: {
|
|
181
|
-
x: { ...sharedScaleBase, stacked: true },
|
|
182
|
-
y: { ...yScale, stacked: true },
|
|
183
346
|
},
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
347
|
+
{
|
|
348
|
+
key: "models",
|
|
349
|
+
header: "Models",
|
|
350
|
+
title: "Distinct models used through this provider",
|
|
351
|
+
align: "right",
|
|
352
|
+
sort: p => p.models,
|
|
353
|
+
render: p => <span className="num">{formatInteger(p.models)}</span>,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
key: "tokens",
|
|
357
|
+
header: "Tokens",
|
|
358
|
+
title: "Uncached input + cache reads + cache writes + output",
|
|
359
|
+
align: "right",
|
|
360
|
+
sort: p => p.totalTokens,
|
|
361
|
+
render: p => (
|
|
362
|
+
<MeterCell
|
|
363
|
+
value={p.totalTokens}
|
|
364
|
+
max={maxTokens}
|
|
365
|
+
display={formatCompact(p.totalTokens)}
|
|
366
|
+
color={colors.get(p.provider)}
|
|
367
|
+
/>
|
|
368
|
+
),
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
key: "share",
|
|
372
|
+
header: "Share",
|
|
373
|
+
title: "Fraction of all tokens in range",
|
|
374
|
+
align: "right",
|
|
375
|
+
sort: p => p.totalTokens,
|
|
376
|
+
render: p => (
|
|
377
|
+
<span className="num muted">{formatPercent(grandTokens > 0 ? p.totalTokens / grandTokens : 0)}</span>
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
key: "cost",
|
|
382
|
+
header: "Cost",
|
|
383
|
+
title: "API-equivalent estimate",
|
|
384
|
+
align: "right",
|
|
385
|
+
sort: p => p.totalCost,
|
|
386
|
+
render: p => (
|
|
387
|
+
<span
|
|
388
|
+
className="num"
|
|
389
|
+
title={p.unpricedRequests > 0 ? `${formatInteger(p.unpricedRequests)} unpriced` : undefined}
|
|
390
|
+
>
|
|
391
|
+
{formatEstimatedCost(p.totalCost, p.unpricedRequests)}
|
|
392
|
+
</span>
|
|
393
|
+
),
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
key: "tps",
|
|
397
|
+
header: "Tokens/s",
|
|
398
|
+
title: "Average output throughput",
|
|
399
|
+
align: "right",
|
|
400
|
+
sort: p => p.avgTokensPerSecond ?? -1,
|
|
401
|
+
render: p => <span className="num">{formatTokensPerSecond(p.avgTokensPerSecond)}</span>,
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
key: "premium",
|
|
405
|
+
header: "Premium",
|
|
406
|
+
title: "Premium requests (subscription multiplier units)",
|
|
407
|
+
align: "right",
|
|
408
|
+
sort: p => p.totalPremiumRequests,
|
|
409
|
+
render: p => (
|
|
410
|
+
<span className={p.totalPremiumRequests > 0 ? "num" : "num dim"}>
|
|
411
|
+
{formatInteger(Math.round(p.totalPremiumRequests * 100) / 100)}
|
|
412
|
+
</span>
|
|
413
|
+
),
|
|
414
|
+
},
|
|
415
|
+
];
|
|
416
|
+
}, [providers, colors]);
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<Table
|
|
420
|
+
rows={providers}
|
|
421
|
+
rowKey={p => p.provider}
|
|
422
|
+
columns={columns}
|
|
423
|
+
initialSort={{ key: "tokens", dir: "desc" }}
|
|
424
|
+
limit={12}
|
|
425
|
+
selectedKey={open}
|
|
426
|
+
onRowClick={p => setOpen(prev => (prev === p.provider ? null : p.provider))}
|
|
427
|
+
expanded={p => (p.provider === open ? <TokenMix provider={p} /> : null)}
|
|
428
|
+
empty={<EmptyState title="No requests in this range" />}
|
|
429
|
+
/>
|
|
193
430
|
);
|
|
431
|
+
}
|
|
194
432
|
|
|
433
|
+
function TokenMix({ provider }: { provider: ProviderAggregate }) {
|
|
434
|
+
const mix = {
|
|
435
|
+
input: provider.totalInputTokens,
|
|
436
|
+
cacheRead: provider.totalCacheReadTokens,
|
|
437
|
+
cacheWrite: provider.totalCacheWriteTokens,
|
|
438
|
+
output: provider.totalOutputTokens,
|
|
439
|
+
};
|
|
195
440
|
return (
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
{ value: "cost" as const, label: "API-equivalent estimate" },
|
|
208
|
-
]}
|
|
209
|
-
value={metric}
|
|
210
|
-
onChange={setMetric}
|
|
211
|
-
/>
|
|
212
|
-
}
|
|
213
|
-
>
|
|
214
|
-
<div className="h-[300px]">
|
|
215
|
-
{chartData.labels.length === 0 ? (
|
|
216
|
-
<EmptyState message="No provider activity in this range" />
|
|
217
|
-
) : (
|
|
218
|
-
<Bar data={data} options={options} />
|
|
219
|
-
)}
|
|
220
|
-
</div>
|
|
221
|
-
</Panel>
|
|
441
|
+
<div className="providers-mix">
|
|
442
|
+
<ShareBar segments={TOKEN_MIX.map(m => ({ key: m.key, label: m.label, value: mix[m.key], color: m.color }))} />
|
|
443
|
+
<Legend
|
|
444
|
+
items={TOKEN_MIX.map(m => ({
|
|
445
|
+
key: m.key,
|
|
446
|
+
label: m.label,
|
|
447
|
+
color: m.color,
|
|
448
|
+
value: `${formatCompact(mix[m.key])} · ${provider.totalTokens > 0 ? formatPercent(mix[m.key] / provider.totalTokens, 0) : "–"}`,
|
|
449
|
+
}))}
|
|
450
|
+
/>
|
|
451
|
+
</div>
|
|
222
452
|
);
|
|
223
453
|
}
|
|
224
454
|
|
|
@@ -226,72 +456,66 @@ function ProviderTrendPanel({ stats }: { stats: ProviderDashboardStats }) {
|
|
|
226
456
|
// Peak burn hours
|
|
227
457
|
// ---------------------------------------------------------------------------
|
|
228
458
|
|
|
229
|
-
const ALL_PROVIDERS = "
|
|
459
|
+
const ALL_PROVIDERS = "";
|
|
230
460
|
|
|
231
|
-
function
|
|
461
|
+
function PeakHoursCard({
|
|
462
|
+
hourly,
|
|
463
|
+
providers,
|
|
464
|
+
stale,
|
|
465
|
+
}: {
|
|
466
|
+
hourly: ProviderHourlyPoint[];
|
|
467
|
+
providers: ProviderAggregate[];
|
|
468
|
+
stale: boolean;
|
|
469
|
+
}) {
|
|
232
470
|
const [provider, setProvider] = useState(ALL_PROVIDERS);
|
|
233
|
-
const
|
|
234
|
-
|
|
471
|
+
const current =
|
|
472
|
+
provider === ALL_PROVIDERS || providers.some(p => p.provider === provider) ? provider : ALL_PROVIDERS;
|
|
235
473
|
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
const
|
|
474
|
+
const hours = useMemo(() => {
|
|
475
|
+
const tokens = Array.from({ length: 24 }, () => 0);
|
|
476
|
+
const output = Array.from({ length: 24 }, () => 0);
|
|
477
|
+
const requests = Array.from({ length: 24 }, () => 0);
|
|
239
478
|
for (const point of hourly) {
|
|
240
|
-
if (
|
|
479
|
+
if (current !== ALL_PROVIDERS && point.provider !== current) continue;
|
|
241
480
|
tokens[point.hour] += point.totalTokens;
|
|
481
|
+
output[point.hour] += point.outputTokens;
|
|
482
|
+
requests[point.hour] += point.requests;
|
|
242
483
|
}
|
|
243
484
|
let peak = 0;
|
|
244
|
-
for (let hour = 1; hour < 24; hour++)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
},
|
|
263
|
-
],
|
|
264
|
-
}),
|
|
265
|
-
[tokensByHour, peakHour],
|
|
266
|
-
);
|
|
485
|
+
for (let hour = 1; hour < 24; hour++) if (tokens[hour] > tokens[peak]) peak = hour;
|
|
486
|
+
const hasData = tokens[peak] > 0;
|
|
487
|
+
const series: ChartSeries[] = [
|
|
488
|
+
{
|
|
489
|
+
key: "tokens",
|
|
490
|
+
label: "Tokens",
|
|
491
|
+
color: "var(--chart-primary)",
|
|
492
|
+
values: tokens.map((v, hour) => (hasData && hour === peak ? null : v)),
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
key: "peak",
|
|
496
|
+
label: "Tokens (peak hour)",
|
|
497
|
+
color: "var(--chart-secondary)",
|
|
498
|
+
values: tokens.map((v, hour) => (hasData && hour === peak ? v : null)),
|
|
499
|
+
},
|
|
500
|
+
];
|
|
501
|
+
return { series, output, requests, peak, hasData };
|
|
502
|
+
}, [hourly, current]);
|
|
267
503
|
|
|
268
|
-
const
|
|
269
|
-
const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatCompact });
|
|
270
|
-
return {
|
|
271
|
-
responsive: true,
|
|
272
|
-
maintainAspectRatio: false,
|
|
273
|
-
plugins: buildSharedPlugins({
|
|
274
|
-
chartTheme,
|
|
275
|
-
showLegend: false,
|
|
276
|
-
defaultLabel: "Tokens",
|
|
277
|
-
formatValue: formatCompact,
|
|
278
|
-
}),
|
|
279
|
-
scales: { x: sharedScaleBase, y: yScale },
|
|
280
|
-
};
|
|
281
|
-
}, [chartTheme]);
|
|
504
|
+
const hourLabel = (hour: number) => `${String(hour).padStart(2, "0")}:00`;
|
|
282
505
|
|
|
283
506
|
return (
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
507
|
+
<Card
|
|
508
|
+
index={3}
|
|
509
|
+
title="Peak burn hours"
|
|
510
|
+
description={
|
|
511
|
+
hours.hasData
|
|
512
|
+
? `Tokens by local hour of day; peak at ${hourLabel(hours.peak)}`
|
|
513
|
+
: "Tokens by local hour of day"
|
|
290
514
|
}
|
|
291
515
|
actions={
|
|
292
516
|
<select
|
|
293
|
-
className="
|
|
294
|
-
value={
|
|
517
|
+
className="input providers-select"
|
|
518
|
+
value={current}
|
|
295
519
|
onChange={e => setProvider(e.target.value)}
|
|
296
520
|
aria-label="Provider"
|
|
297
521
|
>
|
|
@@ -303,11 +527,31 @@ function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[];
|
|
|
303
527
|
))}
|
|
304
528
|
</select>
|
|
305
529
|
}
|
|
530
|
+
stale={stale}
|
|
306
531
|
>
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
532
|
+
<Chart
|
|
533
|
+
slots={24}
|
|
534
|
+
tickLabel={hour => String(hour).padStart(2, "0")}
|
|
535
|
+
tooltipTitle={hour => `${hourLabel(hour)}–${hourLabel((hour + 1) % 24)}`}
|
|
536
|
+
series={hours.series}
|
|
537
|
+
height={260}
|
|
538
|
+
showTotal={false}
|
|
539
|
+
formatTooltip={formatInteger}
|
|
540
|
+
emptyLabel="No activity in this range"
|
|
541
|
+
tooltipExtra={hour => (
|
|
542
|
+
<>
|
|
543
|
+
<div className="chart-tooltip-row">
|
|
544
|
+
<span className="chart-tooltip-label">Output</span>
|
|
545
|
+
<span className="chart-tooltip-value">{formatInteger(hours.output[hour])}</span>
|
|
546
|
+
</div>
|
|
547
|
+
<div className="chart-tooltip-row">
|
|
548
|
+
<span className="chart-tooltip-label">Requests</span>
|
|
549
|
+
<span className="chart-tooltip-value">{formatInteger(hours.requests[hour])}</span>
|
|
550
|
+
</div>
|
|
551
|
+
</>
|
|
552
|
+
)}
|
|
553
|
+
/>
|
|
554
|
+
</Card>
|
|
311
555
|
);
|
|
312
556
|
}
|
|
313
557
|
|
|
@@ -315,204 +559,492 @@ function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[];
|
|
|
315
559
|
// Subscription window insights
|
|
316
560
|
// ---------------------------------------------------------------------------
|
|
317
561
|
|
|
318
|
-
function
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
562
|
+
function WindowInsightsTable({
|
|
563
|
+
insights,
|
|
564
|
+
selected,
|
|
565
|
+
onSelect,
|
|
566
|
+
}: {
|
|
567
|
+
insights: ProviderWindowInsight[];
|
|
568
|
+
selected: WindowRef | null;
|
|
569
|
+
onSelect: (ref: WindowRef) => void;
|
|
570
|
+
}) {
|
|
571
|
+
return (
|
|
572
|
+
<Table
|
|
573
|
+
rows={insights}
|
|
574
|
+
rowKey={i => `${i.provider}::${i.windowKey}`}
|
|
575
|
+
columns={INSIGHT_COLUMNS}
|
|
576
|
+
initialSort={{ key: "consumed", dir: "desc" }}
|
|
577
|
+
limit={12}
|
|
578
|
+
selectedKey={selected ? `${selected.provider}::${selected.windowKey}` : null}
|
|
579
|
+
onRowClick={i => onSelect({ provider: i.provider, windowKey: i.windowKey })}
|
|
580
|
+
empty={<EmptyState title="No usage snapshots in this range" hint={SNAPSHOT_HINT} />}
|
|
581
|
+
/>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const INSIGHT_COLUMNS: Column<ProviderWindowInsight>[] = [
|
|
586
|
+
{
|
|
587
|
+
key: "window",
|
|
588
|
+
header: "Window",
|
|
589
|
+
sort: i => `${i.provider} ${i.windowLabel}`,
|
|
590
|
+
render: i => <LabelCell primary={i.windowLabel} secondary={<span className="mono">{i.provider}</span>} />,
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
key: "accounts",
|
|
594
|
+
header: "Accounts",
|
|
595
|
+
title: "Accounts with at least one snapshot for this window in range",
|
|
596
|
+
align: "right",
|
|
597
|
+
sort: i => i.accounts,
|
|
598
|
+
render: i => <span className="num">{formatInteger(i.accounts)}</span>,
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
key: "cycles",
|
|
602
|
+
header: "Resets",
|
|
603
|
+
title: "Window resets observed (drops in used fraction)",
|
|
604
|
+
align: "right",
|
|
605
|
+
sort: i => i.cycles,
|
|
606
|
+
render: i => <span className="num muted">{formatInteger(i.cycles)}</span>,
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
key: "consumed",
|
|
610
|
+
header: "Windows burned",
|
|
611
|
+
title: "Subscription-window equivalents consumed in range (sum of used-fraction increases across accounts)",
|
|
612
|
+
align: "right",
|
|
613
|
+
sort: i => i.fractionConsumed,
|
|
614
|
+
render: i => <span className="num">{i.fractionConsumed.toFixed(2)}</span>,
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
key: "capacity",
|
|
618
|
+
header: "Tokens / window",
|
|
619
|
+
title: "Provider tokens burned in range ÷ windows burned — what one full window is worth",
|
|
620
|
+
align: "right",
|
|
621
|
+
sort: i => i.estTokensPerWindow ?? -1,
|
|
622
|
+
render: i =>
|
|
623
|
+
i.estTokensPerWindow !== null ? (
|
|
624
|
+
<span className="num">{formatCompact(i.estTokensPerWindow)}</span>
|
|
625
|
+
) : (
|
|
626
|
+
<span className="dim" title="Too little of the window was consumed to extrapolate">
|
|
627
|
+
–
|
|
350
628
|
</span>
|
|
351
629
|
),
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
key: "peak",
|
|
633
|
+
header: "Peak utilization",
|
|
634
|
+
title: "Peak of summed used fraction across accounts at any sampled instant; the bar is relative to fleet capacity",
|
|
635
|
+
align: "right",
|
|
636
|
+
sort: i => i.peakConcurrentFraction,
|
|
637
|
+
render: i => {
|
|
638
|
+
const load = i.accounts > 0 ? i.peakConcurrentFraction / i.accounts : 0;
|
|
639
|
+
return (
|
|
640
|
+
<MeterCell
|
|
641
|
+
value={i.peakConcurrentFraction}
|
|
642
|
+
max={i.accounts}
|
|
643
|
+
display={formatPercent(i.peakConcurrentFraction, 0)}
|
|
644
|
+
color={load >= 0.9 ? "var(--warn)" : undefined}
|
|
645
|
+
/>
|
|
646
|
+
);
|
|
352
647
|
},
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
{i.idealAccounts
|
|
364
|
-
</
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
key: "ideal",
|
|
651
|
+
header: "Accounts needed",
|
|
652
|
+
title: "Accounts needed to keep peak demand under 90% of fleet capacity",
|
|
653
|
+
align: "right",
|
|
654
|
+
sort: i => i.idealAccounts - i.accounts,
|
|
655
|
+
render: i =>
|
|
656
|
+
i.idealAccounts > i.accounts ? (
|
|
657
|
+
<Badge tone="warn" mono>
|
|
658
|
+
{formatInteger(i.idealAccounts)} · have {formatInteger(i.accounts)}
|
|
659
|
+
</Badge>
|
|
660
|
+
) : (
|
|
661
|
+
<span className="num">{formatInteger(i.idealAccounts)}</span>
|
|
365
662
|
),
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
key: "exhausted",
|
|
666
|
+
header: "Exhaustions",
|
|
667
|
+
title: "Transitions into an exhausted state observed in range",
|
|
668
|
+
align: "right",
|
|
669
|
+
sort: i => i.exhaustedEvents,
|
|
670
|
+
render: i =>
|
|
671
|
+
i.exhaustedEvents > 0 ? (
|
|
672
|
+
<Badge tone="warn" mono>
|
|
673
|
+
{formatInteger(i.exhaustedEvents)} exhausted
|
|
674
|
+
</Badge>
|
|
675
|
+
) : (
|
|
676
|
+
<span className="num dim">0</span>
|
|
375
677
|
),
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
return (
|
|
380
|
-
<Panel
|
|
381
|
-
title="Subscription Windows"
|
|
382
|
-
subtitle="What each usage window buys you, and how many accounts peak demand needs"
|
|
383
|
-
>
|
|
384
|
-
<DataTable
|
|
385
|
-
columns={columns}
|
|
386
|
-
data={insights}
|
|
387
|
-
keyExtractor={i => `${i.provider}::${i.windowKey}`}
|
|
388
|
-
emptyText="No usage snapshots recorded yet — they accumulate whenever usage is fetched (TUI footer, /usage, omp usage)"
|
|
389
|
-
/>
|
|
390
|
-
</Panel>
|
|
391
|
-
);
|
|
392
|
-
}
|
|
678
|
+
},
|
|
679
|
+
];
|
|
393
680
|
|
|
394
681
|
// ---------------------------------------------------------------------------
|
|
395
682
|
// Window utilization
|
|
396
683
|
// ---------------------------------------------------------------------------
|
|
397
684
|
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
[
|
|
685
|
+
const MINUTE_MS = 60_000;
|
|
686
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
687
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
688
|
+
/** Candidate utilization bucket sizes; the finest one within {@link MAX_UTIL_SLOTS} wins. */
|
|
689
|
+
const UTIL_STEPS = [
|
|
690
|
+
5 * MINUTE_MS,
|
|
691
|
+
15 * MINUTE_MS,
|
|
692
|
+
30 * MINUTE_MS,
|
|
693
|
+
HOUR_MS,
|
|
694
|
+
2 * HOUR_MS,
|
|
695
|
+
3 * HOUR_MS,
|
|
696
|
+
6 * HOUR_MS,
|
|
697
|
+
12 * HOUR_MS,
|
|
698
|
+
DAY_MS,
|
|
699
|
+
];
|
|
700
|
+
const MAX_UTIL_SLOTS = 240;
|
|
701
|
+
/**
|
|
702
|
+
* Snapshots are recorded whenever usage is fetched, so an account's samples
|
|
703
|
+
* are irregular. A reading holds until the next one, but only this long —
|
|
704
|
+
* longer silences render as gaps rather than invented plateaus.
|
|
705
|
+
*/
|
|
706
|
+
const MAX_HOLD_MS = 6 * HOUR_MS;
|
|
707
|
+
|
|
708
|
+
interface AccountRow {
|
|
709
|
+
series: UsageWindowSeries;
|
|
710
|
+
/** Account label, numbered when another account shares it. */
|
|
711
|
+
name: string;
|
|
712
|
+
color: string | null;
|
|
713
|
+
latest: { fraction: number; exhausted: boolean; timestamp: number } | null;
|
|
714
|
+
peak: number | null;
|
|
715
|
+
samples: number;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function WindowUtilizationCard({
|
|
719
|
+
insights,
|
|
720
|
+
accounts,
|
|
721
|
+
selected,
|
|
722
|
+
onSelect,
|
|
723
|
+
}: {
|
|
724
|
+
/** Every provider's window insights; `null` while they load. */
|
|
725
|
+
insights: ProviderWindowInsight[] | null;
|
|
726
|
+
/** Window stats whose `usageSeries` covers only the selected provider. */
|
|
727
|
+
accounts: QueryResult<ProviderWindowStats>;
|
|
728
|
+
selected: WindowRef | null;
|
|
729
|
+
onSelect: (ref: WindowRef) => void;
|
|
730
|
+
}) {
|
|
731
|
+
const [hidden, toggleHidden] = useHiddenSeries();
|
|
732
|
+
|
|
733
|
+
const providers = useMemo(() => [...new Set((insights ?? []).map(i => i.provider))], [insights]);
|
|
734
|
+
const windows = useMemo(
|
|
735
|
+
() =>
|
|
736
|
+
(insights ?? [])
|
|
737
|
+
.filter(i => i.provider === selected?.provider)
|
|
738
|
+
.map(i => ({ key: i.windowKey, label: i.windowLabel })),
|
|
739
|
+
[insights, selected?.provider],
|
|
740
|
+
);
|
|
741
|
+
// While another provider's series are still on screen, show none of them.
|
|
742
|
+
const providerSeries = useMemo(
|
|
743
|
+
() => (accounts.data?.usageSeries ?? []).filter(s => s.provider === selected?.provider),
|
|
744
|
+
[accounts.data, selected?.provider],
|
|
453
745
|
);
|
|
746
|
+
const loadingSeries = providerSeries.length === 0 && (accounts.loading || accounts.stale);
|
|
454
747
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
748
|
+
// Distinct accounts can share a label (one email in two orgs): number the repeats.
|
|
749
|
+
const names = useMemo(() => {
|
|
750
|
+
const keysByLabel = new Map<string, string[]>();
|
|
751
|
+
for (const s of providerSeries) {
|
|
752
|
+
const keys = keysByLabel.get(s.accountLabel) ?? [];
|
|
753
|
+
if (!keys.includes(s.accountKey)) keys.push(s.accountKey);
|
|
754
|
+
keysByLabel.set(s.accountLabel, keys);
|
|
755
|
+
}
|
|
756
|
+
const out = new Map<string, string>();
|
|
757
|
+
for (const [label, keys] of keysByLabel) {
|
|
758
|
+
keys.sort();
|
|
759
|
+
keys.forEach((key, i) => out.set(key, keys.length > 1 ? `${label} #${i + 1}` : label));
|
|
760
|
+
}
|
|
761
|
+
return out;
|
|
762
|
+
}, [providerSeries]);
|
|
763
|
+
|
|
764
|
+
const chart = useMemo(
|
|
765
|
+
() =>
|
|
766
|
+
buildUtilization(
|
|
767
|
+
providerSeries.filter(s => s.windowKey === selected?.windowKey),
|
|
768
|
+
names,
|
|
769
|
+
),
|
|
770
|
+
[providerSeries, names, selected?.windowKey],
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
const rows = useMemo<AccountRow[]>(() => {
|
|
774
|
+
const colorOf = new Map(chart.series.map(s => [s.key, s.color]));
|
|
775
|
+
return providerSeries
|
|
776
|
+
.map(series => {
|
|
777
|
+
let latest: AccountRow["latest"] = null;
|
|
778
|
+
let peak: number | null = null;
|
|
779
|
+
for (const p of series.points) {
|
|
780
|
+
if (p.usedFraction === null) continue;
|
|
781
|
+
peak = Math.max(peak ?? 0, p.usedFraction);
|
|
782
|
+
if (!latest || p.timestamp >= latest.timestamp)
|
|
783
|
+
latest = { fraction: p.usedFraction, exhausted: p.exhausted, timestamp: p.timestamp };
|
|
784
|
+
}
|
|
785
|
+
const color = series.windowKey === selected?.windowKey ? (colorOf.get(series.accountKey) ?? null) : null;
|
|
786
|
+
const name = names.get(series.accountKey) ?? series.accountLabel;
|
|
787
|
+
return { series, name, color, latest, peak, samples: series.points.length };
|
|
788
|
+
})
|
|
789
|
+
.sort(
|
|
790
|
+
(a, b) =>
|
|
791
|
+
Number(b.color !== null) - Number(a.color !== null) ||
|
|
792
|
+
a.series.windowLabel.localeCompare(b.series.windowLabel) ||
|
|
793
|
+
(b.latest?.fraction ?? -1) - (a.latest?.fraction ?? -1),
|
|
794
|
+
);
|
|
795
|
+
}, [providerSeries, names, chart.series, selected?.windowKey]);
|
|
796
|
+
|
|
797
|
+
const windowLabel = windows.find(w => w.key === selected?.windowKey)?.label;
|
|
487
798
|
|
|
488
799
|
return (
|
|
489
|
-
<
|
|
490
|
-
|
|
491
|
-
|
|
800
|
+
<Card
|
|
801
|
+
index={5}
|
|
802
|
+
title="Window utilization"
|
|
803
|
+
description={
|
|
804
|
+
chart.buckets.length > 0
|
|
805
|
+
? `${windowLabel ?? "Window"} used fraction per account, ${format(new Date(chart.first), "MMM d HH:mm")} – ${format(new Date(chart.last), "MMM d HH:mm")}`
|
|
806
|
+
: "Recorded limit utilization per account"
|
|
807
|
+
}
|
|
492
808
|
actions={
|
|
493
|
-
|
|
494
|
-
<
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
809
|
+
selected && (
|
|
810
|
+
<div className="row" style={{ gap: 6 }}>
|
|
811
|
+
<select
|
|
812
|
+
className="input providers-select"
|
|
813
|
+
value={selected.provider}
|
|
814
|
+
onChange={e => {
|
|
815
|
+
const next = e.target.value;
|
|
816
|
+
const first = insights?.find(i => i.provider === next);
|
|
817
|
+
if (first) onSelect({ provider: next, windowKey: first.windowKey });
|
|
818
|
+
}}
|
|
819
|
+
aria-label="Provider"
|
|
820
|
+
>
|
|
821
|
+
{providers.map(p => (
|
|
822
|
+
<option key={p} value={p}>
|
|
823
|
+
{p}
|
|
824
|
+
</option>
|
|
825
|
+
))}
|
|
826
|
+
</select>
|
|
827
|
+
<select
|
|
828
|
+
className="input providers-select"
|
|
829
|
+
value={selected.windowKey}
|
|
830
|
+
onChange={e => onSelect({ provider: selected.provider, windowKey: e.target.value })}
|
|
831
|
+
aria-label="Window"
|
|
832
|
+
>
|
|
833
|
+
{windows.map(w => (
|
|
834
|
+
<option key={w.key} value={w.key}>
|
|
835
|
+
{w.label}
|
|
836
|
+
</option>
|
|
837
|
+
))}
|
|
838
|
+
</select>
|
|
839
|
+
</div>
|
|
840
|
+
)
|
|
507
841
|
}
|
|
842
|
+
stale={accounts.stale}
|
|
508
843
|
>
|
|
509
|
-
|
|
510
|
-
{
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
844
|
+
{insights === null ? (
|
|
845
|
+
<ChartSkeleton height={260} />
|
|
846
|
+
) : selected === null ? (
|
|
847
|
+
<EmptyState title="No usage snapshots in this range" hint={SNAPSHOT_HINT} />
|
|
848
|
+
) : (
|
|
849
|
+
<QueryView query={accounts} skeleton={<ChartSkeleton height={260} />}>
|
|
850
|
+
{() =>
|
|
851
|
+
loadingSeries ? (
|
|
852
|
+
<ChartSkeleton height={260} />
|
|
853
|
+
) : (
|
|
854
|
+
<div className="stack" style={{ gap: 16 }}>
|
|
855
|
+
<div className="stack" style={{ gap: 12 }}>
|
|
856
|
+
<Chart
|
|
857
|
+
slots={chart.buckets.length}
|
|
858
|
+
tickLabel={i =>
|
|
859
|
+
formatTick(
|
|
860
|
+
chart.buckets[i],
|
|
861
|
+
chart.last - chart.first > DAY_MS ? DAY_MS : chart.bucketMs,
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
tooltipTitle={i => formatBucket(chart.buckets[i], HOUR_MS)}
|
|
865
|
+
series={chart.series}
|
|
866
|
+
hidden={hidden}
|
|
867
|
+
kind="line"
|
|
868
|
+
stacked={false}
|
|
869
|
+
height={260}
|
|
870
|
+
yMax={Math.max(1, chart.max)}
|
|
871
|
+
format={v => formatPercent(v, 0)}
|
|
872
|
+
formatTooltip={v => formatPercent(v, 1)}
|
|
873
|
+
references={[{ value: 1, label: "100%" }]}
|
|
874
|
+
emptyLabel="No utilization readings for this window"
|
|
875
|
+
tooltipExtra={i => {
|
|
876
|
+
const exhausted = chart.exhausted[i];
|
|
877
|
+
return exhausted.length > 0 ? (
|
|
878
|
+
<div className="chart-tooltip-row">
|
|
879
|
+
<span className="chart-tooltip-label tone-bad">Exhausted</span>
|
|
880
|
+
<span className="chart-tooltip-value">{exhausted.join(", ")}</span>
|
|
881
|
+
</div>
|
|
882
|
+
) : null;
|
|
883
|
+
}}
|
|
884
|
+
/>
|
|
885
|
+
{chart.series.length > 1 && (
|
|
886
|
+
<Legend
|
|
887
|
+
items={chart.series.map(s => ({ key: s.key, label: s.label, color: s.color }))}
|
|
888
|
+
hidden={hidden}
|
|
889
|
+
onToggle={toggleHidden}
|
|
890
|
+
/>
|
|
891
|
+
)}
|
|
892
|
+
</div>
|
|
893
|
+
<div className="providers-accounts">
|
|
894
|
+
<Table
|
|
895
|
+
rows={rows}
|
|
896
|
+
rowKey={r => `${r.series.windowKey}::${r.series.accountKey}`}
|
|
897
|
+
columns={ACCOUNT_COLUMNS}
|
|
898
|
+
limit={16}
|
|
899
|
+
onRowClick={r => onSelect({ provider: r.series.provider, windowKey: r.series.windowKey })}
|
|
900
|
+
dense
|
|
901
|
+
/>
|
|
902
|
+
</div>
|
|
903
|
+
</div>
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
</QueryView>
|
|
907
|
+
)}
|
|
908
|
+
</Card>
|
|
517
909
|
);
|
|
518
910
|
}
|
|
911
|
+
|
|
912
|
+
interface UtilizationChart {
|
|
913
|
+
buckets: number[];
|
|
914
|
+
bucketMs: number;
|
|
915
|
+
first: number;
|
|
916
|
+
last: number;
|
|
917
|
+
max: number;
|
|
918
|
+
series: ChartSeries[];
|
|
919
|
+
/** Accounts seen exhausted per bucket. */
|
|
920
|
+
exhausted: string[][];
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** One line per account over a shared axis spanning the window's recorded snapshots. */
|
|
924
|
+
function buildUtilization(accounts: UsageWindowSeries[], names: ReadonlyMap<string, string>): UtilizationChart {
|
|
925
|
+
let first = Number.POSITIVE_INFINITY;
|
|
926
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
927
|
+
for (const s of accounts) {
|
|
928
|
+
for (const p of s.points) {
|
|
929
|
+
if (p.timestamp < first) first = p.timestamp;
|
|
930
|
+
if (p.timestamp > last) last = p.timestamp;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (!(last >= first))
|
|
934
|
+
return { buckets: [], bucketMs: HOUR_MS, first: 0, last: 0, max: 0, series: [], exhausted: [] };
|
|
935
|
+
|
|
936
|
+
const span = last - first;
|
|
937
|
+
const bucketMs = UTIL_STEPS.find(step => span / step < MAX_UTIL_SLOTS) ?? DAY_MS;
|
|
938
|
+
const buckets = bucketAxis("all", [first], bucketMs, last);
|
|
939
|
+
const index = (ts: number) => Math.floor((ts - buckets[0]) / bucketMs);
|
|
940
|
+
const exhausted = buckets.map((): string[] => []);
|
|
941
|
+
let max = 0;
|
|
942
|
+
|
|
943
|
+
const nameOf = (account: UsageWindowSeries) => names.get(account.accountKey) ?? account.accountLabel;
|
|
944
|
+
const ordered = [...accounts].sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
|
|
945
|
+
const series = ordered.map((account, rank): ChartSeries => {
|
|
946
|
+
// Last reading in each bucket, then hold it forward across short silences.
|
|
947
|
+
const values: (number | null)[] = buckets.map(() => null);
|
|
948
|
+
const readAt: number[] = buckets.map(() => 0);
|
|
949
|
+
for (const p of account.points) {
|
|
950
|
+
const i = index(p.timestamp);
|
|
951
|
+
if (i < 0 || i >= buckets.length) continue;
|
|
952
|
+
if (p.exhausted && !exhausted[i].includes(nameOf(account))) exhausted[i].push(nameOf(account));
|
|
953
|
+
if (p.usedFraction === null || p.timestamp < readAt[i]) continue;
|
|
954
|
+
values[i] = p.usedFraction;
|
|
955
|
+
readAt[i] = p.timestamp;
|
|
956
|
+
max = Math.max(max, p.usedFraction);
|
|
957
|
+
}
|
|
958
|
+
let held: number | null = null;
|
|
959
|
+
let heldAt = 0;
|
|
960
|
+
for (let i = 0; i < buckets.length; i++) {
|
|
961
|
+
if (values[i] !== null) {
|
|
962
|
+
held = values[i];
|
|
963
|
+
heldAt = readAt[i];
|
|
964
|
+
} else if (held !== null && buckets[i] - heldAt <= MAX_HOLD_MS) {
|
|
965
|
+
values[i] = held;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
key: account.accountKey,
|
|
970
|
+
label: nameOf(account),
|
|
971
|
+
color: SERIES_COLORS[rank % SERIES_COLORS.length],
|
|
972
|
+
values,
|
|
973
|
+
};
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
return { buckets, bucketMs, first, last, max, series, exhausted };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function statusBadge(latest: AccountRow["latest"]): ReactNode {
|
|
980
|
+
if (!latest) return <span className="dim">No reading</span>;
|
|
981
|
+
if (latest.exhausted) return <Badge tone="bad">Exhausted</Badge>;
|
|
982
|
+
if (latest.fraction >= 0.8) return <Badge tone="warn">High</Badge>;
|
|
983
|
+
return <Badge tone="ok">OK</Badge>;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const ACCOUNT_COLUMNS: Column<AccountRow>[] = [
|
|
987
|
+
{
|
|
988
|
+
key: "account",
|
|
989
|
+
header: "Account",
|
|
990
|
+
sort: r => r.name,
|
|
991
|
+
render: r => (
|
|
992
|
+
<span className="row" style={{ gap: 8 }}>
|
|
993
|
+
<span className="providers-swatch-slot">{r.color && <Swatch color={r.color} />}</span>
|
|
994
|
+
<span className="truncate providers-account" title={r.series.accountKey}>
|
|
995
|
+
{r.name}
|
|
996
|
+
</span>
|
|
997
|
+
</span>
|
|
998
|
+
),
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
key: "window",
|
|
1002
|
+
header: "Window",
|
|
1003
|
+
sort: r => r.series.windowLabel,
|
|
1004
|
+
render: r => <span className={r.color ? undefined : "muted"}>{r.series.windowLabel}</span>,
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
key: "latest",
|
|
1008
|
+
header: "Latest used",
|
|
1009
|
+
align: "right",
|
|
1010
|
+
sort: r => r.latest?.fraction ?? -1,
|
|
1011
|
+
render: r =>
|
|
1012
|
+
r.latest ? (
|
|
1013
|
+
<MeterCell
|
|
1014
|
+
value={r.latest.fraction}
|
|
1015
|
+
max={1}
|
|
1016
|
+
display={formatPercent(r.latest.fraction, 0)}
|
|
1017
|
+
color={r.latest.exhausted ? "var(--bad)" : r.latest.fraction >= 0.8 ? "var(--warn)" : "var(--ok)"}
|
|
1018
|
+
/>
|
|
1019
|
+
) : (
|
|
1020
|
+
<span className="dim">–</span>
|
|
1021
|
+
),
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
key: "status",
|
|
1025
|
+
header: "Status",
|
|
1026
|
+
sort: r => (r.latest?.exhausted ? 2 : (r.latest?.fraction ?? 0) >= 0.8 ? 1 : 0),
|
|
1027
|
+
render: r => statusBadge(r.latest),
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
key: "peak",
|
|
1031
|
+
header: "Peak in range",
|
|
1032
|
+
align: "right",
|
|
1033
|
+
sort: r => r.peak ?? -1,
|
|
1034
|
+
render: r => <span className="num">{r.peak === null ? "–" : formatPercent(r.peak, 0)}</span>,
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
key: "samples",
|
|
1038
|
+
header: "Snapshots",
|
|
1039
|
+
align: "right",
|
|
1040
|
+
sort: r => r.samples,
|
|
1041
|
+
render: r => <span className="num muted">{formatInteger(r.samples)}</span>,
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
key: "recorded",
|
|
1045
|
+
header: "Recorded",
|
|
1046
|
+
align: "right",
|
|
1047
|
+
sort: r => r.latest?.timestamp ?? 0,
|
|
1048
|
+
render: r => <span className="muted">{r.latest ? formatRelativeTime(r.latest.timestamp) : "–"}</span>,
|
|
1049
|
+
},
|
|
1050
|
+
];
|