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 79002bc  fix(ui): drop the path from an agent link that does not need 
it
79002bc is described below

commit 79002bc231bc53301b8632a60440f228d74bdc0d
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 22:32:06 2026 +0800

    fix(ui): drop the path from an agent link that does not need it
---
 web/src/lib/agents.ts                | 788 ++++++++++++++++++-----------------
 web/src/pages/AgentDashboardPage.tsx | 699 +++++++++++++++----------------
 web/src/pages/AgentsPage.tsx         | 488 +++++++++++-----------
 3 files changed, 992 insertions(+), 983 deletions(-)

diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 0a2043d..2afaac4 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -1,391 +1,397 @@
-// Copyright 2026 The casbin Authors. All Rights Reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as React from "react";
-import i18next from "i18next";
-
-import * as AgentBackend from "@/backend/AgentBackend";
-import * as Setting from "@/Setting";
-import type {BadgeVariant} from "@/components/ui/badge";
-import type {Agent, AgentRuntime, AgentSession} from "@/types";
-
-/** How long a started app is given before its process is looked for again. */
-const runtimeSettleMs = 2000;
-
-/** Routing through the local proxy, which is what makes a switch hot. */
-export const gatewayMode = "gateway";
-export const directMode = "direct";
-
-/** The monitor keys the two Codex front ends under one agent id. */
-export function monitorAgentId(agentId: string) {
-  return agentId === "codex_vscode" || agentId === "codex-vscode" ? 
"codex-cli" : agentId;
-}
-
-/**
- * agentKey identifies one installation. The same agent can be installed twice
- * on a host - once per user account, or once per install method - so neither
- * the agent id nor the path is unique on its own.
- */
-export function agentKey(agent: Pick<Agent, "owner" | "path">) {
-  return `${agent.owner}:${agent.path}`;
-}
-
-/**
- * The base URL an agent is pointed at to reach its own provider. One URL 
serves
- * every wire format: an OpenAI client appends /chat/completions to it, Codex
- * appends /responses, and an Anthropic one appends /v1/messages.
- */
-export function agentProxyBaseUrl(agentId: string) {
-  return `${Setting.ServerUrl || 
window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
-}
-
-/**
- * A config file that carries the same setting for good, for the agents that
- * have one. Empty when only the environment is known to work.
- */
-export function agentSetupNoteKey(agentId: string) {
-  switch (agentId) {
-  case "claude-code":
-    return "agent:Claude Code config hint";
-  case "codex-cli":
-    return "agent:Codex CLI config hint";
-  default:
-    return "";
-  }
-}
-
-/**
- * Mirrors the check in agentprovider's Codex writer: Codex speaks only the
- * OpenAI Responses API, so it reaches a chat completions provider through
- * Gateway, which translates, or not at all.
- */
-export function agentNeedsResponsesApi(agentId: string) {
-  return agentId.startsWith("codex");
-}
-
-/** The detail page route for one installation, disambiguated by its path. */
-export function agentDetailPath(agent: Agent) {
-  return 
`/agents/${encodeURIComponent(agent.agentId)}?path=${encodeURIComponent(agent.path)}`;
-}
-
-/**
- * useAgents owns the local-installation scan and the monitoring toggle, which
- * the dashboard, the list page and the detail page all need.
- */
-export function useAgents(enabled = true) {
-  const [agents, setAgents] = React.useState<Agent[]>([]);
-  const [loading, setLoading] = React.useState(false);
-  const [error, setError] = React.useState("");
-  const [busyKey, setBusyKey] = React.useState("");
-  // The run state is keyed by installation and loaded on its own, so it can be
-  // refreshed after a start or a stop without scanning the disk again.
-  const [runtime, setRuntime] = React.useState<Record<string, 
AgentRuntime>>({});
-  const [runBusyKey, setRunBusyKey] = React.useState("");
-  // A scan inside a container reads the container's filesystem, not the 
host's,
-  // which is why it can come back empty on a machine full of agents.
-  const [inContainer, setInContainer] = React.useState(false);
-  // A scan is only "done" once the first response has landed, which is what
-  // tells an empty list apart from a list that has not arrived yet.
-  const [scanned, setScanned] = React.useState(false);
-
-  const scan = React.useCallback(
-    (forceRefresh = false) => {
-      if (!enabled) {
-        return;
-      }
-
-      setLoading(true);
-      setError("");
-      AgentBackend.getAgents(forceRefresh)
-        .then(res => {
-          if (res.status === "ok") {
-            setAgents(res.data ?? []);
-            setInContainer(res.data2 === true);
-          } else {
-            setError(res.msg || i18next.t("agent:Failed to scan agents"));
-          }
-        })
-        .catch(err => setError(err.message || String(err)))
-        .then(() => {
-          setLoading(false);
-          setScanned(true);
-        });
-    },
-    [enabled],
-  );
-
-  const loadRuntime = React.useCallback((forceRefresh = false) => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentProcesses(forceRefresh)
-      .then(res => {
-        if (res.status === "ok") {
-          const next: Record<string, AgentRuntime> = {};
-          (res.data ?? []).forEach(item => {
-            next[agentKey(item)] = item;
-          });
-          setRuntime(next);
-        }
-      })
-      .catch(() => undefined);
-  }, [enabled]);
-
-  React.useEffect(() => {
-    scan();
-    loadRuntime();
-  }, [scan, loadRuntime]);
-
-  /**
-   * toggleRunning starts the agent or ends every process of it. A desktop app
-   * takes a moment to show up in the process table, so the run state is read
-   * again shortly after the call rather than only once.
-   */
-  const toggleRunning = React.useCallback(
-    (agent: Agent, running: boolean) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
-
-      setRunBusyKey(agentKey(agent));
-      (running ? AgentBackend.stopAgent(target) : 
AgentBackend.startAgent(target))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent 
started")}: ${agent.name}`,
-            );
-            setTimeout(() => loadRuntime(true), runtimeSettleMs);
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg ||
-                i18next.t(running ? "agent:Failed to stop the agent" : 
"agent:Failed to start the agent"),
-            );
-          }
-          loadRuntime(true);
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setRunBusyKey(""));
-    },
-    [loadRuntime],
-  );
-
-  const togglePatch = React.useCallback(
-    (agent: Agent) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
-      const patched = agent.patched;
-
-      setBusyKey(agentKey(agent));
-      (patched ? AgentBackend.unpatchAgent(target) : 
AgentBackend.patchAgent(target))
-        .then(res => {
-          if (res.status === "ok") {
-            const done = patched
-              ? i18next.t("agent:Monitoring disabled")
-              : i18next.t("agent:Monitoring enabled");
-            const followup = res.data?.followup || agent.followup;
-            Setting.showMessage(
-              "success",
-              followup ? `${done}: ${agent.name}. ${followup}` : `${done}: 
${agent.name}`,
-            );
-            scan();
-          } else {
-            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
update agent patch"));
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  /**
-   * setRouting saves where an agent's requests go. Only the changed part is
-   * passed in; the rest of the routing is carried over from the agent so a
-   * fallback list is not dropped by a provider change.
-   */
-  const setRouting = React.useCallback(
-    (agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
-      const next: AgentBackend.AgentRouting = {
-        provider: routing.provider ?? agent.provider,
-        fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
-        mode: routing.mode ?? agent.mode ?? gatewayMode,
-      };
-
-      setBusyKey(agentKey(agent));
-      AgentBackend.updateAgentRouting(agent.agentId, next)
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              next.provider === ""
-                ? i18next.t("agent:Provider cleared")
-                : `${i18next.t("agent:Provider saved")}: ${next.provider}`,
-            );
-            scan();
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg || i18next.t("agent:Failed to update agent provider"),
-            );
-            // The routing itself may already be stored: only writing the 
agent's
-            // own configuration file failed, and the page has to show that.
-            scan();
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  /**
-   * writeProvider writes the bound provider into the agent's own config file, 
or
-   * puts back what the file held before Gateway first wrote it.
-   */
-  const writeProvider = React.useCallback(
-    (agent: Agent, restore = false) => {
-      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
-
-      setBusyKey(agentKey(agent));
-      (restore
-        ? AgentBackend.restoreAgentProvider(target)
-        : AgentBackend.applyAgentProvider(target))
-        .then(res => {
-          if (res.status === "ok") {
-            Setting.showMessage(
-              "success",
-              restore
-                ? i18next.t("agent:Configuration restored")
-                : i18next.t("agent:Configuration written"),
-            );
-            scan();
-          } else {
-            Setting.showMessage(
-              "error",
-              res.msg || i18next.t("agent:Failed to write the agent 
configuration"),
-            );
-          }
-        })
-        .catch(err => Setting.showMessage("error", err.message || String(err)))
-        .then(() => setBusyKey(""));
-    },
-    [scan],
-  );
-
-  return {
-    agents,
-    loading,
-    error,
-    busyKey,
-    scanned,
-    inContainer,
-    runtime,
-    runBusyKey,
-    scan,
-    loadRuntime,
-    toggleRunning,
-    togglePatch,
-    setRouting,
-    writeProvider,
-  };
-}
-
-/** The run state of one installation, before the first listing has landed. */
-export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent) 
{
-  return runtime[agentKey(agent)];
-}
-
-/** What one agent has been up to, derived from its monitoring sessions. */
-export interface AgentActivity {
-  sessionCount: number;
-  recordCount: number;
-  lastTime: string;
-}
-
-/**
- * useAgentSessions loads the live session summaries. Each summary already
- * carries its agent, record count and last activity, so the per-agent totals
- * the dashboard shows are folded out of this one request rather than out of 
the
- * far larger raw record list.
- */
-export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) {
-  const [sessions, setSessions] = React.useState<AgentSession[]>([]);
-  const [error, setError] = React.useState("");
-
-  const load = React.useCallback(() => {
-    if (!enabled) {
-      return;
-    }
-
-    AgentBackend.getAgentSessions(agent)
-      .then(res => {
-        if (res.status === "ok") {
-          setSessions(res.data ?? []);
-          setError("");
-        } else {
-          setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
-        }
-      })
-      .catch(err => setError(err.message || String(err)));
-  }, [enabled, agent]);
-
-  React.useEffect(() => {
-    load();
-    if (!enabled || refreshMs <= 0) {
-      return undefined;
-    }
-    const interval = setInterval(load, refreshMs);
-    return () => clearInterval(interval);
-  }, [enabled, load, refreshMs]);
-
-  const activity = React.useMemo(() => {
-    const totals: Record<string, AgentActivity> = {};
-    sessions.forEach(session => {
-      const current = totals[session.agent] ?? {sessionCount: 0, recordCount: 
0, lastTime: ""};
-      totals[session.agent] = {
-        sessionCount: current.sessionCount + 1,
-        recordCount: current.recordCount + session.recordCount,
-        lastTime:
-          current.lastTime === "" || session.lastTime > current.lastTime
-            ? session.lastTime
-            : current.lastTime,
-      };
-    });
-    return totals;
-  }, [sessions]);
-
-  const recordCount = React.useMemo(
-    () => sessions.reduce((total, session) => total + session.recordCount, 0),
-    [sessions],
-  );
-
-  return {sessions, activity, recordCount, error, reload: load};
-}
-
-/** The activity of the installation, looked up under the id the monitor uses. 
*/
-export function activityOf(activity: Record<string, AgentActivity>, agent: 
Agent) {
-  return activity[monitorAgentId(agent.agentId)];
-}
-
-/** The badge colour every record table paints an outcome with. */
-export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
-  return (
-    {
-      attempted: "info",
-      denied: "warning",
-      failure: "danger",
-      success: "success",
-    } as const
-  )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
-}
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as Setting from "@/Setting";
+import type {BadgeVariant} from "@/components/ui/badge";
+import type {Agent, AgentRuntime, AgentSession} from "@/types";
+
+/** How long a started app is given before its process is looked for again. */
+const runtimeSettleMs = 2000;
+
+/** Routing through the local proxy, which is what makes a switch hot. */
+export const gatewayMode = "gateway";
+export const directMode = "direct";
+
+/** The monitor keys the two Codex front ends under one agent id. */
+export function monitorAgentId(agentId: string) {
+  return agentId === "codex_vscode" || agentId === "codex-vscode" ? 
"codex-cli" : agentId;
+}
+
+/**
+ * agentKey identifies one installation. The same agent can be installed twice
+ * on a host - once per user account, or once per install method - so neither
+ * the agent id nor the path is unique on its own.
+ */
+export function agentKey(agent: Pick<Agent, "owner" | "path">) {
+  return `${agent.owner}:${agent.path}`;
+}
+
+/**
+ * The base URL an agent is pointed at to reach its own provider. One URL 
serves
+ * every wire format: an OpenAI client appends /chat/completions to it, Codex
+ * appends /responses, and an Anthropic one appends /v1/messages.
+ */
+export function agentProxyBaseUrl(agentId: string) {
+  return `${Setting.ServerUrl || 
window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
+}
+
+/**
+ * A config file that carries the same setting for good, for the agents that
+ * have one. Empty when only the environment is known to work.
+ */
+export function agentSetupNoteKey(agentId: string) {
+  switch (agentId) {
+  case "claude-code":
+    return "agent:Claude Code config hint";
+  case "codex-cli":
+    return "agent:Codex CLI config hint";
+  default:
+    return "";
+  }
+}
+
+/**
+ * Mirrors the check in agentprovider's Codex writer: Codex speaks only the
+ * OpenAI Responses API, so it reaches a chat completions provider through
+ * Gateway, which translates, or not at all.
+ */
+export function agentNeedsResponsesApi(agentId: string) {
+  return agentId.startsWith("codex");
+}
+
+/**
+ * The detail page route for one installation. The path is only spelled out 
when
+ * the same agent is installed more than once, which is what makes the id alone
+ * ambiguous; otherwise the URL stays short.
+ */
+export function agentDetailPath(agent: Agent, agents: Agent[] = []) {
+  const url = `/agents/${encodeURIComponent(agent.agentId)}`;
+  const duplicated = agents.filter(candidate => candidate.agentId === 
agent.agentId).length > 1;
+  return duplicated ? `${url}?path=${encodeURIComponent(agent.path)}` : url;
+}
+
+/**
+ * useAgents owns the local-installation scan and the monitoring toggle, which
+ * the dashboard, the list page and the detail page all need.
+ */
+export function useAgents(enabled = true) {
+  const [agents, setAgents] = React.useState<Agent[]>([]);
+  const [loading, setLoading] = React.useState(false);
+  const [error, setError] = React.useState("");
+  const [busyKey, setBusyKey] = React.useState("");
+  // The run state is keyed by installation and loaded on its own, so it can be
+  // refreshed after a start or a stop without scanning the disk again.
+  const [runtime, setRuntime] = React.useState<Record<string, 
AgentRuntime>>({});
+  const [runBusyKey, setRunBusyKey] = React.useState("");
+  // A scan inside a container reads the container's filesystem, not the 
host's,
+  // which is why it can come back empty on a machine full of agents.
+  const [inContainer, setInContainer] = React.useState(false);
+  // A scan is only "done" once the first response has landed, which is what
+  // tells an empty list apart from a list that has not arrived yet.
+  const [scanned, setScanned] = React.useState(false);
+
+  const scan = React.useCallback(
+    (forceRefresh = false) => {
+      if (!enabled) {
+        return;
+      }
+
+      setLoading(true);
+      setError("");
+      AgentBackend.getAgents(forceRefresh)
+        .then(res => {
+          if (res.status === "ok") {
+            setAgents(res.data ?? []);
+            setInContainer(res.data2 === true);
+          } else {
+            setError(res.msg || i18next.t("agent:Failed to scan agents"));
+          }
+        })
+        .catch(err => setError(err.message || String(err)))
+        .then(() => {
+          setLoading(false);
+          setScanned(true);
+        });
+    },
+    [enabled],
+  );
+
+  const loadRuntime = React.useCallback((forceRefresh = false) => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentProcesses(forceRefresh)
+      .then(res => {
+        if (res.status === "ok") {
+          const next: Record<string, AgentRuntime> = {};
+          (res.data ?? []).forEach(item => {
+            next[agentKey(item)] = item;
+          });
+          setRuntime(next);
+        }
+      })
+      .catch(() => undefined);
+  }, [enabled]);
+
+  React.useEffect(() => {
+    scan();
+    loadRuntime();
+  }, [scan, loadRuntime]);
+
+  /**
+   * toggleRunning starts the agent or ends every process of it. A desktop app
+   * takes a moment to show up in the process table, so the run state is read
+   * again shortly after the call rather than only once.
+   */
+  const toggleRunning = React.useCallback(
+    (agent: Agent, running: boolean) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+
+      setRunBusyKey(agentKey(agent));
+      (running ? AgentBackend.stopAgent(target) : 
AgentBackend.startAgent(target))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              `${i18next.t(running ? "agent:Agent stopped" : "agent:Agent 
started")}: ${agent.name}`,
+            );
+            setTimeout(() => loadRuntime(true), runtimeSettleMs);
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg ||
+                i18next.t(running ? "agent:Failed to stop the agent" : 
"agent:Failed to start the agent"),
+            );
+          }
+          loadRuntime(true);
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setRunBusyKey(""));
+    },
+    [loadRuntime],
+  );
+
+  const togglePatch = React.useCallback(
+    (agent: Agent) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+      const patched = agent.patched;
+
+      setBusyKey(agentKey(agent));
+      (patched ? AgentBackend.unpatchAgent(target) : 
AgentBackend.patchAgent(target))
+        .then(res => {
+          if (res.status === "ok") {
+            const done = patched
+              ? i18next.t("agent:Monitoring disabled")
+              : i18next.t("agent:Monitoring enabled");
+            const followup = res.data?.followup || agent.followup;
+            Setting.showMessage(
+              "success",
+              followup ? `${done}: ${agent.name}. ${followup}` : `${done}: 
${agent.name}`,
+            );
+            scan();
+          } else {
+            Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
update agent patch"));
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  /**
+   * setRouting saves where an agent's requests go. Only the changed part is
+   * passed in; the rest of the routing is carried over from the agent so a
+   * fallback list is not dropped by a provider change.
+   */
+  const setRouting = React.useCallback(
+    (agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
+      const next: AgentBackend.AgentRouting = {
+        provider: routing.provider ?? agent.provider,
+        fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
+        mode: routing.mode ?? agent.mode ?? gatewayMode,
+      };
+
+      setBusyKey(agentKey(agent));
+      AgentBackend.updateAgentRouting(agent.agentId, next)
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              next.provider === ""
+                ? i18next.t("agent:Provider cleared")
+                : `${i18next.t("agent:Provider saved")}: ${next.provider}`,
+            );
+            scan();
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg || i18next.t("agent:Failed to update agent provider"),
+            );
+            // The routing itself may already be stored: only writing the 
agent's
+            // own configuration file failed, and the page has to show that.
+            scan();
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  /**
+   * writeProvider writes the bound provider into the agent's own config file, 
or
+   * puts back what the file held before Gateway first wrote it.
+   */
+  const writeProvider = React.useCallback(
+    (agent: Agent, restore = false) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+
+      setBusyKey(agentKey(agent));
+      (restore
+        ? AgentBackend.restoreAgentProvider(target)
+        : AgentBackend.applyAgentProvider(target))
+        .then(res => {
+          if (res.status === "ok") {
+            Setting.showMessage(
+              "success",
+              restore
+                ? i18next.t("agent:Configuration restored")
+                : i18next.t("agent:Configuration written"),
+            );
+            scan();
+          } else {
+            Setting.showMessage(
+              "error",
+              res.msg || i18next.t("agent:Failed to write the agent 
configuration"),
+            );
+          }
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        .then(() => setBusyKey(""));
+    },
+    [scan],
+  );
+
+  return {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+    setRouting,
+    writeProvider,
+  };
+}
+
+/** The run state of one installation, before the first listing has landed. */
+export function runtimeOf(runtime: Record<string, AgentRuntime>, agent: Agent) 
{
+  return runtime[agentKey(agent)];
+}
+
+/** What one agent has been up to, derived from its monitoring sessions. */
+export interface AgentActivity {
+  sessionCount: number;
+  recordCount: number;
+  lastTime: string;
+}
+
+/**
+ * useAgentSessions loads the live session summaries. Each summary already
+ * carries its agent, record count and last activity, so the per-agent totals
+ * the dashboard shows are folded out of this one request rather than out of 
the
+ * far larger raw record list.
+ */
+export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) {
+  const [sessions, setSessions] = React.useState<AgentSession[]>([]);
+  const [error, setError] = React.useState("");
+
+  const load = React.useCallback(() => {
+    if (!enabled) {
+      return;
+    }
+
+    AgentBackend.getAgentSessions(agent)
+      .then(res => {
+        if (res.status === "ok") {
+          setSessions(res.data ?? []);
+          setError("");
+        } else {
+          setError(res.msg || i18next.t("agent:Failed to get agent sessions"));
+        }
+      })
+      .catch(err => setError(err.message || String(err)));
+  }, [enabled, agent]);
+
+  React.useEffect(() => {
+    load();
+    if (!enabled || refreshMs <= 0) {
+      return undefined;
+    }
+    const interval = setInterval(load, refreshMs);
+    return () => clearInterval(interval);
+  }, [enabled, load, refreshMs]);
+
+  const activity = React.useMemo(() => {
+    const totals: Record<string, AgentActivity> = {};
+    sessions.forEach(session => {
+      const current = totals[session.agent] ?? {sessionCount: 0, recordCount: 
0, lastTime: ""};
+      totals[session.agent] = {
+        sessionCount: current.sessionCount + 1,
+        recordCount: current.recordCount + session.recordCount,
+        lastTime:
+          current.lastTime === "" || session.lastTime > current.lastTime
+            ? session.lastTime
+            : current.lastTime,
+      };
+    });
+    return totals;
+  }, [sessions]);
+
+  const recordCount = React.useMemo(
+    () => sessions.reduce((total, session) => total + session.recordCount, 0),
+    [sessions],
+  );
+
+  return {sessions, activity, recordCount, error, reload: load};
+}
+
+/** The activity of the installation, looked up under the id the monitor uses. 
*/
+export function activityOf(activity: Record<string, AgentActivity>, agent: 
Agent) {
+  return activity[monitorAgentId(agent.agentId)];
+}
+
+/** The badge colour every record table paints an outcome with. */
+export function getOutcomeVariant(outcome: string | undefined): BadgeVariant {
+  return (
+    {
+      attempted: "info",
+      denied: "warning",
+      failure: "danger",
+      success: "success",
+    } as const
+  )[outcome as "attempted" | "denied" | "failure" | "success"] ?? "muted";
+}
diff --git a/web/src/pages/AgentDashboardPage.tsx 
b/web/src/pages/AgentDashboardPage.tsx
index 0b49a18..26a78a3 100644
--- a/web/src/pages/AgentDashboardPage.tsx
+++ b/web/src/pages/AgentDashboardPage.tsx
@@ -1,348 +1,351 @@
-// 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,
-  Check,
-  ChevronRight,
-  Container,
-  FileSearch,
-  MessageSquare,
-  RefreshCw,
-  ShieldCheck,
-} from "lucide-react";
-import i18next from "i18next";
-
-import * as ProviderBackend from "@/backend/ProviderBackend";
-import * as Setting from "@/Setting";
-import {AgentIcon} from "@/components/AgentIcon";
-import {RunBadge, RunButton} from "@/components/AgentRunControl";
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {EmptyState} from "@/components/shared/empty-state";
-import {Loading} from "@/components/shared/loading";
-import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
-import {PageContainer, PageHeader} from "@/components/shared/page-header";
-import {StatCard} from "@/components/shared/stat-card";
-import {MessageAlert} from "@/components/ui/alert";
-import {Badge} from "@/components/ui/badge";
-import {Button} from "@/components/ui/button";
-import {Card, CardContent, CardHeader} from "@/components/ui/card";
-import {Switch} from "@/components/ui/switch";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import {
-  activityOf,
-  agentDetailPath,
-  agentKey,
-  runtimeOf,
-  useAgents,
-  useAgentSessions,
-} from "@/lib/agents";
-import {cn} from "@/lib/utils";
-import type {Account, Agent, AgentRuntime} from "@/types";
-
-/** One labelled line inside an agent card. */
-function CardRow({label, children}: {label: string; children: 
React.ReactNode}) {
-  return (
-    <div className="flex items-baseline gap-2">
-      <span className="text-muted-foreground shrink-0 text-xs">{label}</span>
-      <span className="min-w-0 flex-1 truncate text-right">{children}</span>
-    </div>
-  );
-}
-
-function AgentCard({
-  agent,
-  busy,
-  activity,
-  status,
-  runBusy,
-  onToggle,
-  onRun,
-}: {
-  agent: Agent;
-  busy: boolean;
-  activity?: {sessionCount: number; recordCount: number; lastTime: string};
-  status?: AgentRuntime;
-  runBusy: boolean;
-  onToggle: () => void;
-  onRun: (agent: Agent, running: boolean) => void;
-}) {
-  const action = i18next.t(`agent:${agent.patched ? "Unpatch" : "Patch"}`);
-  const note = [agent.notice, agent.followup].filter(Boolean).join(" ");
-
-  const toggle = (
-    <Switch
-      checked={agent.patched}
-      disabled={!agent.supported || busy}
-      aria-label={action}
-      // The confirmation dialog owns the click, so the switch never flips on
-      // its own - it only ever mirrors what the last scan reported.
-      onCheckedChange={() => undefined}
-    />
-  );
-
-  return (
-    <Card className="hover:border-ring/50 flex flex-col gap-3 py-4 
transition-colors">
-      <CardHeader className="flex flex-row items-start gap-3 px-4">
-        <AgentIcon
-          agent={agent.agentId || agent.name}
-          size={40}
-          fallback={<Bot className="text-muted-foreground size-10" />}
-        />
-        <div className="min-w-0 flex-1">
-          <Link to={agentDetailPath(agent)} className="hover:text-primary 
block truncate font-semibold hover:underline">
-            {agent.name}
-          </Link>
-          <div className="mt-1 flex flex-wrap items-center gap-1.5">
-            <RunBadge status={status} />
-            <Badge variant="muted">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
-            {agent.installMethod ? <Badge 
variant="muted">{agent.installMethod}</Badge> : null}
-          </div>
-        </div>
-        <div className="flex shrink-0 items-center gap-2">
-          {busy ? <Loading type="small" className="py-0" /> : null}
-          {agent.supported ? (
-            // The dialog trigger clones its child to attach the click, so the
-            // switch has to be that child - a wrapper that is not a DOM 
element
-            // would drop the handler and the switch would do nothing.
-            <ConfirmDialog
-              title={`${action} ${agent.name}?`}
-              description={note || undefined}
-              confirmText={action}
-              variant={agent.patched ? "destructive" : "default"}
-              onConfirm={onToggle}
-            >
-              {toggle}
-            </ConfirmDialog>
-          ) : (
-            <SimpleTooltip title={agent.detail || i18next.t("agent:Not 
supported")}>
-              <span>{toggle}</span>
-            </SimpleTooltip>
-          )}
-        </div>
-      </CardHeader>
-
-      <CardContent className="flex flex-1 flex-col gap-2 px-4 text-sm">
-        <CardRow label={i18next.t("general:Path")}>
-          <SimpleTooltip title={agent.path}>
-            <span className="inline-flex max-w-full">
-              <CodeText copyable>{agent.path}</CodeText>
-            </span>
-          </SimpleTooltip>
-        </CardRow>
-        <CardRow label={i18next.t("general:Owner")}>
-          <span className="truncate text-xs">{agent.owner}</span>
-        </CardRow>
-
-        <div className="mt-auto flex flex-col gap-2 border-t pt-3">
-          {/* The patcher's own wording is the most precise thing there is 
about
-              a given state, so it hangs off the status line as its tooltip. 
*/}
-          <SimpleTooltip title={agent.detail}>
-            <span className="text-muted-foreground min-w-0 truncate text-xs">
-              {!agent.supported
-                ? i18next.t("agent:Not supported")
-                : !agent.patched
-                  ? i18next.t("agent:Turn on monitoring to collect activity")
-                  : activity
-                    ? `${i18next
-                      .t("agent:{count} sessions")
-                      .replace("{count}", String(activity.sessionCount))} · 
${new Date(
-                      activity.lastTime,
-                    ).toLocaleString()}`
-                    : i18next.t("agent:Monitoring, no activity yet")}
-            </span>
-          </SimpleTooltip>
-          <div className="flex items-center justify-between gap-2">
-            <RunButton agent={agent} status={status} busy={runBusy} 
onToggle={onRun} />
-            <Link
-              to={agentDetailPath(agent)}
-              className="text-primary inline-flex shrink-0 items-center 
text-xs hover:underline"
-            >
-              {i18next.t("agent:Details")}
-              <ChevronRight className="size-3.5" />
-            </Link>
-          </div>
-        </div>
-      </CardContent>
-    </Card>
-  );
-}
-
-/** The two steps between a fresh install and an agent running on your own 
key. */
-function SetupGuide({hasProvider, agents}: {hasProvider: boolean; agents: 
Agent[]}) {
-  const steps = [
-    {
-      done: hasProvider,
-      title: i18next.t("agent:Add a provider"),
-      hint: i18next.t("agent:Add a provider hint"),
-      to: "/providers",
-    },
-    {
-      done: agents.some(agent => agent.provider !== ""),
-      title: i18next.t("agent:Bind the provider"),
-      hint: i18next.t("agent:Bind the provider hint"),
-      // With nothing scanned there is no agent to open, only the list saying 
so.
-      to: agents.length > 0 ? agentDetailPath(agents[0]) : "/agents",
-    },
-  ];
-
-  return (
-    <Card className="gap-3 py-4">
-      <CardHeader className="flex flex-col gap-1 px-4">
-        <span className="font-semibold">{i18next.t("agent:Use your own 
API")}</span>
-        <span className="text-muted-foreground text-sm">{i18next.t("agent:Use 
your own API hint")}</span>
-      </CardHeader>
-      <CardContent className="flex flex-col gap-3 px-4">
-        {steps.map((step, index) => (
-          <div key={step.title} className="flex items-center gap-3">
-            <span
-              className={cn(
-                "flex size-6 shrink-0 items-center justify-center rounded-full 
text-xs",
-                step.done ? "bg-success/15 text-success" : "bg-muted 
text-muted-foreground",
-              )}
-            >
-              {step.done ? <Check className="size-3.5" /> : index + 1}
-            </span>
-            <div className="min-w-0 flex-1">
-              <p className="text-sm font-medium">{step.title}</p>
-              <p className="text-muted-foreground text-xs">{step.hint}</p>
-            </div>
-            <Button asChild variant="outline" size="sm">
-              <Link to={step.to}>{i18next.t("general:Open")}</Link>
-            </Button>
-          </div>
-        ))}
-      </CardContent>
-    </Card>
-  );
-}
-
-export default function AgentDashboardPage({account}: {account: Account}) {
-  const isAdmin = Setting.isAdminUser(account);
-  const {
-    agents,
-    loading,
-    error,
-    busyKey,
-    scanned,
-    inContainer,
-    runtime,
-    runBusyKey,
-    scan,
-    loadRuntime,
-    toggleRunning,
-    togglePatch,
-  } = useAgents(isAdmin);
-  const {activity, recordCount} = useAgentSessions(isAdmin, "", 5000);
-  // null until the provider count is known, so the guide never flashes.
-  const [hasProvider, setHasProvider] = React.useState<boolean | null>(null);
-
-  React.useEffect(() => {
-    if (!isAdmin) {
-      return;
-    }
-    ProviderBackend.getProviders(account.name, 1, 1)
-      .then(res => setHasProvider(res.status === "ok" && (res.data2 ?? 0) > 0))
-      .catch(() => setHasProvider(false));
-  }, [isAdmin, account.name]);
-
-  if (!isAdmin) {
-    return <UnauthorizedResult />;
-  }
-
-  const patchedCount = agents.filter(agent => agent.patched).length;
-  const sessionCount = Object.values(activity).reduce((total, entry) => total 
+ entry.sessionCount, 0);
-  const setupDone = hasProvider === true && agents.some(agent => 
agent.provider !== "");
-
-  return (
-    <PageContainer>
-      <PageHeader
-        title={i18next.t("agent:Agents on this machine")}
-        description={account.hostname}
-        actions={
-          <Button
-            variant="outline"
-            onClick={() => {
-              scan(true);
-              loadRuntime(true);
-            }}
-            loading={loading}
-          >
-            <RefreshCw />
-            {i18next.t("agent:Scan")}
-          </Button>
-        }
-      />
-
-      {error ? <MessageAlert title={error} /> : null}
-
-      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
-        <StatCard label={i18next.t("agent:Installed")} value={agents.length} 
icon={Bot} />
-        <StatCard label={i18next.t("agent:Monitored")} value={patchedCount} 
icon={ShieldCheck} tone="success" />
-        <StatCard label={i18next.t("agent:Agent Sessions")} 
value={sessionCount} icon={MessageSquare} />
-        <StatCard label={i18next.t("agent:Records")} value={recordCount} 
icon={FileSearch} />
-      </div>
-
-      {scanned && hasProvider !== null && !setupDone ? (
-        <SetupGuide hasProvider={hasProvider} agents={agents} />
-      ) : null}
-
-      {!scanned ? (
-        <Loading tip={i18next.t("agent:Scan")} />
-      ) : agents.length === 0 ? (
-        <Card className="py-0">
-          <EmptyState
-            icon={inContainer ? Container : Bot}
-            title={i18next.t(inContainer ? "agent:Running in a container" : 
"agent:No supported agents found")}
-            description={i18next.t(
-              inContainer
-                ? "agent:Running in a container detail"
-                : "agent:Install an AI agent on this machine, then scan again",
-            )}
-            action={
-              <Button
-                variant="outline"
-                onClick={() => {
-                  scan(true);
-                  loadRuntime(true);
-                }}
-                loading={loading}
-              >
-                <RefreshCw />
-                {i18next.t("agent:Scan")}
-              </Button>
-            }
-          />
-        </Card>
-      ) : (
-        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
-          {agents.map(agent => (
-            <AgentCard
-              key={agentKey(agent)}
-              agent={agent}
-              busy={busyKey === agentKey(agent)}
-              activity={activityOf(activity, agent)}
-              status={runtimeOf(runtime, agent)}
-              runBusy={runBusyKey === agentKey(agent)}
-              onToggle={() => togglePatch(agent)}
-              onRun={toggleRunning}
-            />
-          ))}
-        </div>
-      )}
-    </PageContainer>
-  );
-}
+// 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,
+  Check,
+  ChevronRight,
+  Container,
+  FileSearch,
+  MessageSquare,
+  RefreshCw,
+  ShieldCheck,
+} from "lucide-react";
+import i18next from "i18next";
+
+import * as ProviderBackend from "@/backend/ProviderBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {RunBadge, RunButton} from "@/components/AgentRunControl";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {EmptyState} from "@/components/shared/empty-state";
+import {Loading} from "@/components/shared/loading";
+import {CodeText, UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {StatCard} from "@/components/shared/stat-card";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent, CardHeader} from "@/components/ui/card";
+import {Switch} from "@/components/ui/switch";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {
+  activityOf,
+  agentDetailPath,
+  agentKey,
+  runtimeOf,
+  useAgents,
+  useAgentSessions,
+} from "@/lib/agents";
+import {cn} from "@/lib/utils";
+import type {Account, Agent, AgentRuntime} from "@/types";
+
+/** One labelled line inside an agent card. */
+function CardRow({label, children}: {label: string; children: 
React.ReactNode}) {
+  return (
+    <div className="flex items-baseline gap-2">
+      <span className="text-muted-foreground shrink-0 text-xs">{label}</span>
+      <span className="min-w-0 flex-1 truncate text-right">{children}</span>
+    </div>
+  );
+}
+
+function AgentCard({
+  agent,
+  detailTo,
+  busy,
+  activity,
+  status,
+  runBusy,
+  onToggle,
+  onRun,
+}: {
+  agent: Agent;
+  detailTo: string;
+  busy: boolean;
+  activity?: {sessionCount: number; recordCount: number; lastTime: string};
+  status?: AgentRuntime;
+  runBusy: boolean;
+  onToggle: () => void;
+  onRun: (agent: Agent, running: boolean) => void;
+}) {
+  const action = i18next.t(`agent:${agent.patched ? "Unpatch" : "Patch"}`);
+  const note = [agent.notice, agent.followup].filter(Boolean).join(" ");
+
+  const toggle = (
+    <Switch
+      checked={agent.patched}
+      disabled={!agent.supported || busy}
+      aria-label={action}
+      // The confirmation dialog owns the click, so the switch never flips on
+      // its own - it only ever mirrors what the last scan reported.
+      onCheckedChange={() => undefined}
+    />
+  );
+
+  return (
+    <Card className="hover:border-ring/50 flex flex-col gap-3 py-4 
transition-colors">
+      <CardHeader className="flex flex-row items-start gap-3 px-4">
+        <AgentIcon
+          agent={agent.agentId || agent.name}
+          size={40}
+          fallback={<Bot className="text-muted-foreground size-10" />}
+        />
+        <div className="min-w-0 flex-1">
+          <Link to={detailTo} className="hover:text-primary block truncate 
font-semibold hover:underline">
+            {agent.name}
+          </Link>
+          <div className="mt-1 flex flex-wrap items-center gap-1.5">
+            <RunBadge status={status} />
+            <Badge variant="muted">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
+            {agent.installMethod ? <Badge 
variant="muted">{agent.installMethod}</Badge> : null}
+          </div>
+        </div>
+        <div className="flex shrink-0 items-center gap-2">
+          {busy ? <Loading type="small" className="py-0" /> : null}
+          {agent.supported ? (
+            // The dialog trigger clones its child to attach the click, so the
+            // switch has to be that child - a wrapper that is not a DOM 
element
+            // would drop the handler and the switch would do nothing.
+            <ConfirmDialog
+              title={`${action} ${agent.name}?`}
+              description={note || undefined}
+              confirmText={action}
+              variant={agent.patched ? "destructive" : "default"}
+              onConfirm={onToggle}
+            >
+              {toggle}
+            </ConfirmDialog>
+          ) : (
+            <SimpleTooltip title={agent.detail || i18next.t("agent:Not 
supported")}>
+              <span>{toggle}</span>
+            </SimpleTooltip>
+          )}
+        </div>
+      </CardHeader>
+
+      <CardContent className="flex flex-1 flex-col gap-2 px-4 text-sm">
+        <CardRow label={i18next.t("general:Path")}>
+          <SimpleTooltip title={agent.path}>
+            <span className="inline-flex max-w-full">
+              <CodeText copyable>{agent.path}</CodeText>
+            </span>
+          </SimpleTooltip>
+        </CardRow>
+        <CardRow label={i18next.t("general:Owner")}>
+          <span className="truncate text-xs">{agent.owner}</span>
+        </CardRow>
+
+        <div className="mt-auto flex flex-col gap-2 border-t pt-3">
+          {/* The patcher's own wording is the most precise thing there is 
about
+              a given state, so it hangs off the status line as its tooltip. 
*/}
+          <SimpleTooltip title={agent.detail}>
+            <span className="text-muted-foreground min-w-0 truncate text-xs">
+              {!agent.supported
+                ? i18next.t("agent:Not supported")
+                : !agent.patched
+                  ? i18next.t("agent:Turn on monitoring to collect activity")
+                  : activity
+                    ? `${i18next
+                      .t("agent:{count} sessions")
+                      .replace("{count}", String(activity.sessionCount))} · 
${new Date(
+                      activity.lastTime,
+                    ).toLocaleString()}`
+                    : i18next.t("agent:Monitoring, no activity yet")}
+            </span>
+          </SimpleTooltip>
+          <div className="flex items-center justify-between gap-2">
+            <RunButton agent={agent} status={status} busy={runBusy} 
onToggle={onRun} />
+            <Link
+              to={detailTo}
+              className="text-primary inline-flex shrink-0 items-center 
text-xs hover:underline"
+            >
+              {i18next.t("agent:Details")}
+              <ChevronRight className="size-3.5" />
+            </Link>
+          </div>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+/** The two steps between a fresh install and an agent running on your own 
key. */
+function SetupGuide({hasProvider, agents}: {hasProvider: boolean; agents: 
Agent[]}) {
+  const steps = [
+    {
+      done: hasProvider,
+      title: i18next.t("agent:Add a provider"),
+      hint: i18next.t("agent:Add a provider hint"),
+      to: "/providers",
+    },
+    {
+      done: agents.some(agent => agent.provider !== ""),
+      title: i18next.t("agent:Bind the provider"),
+      hint: i18next.t("agent:Bind the provider hint"),
+      // With nothing scanned there is no agent to open, only the list saying 
so.
+      to: agents.length > 0 ? agentDetailPath(agents[0], agents) : "/agents",
+    },
+  ];
+
+  return (
+    <Card className="gap-3 py-4">
+      <CardHeader className="flex flex-col gap-1 px-4">
+        <span className="font-semibold">{i18next.t("agent:Use your own 
API")}</span>
+        <span className="text-muted-foreground text-sm">{i18next.t("agent:Use 
your own API hint")}</span>
+      </CardHeader>
+      <CardContent className="flex flex-col gap-3 px-4">
+        {steps.map((step, index) => (
+          <div key={step.title} className="flex items-center gap-3">
+            <span
+              className={cn(
+                "flex size-6 shrink-0 items-center justify-center rounded-full 
text-xs",
+                step.done ? "bg-success/15 text-success" : "bg-muted 
text-muted-foreground",
+              )}
+            >
+              {step.done ? <Check className="size-3.5" /> : index + 1}
+            </span>
+            <div className="min-w-0 flex-1">
+              <p className="text-sm font-medium">{step.title}</p>
+              <p className="text-muted-foreground text-xs">{step.hint}</p>
+            </div>
+            <Button asChild variant="outline" size="sm">
+              <Link to={step.to}>{i18next.t("general:Open")}</Link>
+            </Button>
+          </div>
+        ))}
+      </CardContent>
+    </Card>
+  );
+}
+
+export default function AgentDashboardPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+  } = useAgents(isAdmin);
+  const {activity, recordCount} = useAgentSessions(isAdmin, "", 5000);
+  // null until the provider count is known, so the guide never flashes.
+  const [hasProvider, setHasProvider] = React.useState<boolean | null>(null);
+
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return;
+    }
+    ProviderBackend.getProviders(account.name, 1, 1)
+      .then(res => setHasProvider(res.status === "ok" && (res.data2 ?? 0) > 0))
+      .catch(() => setHasProvider(false));
+  }, [isAdmin, account.name]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const patchedCount = agents.filter(agent => agent.patched).length;
+  const sessionCount = Object.values(activity).reduce((total, entry) => total 
+ entry.sessionCount, 0);
+  const setupDone = hasProvider === true && agents.some(agent => 
agent.provider !== "");
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("agent:Agents on this machine")}
+        description={account.hostname}
+        actions={
+          <Button
+            variant="outline"
+            onClick={() => {
+              scan(true);
+              loadRuntime(true);
+            }}
+            loading={loading}
+          >
+            <RefreshCw />
+            {i18next.t("agent:Scan")}
+          </Button>
+        }
+      />
+
+      {error ? <MessageAlert title={error} /> : null}
+
+      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
+        <StatCard label={i18next.t("agent:Installed")} value={agents.length} 
icon={Bot} />
+        <StatCard label={i18next.t("agent:Monitored")} value={patchedCount} 
icon={ShieldCheck} tone="success" />
+        <StatCard label={i18next.t("agent:Agent Sessions")} 
value={sessionCount} icon={MessageSquare} />
+        <StatCard label={i18next.t("agent:Records")} value={recordCount} 
icon={FileSearch} />
+      </div>
+
+      {scanned && hasProvider !== null && !setupDone ? (
+        <SetupGuide hasProvider={hasProvider} agents={agents} />
+      ) : null}
+
+      {!scanned ? (
+        <Loading tip={i18next.t("agent:Scan")} />
+      ) : agents.length === 0 ? (
+        <Card className="py-0">
+          <EmptyState
+            icon={inContainer ? Container : Bot}
+            title={i18next.t(inContainer ? "agent:Running in a container" : 
"agent:No supported agents found")}
+            description={i18next.t(
+              inContainer
+                ? "agent:Running in a container detail"
+                : "agent:Install an AI agent on this machine, then scan again",
+            )}
+            action={
+              <Button
+                variant="outline"
+                onClick={() => {
+                  scan(true);
+                  loadRuntime(true);
+                }}
+                loading={loading}
+              >
+                <RefreshCw />
+                {i18next.t("agent:Scan")}
+              </Button>
+            }
+          />
+        </Card>
+      ) : (
+        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
+          {agents.map(agent => (
+            <AgentCard
+              key={agentKey(agent)}
+              agent={agent}
+              detailTo={agentDetailPath(agent, agents)}
+              busy={busyKey === agentKey(agent)}
+              activity={activityOf(activity, agent)}
+              status={runtimeOf(runtime, agent)}
+              runBusy={runBusyKey === agentKey(agent)}
+              onToggle={() => togglePatch(agent)}
+              onRun={toggleRunning}
+            />
+          ))}
+        </div>
+      )}
+    </PageContainer>
+  );
+}
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index a58f18f..bdeb9a2 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -1,244 +1,244 @@
-// 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 {Link} from "react-router-dom";
-import {Bot, Container, RefreshCw} from "lucide-react";
-import i18next from "i18next";
-
-import * as Setting from "@/Setting";
-import {AgentIcon} from "@/components/AgentIcon";
-import {RunBadge, RunButton} from "@/components/AgentRunControl";
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {DataTable, type Column} from "@/components/shared/data-table";
-import {CodeText, 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 {SimpleTooltip} from "@/components/ui/tooltip";
-import {
-  agentDetailPath,
-  agentKey,
-  directMode,
-  monitorAgentId,
-  runtimeOf,
-  useAgents,
-} from "@/lib/agents";
-import type {Account, Agent} from "@/types";
-
-export default function AgentsPage({account}: {account: Account}) {
-  const isAdmin = Setting.isAdminUser(account);
-  const {
-    agents,
-    loading,
-    error,
-    busyKey,
-    inContainer,
-    runtime,
-    runBusyKey,
-    scan,
-    loadRuntime,
-    toggleRunning,
-    togglePatch,
-  } = useAgents(isAdmin);
-
-  if (!isAdmin) {
-    return <UnauthorizedResult />;
-  }
-
-  const columns: Column<Agent>[] = [
-    {
-      title: i18next.t("agent:Agent"),
-      key: "name",
-      dataIndex: "name",
-      render: (value: string, record) => (
-        <Link to={agentDetailPath(record)} className="hover:text-primary flex 
items-center gap-2">
-          <AgentIcon agent={record.agentId || value} fallback={<Bot 
className="text-muted-foreground size-4" />} />
-          <span className="font-medium hover:underline">{value}</span>
-        </Link>
-      ),
-    },
-    {
-      title: i18next.t("agent:Run Status"),
-      key: "running",
-      render: (_value, record) => <RunBadge status={runtimeOf(runtime, 
record)} />,
-    },
-    {
-      title: i18next.t("agent:Version"),
-      key: "version",
-      dataIndex: "version",
-      render: (value: string) => value || i18next.t("agent:Unknown"),
-    },
-    {
-      title: i18next.t("agent:Install Method"),
-      key: "installMethod",
-      dataIndex: "installMethod",
-      render: (value: string) => <Badge variant="muted">{value || "-"}</Badge>,
-    },
-    {
-      title: i18next.t("general:Owner"),
-      key: "owner",
-      dataIndex: "owner",
-    },
-    {
-      title: i18next.t("general:Path"),
-      key: "path",
-      dataIndex: "path",
-      ellipsis: true,
-      render: (value: string) => <CodeText copyable>{value}</CodeText>,
-    },
-    {
-      title: i18next.t("agent:Provider"),
-      key: "provider",
-      dataIndex: "provider",
-      render: (value: string, record) =>
-        value ? (
-          <div className="flex flex-col items-start gap-1">
-            <Link to={`/providers/${value}`} className="text-primary 
hover:underline">
-              {value}
-            </Link>
-            <span className="flex flex-wrap items-center gap-1">
-              {record.fallbacks?.length ? (
-                <SimpleTooltip title={record.fallbacks.join(", ")}>
-                  <Badge 
variant="muted">{`+${record.fallbacks.length}`}</Badge>
-                </SimpleTooltip>
-              ) : null}
-              {record.providerConfig?.applied ? (
-                <SimpleTooltip title={record.providerConfig.detail}>
-                  <Badge variant="success">
-                    {i18next.t(record.mode === directMode ? "agent:Direct" : 
"agent:Gateway")}
-                  </Badge>
-                </SimpleTooltip>
-              ) : null}
-            </span>
-          </div>
-        ) : (
-          <span className="text-muted-foreground">{i18next.t("agent:No 
provider")}</span>
-        ),
-    },
-    {
-      title: i18next.t("agent:Patch Status"),
-      key: "patched",
-      render: (_value, record) => {
-        const badge = !record.supported ? (
-          <Badge variant="muted">{i18next.t("agent:Not supported")}</Badge>
-        ) : record.patched ? (
-          <Badge variant="success">{i18next.t("agent:Patched")}</Badge>
-        ) : (
-          <Badge variant="muted">{i18next.t("agent:Not patched")}</Badge>
-        );
-        return (
-          <SimpleTooltip title={record.detail}>
-            <span>{badge}</span>
-          </SimpleTooltip>
-        );
-      },
-    },
-    {
-      title: i18next.t("agent:Records"),
-      key: "records",
-      render: (_value, record) =>
-        record.patched ? (
-          <Link
-            
to={`/agent-records?agent=${encodeURIComponent(monitorAgentId(record.agentId))}`}
-            className="text-primary hover:underline"
-          >
-            {i18next.t("agent:View Records")}
-          </Link>
-        ) : null,
-    },
-    {
-      title: i18next.t("general:Action"),
-      key: "action",
-      render: (_value, record) => (
-        <div className="flex items-center gap-2">
-          <RunButton
-            agent={record}
-            status={runtimeOf(runtime, record)}
-            busy={runBusyKey === agentKey(record)}
-            onToggle={toggleRunning}
-          />
-          {patchButton(record)}
-        </div>
-      ),
-    },
-  ];
-
-  function patchButton(record: Agent) {
-    if (!record.supported) {
-      return (
-        <Button size="sm" variant="outline" disabled>
-          {i18next.t("agent:Patch")}
-        </Button>
-      );
-    }
-
-    const action = i18next.t(`agent:${record.patched ? "Unpatch" : "Patch"}`);
-    const note = [record.notice, record.followup].filter(Boolean).join(" ");
-    return (
-      <ConfirmDialog
-        title={`${action} ${record.name}?`}
-        description={note || undefined}
-        confirmText={action}
-        variant={record.patched ? "destructive" : "default"}
-        onConfirm={() => togglePatch(record)}
-      >
-        <Button
-          size="sm"
-          variant={record.patched ? "outline" : "default"}
-          loading={busyKey === agentKey(record)}
-        >
-          {action}
-        </Button>
-      </ConfirmDialog>
-    );
-  }
-
-  return (
-    <PageContainer>
-      <PageHeader title={i18next.t("agent:Agents")} 
description={account.hostname} />
-
-      {error ? <MessageAlert title={error} /> : null}
-
-      <DataTable
-        title={i18next.t("agent:Agents")}
-        description={`${agents.length} ${i18next.t("agent:Agents")}`}
-        columns={columns}
-        dataSource={agents}
-        rowKey={agentKey}
-        loading={loading}
-        pageSize={0}
-        searchable
-        emptyIcon={inContainer ? Container : Bot}
-        emptyText={i18next.t(
-          inContainer ? "agent:Running in a container detail" : "agent:No 
supported agents found",
-        )}
-        toolbar={
-          <Button
-            variant="outline"
-            size="sm"
-            onClick={() => {
-              scan(true);
-              loadRuntime();
-            }}
-            loading={loading}
-          >
-            <RefreshCw />
-            {i18next.t("agent:Scan")}
-          </Button>
-        }
-      />
-    </PageContainer>
-  );
-}
+// 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 {Link} from "react-router-dom";
+import {Bot, Container, RefreshCw} from "lucide-react";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {RunBadge, RunButton} from "@/components/AgentRunControl";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {DataTable, type Column} from "@/components/shared/data-table";
+import {CodeText, 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 {SimpleTooltip} from "@/components/ui/tooltip";
+import {
+  agentDetailPath,
+  agentKey,
+  directMode,
+  monitorAgentId,
+  runtimeOf,
+  useAgents,
+} from "@/lib/agents";
+import type {Account, Agent} from "@/types";
+
+export default function AgentsPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const {
+    agents,
+    loading,
+    error,
+    busyKey,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+  } = useAgents(isAdmin);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const columns: Column<Agent>[] = [
+    {
+      title: i18next.t("agent:Agent"),
+      key: "name",
+      dataIndex: "name",
+      render: (value: string, record) => (
+        <Link to={agentDetailPath(record, agents)} 
className="hover:text-primary flex items-center gap-2">
+          <AgentIcon agent={record.agentId || value} fallback={<Bot 
className="text-muted-foreground size-4" />} />
+          <span className="font-medium hover:underline">{value}</span>
+        </Link>
+      ),
+    },
+    {
+      title: i18next.t("agent:Run Status"),
+      key: "running",
+      render: (_value, record) => <RunBadge status={runtimeOf(runtime, 
record)} />,
+    },
+    {
+      title: i18next.t("agent:Version"),
+      key: "version",
+      dataIndex: "version",
+      render: (value: string) => value || i18next.t("agent:Unknown"),
+    },
+    {
+      title: i18next.t("agent:Install Method"),
+      key: "installMethod",
+      dataIndex: "installMethod",
+      render: (value: string) => <Badge variant="muted">{value || "-"}</Badge>,
+    },
+    {
+      title: i18next.t("general:Owner"),
+      key: "owner",
+      dataIndex: "owner",
+    },
+    {
+      title: i18next.t("general:Path"),
+      key: "path",
+      dataIndex: "path",
+      ellipsis: true,
+      render: (value: string) => <CodeText copyable>{value}</CodeText>,
+    },
+    {
+      title: i18next.t("agent:Provider"),
+      key: "provider",
+      dataIndex: "provider",
+      render: (value: string, record) =>
+        value ? (
+          <div className="flex flex-col items-start gap-1">
+            <Link to={`/providers/${value}`} className="text-primary 
hover:underline">
+              {value}
+            </Link>
+            <span className="flex flex-wrap items-center gap-1">
+              {record.fallbacks?.length ? (
+                <SimpleTooltip title={record.fallbacks.join(", ")}>
+                  <Badge 
variant="muted">{`+${record.fallbacks.length}`}</Badge>
+                </SimpleTooltip>
+              ) : null}
+              {record.providerConfig?.applied ? (
+                <SimpleTooltip title={record.providerConfig.detail}>
+                  <Badge variant="success">
+                    {i18next.t(record.mode === directMode ? "agent:Direct" : 
"agent:Gateway")}
+                  </Badge>
+                </SimpleTooltip>
+              ) : null}
+            </span>
+          </div>
+        ) : (
+          <span className="text-muted-foreground">{i18next.t("agent:No 
provider")}</span>
+        ),
+    },
+    {
+      title: i18next.t("agent:Patch Status"),
+      key: "patched",
+      render: (_value, record) => {
+        const badge = !record.supported ? (
+          <Badge variant="muted">{i18next.t("agent:Not supported")}</Badge>
+        ) : record.patched ? (
+          <Badge variant="success">{i18next.t("agent:Patched")}</Badge>
+        ) : (
+          <Badge variant="muted">{i18next.t("agent:Not patched")}</Badge>
+        );
+        return (
+          <SimpleTooltip title={record.detail}>
+            <span>{badge}</span>
+          </SimpleTooltip>
+        );
+      },
+    },
+    {
+      title: i18next.t("agent:Records"),
+      key: "records",
+      render: (_value, record) =>
+        record.patched ? (
+          <Link
+            
to={`/agent-records?agent=${encodeURIComponent(monitorAgentId(record.agentId))}`}
+            className="text-primary hover:underline"
+          >
+            {i18next.t("agent:View Records")}
+          </Link>
+        ) : null,
+    },
+    {
+      title: i18next.t("general:Action"),
+      key: "action",
+      render: (_value, record) => (
+        <div className="flex items-center gap-2">
+          <RunButton
+            agent={record}
+            status={runtimeOf(runtime, record)}
+            busy={runBusyKey === agentKey(record)}
+            onToggle={toggleRunning}
+          />
+          {patchButton(record)}
+        </div>
+      ),
+    },
+  ];
+
+  function patchButton(record: Agent) {
+    if (!record.supported) {
+      return (
+        <Button size="sm" variant="outline" disabled>
+          {i18next.t("agent:Patch")}
+        </Button>
+      );
+    }
+
+    const action = i18next.t(`agent:${record.patched ? "Unpatch" : "Patch"}`);
+    const note = [record.notice, record.followup].filter(Boolean).join(" ");
+    return (
+      <ConfirmDialog
+        title={`${action} ${record.name}?`}
+        description={note || undefined}
+        confirmText={action}
+        variant={record.patched ? "destructive" : "default"}
+        onConfirm={() => togglePatch(record)}
+      >
+        <Button
+          size="sm"
+          variant={record.patched ? "outline" : "default"}
+          loading={busyKey === agentKey(record)}
+        >
+          {action}
+        </Button>
+      </ConfirmDialog>
+    );
+  }
+
+  return (
+    <PageContainer>
+      <PageHeader title={i18next.t("agent:Agents")} 
description={account.hostname} />
+
+      {error ? <MessageAlert title={error} /> : null}
+
+      <DataTable
+        title={i18next.t("agent:Agents")}
+        description={`${agents.length} ${i18next.t("agent:Agents")}`}
+        columns={columns}
+        dataSource={agents}
+        rowKey={agentKey}
+        loading={loading}
+        pageSize={0}
+        searchable
+        emptyIcon={inContainer ? Container : Bot}
+        emptyText={i18next.t(
+          inContainer ? "agent:Running in a container detail" : "agent:No 
supported agents found",
+        )}
+        toolbar={
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={() => {
+              scan(true);
+              loadRuntime();
+            }}
+            loading={loading}
+          >
+            <RefreshCw />
+            {i18next.t("agent:Scan")}
+          </Button>
+        }
+      />
+    </PageContainer>
+  );
+}


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

Reply via email to