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

commit b321a12c94d8bde2b70e4648095e98c5887be6d2
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 15:59:38 2026 +0800

    feat(agents): draw a 30-day usage sparkline on each card
---
 object/agent_usage.go                  |  29 +++++-
 web/src/components/AgentGridCard.tsx   |  65 ++++++++------
 web/src/components/AgentUsageTrend.tsx | 158 +++++++++++++++++++++++++++++++++
 web/src/types.ts                       |   3 +
 4 files changed, 226 insertions(+), 29 deletions(-)

diff --git a/object/agent_usage.go b/object/agent_usage.go
index b8ffd3d..03f00af 100644
--- a/object/agent_usage.go
+++ b/object/agent_usage.go
@@ -17,6 +17,7 @@ package object
 import (
        "sort"
        "strings"
+       "time"
 
        "github.com/apache/casbin-gateway/agent"
        "github.com/apache/casbin-gateway/agenthistory"
@@ -68,6 +69,10 @@ type AgentUsageStat struct {
        // listing every agent shows next to its totals.
        LastTime  string `json:"lastTime"`
        LastModel string `json:"lastModel"`
+       // Days is the recent per-day breakdown of this stat, oldest first. It 
is
+       // filled for agents only, so a card can draw the shape of the spend 
rather
+       // than only its total.
+       Days []AgentUsageStat `json:"days,omitempty"`
 }
 
 // AgentUsage is what the transcripts on this machine add up to.
@@ -84,6 +89,11 @@ type AgentUsage struct {
        Since string `json:"since"`
 }
 
+// agentTrendDays is how far back an agent's own per-day breakdown reaches. The
+// cards draw a month; carrying every day a transcript covers would grow the
+// listing without anything reading it.
+const agentTrendDays = 30
+
 // unknownModel stands in for a turn whose transcript never named a model. It 
is
 // priced like any other unknown one, which is to say not at all.
 const unknownModel = "unknown"
@@ -105,6 +115,8 @@ func GetAgentUsage(sessions []agenthistory.Session, since 
string) *AgentUsage {
        agents := map[string]*AgentUsageStat{}
        models := map[string]*AgentUsageStat{}
        days := map[string]*AgentUsageStat{}
+       agentDays := map[string]map[string]*AgentUsageStat{}
+       trendSince := time.Now().AddDate(0, 0, -(agentTrendDays - 
1)).Format(time.DateOnly)
 
        for _, session := range sessions {
                counted := false
@@ -121,12 +133,21 @@ func GetAgentUsage(sessions []agenthistory.Session, since 
string) *AgentUsage {
                        cost, priced := GetLlmLongCacheCost(model, 
bucket.PromptTokens, bucket.CompletionTokens,
                                bucket.CacheWriteTokens, 
bucket.LongCacheTokens, bucket.CacheReadTokens)
 
-                       for _, stat := range []*AgentUsageStat{
+                       counters := []*AgentUsageStat{
                                &usage.Totals,
                                statOf(agents, session.Agent),
                                statOf(models, model),
                                statOf(days, bucket.Day),
-                       } {
+                       }
+                       if bucket.Day >= trendSince {
+                               byDay, found := agentDays[session.Agent]
+                               if !found {
+                                       byDay = map[string]*AgentUsageStat{}
+                                       agentDays[session.Agent] = byDay
+                               }
+                               counters = append(counters, statOf(byDay, 
bucket.Day))
+                       }
+                       for _, stat := range counters {
                                stat.add(bucket, cost, priced)
                        }
 
@@ -143,6 +164,10 @@ func GetAgentUsage(sessions []agenthistory.Session, since 
string) *AgentUsage {
                }
        }
 
+       for name, stat := range agents {
+               stat.Days = sorted(agentDays[name], byName)
+       }
+
        usage.Agents = sorted(agents, byCost)
        usage.Models = sorted(models, byCost)
        usage.Days = sorted(days, byName)
diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
index 0c661d5..9d4c012 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -19,6 +19,7 @@ import i18next from "i18next";
 
 import {AgentIcon} from "@/components/AgentIcon";
 import {AgentCardInstances} from "@/components/AgentInstances";
+import {AgentUsageTrend, hasUsageTrend} from "@/components/AgentUsageTrend";
 import {RunButton, RunDot} from "@/components/AgentRunControl";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {QuotaBadge} from "@/components/ProviderQuota";
@@ -212,6 +213,12 @@ export function AgentGridCard({
   // Off is only the truth about the relayed totals; a transcript is written
   // whether or not Gateway is recording anything.
   const counted = spent !== undefined || recording;
+  const costHint =
+    spent && spent.unpriced > 0
+      ? i18next
+        .t("llm:{count} of these requests have no list price")
+        .replace("{count}", spent.unpriced.toLocaleString())
+      : sourceHint;
   const lastModel = spent?.lastModel || stats?.lastModel;
   const lastTime = spent?.lastTime || stats?.lastTime;
 
@@ -306,34 +313,38 @@ export function AgentGridCard({
         <Divider />
 
         <div className="space-y-3">
-          <div className="grid grid-cols-3 gap-1.5">
-            <Metric
-              label={i18next.t("llm:Requests")}
-              value={counted ? ((spent ?? stats)?.requests ?? 
0).toLocaleString() : dash}
-              hint={
-                sourceHint ??
-                (stats && stats.failed > 0
-                  ? `${stats.failed.toLocaleString()} 
${i18next.t("llm:failed")}`
-                  : undefined)
-              }
+          {/* The shape of the spend wherever the transcripts carry a history,
+              and the bare totals for the agents whose only account is what
+              Gateway relayed - that source keeps no per-day breakdown. */}
+          {spent && hasUsageTrend(spent) ? (
+            <AgentUsageTrend
+              stat={spent}
+              hints={{tokens: sourceHint, requests: sourceHint, cost: 
costHint}}
             />
-            <Metric
-              label={i18next.t("llm:Tokens")}
-              value={counted ? formatTokens(spent?.totalTokens ?? 
stats?.tokens ?? 0) : dash}
-              hint={sourceHint}
-            />
-            <Metric
-              label={i18next.t("llm:Cost")}
-              value={counted ? formatCost(spent?.cost ?? stats?.cost ?? 0) : 
dash}
-              hint={
-                spent && spent.unpriced > 0
-                  ? i18next
-                    .t("llm:{count} of these requests have no list price")
-                    .replace("{count}", spent.unpriced.toLocaleString())
-                  : sourceHint
-              }
-            />
-          </div>
+          ) : (
+            <div className="grid grid-cols-3 gap-1.5">
+              <Metric
+                label={i18next.t("llm:Requests")}
+                value={counted ? ((spent ?? stats)?.requests ?? 
0).toLocaleString() : dash}
+                hint={
+                  sourceHint ??
+                  (stats && stats.failed > 0
+                    ? `${stats.failed.toLocaleString()} 
${i18next.t("llm:failed")}`
+                    : undefined)
+                }
+              />
+              <Metric
+                label={i18next.t("llm:Tokens")}
+                value={counted ? formatTokens(spent?.totalTokens ?? 
stats?.tokens ?? 0) : dash}
+                hint={sourceHint}
+              />
+              <Metric
+                label={i18next.t("llm:Cost")}
+                value={counted ? formatCost(spent?.cost ?? stats?.cost ?? 0) : 
dash}
+                hint={costHint}
+              />
+            </div>
+          )}
 
           <PlanUsage quota={quota} />
 
diff --git a/web/src/components/AgentUsageTrend.tsx 
b/web/src/components/AgentUsageTrend.tsx
new file mode 100644
index 0000000..906112d
--- /dev/null
+++ b/web/src/components/AgentUsageTrend.tsx
@@ -0,0 +1,158 @@
+// 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 {Bar, BarChart, XAxis, YAxis} from "recharts";
+import i18next from "i18next";
+
+import {
+  ChartContainer,
+  ChartTooltip,
+  ChartTooltipContent,
+  type ChartConfig,
+} from "@/components/ui/chart";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {fillDays, formatCost, formatTokens, type UsagePoint} from 
"@/lib/usage";
+import type {AgentUsageStat} from "@/types";
+
+/** How far back a card's trend reaches. The server sends at most this many. */
+export const trendDays = 30;
+
+/** Whether an agent's transcripts hold enough of a recent history to draw. */
+export function hasUsageTrend(stat?: AgentUsageStat) {
+  return (stat?.days?.length ?? 0) > 0;
+}
+
+/** One number of the summary line: what it is, then what it reads. */
+function Stat({
+  label,
+  value,
+  hint,
+}: {
+  label: React.ReactNode;
+  value: React.ReactNode;
+  hint?: React.ReactNode;
+}) {
+  const shown = (
+    <span className="min-w-0 truncate">
+      <span className="text-muted-foreground text-[11px]">{label} </span>
+      <span className="text-[13px] font-medium tabular-nums">{value}</span>
+    </span>
+  );
+
+  return hint ? <SimpleTooltip title={hint}>{shown}</SimpleTooltip> : shown;
+}
+
+/** One line of the tooltip, which is a whole day rather than a single series. 
*/
+function TipRow({label, value}: {label: React.ReactNode; value: 
React.ReactNode}) {
+  return (
+    <div className="flex w-full items-center justify-between gap-3">
+      <span className="text-muted-foreground">{label}</span>
+      <span className="font-mono tabular-nums">{value}</span>
+    </div>
+  );
+}
+
+/**
+ * What one agent spent, as the shape of it: a bar per day over the last month,
+ * with the totals it adds up to on either side. A card is too narrow for axes,
+ * so the days are named in the tooltip and the window in the caption.
+ */
+export function AgentUsageTrend({
+  stat,
+  hints,
+}: {
+  stat: AgentUsageStat;
+  /** The same notes the totals carry elsewhere: where they were read from,
+   *  and how much of the cost is missing a list price. */
+  hints?: {tokens?: React.ReactNode; cost?: React.ReactNode; requests?: 
React.ReactNode};
+}) {
+  const config: ChartConfig = {
+    tokens: {label: i18next.t("llm:Tokens"), color: "var(--chart-1)"},
+  };
+  // The quiet days are filled in here rather than by the server: a week nobody
+  // worked is a gap in the bars, not a chart that is a week shorter.
+  const points: UsagePoint[] = fillDays(
+    (stat.days ?? []).map(day => ({
+      bucket: day.name,
+      requests: day.requests,
+      promptTokens: day.promptTokens,
+      completionTokens: day.completionTokens,
+      cacheReadTokens: day.cacheReadTokens,
+      cacheWriteTokens: day.cacheWriteTokens,
+      cost: day.cost,
+    })),
+    trendDays,
+  );
+  const data = points.map(point => ({
+    ...point,
+    tokens:
+      point.promptTokens + point.completionTokens + point.cacheReadTokens + 
point.cacheWriteTokens,
+  }));
+  const window = i18next.t("usage:Last {count} days").replace("{count}", 
String(trendDays));
+
+  return (
+    <div className="space-y-1">
+      <div className="flex items-baseline justify-between gap-2">
+        <Stat
+          label={i18next.t("llm:Tokens")}
+          value={formatTokens(stat.totalTokens)}
+          hint={hints?.tokens}
+        />
+        <Stat label={i18next.t("llm:Cost")} value={formatCost(stat.cost)} 
hint={hints?.cost} />
+      </div>
+
+      <ChartContainer config={config} className="aspect-auto h-[52px] w-full">
+        <BarChart data={data} margin={{top: 2, right: 0, left: 0, bottom: 0}}>
+          <XAxis dataKey="bucket" hide />
+          <YAxis hide domain={[0, "dataMax"]} />
+          <ChartTooltip
+            cursor={false}
+            content={
+              <ChartTooltipContent
+                hideIndicator
+                labelFormatter={(_, payload) => 
String(payload?.[0]?.payload?.bucket ?? "")}
+                formatter={(_value, _name, item) => {
+                  const day = item.payload as (typeof data)[number];
+                  return (
+                    <div className="grid w-full gap-1">
+                      <TipRow
+                        label={i18next.t("llm:Requests")}
+                        value={day.requests.toLocaleString()}
+                      />
+                      <TipRow label={i18next.t("llm:Tokens")} 
value={formatTokens(day.tokens)} />
+                      <TipRow label={i18next.t("llm:Cost")} 
value={formatCost(day.cost)} />
+                    </div>
+                  );
+                }}
+              />
+            }
+          />
+          {/* Redrawn every time the totals are polled, so it does not animate:
+              the bars would slide up from zero once a minute. */}
+          <Bar dataKey="tokens" fill="var(--color-tokens)" radius={1} 
isAnimationActive={false} />
+        </BarChart>
+      </ChartContainer>
+
+      <div className="text-muted-foreground flex items-center justify-between 
gap-2 text-[11px]">
+        <Stat
+          label={i18next.t("llm:Requests")}
+          value={stat.requests.toLocaleString()}
+          hint={hints?.requests}
+        />
+        <span className="shrink-0">{window}</span>
+      </div>
+    </div>
+  );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index 63f9190..951065a 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -787,6 +787,9 @@ export interface AgentUsageStat {
   /** Filled for agents only, which is what the card beside the totals shows. 
*/
   lastTime: string;
   lastModel: string;
+  /** The recent per-day breakdown, oldest first. Filled for agents only, and
+   *  is what their cards draw the trend from. */
+  days?: AgentUsageStat[];
 }
 
 /**


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to