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]

Reply via email to