This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 75a4b0d feat(usage): add a usage dashboard with a spend trend over
time
75a4b0d is described below
commit 75a4b0d4e87b918e96c429eca9297d0957ef4ff1
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 21:20:12 2026 +0800
feat(usage): add a usage dashboard with a spend trend over time
---
controllers/llm_record.go | 32 ++-
object/llm_record.go | 114 ++++++++
routers/router.go | 1 +
web/src/App.tsx | 2 +
web/src/backend/LlmRecordBackend.ts | 12 +-
web/src/components/usage/usage-breakdown.tsx | 104 +++++++
web/src/components/usage/usage-hero.tsx | 172 ++++++++++++
web/src/components/usage/usage-trend-chart.tsx | 175 ++++++++++++
web/src/lib/usage.ts | 134 +++++++++
web/src/locales/en/data.json | 19 ++
web/src/locales/zh/data.json | 19 ++
web/src/nav.ts | 2 +
web/src/pages/UsagePage.tsx | 372 +++++++++++++++++++++++++
web/src/types.ts | 13 +
14 files changed, 1169 insertions(+), 2 deletions(-)
diff --git a/controllers/llm_record.go b/controllers/llm_record.go
index 366b0ba..c566e1c 100644
--- a/controllers/llm_record.go
+++ b/controllers/llm_record.go
@@ -31,6 +31,10 @@ const llmStreamHeartbeat = 25 * time.Second
const llmStatsTopModels = 8
+// llmStatsMaxTop bounds what a caller may ask for, so a "top models" table
+// cannot be made to return every model name ever relayed.
+const llmStatsMaxTop = 100
+
// A record holds what a user asked a model, so unlike the proxy endpoints
// themselves these are limited to Gateway administrators.
@@ -83,7 +87,17 @@ func (c *ApiController) GetLlmRecordStats() {
return
}
- stats, err := object.GetLlmRecordStats(c.readLlmRecordFilter(),
llmStatsTopModels)
+ // The dashboard's tables want every model it has seen, where the
records
+ // page only has room for a row of badges.
+ top, _ := strconv.Atoi(c.Input().Get("top"))
+ if top < 1 {
+ top = llmStatsTopModels
+ }
+ if top > llmStatsMaxTop {
+ top = llmStatsMaxTop
+ }
+
+ stats, err := object.GetLlmRecordStats(c.readLlmRecordFilter(), top)
if err != nil {
c.ResponseError(err.Error())
return
@@ -92,6 +106,22 @@ func (c *ApiController) GetLlmRecordStats() {
c.ResponseOk(stats)
}
+// GetLlmUsageTrend groups the same window into time buckets, which is what the
+// usage dashboard draws. "bucket" is "hour" or "day"; anything else is a day.
+func (c *ApiController) GetLlmUsageTrend() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ points, err := object.GetLlmUsageTrend(c.readLlmRecordFilter(),
c.Input().Get("bucket"))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(points)
+}
+
// GetLlmAgentStats totals what each agent has relayed, for the page that lists
// every agent side by side.
func (c *ApiController) GetLlmAgentStats() {
diff --git a/object/llm_record.go b/object/llm_record.go
index 810c22f..e1e843b 100644
--- a/object/llm_record.go
+++ b/object/llm_record.go
@@ -16,6 +16,7 @@ package object
import (
"encoding/json"
+ "fmt"
"strings"
"sync"
"sync/atomic"
@@ -158,6 +159,21 @@ type LlmRecordStats struct {
Providers []LlmProviderStat `json:"providers"`
}
+// LlmTrendPoint is one time bucket of the relayed traffic. The dashboard draws
+// these as a line, which is the one thing a total cannot show: when the spend
+// happened.
+type LlmTrendPoint struct {
+ Bucket string `json:"bucket"`
+ Requests int64 `json:"requests"`
+ Failed int64 `json:"failed"`
+ PromptTokens int64 `json:"promptTokens"`
+ CompletionTokens int64 `json:"completionTokens"`
+ CacheReadTokens int64 `json:"cacheReadTokens"`
+ CacheWriteTokens int64 `json:"cacheWriteTokens"`
+ TotalTokens int64 `json:"totalTokens"`
+ Cost float64 `json:"cost"`
+}
+
// LlmRecordStatus is what the management page shows about the recorder itself,
// so that a gap in the records is visible instead of silent.
type LlmRecordStatus struct {
@@ -657,6 +673,104 @@ func GetLlmRecordStats(filter LlmRecordFilter, topModels
int) (*LlmRecordStats,
return stats, nil
}
+// llmTrendMaxPoints bounds a filled series, so an hourly bucket over a window
+// of months cannot answer with a point per hour of all of them.
+const llmTrendMaxPoints = 1500
+
+// llmBucketOf cuts a time down to the bucket it belongs to, the same way the
+// query cuts created_time: both read the format util.FormatTime writes, so the
+// filled buckets and the counted ones are spelled alike.
+func llmBucketOf(moment time.Time, hourly bool) string {
+ text := util.FormatTime(moment)
+ if hourly {
+ return text[:13]
+ }
+ return text[:10]
+}
+
+// GetLlmUsageTrend groups the records a filter matches into time buckets, by
+// hour or by day. The empty buckets are filled in: a day nothing was relayed
+// belongs in the chart as a zero, not as a point the line steps over.
+//
+// The bucket is cut out of created_time rather than computed with a date
+// function, because created_time is a formatted string and the three drivers
+// Gateway supports each spell their date functions differently.
+func GetLlmUsageTrend(filter LlmRecordFilter, bucket string) ([]LlmTrendPoint,
error) {
+ hourly := bucket == "hour"
+ width := 10
+ if hourly {
+ width = 13
+ }
+
+ session := llmRecordSession(filter)
+ defer session.Close()
+ counted := []LlmTrendPoint{}
+ err := session.Table("llm_record").
+ Select(fmt.Sprintf("substr(created_time, 1, %d) as bucket,
COUNT(*) as requests, ", width) +
+ "SUM(CASE WHEN status >= 200 AND status < 300 THEN 0
ELSE 1 END) as failed, " +
+ "SUM(prompt_tokens) as prompt_tokens,
SUM(completion_tokens) as completion_tokens, " +
+ "SUM(cache_read_tokens) as cache_read_tokens,
SUM(cache_write_tokens) as cache_write_tokens, " +
+ "SUM(total_tokens) as total_tokens, SUM(cost) as cost").
+ GroupBy("bucket").
+ Asc("bucket").
+ Find(&counted)
+ if err != nil {
+ return nil, err
+ }
+ if len(counted) == 0 {
+ return counted, nil
+ }
+
+ // The window starts where the filter says, and where it says nothing
at the
+ // oldest record there is: filling an "all time" series from the epoch
would
+ // be a leading run of zeros as long as the calendar.
+ start, err := time.Parse(time.RFC3339, filter.Since)
+ if err != nil {
+ start, err = time.Parse(time.RFC3339,
llmBucketStart(counted[0].Bucket, hourly))
+ if err != nil {
+ return counted, nil
+ }
+ }
+
+ step := 24 * time.Hour
+ if hourly {
+ step = time.Hour
+ }
+ byBucket := map[string]LlmTrendPoint{}
+ for _, point := range counted {
+ byBucket[point.Bucket] = point
+ }
+
+ filled := []LlmTrendPoint{}
+ last := counted[len(counted)-1].Bucket
+ for moment := start; len(filled) < llmTrendMaxPoints; moment =
moment.Add(step) {
+ key := llmBucketOf(moment, hourly)
+ if key > last {
+ break
+ }
+ point, found := byBucket[key]
+ if !found {
+ point = LlmTrendPoint{Bucket: key}
+ }
+ filled = append(filled, point)
+ }
+ if len(filled) == 0 {
+ return counted, nil
+ }
+ return filled, nil
+}
+
+// llmBucketStart completes a bucket key back into a time RFC3339 can parse.
The
+// zone offset is taken from the formatter that wrote the key rather than
+// spelled out here, so the two cannot drift apart.
+func llmBucketStart(bucket string, hourly bool) string {
+ zone := util.FormatTime(time.Now())[len("2006-01-02T15:04:05"):]
+ if hourly {
+ return bucket + ":00:00" + zone
+ }
+ return bucket + "T00:00:00" + zone
+}
+
// GetLlmAgentStats totals every agent at once, so a page showing them side by
// side asks once rather than once per agent.
func GetLlmAgentStats(filter LlmRecordFilter) ([]*LlmAgentStat, error) {
diff --git a/routers/router.go b/routers/router.go
index 21e83b9..9212405 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -90,6 +90,7 @@ func initAPI() {
beego.Router("/api/get-llm-record-status",
&controllers.ApiController{}, "GET:GetLlmRecordStatus")
beego.Router("/api/get-llm-record-stats", &controllers.ApiController{},
"GET:GetLlmRecordStats")
beego.Router("/api/get-llm-agent-stats", &controllers.ApiController{},
"GET:GetLlmAgentStats")
+ beego.Router("/api/get-llm-usage-trend", &controllers.ApiController{},
"GET:GetLlmUsageTrend")
beego.Router("/api/stream-llm-records", &controllers.ApiController{},
"GET:StreamLlmRecords")
beego.Router("/api/delete-llm-record", &controllers.ApiController{},
"POST:DeleteLlmRecord")
beego.Router("/api/clear-llm-records", &controllers.ApiController{},
"POST:ClearLlmRecords")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 1ae29bf..dbee8e3 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -38,6 +38,7 @@ import HomePage from "@/pages/HomePage";
import ProviderEditPage from "@/pages/ProviderEditPage";
import ProviderListPage from "@/pages/ProviderListPage";
import LlmRecordsPage from "@/pages/LlmRecordsPage";
+import UsagePage from "@/pages/UsagePage";
import SettingPage from "@/pages/SettingPage";
import SigninPage from "@/pages/SigninPage";
import type {Account, ThemeAlgorithm} from "@/types";
@@ -284,6 +285,7 @@ export default function App() {
path="/llm-records"
element={requireSignin(user => <LlmRecordsPage
account={user} />)}
/>
+ <Route path="/usage" element={requireSignin(user => <UsagePage
account={user} />)} />
</Routes>
</React.Suspense>
</main>
diff --git a/web/src/backend/LlmRecordBackend.ts
b/web/src/backend/LlmRecordBackend.ts
index b5c749c..ce4cca9 100644
--- a/web/src/backend/LlmRecordBackend.ts
+++ b/web/src/backend/LlmRecordBackend.ts
@@ -14,7 +14,7 @@
import {query, request} from "@/backend/request";
import {ServerUrl} from "@/Setting";
-import type {LlmAgentStat, LlmPrice, LlmRecord, LlmRecordStats,
LlmRecordStatus} from "@/types";
+import type {LlmAgentStat, LlmPrice, LlmRecord, LlmRecordStats,
LlmRecordStatus, LlmTrendPoint} from "@/types";
export interface LlmRecordFilter {
model?: string;
@@ -24,6 +24,8 @@ export interface LlmRecordFilter {
outcome?: string;
/** How far back the list and the totals reach. 0 is everything. */
windowHours?: number;
+ /** How many rows the per-model and per-provider breakdowns may return. */
+ top?: number;
}
export function getLlmRecords(page = 1, pageSize = 25, filter: LlmRecordFilter
= {}) {
@@ -41,6 +43,14 @@ export function getLlmRecordStats(filter: LlmRecordFilter =
{}) {
return
request<LlmRecordStats>(`/api/get-llm-record-stats${query({...filter})}`);
}
+/**
+ * The same window cut into time buckets, with the empty ones filled in by the
+ * server. `bucket` is "hour" or "day".
+ */
+export function getLlmUsageTrend(filter: LlmRecordFilter = {}, bucket: "hour"
| "day" = "day") {
+ return request<LlmTrendPoint[]>(`/api/get-llm-usage-trend${query({...filter,
bucket: bucket})}`);
+}
+
/** The same totals per agent, in one request rather than one request each. */
export function getLlmAgentStats(filter: LlmRecordFilter = {}) {
return
request<LlmAgentStat[]>(`/api/get-llm-agent-stats${query({...filter})}`);
diff --git a/web/src/components/usage/usage-breakdown.tsx
b/web/src/components/usage/usage-breakdown.tsx
new file mode 100644
index 0000000..fef7625
--- /dev/null
+++ b/web/src/components/usage/usage-breakdown.tsx
@@ -0,0 +1,104 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {Badge} from "@/components/ui/badge";
+import {Progress} from "@/components/ui/progress";
+import {formatCost, formatTokens, type UsageRow} from "@/lib/usage";
+
+/**
+ * What a window went on, one row per model, provider or agent. The share bar
+ * is drawn against the largest row rather than the total, so a list where one
+ * name dominates still tells the rest of the rows apart.
+ */
+export function UsageBreakdown({
+ title,
+ nameLabel,
+ rows,
+ loading,
+ showFailed = false,
+ emptyText,
+}: {
+ title: React.ReactNode;
+ nameLabel: React.ReactNode;
+ rows: UsageRow[];
+ loading?: boolean;
+ /** Set for the relayed source, which is the only one that knows an outcome.
*/
+ showFailed?: boolean;
+ emptyText?: React.ReactNode;
+}) {
+ const peak = rows.reduce((largest, row) => Math.max(largest, row.tokens), 0);
+
+ const columns: Column<UsageRow>[] = [
+ {
+ key: "name",
+ title: nameLabel,
+ render: (_value, row) => (
+ <div className="flex min-w-0 flex-col gap-1">
+ <span className="truncate font-mono text-xs">{row.name}</span>
+ {row.detail ? <span className="text-muted-foreground truncate
text-xs">{row.detail}</span> : null}
+ <Progress value={peak === 0 ? 0 : (row.tokens / peak) * 100}
className="h-1" />
+ </div>
+ ),
+ },
+ {
+ key: "requests",
+ title: i18next.t("llm:Requests"),
+ align: "right",
+ width: "120px",
+ sorter: (left, right) => left.requests - right.requests,
+ render: (_value, row) =>
+ showFailed && row.failed ? (
+ <div className="flex items-center justify-end gap-1.5">
+ <span
className="tabular-nums">{row.requests.toLocaleString()}</span>
+ <Badge variant="warning">{row.failed.toLocaleString()}</Badge>
+ </div>
+ ) : (
+ <span className="tabular-nums">{row.requests.toLocaleString()}</span>
+ ),
+ },
+ {
+ key: "tokens",
+ title: i18next.t("llm:Tokens"),
+ align: "right",
+ width: "100px",
+ sorter: (left, right) => left.tokens - right.tokens,
+ render: (_value, row) => <span
className="tabular-nums">{formatTokens(row.tokens)}</span>,
+ },
+ {
+ key: "cost",
+ title: i18next.t("llm:Cost"),
+ align: "right",
+ width: "100px",
+ sorter: (left, right) => left.cost - right.cost,
+ render: (_value, row) => <span
className="tabular-nums">{formatCost(row.cost)}</span>,
+ },
+ ];
+
+ return (
+ <DataTable
+ dense
+ title={title}
+ columns={columns}
+ dataSource={rows}
+ rowKey={row => row.name}
+ loading={loading}
+ pageSize={10}
+ emptyText={emptyText}
+ />
+ );
+}
diff --git a/web/src/components/usage/usage-hero.tsx
b/web/src/components/usage/usage-hero.tsx
new file mode 100644
index 0000000..3d61a0a
--- /dev/null
+++ b/web/src/components/usage/usage-hero.tsx
@@ -0,0 +1,172 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Activity, ArrowDownToLine, ArrowUpFromLine, CircleDollarSign,
Database, Sparkles} from "lucide-react";
+import i18next from "i18next";
+
+import {cn} from "@/lib/utils";
+import {Card, CardContent} from "@/components/ui/card";
+import {Progress} from "@/components/ui/progress";
+import {Skeleton} from "@/components/ui/skeleton";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cacheHitRate, formatCost, formatTokens, type UsageTotals} from
"@/lib/usage";
+
+/** One of the four token counts under the headline number. */
+function MiniStat({
+ icon: Icon,
+ label,
+ value,
+ accent,
+ hint,
+}: {
+ icon: React.ComponentType<{className?: string}>;
+ label: React.ReactNode;
+ value: React.ReactNode;
+ accent: string;
+ hint?: React.ReactNode;
+}) {
+ // SimpleTooltip renders its child alone when there is no title, so the hint
+ // is optional without a branch here.
+ return (
+ <SimpleTooltip title={hint}>
+ <div className="bg-background/60 flex flex-col gap-1 rounded-lg border
p-3">
+ <div className="text-muted-foreground flex items-center gap-1.5
text-xs font-medium">
+ <Icon className={cn("size-3.5 shrink-0", accent)} />
+ <span className="truncate">{label}</span>
+ </div>
+ <span className="text-sm font-semibold tabular-nums">{value}</span>
+ </div>
+ </SimpleTooltip>
+ );
+}
+
+/**
+ * The window's headline: what it cost, in tokens and in dollars, with the four
+ * counts those tokens are made of. Everything here is one window of one
source,
+ * so the totals and the tables below it always describe the same requests.
+ */
+export function UsageHero({
+ totals,
+ loading,
+ windowLabel,
+ cacheWriteHint,
+}: {
+ totals: UsageTotals;
+ loading?: boolean;
+ windowLabel: React.ReactNode;
+ /** Set where the source cannot report cache writes, so a zero is explained.
*/
+ cacheWriteHint?: React.ReactNode;
+}) {
+ const hitRate = cacheHitRate(totals);
+
+ if (loading) {
+ return (
+ <Card>
+ <CardContent className="flex flex-col gap-4">
+ <Skeleton className="h-12 w-64" />
+ <Skeleton className="h-16 w-full" />
+ </CardContent>
+ </Card>
+ );
+ }
+
+ return (
+ <Card>
+ <CardContent className="flex flex-col gap-4">
+ <div className="flex flex-col gap-4 md:flex-row md:items-center
md:justify-between">
+ <div className="min-w-0">
+ <div className="text-muted-foreground text-xs font-medium">
+ {i18next.t("usage:Tokens spent")} · {windowLabel}
+ </div>
+ <div className="mt-1 flex flex-wrap items-baseline gap-2">
+ <span className="text-3xl font-semibold tracking-tight
tabular-nums">
+ {totals.totalTokens.toLocaleString()}
+ </span>
+ <span className="text-muted-foreground bg-muted rounded-md
px-1.5 py-0.5 text-xs">
+ ≈ {formatTokens(totals.totalTokens)}
+ </span>
+ </div>
+ </div>
+
+ <div className="bg-background/60 flex items-center gap-5 rounded-lg
border px-4 py-2.5">
+ <div className="flex flex-col">
+ <span className="text-muted-foreground text-[11px] font-medium">
+ {i18next.t("llm:Requests")}
+ </span>
+ <span className="flex items-center gap-1.5 text-sm font-semibold
tabular-nums">
+ <Activity className="text-info size-3.5" />
+ {totals.requests.toLocaleString()}
+ {totals.failed ? (
+ <span className="text-warning text-xs font-normal">
+ {totals.failed.toLocaleString()} {i18next.t("llm:failed")}
+ </span>
+ ) : null}
+ </span>
+ </div>
+ <div className="bg-border h-8 w-px" />
+ <div className="flex flex-col">
+ <span className="text-muted-foreground text-[11px]
font-medium">{i18next.t("llm:Cost")}</span>
+ <span className="text-success flex items-center gap-1.5 text-sm
font-semibold tabular-nums">
+ <CircleDollarSign className="size-3.5" />
+ {formatCost(totals.cost)}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
+ <MiniStat
+ icon={ArrowDownToLine}
+ label={i18next.t("llm:Fresh input")}
+ value={formatTokens(totals.promptTokens)}
+ accent="text-info"
+ />
+ <MiniStat
+ icon={ArrowUpFromLine}
+ label={i18next.t("llm:Output")}
+ value={formatTokens(totals.completionTokens)}
+ accent="text-chart-4"
+ />
+ <MiniStat
+ icon={Database}
+ label={i18next.t("llm:Cache write")}
+ value={formatTokens(totals.cacheWriteTokens)}
+ accent="text-warning"
+ hint={cacheWriteHint}
+ />
+ <MiniStat
+ icon={Sparkles}
+ label={i18next.t("llm:Cache read")}
+ value={formatTokens(totals.cacheReadTokens)}
+ accent="text-success"
+ />
+ <div className="bg-background/60 col-span-2 flex flex-col
justify-center gap-2 rounded-lg border p-3 lg:col-span-1">
+ <div className="flex items-center justify-between text-xs">
+ <span className="text-muted-foreground
font-medium">{i18next.t("llm:Cache hit rate")}</span>
+ <span className="text-success font-semibold
tabular-nums">{hitRate.toFixed(hitRate >= 99.95 ? 0 : 1)}%</span>
+ </div>
+ <Progress value={hitRate} tone="success" className="h-1.5" />
+ </div>
+ </div>
+
+ {totals.unpriced > 0 ? (
+ <span className="text-muted-foreground text-xs">
+ {i18next.t("llm:{count} records have no price").replace("{count}",
totals.unpriced.toLocaleString())}
+ </span>
+ ) : null}
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/components/usage/usage-trend-chart.tsx
b/web/src/components/usage/usage-trend-chart.tsx
new file mode 100644
index 0000000..267381a
--- /dev/null
+++ b/web/src/components/usage/usage-trend-chart.tsx
@@ -0,0 +1,175 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Area, CartesianGrid, ComposedChart, Line, XAxis, YAxis} from
"recharts";
+import {TrendingUp} from "lucide-react";
+import i18next from "i18next";
+
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from
"@/components/ui/card";
+import {Skeleton} from "@/components/ui/skeleton";
+import {EmptyState} from "@/components/shared/empty-state";
+import {
+ ChartContainer,
+ ChartLegend,
+ ChartLegendContent,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
+import {bucketLabel, formatCost, formatTokens, type UsagePoint} from
"@/lib/usage";
+
+/** The series, in the order they stack. Built per render so a language switch
+ * relabels the legend without a reload. */
+function buildConfig(): ChartConfig {
+ return {
+ promptTokens: {label: i18next.t("llm:Fresh input"), color:
"var(--chart-1)"},
+ completionTokens: {label: i18next.t("llm:Output"), color:
"var(--chart-2)"},
+ cacheWriteTokens: {label: i18next.t("llm:Cache write"), color:
"var(--chart-3)"},
+ cacheReadTokens: {label: i18next.t("llm:Cache read"), color:
"var(--chart-4)"},
+ cost: {label: i18next.t("llm:Cost"), color: "var(--chart-5)"},
+ };
+}
+
+/**
+ * When the window was spent, which is the one thing its totals cannot say. The
+ * token counts stack, because together they are the total; the cost rides a
+ * second axis as a line, because dollars and tokens share no scale and one
+ * axis would flatten whichever of them is smaller.
+ */
+export function UsageTrendChart({
+ points,
+ loading,
+ description,
+}: {
+ points: UsagePoint[];
+ loading?: boolean;
+ description?: React.ReactNode;
+}) {
+ const config = buildConfig();
+ // Labels are cut out of the bucket key rather than parsed as a date: the
+ // server cut that key from a timestamp in its own zone, and reading it as a
+ // Date here would move every point into the browser's.
+ const data = points.map(point => ({...point, label:
bucketLabel(point.bucket)}));
+ // A tick per bucket is unreadable past a few weeks, so only some are drawn.
+ const tickInterval = Math.max(0, Math.ceil(data.length / 12) - 1);
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle
className="text-[15px]">{i18next.t("usage:Trend")}</CardTitle>
+ {description ? <CardDescription>{description}</CardDescription> : null}
+ </CardHeader>
+ <CardContent>
+ {loading ? (
+ <Skeleton className="h-[300px] w-full" />
+ ) : data.length === 0 ? (
+ <EmptyState icon={TrendingUp} title={i18next.t("usage:Nothing was
spent in this window")} />
+ ) : (
+ <ChartContainer config={config} className="aspect-auto h-[300px]
w-full">
+ <ComposedChart data={data} margin={{top: 8, right: 8, left: 0,
bottom: 0}}>
+ <CartesianGrid vertical={false} />
+ <XAxis
+ dataKey="label"
+ tickLine={false}
+ axisLine={false}
+ tickMargin={8}
+ interval={tickInterval}
+ minTickGap={16}
+ />
+ <YAxis
+ yAxisId="tokens"
+ tickLine={false}
+ axisLine={false}
+ width={52}
+ tickFormatter={value => formatTokens(Number(value))}
+ />
+ <YAxis
+ yAxisId="cost"
+ orientation="right"
+ tickLine={false}
+ axisLine={false}
+ width={52}
+ tickFormatter={value => formatCost(Number(value))}
+ />
+ <ChartTooltip
+ content={
+ <ChartTooltipContent
+ formatter={(value, name) => (
+ <div className="flex w-full items-center justify-between
gap-3">
+ <span className="text-muted-foreground">{config[name
as string]?.label ?? name}</span>
+ <span className="font-mono tabular-nums">
+ {name === "cost" ? formatCost(Number(value)) :
Number(value).toLocaleString()}
+ </span>
+ </div>
+ )}
+ />
+ }
+ />
+ <ChartLegend content={<ChartLegendContent />} />
+ {/* Straight segments, not a curve: these are discrete totals
for a
+ day or an hour, and a spline between them would draw a bulge
+ over the neighbours that were spent on. */}
+ <Area
+ yAxisId="tokens"
+ stackId="tokens"
+ type="linear"
+ dataKey="promptTokens"
+ stroke="var(--color-promptTokens)"
+ fill="var(--color-promptTokens)"
+ fillOpacity={0.2}
+ />
+ <Area
+ yAxisId="tokens"
+ stackId="tokens"
+ type="linear"
+ dataKey="completionTokens"
+ stroke="var(--color-completionTokens)"
+ fill="var(--color-completionTokens)"
+ fillOpacity={0.2}
+ />
+ <Area
+ yAxisId="tokens"
+ stackId="tokens"
+ type="linear"
+ dataKey="cacheWriteTokens"
+ stroke="var(--color-cacheWriteTokens)"
+ fill="var(--color-cacheWriteTokens)"
+ fillOpacity={0.2}
+ />
+ <Area
+ yAxisId="tokens"
+ stackId="tokens"
+ type="linear"
+ dataKey="cacheReadTokens"
+ stroke="var(--color-cacheReadTokens)"
+ fill="var(--color-cacheReadTokens)"
+ fillOpacity={0.2}
+ />
+ <Line
+ yAxisId="cost"
+ type="linear"
+ dataKey="cost"
+ stroke="var(--color-cost)"
+ strokeWidth={2}
+ strokeDasharray="4 4"
+ dot={false}
+ />
+ </ComposedChart>
+ </ChartContainer>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/lib/usage.ts b/web/src/lib/usage.ts
index cf4b8a6..59e402b 100644
--- a/web/src/lib/usage.ts
+++ b/web/src/lib/usage.ts
@@ -35,3 +35,137 @@ export function formatTokens(tokens: number) {
}
return `${(tokens / 1000000).toFixed(2)}M`;
}
+
+/**
+ * What one window spent, whichever source counted it: the records Gateway
+ * relayed, or the transcripts the agents wrote themselves. The dashboard draws
+ * one set of cards from either, so both are normalised to this first.
+ */
+export interface UsageTotals {
+ requests: number;
+ promptTokens: number;
+ completionTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ totalTokens: number;
+ cost: number;
+ /** Requests left out of the cost because no list price matched their model.
*/
+ unpriced: number;
+ /** Only the relayed source knows this; the transcripts record no outcome. */
+ failed?: number;
+}
+
+/** One row of a breakdown: what a single model, provider or agent spent. */
+export interface UsageRow {
+ name: string;
+ requests: number;
+ tokens: number;
+ cost: number;
+ failed?: number;
+ /** What the row was last seen doing, where the source knows it. */
+ detail?: string;
+}
+
+/** One point of the trend, with the tokens split the way the chart stacks
them. */
+export interface UsagePoint {
+ bucket: string;
+ requests: number;
+ promptTokens: number;
+ completionTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ cost: number;
+}
+
+export const emptyUsageTotals: UsageTotals = {
+ requests: 0,
+ promptTokens: 0,
+ completionTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ totalTokens: 0,
+ cost: 0,
+ unpriced: 0,
+};
+
+/**
+ * Share of the input tokens that were served out of the prompt cache. Written
+ * over the input alone, because the output tokens were never cacheable and
+ * counting them would quietly hold every rate down.
+ */
+export function cacheHitRate(tokens: {promptTokens: number; cacheReadTokens:
number; cacheWriteTokens: number}) {
+ const input = tokens.promptTokens + tokens.cacheReadTokens +
tokens.cacheWriteTokens;
+ return input === 0 ? 0 : (tokens.cacheReadTokens / input) * 100;
+}
+
+/**
+ * The label a bucket key gets on the axis. A key is either "2026-09-02" or
+ * "2026-09-02T14", cut out of a timestamp by the server, so it is shortened
+ * rather than parsed: reading it as a Date would move it into the browser's
own
+ * zone and label the point with an hour it was never counted in.
+ */
+export function bucketLabel(bucket: string) {
+ const hour = bucket.indexOf("T");
+ return hour === -1 ? bucket.slice(5) : `${bucket.slice(11)}:00`;
+}
+
+/** A Date as the calendar day it falls on locally, spelled the way the day
+ * buckets are: the agents write their transcripts in this machine's own zone,
+ * and toISOString would name a different day for half of it. */
+function localDay(date: Date) {
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${date.getFullYear()}-${month}-${day}`;
+}
+
+/**
+ * Fills in the days a source skipped because nothing was spent on them, over
+ * the whole window that was asked for rather than only between the days that
+ * have data: a quiet week is a flat stretch in the chart, not a chart that is
+ * six days shorter than its own label.
+ *
+ * `days` is the window in days, 0 for everything the source holds — which has
+ * no start of its own, so those are filled between the days present. Only day
+ * buckets are filled; the relayed source has its gaps filled by the server,
+ * which is the side that knows where its window begins.
+ */
+export function fillDays(points: UsagePoint[], days: number): UsagePoint[] {
+ if (points.length === 0) {
+ return points;
+ }
+
+ const step = 24 * 60 * 60 * 1000;
+ // Parsed and stepped through in UTC: the keys are calendar days, and walking
+ // them in the local zone would drop or repeat one wherever the clock moves.
+ const at = (key: string) => Date.parse(`${key}T00:00:00Z`);
+ const keyOf = (stamp: number) => new Date(stamp).toISOString().slice(0, 10);
+
+ const today = localDay(new Date());
+ const oldest = points[0].bucket;
+ const newest = points[points.length - 1].bucket;
+ const first = days > 0 ? localDay(new Date(at(today) - (days - 1) * step)) :
oldest;
+
+ const start = at(first < oldest ? first : oldest);
+ const end = at(newest > today ? newest : today);
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
+ return points;
+ }
+
+ const known = new Map(points.map(point => [point.bucket, point]));
+ const filled: UsagePoint[] = [];
+ for (let stamp = start; stamp <= end && filled.length < 1500; stamp += step)
{
+ const key = keyOf(stamp);
+ filled.push(
+ known.get(key) ?? {
+ bucket: key,
+ requests: 0,
+ promptTokens: 0,
+ completionTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ cost: 0,
+ },
+ );
+ }
+ return filled.length === 0 ? points : filled;
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 3584578..7f144d8 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -628,5 +628,24 @@
"Settings": "Settings",
"Sign-in": "Sign-in",
"Sign-in description": "Casdoor is optional: leave the endpoint empty to
sign in against Gateway's own user table."
+ },
+ "usage": {
+ "Agents description": "What the agents on this machine spent, read from
the transcripts they write themselves. This is the only account of a request
that never went through Gateway.",
+ "By agent": "By agent",
+ "By model": "By model",
+ "By provider": "By provider",
+ "Cache writes are not reported by every protocol": "A provider serving the
OpenAI protocol reports cache hits but never cache writes, so this can read as
zero while the cache is being written.",
+ "Every agent": "Every agent",
+ "Every {count}s": "Every {count}s",
+ "Last {count} days": "Last {count} days",
+ "No auto refresh": "No auto refresh",
+ "Nothing was spent in this window": "Nothing was spent in this window",
+ "Recording is off detail": "llmRecordMode is \"off\", so nothing relayed
is being counted. The agents' own transcripts are counted either way.",
+ "Relayed description": "What Gateway relayed on behalf of the agents. This
is the only account that knows which provider answered, and whether it failed.",
+ "Tokens spent": "Tokens spent",
+ "Trend": "Trend",
+ "Usage": "Usage",
+ "What Gateway relayed": "What Gateway relayed",
+ "What the agents spent": "What the agents spent"
}
}
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 90fb4df..e4ef2f2 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -628,5 +628,24 @@
"Settings": "设置",
"Sign-in": "登录",
"Sign-in description": "Casdoor 是可选的:地址留空则使用 Gateway 自带的用户表登录。"
+ },
+ "usage": {
+ "Agents description": "这台机器上的 agent 花掉的量,读自它们自己写的会话记录。没有经过 Gateway
的请求,只有这一份账。",
+ "By agent": "按 Agent",
+ "By model": "按模型",
+ "By provider": "按 Provider",
+ "Cache writes are not reported by every protocol": "走 OpenAI 协议的 provider
只上报缓存命中,不上报缓存写入,所以这里可能显示为 0 而缓存其实在写。",
+ "Every agent": "全部 Agent",
+ "Every {count}s": "每 {count} 秒",
+ "Last {count} days": "最近 {count} 天",
+ "No auto refresh": "不自动刷新",
+ "Nothing was spent in this window": "这个时间范围内没有消耗",
+ "Recording is off detail": "llmRecordMode 是 \"off\",转发的请求没有被统计。Agent
自己的会话记录不受影响,照常统计。",
+ "Relayed description": "Gateway 代为转发的量。只有这一份账知道是哪个 provider 应答的,以及是否失败。",
+ "Tokens spent": "消耗 Tokens",
+ "Trend": "趋势",
+ "Usage": "用量",
+ "What Gateway relayed": "Gateway 转发的账",
+ "What the agents spent": "Agent 自己的账"
}
}
diff --git a/web/src/nav.ts b/web/src/nav.ts
index b2279f4..4211078 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -15,6 +15,7 @@
import {
Blocks,
Bot,
+ ChartColumn,
FileSearch,
Logs,
MessageSquare,
@@ -74,6 +75,7 @@ export const navGroups: NavGroup[] = [
{key: "/agent-records", label: "agent:Agent Records", icon: FileSearch,
path: "/agent-records", adminOnly: true},
{key: "/providers", label: "provider:Providers", icon: Plug, path:
"/providers"},
{key: "/llm-records", label: "llm:LLM Records", icon: Logs, path:
"/llm-records", adminOnly: true},
+ {key: "/usage", label: "usage:Usage", icon: ChartColumn, path: "/usage",
adminOnly: true},
{key: "/settings", label: "setting:Settings", icon: Settings, path:
"/settings", adminOnly: true},
];
diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx
new file mode 100644
index 0000000..b38aa96
--- /dev/null
+++ b/web/src/pages/UsagePage.tsx
@@ -0,0 +1,372 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Link} from "react-router-dom";
+import {Logs, RefreshCw} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
+import * as Setting from "@/Setting";
+import {UsageBreakdown} from "@/components/usage/usage-breakdown";
+import {UsageHero} from "@/components/usage/usage-hero";
+import {UsageTrendChart} from "@/components/usage/usage-trend-chart";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs";
+import {
+ emptyUsageTotals,
+ fillDays,
+ type UsagePoint,
+ type UsageRow,
+ type UsageTotals,
+} from "@/lib/usage";
+import type {Account} from "@/types";
+
+/**
+ * The two accounts of what the agents cost, which do not measure the same
+ * thing: "agents" is what they wrote in their own transcripts, and is the only
+ * record of a request that never went through Gateway; "relayed" is what
+ * Gateway itself passed on, and is the only one that knows which provider
+ * answered and whether it failed.
+ */
+type UsageSource = "agents" | "relayed";
+
+/** The windows each source offers, in days. 0 is everything it has. */
+const RANGES: Record<UsageSource, number[]> = {
+ agents: [7, 30, 90, 0],
+ relayed: [1, 7, 30, 0],
+};
+
+const REFRESH_OPTIONS = [0, 10000, 30000, 60000];
+
+/** One window's worth of either source, normalised so the page draws one way.
*/
+interface UsageView {
+ totals: UsageTotals;
+ points: UsagePoint[];
+ models: UsageRow[];
+ providers: UsageRow[];
+ agents: UsageRow[];
+}
+
+const emptyView: UsageView = {
+ totals: emptyUsageTotals,
+ points: [],
+ models: [],
+ providers: [],
+ agents: [],
+};
+
+function rangeLabel(days: number) {
+ if (days === 0) {
+ return i18next.t("llm:All time");
+ }
+ if (days === 1) {
+ return i18next.t("llm:Last 24 hours");
+ }
+ return i18next.t("usage:Last {count} days").replace("{count}", String(days));
+}
+
+export default function UsagePage({account}: {account: Account}) {
+ const isAdmin = Setting.isAdminUser(account);
+ const [source, setSource] = React.useState<UsageSource>("agents");
+ const [days, setDays] = React.useState(7);
+ const [agent, setAgent] = React.useState("");
+ const [refreshMs, setRefreshMs] = React.useState(30000);
+ const [view, setView] = React.useState<UsageView>(emptyView);
+ // False until the first answer lands, so an empty window never flashes as
+ // "nothing was spent" before anything has been counted.
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState("");
+ const [recording, setRecording] = React.useState(true);
+ // Every agent seen in either source, so the filter keeps its options when
the
+ // window is narrowed down to one that spent nothing.
+ const [knownAgents, setKnownAgents] = React.useState<string[]>([]);
+
+ React.useEffect(() => {
+ if (!isAdmin) {
+ return;
+ }
+ LlmRecordBackend.getLlmRecordStatus()
+ .then(res => setRecording(res.status !== "ok" || (res.data?.mode ??
"off") !== "off"))
+ .catch(() => undefined);
+ }, [isAdmin]);
+
+ const loadAgents = React.useCallback(async(): Promise<UsageView> => {
+ const res = await AgentBackend.getAgentUsage(agent, days);
+ if (res.status !== "ok" || !res.data) {
+ throw new Error(res.msg || i18next.t("general:Failed to get data"));
+ }
+
+ const usage = res.data;
+ const rowOf = (stat: (typeof usage.agents)[number], detail?: string):
UsageRow => ({
+ name: stat.name,
+ requests: stat.requests,
+ tokens: stat.totalTokens,
+ cost: stat.cost,
+ detail: detail,
+ });
+
+ return {
+ totals: {...usage.totals, failed: undefined},
+ points: fillDays(
+ usage.days.map(day => ({
+ bucket: day.name,
+ requests: day.requests,
+ promptTokens: day.promptTokens,
+ completionTokens: day.completionTokens,
+ cacheReadTokens: day.cacheReadTokens,
+ cacheWriteTokens: day.cacheWriteTokens,
+ cost: day.cost,
+ })),
+ days,
+ ),
+ models: usage.models.map(model => rowOf(model)),
+ providers: [],
+ agents: usage.agents.map(stat => rowOf(stat, stat.lastModel)),
+ };
+ }, [agent, days]);
+
+ const loadRelayed = React.useCallback(async(): Promise<UsageView> => {
+ const filter = {agent: agent, windowHours: days * 24, top: 100};
+ const [statsRes, trendRes, agentsRes] = await Promise.all([
+ LlmRecordBackend.getLlmRecordStats(filter),
+ LlmRecordBackend.getLlmUsageTrend(filter, days === 1 ? "hour" : "day"),
+ LlmRecordBackend.getLlmAgentStats(filter),
+ ]);
+ if (statsRes.status !== "ok" || !statsRes.data) {
+ throw new Error(statsRes.msg || i18next.t("general:Failed to get data"));
+ }
+
+ const stats = statsRes.data;
+ return {
+ totals: {
+ requests: stats.requests,
+ failed: stats.failed,
+ promptTokens: stats.promptTokens,
+ completionTokens: stats.completionTokens,
+ cacheReadTokens: stats.cacheReadTokens,
+ cacheWriteTokens: stats.cacheWriteTokens,
+ totalTokens: stats.totalTokens,
+ cost: stats.cost,
+ unpriced: stats.unpriced,
+ },
+ points: (trendRes.status === "ok" ? (trendRes.data ?? []) :
[]).map(point => ({
+ bucket: point.bucket,
+ requests: point.requests,
+ promptTokens: point.promptTokens,
+ completionTokens: point.completionTokens,
+ cacheReadTokens: point.cacheReadTokens,
+ cacheWriteTokens: point.cacheWriteTokens,
+ cost: point.cost,
+ })),
+ models: stats.models.map(model => ({
+ name: model.model,
+ requests: model.requests,
+ tokens: model.tokens,
+ cost: model.cost,
+ })),
+ providers: stats.providers.map(provider => ({
+ name: provider.provider,
+ requests: provider.requests,
+ failed: provider.failed,
+ tokens: provider.tokens,
+ cost: provider.cost,
+ })),
+ agents: (agentsRes.status === "ok" ? (agentsRes.data ?? []) :
[]).map(stat => ({
+ name: stat.agent,
+ requests: stat.requests,
+ failed: stat.failed,
+ tokens: stat.tokens,
+ cost: stat.cost,
+ detail: stat.lastModel,
+ })),
+ };
+ }, [agent, days]);
+
+ const load = React.useCallback(
+ (spinner: boolean) => {
+ if (!isAdmin) {
+ return;
+ }
+ if (spinner) {
+ setLoading(true);
+ }
+ (source === "agents" ? loadAgents() : loadRelayed())
+ .then(next => {
+ setView(next);
+ setError("");
+ // The agents a window happens to cover are the only names there are
+ // to filter by, so the list only ever grows.
+ setKnownAgents(known => {
+ const merged = new Set(known);
+ next.agents.forEach(row => merged.add(row.name));
+ return [...merged].sort();
+ });
+ })
+ .catch(failure => {
+ setError(failure.message || String(failure));
+ setView(emptyView);
+ })
+ .then(() => setLoading(false));
+ },
+ [isAdmin, source, loadAgents, loadRelayed],
+ );
+
+ React.useEffect(() => {
+ load(true);
+ }, [load]);
+
+ React.useEffect(() => {
+ if (refreshMs === 0) {
+ return undefined;
+ }
+ // Refreshed without the spinner, so a page left open does not blink every
+ // time it catches up.
+ const interval = setInterval(() => load(false), refreshMs);
+ return () => clearInterval(interval);
+ }, [refreshMs, load]);
+
+ if (!isAdmin) {
+ return <UnauthorizedResult />;
+ }
+
+ const changeSource = (next: UsageSource) => {
+ setSource(next);
+ // Each source offers the windows its own granularity can draw, so a window
+ // the new one does not have falls back to its default.
+ if (!RANGES[next].includes(days)) {
+ setDays(7);
+ }
+ };
+
+ const window = rangeLabel(days);
+ const relayed = source === "relayed";
+ // A provider that speaks the OpenAI protocol reports cache hits but never
+ // cache writes, so a zero there is the protocol, not the truth.
+ const cacheWriteHint =
+ relayed && view.totals.cacheWriteTokens === 0 &&
view.totals.cacheReadTokens > 0
+ ? i18next.t("usage:Cache writes are not reported by every protocol")
+ : undefined;
+
+ return (
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("usage:Usage")}
+ description={i18next.t(relayed ? "usage:Relayed description" :
"usage:Agents description")}
+ actions={
+ <>
+ <SimpleSelect
+ className="w-[150px]"
+ value={agent}
+ onChange={setAgent}
+ options={[
+ {label: i18next.t("usage:Every agent"), value: ""},
+ ...knownAgents.map(name => ({label: name, value: name})),
+ ]}
+ />
+ <SimpleSelect
+ className="w-[150px]"
+ value={String(days)}
+ onChange={value => setDays(Number(value))}
+ options={RANGES[source].map(option => ({label:
rangeLabel(option), value: String(option)}))}
+ />
+ <SimpleSelect
+ className="w-[150px]"
+ value={String(refreshMs)}
+ onChange={value => setRefreshMs(Number(value))}
+ options={REFRESH_OPTIONS.map(option => ({
+ label:
+ option === 0
+ ? i18next.t("usage:No auto refresh")
+ : i18next.t("usage:Every {count}s").replace("{count}",
String(option / 1000)),
+ value: String(option),
+ }))}
+ />
+ <Button variant="outline" onClick={() => load(true)}
loading={loading}>
+ <RefreshCw />
+ {i18next.t("general:Refresh")}
+ </Button>
+ </>
+ }
+ />
+
+ <Tabs value={source} onValueChange={value => changeSource(value as
UsageSource)}>
+ <TabsList>
+ <TabsTrigger value="agents">{i18next.t("usage:What the agents
spent")}</TabsTrigger>
+ <TabsTrigger value="relayed">{i18next.t("usage:What Gateway
relayed")}</TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {error ? <MessageAlert title={error} /> : null}
+
+ {relayed && !recording ? (
+ <MessageAlert
+ variant="warning"
+ title={i18next.t("llm:Recording is off")}
+ description={i18next.t("usage:Recording is off detail")}
+ action={
+ <Button asChild size="sm" variant="outline">
+ <Link to="/llm-records">
+ <Logs />
+ {i18next.t("llm:LLM Records")}
+ </Link>
+ </Button>
+ }
+ />
+ ) : null}
+
+ <UsageHero
+ totals={view.totals}
+ loading={loading}
+ windowLabel={window}
+ cacheWriteHint={cacheWriteHint}
+ />
+
+ <UsageTrendChart points={view.points} loading={loading}
description={window} />
+
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
+ <UsageBreakdown
+ title={i18next.t("usage:By model")}
+ nameLabel={i18next.t("agent:Model")}
+ rows={view.models}
+ loading={loading}
+ emptyText={i18next.t("usage:Nothing was spent in this window")}
+ />
+ <UsageBreakdown
+ title={i18next.t("usage:By agent")}
+ nameLabel={i18next.t("agent:Agent")}
+ rows={view.agents}
+ loading={loading}
+ showFailed={relayed}
+ emptyText={i18next.t("usage:Nothing was spent in this window")}
+ />
+ {relayed ? (
+ <UsageBreakdown
+ title={i18next.t("usage:By provider")}
+ nameLabel={i18next.t("provider:Provider")}
+ rows={view.providers}
+ loading={loading}
+ showFailed
+ emptyText={i18next.t("usage:Nothing was spent in this window")}
+ />
+ ) : null}
+ </div>
+ </PageContainer>
+ );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index 45312ca..5fb0ae1 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -341,6 +341,19 @@ export interface LlmRecordStats {
providers: LlmProviderStat[];
}
+/** One time bucket of the relayed traffic, as the usage trend is drawn from.
*/
+export interface LlmTrendPoint {
+ bucket: string;
+ requests: number;
+ failed: number;
+ promptTokens: number;
+ completionTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ totalTokens: number;
+ cost: number;
+}
+
/** One agent's share of the relayed requests, with what it last asked for. */
export interface LlmAgentStat {
agent: string;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]