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 d7d710b  feat(ui): make the home page one card per agent
d7d710b is described below

commit d7d710bdb2aad1c8cce9a3af1fe83eab0b5ec575
Author: Yang Luo <[email protected]>
AuthorDate: Mon Aug 24 13:01:52 2026 +0800

    feat(ui): make the home page one card per agent
---
 controllers/llm_record.go            |  16 ++
 object/llm_record.go                 |  57 ++++++
 routers/router.go                    |   1 +
 web/src/backend/LlmRecordBackend.ts  |   7 +-
 web/src/components/AgentGridCard.tsx | 338 ++++++++++++++++++++++++++++++++
 web/src/locales/en/data.json         |   1 +
 web/src/locales/zh/data.json         |   1 +
 web/src/pages/HomePage.tsx           | 370 ++++++++---------------------------
 web/src/types.ts                     |  12 ++
 9 files changed, 514 insertions(+), 289 deletions(-)

diff --git a/controllers/llm_record.go b/controllers/llm_record.go
index 75275f5..366b0ba 100644
--- a/controllers/llm_record.go
+++ b/controllers/llm_record.go
@@ -92,6 +92,22 @@ func (c *ApiController) GetLlmRecordStats() {
        c.ResponseOk(stats)
 }
 
+// GetLlmAgentStats totals what each agent has relayed, for the page that lists
+// every agent side by side.
+func (c *ApiController) GetLlmAgentStats() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       stats, err := object.GetLlmAgentStats(c.readLlmRecordFilter())
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(stats)
+}
+
 // StreamLlmRecords pushes records to the page as they are written, so what a
 // model is being sent is visible while it happens rather than on the next
 // refresh.
diff --git a/object/llm_record.go b/object/llm_record.go
index ea56dbf..c65d10d 100644
--- a/object/llm_record.go
+++ b/object/llm_record.go
@@ -115,6 +115,19 @@ type LlmProviderStat struct {
        Cost     float64 `json:"cost"`
 }
 
+// LlmAgentStat is one agent's share of the records, with what it last asked
+// for: the page listing every agent shows the model in use, not only totals.
+type LlmAgentStat struct {
+       Agent        string  `json:"agent"`
+       Requests     int64   `json:"requests"`
+       Failed       int64   `json:"failed"`
+       Tokens       int64   `json:"tokens"`
+       Cost         float64 `json:"cost"`
+       LastTime     string  `json:"lastTime"`
+       LastModel    string  `json:"lastModel"`
+       LastProvider string  `json:"lastProvider"`
+}
+
 // LlmRecordStats totals the records a filter matches.
 type LlmRecordStats struct {
        Requests         int64             `json:"requests"`
@@ -629,6 +642,50 @@ func GetLlmRecordStats(filter LlmRecordFilter, topModels 
int) (*LlmRecordStats,
        return stats, nil
 }
 
+// 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) {
+       session := llmRecordSession(filter)
+       defer session.Close()
+       stats := []*LlmAgentStat{}
+       err := session.Table("llm_record").
+               Select("agent as agent, COUNT(*) as requests, " +
+                       "SUM(CASE WHEN status >= 200 AND status < 300 THEN 0 
ELSE 1 END) as failed, " +
+                       "SUM(total_tokens) as tokens, SUM(cost) as cost").
+               Where("agent <> ''").
+               GroupBy("agent").
+               Desc("requests").
+               Find(&stats)
+       if err != nil {
+               return nil, err
+       }
+
+       // The newest record of each agent carries the model and provider that 
agent
+       // is on right now, which no total can say.
+       lastSession := llmRecordSession(filter)
+       defer lastSession.Close()
+       last := []*LlmAgentStat{}
+       err = lastSession.Table("llm_record").
+               Select("agent as agent, model as last_model, provider as 
last_provider, created_time as last_time").
+               Where("id in (select max(id) from llm_record where agent <> '' 
group by agent)").
+               Find(&last)
+       if err != nil {
+               return nil, err
+       }
+
+       for _, stat := range stats {
+               for _, item := range last {
+                       if item.Agent == stat.Agent {
+                               stat.LastModel = item.LastModel
+                               stat.LastProvider = item.LastProvider
+                               stat.LastTime = item.LastTime
+                               break
+                       }
+               }
+       }
+       return stats, nil
+}
+
 // llmSubscriberBuffer is how far a live watcher may fall behind before it
 // starts losing records.
 const llmSubscriberBuffer = 64
diff --git a/routers/router.go b/routers/router.go
index 5aabc2f..7b5e1a8 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -126,6 +126,7 @@ func initAPI() {
        beego.Router("/api/get-llm-record", &controllers.ApiController{}, 
"GET:GetLlmRecord")
        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/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/backend/LlmRecordBackend.ts 
b/web/src/backend/LlmRecordBackend.ts
index f5a173f..b5c749c 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 {LlmPrice, LlmRecord, LlmRecordStats, LlmRecordStatus} from 
"@/types";
+import type {LlmAgentStat, LlmPrice, LlmRecord, LlmRecordStats, 
LlmRecordStatus} from "@/types";
 
 export interface LlmRecordFilter {
   model?: string;
@@ -41,6 +41,11 @@ export function getLlmRecordStats(filter: LlmRecordFilter = 
{}) {
   return 
request<LlmRecordStats>(`/api/get-llm-record-stats${query({...filter})}`);
 }
 
+/** 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})}`);
+}
+
 /**
  * Opens the live feed of records; the browser reconnects on its own. Returns
  * the function that closes it.
diff --git a/web/src/components/AgentGridCard.tsx 
b/web/src/components/AgentGridCard.tsx
new file mode 100644
index 0000000..05b4b7f
--- /dev/null
+++ b/web/src/components/AgentGridCard.tsx
@@ -0,0 +1,338 @@
+// 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 {Bot, ChevronRight} from "lucide-react";
+import i18next from "i18next";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {RunBadge, RunButton} from "@/components/AgentRunControl";
+import {ProviderIcon} from "@/components/ProviderIcon";
+import {QuotaBadge} from "@/components/ProviderQuota";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Card, CardContent} from "@/components/ui/card";
+import {Progress} from "@/components/ui/progress";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import {Switch} from "@/components/ui/switch";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {
+  agentCanUse,
+  agentDetailPath,
+  directMode,
+  monitorAgentId,
+  type AgentActivity,
+} from "@/lib/agents";
+import {providerIdOf, providerProtocol} from "@/lib/providers";
+import {formatCost, formatTokens} from "@/lib/usage";
+import type {
+  Agent,
+  AgentRuntime,
+  LlmAgentStat,
+  Provider,
+  ProviderHealth,
+  ProviderQuota,
+} from "@/types";
+
+/** Radix rejects an empty item value, so "unbound" needs a stand-in. */
+const noProvider = "-";
+
+/** One number of the card, and what it is. */
+function Metric({
+  label,
+  value,
+  hint,
+  to,
+}: {
+  label: React.ReactNode;
+  value: React.ReactNode;
+  hint?: React.ReactNode;
+  to?: string;
+}) {
+  const shown = (
+    <span className="block truncate text-sm font-semibold 
tabular-nums">{value}</span>
+  );
+
+  return (
+    <div className="min-w-0">
+      <span className="text-muted-foreground block truncate 
text-xs">{label}</span>
+      {hint ? (
+        <SimpleTooltip title={hint}>
+          <span className="block min-w-0">{shown}</span>
+        </SimpleTooltip>
+      ) : to ? (
+        <Link to={to} className="hover:text-primary block min-w-0">
+          {shown}
+        </Link>
+      ) : (
+        shown
+      )}
+    </div>
+  );
+}
+
+/** What the vendor of the bound provider says is left on the plan. */
+function PlanUsage({quota}: {quota?: ProviderQuota}) {
+  // A provider nobody can read a balance from has nothing to show here.
+  if (!quota || (!quota.supported && quota.error === "")) {
+    return null;
+  }
+
+  const percent =
+    quota.total !== null && quota.total > 0 && quota.used !== null
+      ? Math.min(100, (quota.used / quota.total) * 100)
+      : null;
+
+  return (
+    <div className="space-y-1.5">
+      <div className="flex items-center justify-between gap-2">
+        <span className="text-muted-foreground 
text-xs">{i18next.t("provider:Plan usage")}</span>
+        <QuotaBadge quota={quota} />
+      </div>
+      {percent === null ? null : (
+        <Progress
+          value={percent}
+          tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : 
"success"}
+          className="h-1.5"
+        />
+      )}
+    </div>
+  );
+}
+
+/**
+ * One agent installed on this machine, as the card the home page is built out
+ * of: what it is on now, what it has spent there, and the controls that change
+ * both. Picking another provider is the one click the page exists for.
+ */
+export function AgentGridCard({
+  agent,
+  agents,
+  providers,
+  health,
+  quota,
+  stats,
+  activity,
+  status,
+  recording,
+  busy,
+  runBusy,
+  onEnable,
+  onToggleRunning,
+  onTogglePatch,
+}: {
+  agent: Agent;
+  /** Every installation, which is what decides whether the link needs a path. 
*/
+  agents: Agent[];
+  providers: Provider[];
+  health: ProviderHealth[];
+  quota?: ProviderQuota;
+  stats?: LlmAgentStat;
+  activity?: AgentActivity;
+  status?: AgentRuntime;
+  /** False while llmRecordMode is off, when a zero would be a lie. */
+  recording: boolean;
+  busy: boolean;
+  runBusy: boolean;
+  onEnable: (providerId: string) => void;
+  onToggleRunning: (agent: Agent, running: boolean) => void;
+  onTogglePatch: () => void;
+}) {
+  const bound = providers.find(provider => providerIdOf(provider) === 
agent.provider);
+  const boundHealth = health.find(item => item.provider === agent.provider);
+  // An agent bound directly reads the provider's own answers, so only the ones
+  // it can speak to are offered; through the gateway they all are. Whatever it
+  // is on stays listed either way, so the box shows where it points today.
+  const options = providers.filter(
+    provider => agentCanUse(agent, provider) || providerIdOf(provider) === 
agent.provider,
+  );
+  const patchAction = i18next.t(`agent:${agent.patched ? "Unpatch" : 
"Patch"}`);
+  const monitorId = monitorAgentId(agent.agentId);
+  const dash = <span className="text-muted-foreground">-</span>;
+  const offHint = recording ? undefined : i18next.t("llm:Recording is off");
+
+  return (
+    <Card className="gap-0 py-0">
+      <CardContent className="flex h-full flex-col gap-3 p-4">
+        <div className="flex items-start gap-2.5">
+          <AgentIcon
+            agent={agent.agentId || agent.name}
+            size={26}
+            fallback={<Bot className="text-muted-foreground size-6" />}
+          />
+          <div className="min-w-0 flex-1">
+            <div className="flex min-w-0 items-center gap-2">
+              <Link
+                to={agentDetailPath(agent, agents)}
+                className="min-w-0 truncate font-medium hover:underline"
+              >
+                {agent.name}
+              </Link>
+              <Badge variant="muted">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
+            </div>
+            <SimpleTooltip title={agent.path}>
+              <p className="text-muted-foreground truncate font-mono 
text-xs">{agent.path}</p>
+            </SimpleTooltip>
+          </div>
+          <RunBadge status={status} />
+        </div>
+
+        <div className="space-y-1.5">
+          <div className="flex items-center justify-between gap-2">
+            <span className="text-muted-foreground 
text-xs">{i18next.t("agent:Provider")}</span>
+            <span className="flex flex-wrap items-center gap-1.5">
+              {bound === undefined ? null : (
+                <Badge variant="muted">
+                  {i18next.t(agent.mode === directMode ? "agent:Direct" : 
"agent:Gateway")}
+                </Badge>
+              )}
+              {boundHealth && !boundHealth.healthy ? (
+                <SimpleTooltip title={boundHealth.lastError}>
+                  <span>
+                    <Badge variant="warning">{i18next.t("agent:Cooling 
down")}</Badge>
+                  </span>
+                </SimpleTooltip>
+              ) : null}
+            </span>
+          </div>
+
+          <Select
+            value={agent.provider === "" ? noProvider : agent.provider}
+            disabled={busy}
+            onValueChange={value => onEnable(value === noProvider ? "" : 
value)}
+          >
+            <SelectTrigger className="w-full">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value={noProvider}>
+                <span className="text-muted-foreground">{i18next.t("agent:No 
provider")}</span>
+              </SelectItem>
+              {options.map(provider => (
+                <SelectItem key={providerIdOf(provider)} 
value={providerIdOf(provider)}>
+                  <ProviderIcon icon={provider.icon} 
baseUrl={provider.baseUrl} size={16} />
+                  {provider.displayName || provider.name}
+                  <span className="text-muted-foreground ml-2 text-xs">
+                    {providerProtocol(provider.type)}
+                  </span>
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+
+        <div className="flex items-center justify-between gap-2">
+          <span className="text-muted-foreground 
text-xs">{i18next.t("agent:Model")}</span>
+          {stats?.lastModel ? (
+            <SimpleTooltip
+              title={`${i18next.t("agent:Last activity")}: ${new 
Date(stats.lastTime).toLocaleString()}`}
+            >
+              <span className="min-w-0 truncate font-mono 
text-xs">{stats.lastModel}</span>
+            </SimpleTooltip>
+          ) : (
+            <span className="text-muted-foreground text-xs">-</span>
+          )}
+        </div>
+
+        <div className="grid grid-cols-4 gap-2 rounded-md border p-2.5">
+          <Metric
+            label={i18next.t("llm:Requests")}
+            value={recording ? (stats?.requests ?? 0).toLocaleString() : dash}
+            hint={
+              offHint ??
+              (stats && stats.failed > 0
+                ? `${stats.failed.toLocaleString()} ${i18next.t("llm:failed")}`
+                : undefined)
+            }
+          />
+          <Metric
+            label={i18next.t("llm:Tokens")}
+            value={recording ? formatTokens(stats?.tokens ?? 0) : dash}
+            hint={offHint}
+          />
+          <Metric
+            label={i18next.t("llm:Cost")}
+            value={recording ? formatCost(stats?.cost ?? 0) : dash}
+            hint={offHint}
+          />
+          <Metric
+            label={i18next.t("agent:Records")}
+            value={agent.patched ? (activity?.recordCount ?? 
0).toLocaleString() : dash}
+            hint={
+              agent.patched
+                ? activity
+                  ? `${activity.sessionCount.toLocaleString()} 
${i18next.t("agent:Agent Sessions")}`
+                  : undefined
+                : i18next.t("agent:Turn on monitoring to collect activity")
+            }
+            to={
+              agent.patched
+                ? `/agent-records?agent=${encodeURIComponent(monitorId)}`
+                : undefined
+            }
+          />
+        </div>
+
+        <PlanUsage quota={quota} />
+
+        <div className="mt-auto flex flex-wrap items-center gap-3 border-t 
pt-3">
+          <RunButton agent={agent} status={status} busy={runBusy} 
onToggle={onToggleRunning} />
+
+          <label className="text-muted-foreground flex items-center gap-1.5 
text-xs">
+            {i18next.t("agent:Monitored")}
+            {agent.supported ? (
+              <ConfirmDialog
+                title={`${patchAction} ${agent.name}?`}
+                description={[agent.notice, 
agent.followup].filter(Boolean).join(" ") || undefined}
+                confirmText={patchAction}
+                variant={agent.patched ? "destructive" : "default"}
+                onConfirm={onTogglePatch}
+              >
+                {/* The dialog owns the click, so the switch only ever mirrors
+                    what the last scan reported. */}
+                <Switch
+                  checked={agent.patched}
+                  disabled={busy}
+                  aria-label={patchAction}
+                  onCheckedChange={() => undefined}
+                />
+              </ConfirmDialog>
+            ) : (
+              <SimpleTooltip title={agent.detail || i18next.t("agent:Not 
supported")}>
+                <span>
+                  <Switch checked={false} disabled 
aria-label={i18next.t("agent:Patch")} />
+                </span>
+              </SimpleTooltip>
+            )}
+          </label>
+
+          <Link
+            to={agentDetailPath(agent, agents)}
+            className="text-primary ml-auto inline-flex items-center text-sm 
hover:underline"
+          >
+            {i18next.t("agent:Details")}
+            <ChevronRight className="size-4" />
+          </Link>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index f161e39..974c25b 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -585,6 +585,7 @@
     "Paste a provider link": "Paste a provider link",
     "Paste a provider link hint": "Vendors publish these as \"add to CC 
Switch\" buttons. Gateway reads the same link and fills the form in below, and 
stores nothing until you confirm.",
     "Path hint": "A dotted path into the answer, where a number indexes an 
array: balance_infos.0.total_balance.",
+    "Plan usage": "Plan usage",
     "Preset": "Preset",
     "Preset hint": "Fills in the paths for a family of sites, so they do not 
have to be read out of the vendor's API documentation.",
     "Priority": "Priority",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 3035c5a..6cfef02 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -585,6 +585,7 @@
     "Paste a provider link": "粘贴供应商链接",
     "Paste a provider link hint": "中转站通常把它做成“添加到 CC Switch”的按钮。Gateway 
读的是同一种链接,读完只会把下面的表单填好,你确认之前不会保存任何东西。",
     "Path hint": "返回内容里的点分路径,数字表示数组下标:balance_infos.0.total_balance。",
+    "Plan usage": "套餐用量",
     "Preset": "预设",
     "Preset hint": "为某一类站点填好字段路径,省去翻厂商 API 文档。",
     "Priority": "优先级",
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index fb8e277..e5882e7 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -14,179 +14,32 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, Check, ChevronRight, CircleX, Container, Plug, Plus, RefreshCw, 
Table2} from "lucide-react";
+import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react";
 import i18next from "i18next";
 
+import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
 import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
-import {AgentIcon} from "@/components/AgentIcon";
-import {RunBadge, RunButton} from "@/components/AgentRunControl";
-import {ProviderIcon} from "@/components/ProviderIcon";
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {AgentGridCard} from "@/components/AgentGridCard";
 import {EmptyState} from "@/components/shared/empty-state";
 import {Loading} from "@/components/shared/loading";
 import {UnauthorizedResult} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {MessageAlert} from "@/components/ui/alert";
-import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
-import {Switch} from "@/components/ui/switch";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import {
-  agentCanUse,
-  agentDetailPath,
-  agentKey,
-  agentNeedsResponsesApi,
-  agentProtocol,
-  runtimeOf,
-  useAgents,
-} from "@/lib/agents";
-import {
-  providerIdOf,
-  providerProtocol,
-  servesResponsesApi,
-  usesClientAuth,
-} from "@/lib/providers";
-import {cn} from "@/lib/utils";
-import type {Account, Agent, AgentRuntime, Provider, ProviderHealth} from 
"@/types";
+import {Card} from "@/components/ui/card";
+import {activityOf, agentKey, runtimeOf, useAgents, useAgentSessions} from 
"@/lib/agents";
+import type {Account, LlmAgentStat, Provider, ProviderHealth, ProviderQuota} 
from "@/types";
 
-/** One row of the rail: the tool, and the provider it is on right now. */
-function AgentRow({
-  agent,
-  provider,
-  status,
-  active,
-  onSelect,
-}: {
-  agent: Agent;
-  provider: string;
-  status?: AgentRuntime;
-  active: boolean;
-  onSelect: () => void;
-}) {
-  return (
-    <SimpleTooltip title={agent.path} side="right">
-      <button
-        type="button"
-        onClick={onSelect}
-        className={cn(
-          "flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left 
transition-colors",
-          active ? "bg-accent text-accent-foreground" : "hover:bg-accent/60",
-        )}
-      >
-        <AgentIcon
-          agent={agent.agentId || agent.name}
-          size={22}
-          fallback={<Bot className="text-muted-foreground size-5" />}
-        />
-        <span className="min-w-0 flex-1">
-          <span className="flex items-center gap-1.5">
-            <span className="truncate text-sm font-medium">{agent.name}</span>
-            {status?.running ? <span className="bg-success size-1.5 shrink-0 
rounded-full" /> : null}
-          </span>
-          <span className="text-muted-foreground block truncate 
text-xs">{provider}</span>
-        </span>
-      </button>
-    </SimpleTooltip>
-  );
-}
-
-/** Why this agent cannot be put on this provider, empty when it can. */
-function blockedReason(agent: Agent, provider: Provider) {
-  if (agentCanUse(agent, provider)) {
-    return "";
-  }
-  return agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)
-    ? i18next.t("agent:Gateway only hint")
-    : i18next.t("agent:Provider speaks another API").replace("{protocol}", 
agentProtocol(agent));
-}
-
-/** One provider the selected agent can be put on, with the button that does 
it. */
-function ProviderChoice({
-  provider,
-  health,
-  active,
-  blocked,
-  busy,
-  waiting,
-  onEnable,
-}: {
-  provider: Provider;
-  health?: ProviderHealth;
-  active: boolean;
-  /** Why this agent cannot reach it as routed now, empty when it can. */
-  blocked: string;
-  busy: boolean;
-  /** Another card of the same agent is being enabled. */
-  waiting: boolean;
-  onEnable: () => void;
-}) {
-  const protocol = providerProtocol(provider.type);
-
-  return (
-    <Card className={cn("gap-0 py-0", active && "border-primary 
ring-primary/20 ring-1")}>
-      <CardContent className="flex h-full flex-col gap-3 p-4">
-        <div className="flex items-start gap-2">
-          <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} 
alt={provider.name} size={22} />
-          <div className="min-w-0 flex-1">
-            <p className="truncate font-medium">{provider.displayName || 
provider.name}</p>
-            <SimpleTooltip title={provider.baseUrl}>
-              <p className="text-muted-foreground truncate font-mono 
text-xs">{provider.baseUrl || "-"}</p>
-            </SimpleTooltip>
-          </div>
-        </div>
-
-        <div className="flex flex-wrap items-center gap-1.5">
-          <Badge variant={protocol === "anthropic" ? "info" : 
"success"}>{protocol}</Badge>
-          {usesClientAuth(provider) ? (
-            <Badge variant="muted">{i18next.t("provider:Caller's own 
login")}</Badge>
-          ) : null}
-          {provider.status === "enabled" ? null : (
-            <Badge variant="muted">
-              <CircleX />
-              {i18next.t("provider:Disabled")}
-            </Badge>
-          )}
-          {health && !health.healthy ? (
-            <SimpleTooltip title={health.lastError}>
-              <span>
-                <Badge variant="warning">{i18next.t("agent:Cooling 
down")}</Badge>
-              </span>
-            </SimpleTooltip>
-          ) : null}
-        </div>
-
-        <div className="mt-auto flex items-center gap-2 border-t pt-3">
-          {active ? (
-            <Badge variant="success">
-              <Check />
-              {i18next.t("agent:In use")}
-            </Badge>
-          ) : blocked === "" ? (
-            <Button size="sm" loading={busy} disabled={waiting} 
onClick={onEnable}>
-              {i18next.t("agent:Enable")}
-            </Button>
-          ) : (
-            <SimpleTooltip title={blocked}>
-              <span>
-                <Button size="sm" variant="outline" disabled>
-                  {i18next.t("agent:Enable")}
-                </Button>
-              </span>
-            </SimpleTooltip>
-          )}
-        </div>
-      </CardContent>
-    </Card>
-  );
-}
+/** How often the numbers on the cards are read again. */
+const refreshMs = 10000;
 
 /**
- * The home screen: the agents installed on this machine on the left, and the
- * providers the selected one can be put on to the right of it. One click on a
- * card is the whole switch — the routing is stored and, where Gateway writes
- * the agent's configuration file, that file is rewritten with it.
+ * The home screen: one card per agent installed on this machine. Each card
+ * carries what that agent is on, what it has spent there and whether it is
+ * running, and the provider box switches it over in one click — the routing is
+ * stored and, where Gateway writes the agent's configuration file, that file 
is
+ * rewritten with it.
  */
 export default function HomePage({account}: {account: Account}) {
   const isAdmin = Setting.isAdminUser(account);
@@ -207,11 +60,16 @@ export default function HomePage({account}: {account: 
Account}) {
   } = useAgents(isAdmin);
   const [providers, setProviders] = React.useState<Provider[]>([]);
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
+  const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
+  const [stats, setStats] = React.useState<LlmAgentStat[]>([]);
+  // Nothing is recorded until it is asked for, and until it is a zero on a 
card
+  // would read as "this agent relayed nothing" rather than "nobody counted".
+  const [recording, setRecording] = React.useState(true);
   // False until the first listing lands, so "no providers" never flashes.
   const [loaded, setLoaded] = React.useState(false);
-  const [selected, setSelected] = React.useState("");
-  // Which card was clicked, so only that one spins while the switch is made.
-  const [enabling, setEnabling] = React.useState("");
+  // The sessions feed is only worth polling once something is being watched.
+  const watching = agents.some(agent => agent.patched);
+  const {activity} = useAgentSessions(isAdmin && watching, "", refreshMs);
 
   const loadProviders = React.useCallback(() => {
     if (!isAdmin) {
@@ -221,14 +79,33 @@ export default function HomePage({account}: {account: 
Account}) {
       .then(res => setProviders(res.status === "ok" ? (res.data ?? []) : []))
       .catch(() => setProviders([]))
       .then(() => setLoaded(true));
+
+    // What is already known paints at once; the refresh then asks only the
+    // vendors whose answer has gone stale, so a page view costs at most one
+    // request per provider every ten minutes.
+    ProviderBackend.getProviderQuotas()
+      .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined)
+      .then(() => ProviderBackend.refreshProviderQuotas("", false))
+      .then(res => setQuotas(res?.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => undefined);
   }, [isAdmin, account.name]);
 
   React.useEffect(() => {
     loadProviders();
   }, [loadProviders]);
 
-  // What the proxy has seen of each provider changes as requests are relayed,
-  // so it is polled rather than read once.
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return;
+    }
+    LlmRecordBackend.getLlmRecordStatus()
+      .then(res => setRecording(res.status !== "ok" || (res.data?.mode ?? 
"off") !== "off"))
+      .catch(() => undefined);
+  }, [isAdmin]);
+
+  // What the proxy has seen changes as requests are relayed, so the health and
+  // the per-agent totals are polled rather than read once.
   React.useEffect(() => {
     if (!isAdmin) {
       return undefined;
@@ -238,10 +115,13 @@ export default function HomePage({account}: {account: 
Account}) {
       ProviderBackend.getProviderHealth()
         .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
         .catch(() => setHealth([]));
+      LlmRecordBackend.getLlmAgentStats()
+        .then(res => setStats(res.status === "ok" ? (res.data ?? []) : []))
+        .catch(() => setStats([]));
     };
 
     load();
-    const interval = setInterval(load, 10000);
+    const interval = setInterval(load, refreshMs);
     return () => clearInterval(interval);
   }, [isAdmin]);
 
@@ -249,17 +129,6 @@ export default function HomePage({account}: {account: 
Account}) {
     return <UnauthorizedResult />;
   }
 
-  // The rail keeps its selection across a rescan; the first agent stands in
-  // until something is picked, so the right side is never blank.
-  const agent = agents.find(candidate => agentKey(candidate) === selected) ?? 
agents[0];
-  const status = agent ? runtimeOf(runtime, agent) : undefined;
-  const busy = agent !== undefined && busyKey === agentKey(agent);
-  const nameOf = (id: string) => {
-    const provider = providers.find(candidate => providerIdOf(candidate) === 
id);
-    return provider ? provider.displayName || provider.name : id;
-  };
-  const patchAction = agent ? i18next.t(`agent:${agent.patched ? "Unpatch" : 
"Patch"}`) : "";
-
   const refresh = () => {
     scan(true);
     loadRuntime(true);
@@ -293,6 +162,22 @@ export default function HomePage({account}: {account: 
Account}) {
 
       {error ? <MessageAlert title={error} /> : null}
 
+      {loaded && providers.length === 0 ? (
+        <MessageAlert
+          variant="info"
+          title={i18next.t("provider:No providers yet")}
+          description={i18next.t("provider:No providers yet detail")}
+          action={
+            <Button asChild size="sm">
+              <Link to="/providers">
+                <Plus />
+                {i18next.t("provider:New Provider")}
+              </Link>
+            </Button>
+          }
+        />
+      ) : null}
+
       {!scanned ? (
         <Loading tip={i18next.t("agent:Scan")} />
       ) : agents.length === 0 ? (
@@ -309,117 +194,26 @@ export default function HomePage({account}: {account: 
Account}) {
           />
         </Card>
       ) : (
-        <div className="grid grid-cols-1 gap-4 
lg:grid-cols-[15rem_minmax(0,1fr)]">
-          <Card className="h-max gap-0 py-2">
-            <CardContent className="space-y-0.5 px-2">
-              {agents.map(item => (
-                <AgentRow
-                  key={agentKey(item)}
-                  agent={item}
-                  provider={item.provider === "" ? i18next.t("agent:No 
provider") : nameOf(item.provider)}
-                  status={runtimeOf(runtime, item)}
-                  active={agent !== undefined && agentKey(item) === 
agentKey(agent)}
-                  onSelect={() => setSelected(agentKey(item))}
-                />
-              ))}
-            </CardContent>
-          </Card>
-
-          <div className="min-w-0 space-y-3">
-            {agent === undefined ? null : (
-              <>
-                <div className="flex flex-wrap items-center gap-2 border-b 
pb-3">
-                  <AgentIcon
-                    agent={agent.agentId || agent.name}
-                    size={24}
-                    fallback={<Bot className="text-muted-foreground size-6" />}
-                  />
-                  <span className="font-semibold">{agent.name}</span>
-                  <Badge variant="muted">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
-                  <RunBadge status={status} />
-
-                  <div className="ml-auto flex flex-wrap items-center gap-3">
-                    <label className="text-muted-foreground flex items-center 
gap-1.5 text-xs">
-                      {i18next.t("agent:Monitored")}
-                      {agent.supported ? (
-                        <ConfirmDialog
-                          title={`${patchAction} ${agent.name}?`}
-                          description={[agent.notice, 
agent.followup].filter(Boolean).join(" ") || undefined}
-                          confirmText={patchAction}
-                          variant={agent.patched ? "destructive" : "default"}
-                          onConfirm={() => togglePatch(agent)}
-                        >
-                          {/* The dialog owns the click, so the switch only 
ever
-                              mirrors what the last scan reported. */}
-                          <Switch
-                            checked={agent.patched}
-                            disabled={busy}
-                            aria-label={patchAction}
-                            onCheckedChange={() => undefined}
-                          />
-                        </ConfirmDialog>
-                      ) : (
-                        <SimpleTooltip title={agent.detail || 
i18next.t("agent:Not supported")}>
-                          <span>
-                            <Switch checked={false} disabled 
aria-label={i18next.t("agent:Patch")} />
-                          </span>
-                        </SimpleTooltip>
-                      )}
-                    </label>
-                    <RunButton
-                      agent={agent}
-                      status={status}
-                      busy={runBusyKey === agentKey(agent)}
-                      onToggle={toggleRunning}
-                    />
-                    <Link
-                      to={agentDetailPath(agent, agents)}
-                      className="text-primary inline-flex items-center text-sm 
hover:underline"
-                    >
-                      {i18next.t("agent:Details")}
-                      <ChevronRight className="size-4" />
-                    </Link>
-                  </div>
-                </div>
-
-                {loaded && providers.length === 0 ? (
-                  <Card className="py-0">
-                    <EmptyState
-                      icon={Plug}
-                      title={i18next.t("provider:No providers yet")}
-                      description={i18next.t("provider:No providers yet 
detail")}
-                      action={
-                        <Button asChild>
-                          <Link to="/providers">
-                            <Plus />
-                            {i18next.t("provider:New Provider")}
-                          </Link>
-                        </Button>
-                      }
-                    />
-                  </Card>
-                ) : (
-                  <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 
2xl:grid-cols-3">
-                    {providers.map(provider => (
-                      <ProviderChoice
-                        key={providerIdOf(provider)}
-                        provider={provider}
-                        health={health.find(item => item.provider === 
providerIdOf(provider))}
-                        active={agent.provider === providerIdOf(provider)}
-                        blocked={blockedReason(agent, provider)}
-                        busy={busy && enabling === providerIdOf(provider)}
-                        waiting={busy && enabling !== providerIdOf(provider)}
-                        onEnable={() => {
-                          setEnabling(providerIdOf(provider));
-                          activateProvider(agent, providerIdOf(provider));
-                        }}
-                      />
-                    ))}
-                  </div>
-                )}
-              </>
-            )}
-          </div>
+        <div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
+          {agents.map(agent => (
+            <AgentGridCard
+              key={agentKey(agent)}
+              agent={agent}
+              agents={agents}
+              providers={providers}
+              health={health}
+              quota={quotas.find(item => item.provider === agent.provider)}
+              stats={stats.find(item => item.agent === agent.agentId)}
+              activity={activityOf(activity, agent)}
+              status={runtimeOf(runtime, agent)}
+              recording={recording}
+              busy={busyKey === agentKey(agent)}
+              runBusy={runBusyKey === agentKey(agent)}
+              onEnable={providerId => activateProvider(agent, providerId)}
+              onToggleRunning={toggleRunning}
+              onTogglePatch={() => togglePatch(agent)}
+            />
+          ))}
         </div>
       )}
     </PageContainer>
diff --git a/web/src/types.ts b/web/src/types.ts
index b27fc99..f63fc49 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -410,6 +410,18 @@ export interface LlmRecordStats {
   providers: LlmProviderStat[];
 }
 
+/** One agent's share of the relayed requests, with what it last asked for. */
+export interface LlmAgentStat {
+  agent: string;
+  requests: number;
+  failed: number;
+  tokens: number;
+  cost: number;
+  lastTime: string;
+  lastModel: string;
+  lastProvider: string;
+}
+
 export interface MetricPoint {
   data: string;
   count: number;


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

Reply via email to