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]
