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]