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 0f3321c  feat: add new agent dashboard page
0f3321c is described below

commit 0f3321c5ec2dc2eda956f133f089e55b9a400226
Author: Yang Luo <[email protected]>
AuthorDate: Tue Aug 18 22:36:55 2026 +0800

    feat: add new agent dashboard page
---
 controllers/agent.go                   |   7 +-
 web2/src/App.tsx                       | 132 ++++++-----
 web2/src/backend/AgentBackend.ts       |   4 +-
 web2/src/components/layout/Header.tsx  | 195 ----------------
 web2/src/components/layout/Sidebar.tsx | 382 +++++++++++++++++++++++++++++++
 web2/src/lib/agents.ts                 | 196 ++++++++++++++++
 web2/src/locales/en/data.json          |  39 +++-
 web2/src/locales/zh/data.json          |  47 ++--
 web2/src/pages/AgentDashboardPage.tsx  | 241 ++++++++++++++++++++
 web2/src/pages/AgentDetailPage.tsx     | 402 +++++++++++++++++++++++++++++++++
 web2/src/pages/AgentsPage.tsx          |  78 +------
 web2/src/pages/HomePage.tsx            |  17 --
 web2/tsconfig.app.tsbuildinfo          |   2 +-
 13 files changed, 1373 insertions(+), 369 deletions(-)

diff --git a/controllers/agent.go b/controllers/agent.go
index 8225189..95e50a1 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -112,12 +112,15 @@ func (c *ApiController) GetAgentRecords() {
        }))
 }
 
-// GetAgentSessions groups the current in-memory records by agent session.
+// GetAgentSessions groups the current in-memory records by agent session. The
+// optional agent filter is what an agent's own detail page asks for.
 func (c *ApiController) GetAgentSessions() {
        if c.RequireAdmin() {
                return
        }
-       c.ResponseOk(agentmonitor.ListSessions(agentmonitor.RecordQuery{}))
+       c.ResponseOk(agentmonitor.ListSessions(agentmonitor.RecordQuery{
+               Agent: c.Input().Get("agent"),
+       }))
 }
 
 // AddAgentRecord accepts reports from a hook or MCP process launched locally 
by
diff --git a/web2/src/App.tsx b/web2/src/App.tsx
index 6fea73d..2ffa1e1 100644
--- a/web2/src/App.tsx
+++ b/web2/src/App.tsx
@@ -19,10 +19,12 @@ import * as AccountBackend from "@/backend/AccountBackend";
 import * as Conf from "@/Conf";
 import * as Setting from "@/Setting";
 import {Footer} from "@/components/layout/Footer";
-import {Header} from "@/components/layout/Header";
+import {Sidebar} from "@/components/layout/Sidebar";
 import {PageSpinner} from "@/components/ui/spinner";
 import {TooltipProvider} from "@/components/ui/tooltip";
 import AccountPage from "@/pages/AccountPage";
+import AgentDashboardPage from "@/pages/AgentDashboardPage";
+import AgentDetailPage from "@/pages/AgentDetailPage";
 import AgentRecordsPage from "@/pages/AgentRecordsPage";
 import AgentSessionsPage from "@/pages/AgentSessionsPage";
 import AgentsPage from "@/pages/AgentsPage";
@@ -31,7 +33,6 @@ import CertEditPage from "@/pages/CertEditPage";
 import CertListPage from "@/pages/CertListPage";
 import ChannelEditPage from "@/pages/ChannelEditPage";
 import ChannelListPage from "@/pages/ChannelListPage";
-import HomePage from "@/pages/HomePage";
 import NodeEditPage from "@/pages/NodeEditPage";
 import NodeListPage from "@/pages/NodeListPage";
 import RecordEditPage from "@/pages/RecordEditPage";
@@ -43,9 +44,9 @@ import SiteEditPage from "@/pages/SiteEditPage";
 import SiteListPage from "@/pages/SiteListPage";
 import type {Account} from "@/types";
 
-// The dashboard is the only page that draws charts, and the charting runtime 
is
-// by far the largest dependency here, so it is fetched only when that page is
-// opened.
+// The gateway analytics page is the only one that draws charts, and the
+// charting runtime is by far the largest dependency here, so it is fetched 
only
+// when that page is opened.
 const DashboardPage = React.lazy(() => import("@/pages/DashboardPage"));
 
 Setting.initCasdoorSdk(Conf.AuthConfig);
@@ -118,57 +119,76 @@ export default function App() {
 
   return (
     <TooltipProvider delayDuration={200}>
-      <div className="flex min-h-screen flex-col">
-        <Header account={account} onSignout={signout} />
-        <main className="flex-1">
-          <React.Suspense fallback={<PageSpinner />}>
-            <Routes>
-              <Route path="/callback" element={<AuthCallback />} />
-              <Route path="/home" element={<HomePage />} />
-              <Route path="/" element={<Navigate to="/sites" replace />} />
-              <Route path="/signin" 
element={redirectHomeIfSignedIn(<SigninPage />)} />
-              <Route
-                path="/account"
-                element={requireSignin(user => <AccountPage account={user} />)}
-              />
-              <Route path="/agents" element={requireSignin(user => <AgentsPage 
account={user} />)} />
-              <Route
-                path="/agent-records"
-                element={requireSignin(user => <AgentRecordsPage 
account={user} />)}
-              />
-              <Route
-                path="/agent-sessions"
-                element={requireSignin(user => <AgentSessionsPage 
account={user} />)}
-              />
-              <Route path="/nodes" element={requireSignin(user => 
<NodeListPage account={user} />)} />
-              <Route path="/nodes/:owner/:nodeName" element={requireSignin(() 
=> <NodeEditPage />)} />
-              <Route path="/sites" element={requireSignin(user => 
<SiteListPage account={user} />)} />
-              <Route
-                path="/sites/:owner/:siteName"
-                element={requireSignin(user => <SiteEditPage account={user} 
/>)}
-              />
-              <Route path="/certs" element={requireSignin(user => 
<CertListPage account={user} />)} />
-              <Route path="/certs/:owner/:certName" element={requireSignin(() 
=> <CertEditPage />)} />
-              <Route
-                path="/records"
-                element={requireSignin(user => <RecordListPage account={user} 
/>)}
-              />
-              <Route path="/records/:owner/:id" element={requireSignin(() => 
<RecordEditPage />)} />
-              <Route path="/rules" element={requireSignin(user => 
<RuleListPage account={user} />)} />
-              <Route path="/rules/:owner/:ruleName" element={requireSignin(() 
=> <RuleEditPage />)} />
-              <Route
-                path="/channels"
-                element={requireSignin(user => <ChannelListPage account={user} 
/>)}
-              />
-              <Route
-                path="/channels/:owner/:channelName"
-                element={requireSignin(() => <ChannelEditPage />)}
-              />
-              <Route path="/dashboard" element={requireSignin(() => 
<DashboardPage />)} />
-            </Routes>
-          </React.Suspense>
-        </main>
-        <Footer />
+      <div className="flex min-h-screen">
+        <Sidebar account={account} onSignout={signout} />
+        {/* pt-14 leaves room for the fixed mobile bar the sidebar renders;
+            min-w-0 keeps a wide table from stretching the whole layout. */}
+        <div className="flex min-w-0 flex-1 flex-col pt-14 md:pt-0">
+          <main className="flex-1">
+            <React.Suspense fallback={<PageSpinner />}>
+              <Routes>
+                <Route path="/callback" element={<AuthCallback />} />
+                <Route
+                  path="/"
+                  element={requireSignin(user =>
+                    Setting.isAdminUser(user) ? (
+                      <AgentDashboardPage account={user} />
+                    ) : (
+                      // The dashboard is about the agents on this host, which
+                      // only an admin may see, so everyone else lands on the
+                      // first page they can actually use.
+                      <Navigate to="/sites" replace />
+                    ),
+                  )}
+                />
+                <Route path="/signin" 
element={redirectHomeIfSignedIn(<SigninPage />)} />
+                <Route
+                  path="/account"
+                  element={requireSignin(user => <AccountPage account={user} 
/>)}
+                />
+                <Route path="/agents" element={requireSignin(user => 
<AgentsPage account={user} />)} />
+                <Route
+                  path="/agents/:agentId"
+                  element={requireSignin(user => <AgentDetailPage 
account={user} />)}
+                />
+                <Route
+                  path="/agent-records"
+                  element={requireSignin(user => <AgentRecordsPage 
account={user} />)}
+                />
+                <Route
+                  path="/agent-sessions"
+                  element={requireSignin(user => <AgentSessionsPage 
account={user} />)}
+                />
+                <Route path="/nodes" element={requireSignin(user => 
<NodeListPage account={user} />)} />
+                <Route path="/nodes/:owner/:nodeName" 
element={requireSignin(() => <NodeEditPage />)} />
+                <Route path="/sites" element={requireSignin(user => 
<SiteListPage account={user} />)} />
+                <Route
+                  path="/sites/:owner/:siteName"
+                  element={requireSignin(user => <SiteEditPage account={user} 
/>)}
+                />
+                <Route path="/certs" element={requireSignin(user => 
<CertListPage account={user} />)} />
+                <Route path="/certs/:owner/:certName" 
element={requireSignin(() => <CertEditPage />)} />
+                <Route
+                  path="/records"
+                  element={requireSignin(user => <RecordListPage 
account={user} />)}
+                />
+                <Route path="/records/:owner/:id" element={requireSignin(() => 
<RecordEditPage />)} />
+                <Route path="/rules" element={requireSignin(user => 
<RuleListPage account={user} />)} />
+                <Route path="/rules/:owner/:ruleName" 
element={requireSignin(() => <RuleEditPage />)} />
+                <Route
+                  path="/channels"
+                  element={requireSignin(user => <ChannelListPage 
account={user} />)}
+                />
+                <Route
+                  path="/channels/:owner/:channelName"
+                  element={requireSignin(() => <ChannelEditPage />)}
+                />
+                <Route path="/dashboard" element={requireSignin(() => 
<DashboardPage />)} />
+              </Routes>
+            </React.Suspense>
+          </main>
+          <Footer />
+        </div>
       </div>
     </TooltipProvider>
   );
diff --git a/web2/src/backend/AgentBackend.ts b/web2/src/backend/AgentBackend.ts
index a70529f..1d2c792 100644
--- a/web2/src/backend/AgentBackend.ts
+++ b/web2/src/backend/AgentBackend.ts
@@ -45,6 +45,6 @@ export function getAgentRecords(agent = "", eventType = "", 
outcome = "", sessio
   );
 }
 
-export function getAgentSessions() {
-  return request<AgentSession[]>("/api/get-agent-sessions");
+export function getAgentSessions(agent = "") {
+  return request<AgentSession[]>(`/api/get-agent-sessions${query({agent: 
agent})}`);
 }
diff --git a/web2/src/components/layout/Header.tsx 
b/web2/src/components/layout/Header.tsx
deleted file mode 100644
index 2c5bfab..0000000
--- a/web2/src/components/layout/Header.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-// 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, useLocation, useNavigate} from "react-router-dom";
-import {
-  ChevronDown,
-  FileSearch,
-  LogOut,
-  Menu as MenuIcon,
-  MessageSquare,
-  Settings,
-  Bot,
-} from "lucide-react";
-import i18next from "i18next";
-import {useTranslation} from "react-i18next";
-
-import * as Setting from "@/Setting";
-import {cn} from "@/lib/utils";
-import type {Account} from "@/types";
-import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
-import {Button} from "@/components/ui/button";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuSeparator,
-  DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {LanguageSelect} from "@/components/layout/LanguageSelect";
-
-interface MenuEntry {
-  key: string;
-  label: string;
-  icon?: React.ReactNode;
-  adminOnly?: boolean;
-}
-
-function getMenuEntries(): MenuEntry[] {
-  return [
-    {key: "/dashboard", label: i18next.t("general:Dashboard")},
-    {key: "/agents", label: i18next.t("agent:Agents"), icon: <Bot />, 
adminOnly: true},
-    {
-      key: "/agent-records",
-      label: i18next.t("agent:Agent Records"),
-      icon: <FileSearch />,
-      adminOnly: true,
-    },
-    {
-      key: "/agent-sessions",
-      label: i18next.t("agent:Agent Sessions"),
-      icon: <MessageSquare />,
-      adminOnly: true,
-    },
-    {key: "/nodes", label: i18next.t("general:Nodes")},
-    {key: "/sites", label: i18next.t("general:Sites")},
-    {key: "/certs", label: i18next.t("general:Certs")},
-    {key: "/records", label: i18next.t("general:Records")},
-    {key: "/rules", label: i18next.t("general:Rules")},
-    {key: "/channels", label: i18next.t("channel:Channels")},
-  ];
-}
-
-export function Header({
-  account,
-  onSignout,
-}: {
-  account: Account | null | undefined;
-  onSignout: () => void;
-}) {
-  // Subscribing to the language keeps the menu labels, which are read eagerly
-  // through i18next.t, re-rendering when the language changes.
-  useTranslation();
-  const location = useLocation();
-  const navigate = useNavigate();
-  const [mobileOpen, setMobileOpen] = React.useState(false);
-
-  const isAdmin = Setting.isAdminUser(account);
-  const entries = getMenuEntries().filter(entry => !entry.adminOnly || 
isAdmin);
-  const selected = entries.find(entry => 
location.pathname.startsWith(entry.key))?.key;
-
-  const openProfile = () => {
-    const profileUrl = Setting.getMyProfileUrl(account);
-    if (profileUrl === "") {
-      navigate("/account");
-    } else {
-      Setting.openLink(profileUrl);
-    }
-  };
-
-  const renderNav = (orientation: "row" | "column") => (
-    <nav
-      className={cn(
-        "gap-1",
-        orientation === "row" ? "hidden items-center lg:flex" : "flex flex-col 
p-2 lg:hidden",
-      )}
-    >
-      {entries.map(entry => (
-        <Link
-          key={entry.key}
-          to={entry.key}
-          onClick={() => setMobileOpen(false)}
-          className={cn(
-            "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm 
font-medium transition-colors",
-            selected === entry.key
-              ? "bg-primary/10 text-primary"
-              : "text-muted-foreground hover:bg-accent hover:text-foreground",
-          )}
-        >
-          {entry.icon}
-          {entry.label}
-        </Link>
-      ))}
-    </nav>
-  );
-
-  return (
-    <header className="sticky top-0 z-40 border-b bg-background/95 
backdrop-blur">
-      <div className="flex h-14 items-center gap-3 px-4">
-        <Link to="/" className="flex shrink-0 items-center">
-          <img
-            src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
-            alt="Casbin Gateway"
-            className="h-6 w-auto"
-          />
-        </Link>
-
-        {renderNav("row")}
-
-        <div className="ml-auto flex items-center gap-1">
-          <LanguageSelect />
-          {account ? (
-            <DropdownMenu>
-              <DropdownMenuTrigger asChild>
-                <button className="flex items-center gap-2 rounded-md px-2 
py-1 hover:bg-accent">
-                  <Avatar className="h-8 w-8">
-                    {account.avatar ? <AvatarImage src={account.avatar} 
alt={account.name} /> : null}
-                    <AvatarFallback style={{backgroundColor: 
Setting.getAvatarColor(account.name)}}>
-                      <span className="text-white">
-                        {Setting.getShortName(account.name).slice(0, 
2).toUpperCase()}
-                      </span>
-                    </AvatarFallback>
-                  </Avatar>
-                  <span className="hidden text-sm font-medium md:inline">
-                    {Setting.getShortName(account.displayName || account.name)}
-                  </span>
-                  <ChevronDown className="h-4 w-4 text-muted-foreground" />
-                </button>
-              </DropdownMenuTrigger>
-              <DropdownMenuContent align="end" className="w-48">
-                <DropdownMenuItem onClick={openProfile}>
-                  <Settings />
-                  {i18next.t("account:My Account")}
-                </DropdownMenuItem>
-                <DropdownMenuSeparator />
-                <DropdownMenuItem onClick={onSignout}>
-                  <LogOut />
-                  {i18next.t("account:Sign Out")}
-                </DropdownMenuItem>
-              </DropdownMenuContent>
-            </DropdownMenu>
-          ) : account === null ? (
-            <Button asChild size="sm">
-              <a href={Setting.getSigninUrl() || 
"/signin"}>{i18next.t("account:Sign In")}</a>
-            </Button>
-          ) : null}
-
-          {entries.length > 0 && (
-            <Button
-              variant="ghost"
-              size="icon"
-              className="lg:hidden"
-              aria-label="Menu"
-              onClick={() => setMobileOpen(open => !open)}
-            >
-              <MenuIcon className="h-5 w-5" />
-            </Button>
-          )}
-        </div>
-      </div>
-      {mobileOpen ? <div className="border-t 
lg:hidden">{renderNav("column")}</div> : null}
-    </header>
-  );
-}
diff --git a/web2/src/components/layout/Sidebar.tsx 
b/web2/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..305301a
--- /dev/null
+++ b/web2/src/components/layout/Sidebar.tsx
@@ -0,0 +1,382 @@
+// 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, useLocation, useNavigate} from "react-router-dom";
+import {
+  Bot,
+  ChartColumn,
+  ChevronDown,
+  ChevronRight,
+  FileSearch,
+  Globe,
+  LayoutDashboard,
+  ListFilter,
+  LogOut,
+  Menu as MenuIcon,
+  MessageSquare,
+  PanelLeftClose,
+  PanelLeftOpen,
+  Plug,
+  ScrollText,
+  Server,
+  Settings,
+  ShieldCheck,
+  X,
+} from "lucide-react";
+import i18next from "i18next";
+import {useTranslation} from "react-i18next";
+
+import * as Setting from "@/Setting";
+import {cn} from "@/lib/utils";
+import type {Account} from "@/types";
+import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
+import {Button} from "@/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {Tooltip} from "@/components/ui/tooltip";
+import {LanguageSelect} from "@/components/layout/LanguageSelect";
+
+const collapsedKey = "sidebarCollapsed";
+const advancedOpenKey = "sidebarAdvancedOpen";
+
+interface MenuEntry {
+  key: string;
+  label: string;
+  icon: React.ReactNode;
+  adminOnly?: boolean;
+}
+
+/** The agent tools this product is built around, in the order they are used. 
*/
+function getMainEntries(): MenuEntry[] {
+  return [
+    {key: "/", label: i18next.t("general:Dashboard"), icon: <LayoutDashboard 
/>},
+    {key: "/agents", label: i18next.t("agent:Agents"), icon: <Bot />, 
adminOnly: true},
+    {
+      key: "/agent-sessions",
+      label: i18next.t("agent:Agent Sessions"),
+      icon: <MessageSquare />,
+      adminOnly: true,
+    },
+    {
+      key: "/agent-records",
+      label: i18next.t("agent:Agent Records"),
+      icon: <FileSearch />,
+      adminOnly: true,
+    },
+    {key: "/channels", label: i18next.t("channel:Channels"), icon: <Plug />},
+  ];
+}
+
+/**
+ * The reverse-proxy gateway pages. They are a full feature set, but an
+ * individual running Gateway to manage the agents on their own machine never
+ * needs them, so they live behind one collapsed group instead of competing for
+ * attention with the agent pages above.
+ */
+function getAdvancedEntries(): MenuEntry[] {
+  return [
+    {key: "/sites", label: i18next.t("general:Sites"), icon: <Globe />},
+    {key: "/certs", label: i18next.t("general:Certs"), icon: <ShieldCheck />},
+    {key: "/rules", label: i18next.t("general:Rules"), icon: <ListFilter />},
+    {key: "/nodes", label: i18next.t("general:Nodes"), icon: <Server />},
+    {key: "/records", label: i18next.t("general:Records"), icon: <ScrollText 
/>},
+    {key: "/dashboard", label: i18next.t("general:Gateway Analytics"), icon: 
<ChartColumn />},
+  ];
+}
+
+/**
+ * The dashboard is at "/", which every other path also starts with, so it only
+ * counts as selected on an exact match.
+ */
+function isSelected(pathname: string, key: string) {
+  return key === "/" ? pathname === "/" : pathname.startsWith(key);
+}
+
+function readFlag(key: string, fallback: boolean) {
+  const stored = localStorage.getItem(key);
+  return stored === null ? fallback : stored === "true";
+}
+
+export function Sidebar({
+  account,
+  onSignout,
+}: {
+  account: Account | null | undefined;
+  onSignout: () => void;
+}) {
+  // Subscribing to the language keeps the menu labels, which are read eagerly
+  // through i18next.t, re-rendering when the language changes.
+  useTranslation();
+  const location = useLocation();
+  const navigate = useNavigate();
+  const [collapsed, setCollapsed] = React.useState(() => 
readFlag(collapsedKey, false));
+  const [advancedOpen, setAdvancedOpen] = React.useState(() => 
readFlag(advancedOpenKey, false));
+  const [drawerOpen, setDrawerOpen] = React.useState(false);
+
+  const isAdmin = Setting.isAdminUser(account);
+  const mainEntries = getMainEntries().filter(entry => !entry.adminOnly || 
isAdmin);
+  const advancedEntries = getAdvancedEntries().filter(entry => 
!entry.adminOnly || isAdmin);
+
+  // The drawer covers the page, so leaving it open across a navigation would
+  // hide whatever the viewer just asked for.
+  React.useEffect(() => {
+    setDrawerOpen(false);
+  }, [location.pathname]);
+
+  const toggleCollapsed = () => {
+    setCollapsed(value => {
+      localStorage.setItem(collapsedKey, String(!value));
+      return !value;
+    });
+  };
+
+  const toggleAdvanced = () => {
+    setAdvancedOpen(value => {
+      localStorage.setItem(advancedOpenKey, String(!value));
+      return !value;
+    });
+  };
+
+  const openProfile = () => {
+    const profileUrl = Setting.getMyProfileUrl(account);
+    if (profileUrl === "") {
+      navigate("/account");
+    } else {
+      Setting.openLink(profileUrl);
+    }
+  };
+
+  /** `iconOnly` is the collapsed desktop rail; the drawer is always full 
width. */
+  const renderEntry = (entry: MenuEntry, iconOnly: boolean, indented = false) 
=> {
+    const link = (
+      <Link
+        key={entry.key}
+        to={entry.key}
+        className={cn(
+          "flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium 
transition-colors",
+          iconOnly && "justify-center px-0",
+          indented && !iconOnly && "ml-3",
+          isSelected(location.pathname, entry.key)
+            ? "bg-primary/10 text-primary"
+            : "text-muted-foreground hover:bg-accent hover:text-foreground",
+        )}
+      >
+        <span className="[&>svg]:h-[18px] [&>svg]:w-[18px]">{entry.icon}</span>
+        {iconOnly ? null : <span className="truncate">{entry.label}</span>}
+      </Link>
+    );
+
+    if (!iconOnly) {
+      return link;
+    }
+    return (
+      <Tooltip key={entry.key} title={entry.label} side="right">
+        {link}
+      </Tooltip>
+    );
+  };
+
+  const renderNav = (iconOnly: boolean) => (
+    <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
+      {mainEntries.map(entry => renderEntry(entry, iconOnly))}
+
+      {advancedEntries.length > 0 ? (
+        <div className="mt-2 border-t pt-2">
+          {iconOnly ? (
+            <Tooltip title={i18next.t("general:Advanced")} side="right">
+              <button
+                onClick={toggleAdvanced}
+                className="flex w-full items-center justify-center rounded-md 
px-0 py-2 text-muted-foreground transition-colors hover:bg-accent 
hover:text-foreground"
+                aria-label={i18next.t("general:Advanced")}
+              >
+                <Settings className="h-[18px] w-[18px]" />
+              </button>
+            </Tooltip>
+          ) : (
+            <button
+              onClick={toggleAdvanced}
+              className="flex w-full items-center gap-1 rounded-md px-3 py-2 
text-xs font-semibold uppercase tracking-wide text-muted-foreground 
transition-colors hover:text-foreground"
+            >
+              {advancedOpen ? (
+                <ChevronDown className="h-3.5 w-3.5" />
+              ) : (
+                <ChevronRight className="h-3.5 w-3.5" />
+              )}
+              {i18next.t("general:Advanced")}
+            </button>
+          )}
+          {advancedOpen
+            ? advancedEntries.map(entry => renderEntry(entry, iconOnly, true))
+            : null}
+        </div>
+      ) : null}
+    </nav>
+  );
+
+  const renderAccount = (iconOnly: boolean) => {
+    if (account === undefined) {
+      return null;
+    }
+    if (account === null) {
+      return (
+        <Button asChild size="sm" className={cn("w-full", iconOnly && "px-0")}>
+          <a href={Setting.getSigninUrl() || "/signin"}>
+            {iconOnly ? <LogOut className="h-4 w-4" /> : 
i18next.t("account:Sign In")}
+          </a>
+        </Button>
+      );
+    }
+
+    return (
+      <DropdownMenu>
+        <DropdownMenuTrigger asChild>
+          <button
+            className={cn(
+              "flex w-full items-center gap-2 rounded-md px-2 py-1.5 
hover:bg-accent",
+              iconOnly && "justify-center px-0",
+            )}
+          >
+            <Avatar className="h-8 w-8 shrink-0">
+              {account.avatar ? <AvatarImage src={account.avatar} 
alt={account.name} /> : null}
+              <AvatarFallback style={{backgroundColor: 
Setting.getAvatarColor(account.name)}}>
+                <span className="text-white">
+                  {Setting.getShortName(account.name).slice(0, 
2).toUpperCase()}
+                </span>
+              </AvatarFallback>
+            </Avatar>
+            {iconOnly ? null : (
+              <span className="min-w-0 flex-1 truncate text-left text-sm 
font-medium">
+                {Setting.getShortName(account.displayName || account.name)}
+              </span>
+            )}
+          </button>
+        </DropdownMenuTrigger>
+        <DropdownMenuContent align="start" side="top" className="w-48">
+          <DropdownMenuItem onClick={openProfile}>
+            <Settings />
+            {i18next.t("account:My Account")}
+          </DropdownMenuItem>
+          <DropdownMenuSeparator />
+          <DropdownMenuItem onClick={onSignout}>
+            <LogOut />
+            {i18next.t("account:Sign Out")}
+          </DropdownMenuItem>
+        </DropdownMenuContent>
+      </DropdownMenu>
+    );
+  };
+
+  const renderPanel = (iconOnly: boolean) => (
+    <>
+      <div
+        className={cn(
+          "flex h-14 shrink-0 items-center gap-2 border-b px-3",
+          iconOnly && "justify-center px-0",
+        )}
+      >
+        <Link to="/" className="flex min-w-0 items-center">
+          <img
+            src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
+            alt="Casbin Gateway"
+            className={cn("w-auto", iconOnly ? "h-5" : "h-6")}
+          />
+        </Link>
+      </div>
+
+      {renderNav(iconOnly)}
+
+      <div className={cn("shrink-0 border-t p-2", iconOnly && "flex flex-col 
items-center gap-1")}>
+        {renderAccount(iconOnly)}
+        <div className={cn("mt-1 flex items-center", iconOnly ? "flex-col" : 
"justify-between")}>
+          <LanguageSelect />
+          <Button
+            variant="ghost"
+            size="icon"
+            onClick={toggleCollapsed}
+            aria-label={i18next.t("general:Toggle sidebar")}
+            className="hidden md:inline-flex"
+          >
+            {iconOnly ? (
+              <PanelLeftOpen className="h-5 w-5" />
+            ) : (
+              <PanelLeftClose className="h-5 w-5" />
+            )}
+          </Button>
+        </div>
+      </div>
+    </>
+  );
+
+  return (
+    <>
+      <aside
+        className={cn(
+          "sticky top-0 hidden h-screen shrink-0 flex-col border-r 
bg-background md:flex",
+          collapsed ? "w-16" : "w-60",
+        )}
+      >
+        {renderPanel(collapsed)}
+      </aside>
+
+      {/* Fixed rather than in flow, so that the flex row this sits in stays a
+          sidebar-plus-content layout at every width. The content column leaves
+          room for it with its own top padding. */}
+      <header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center 
gap-2 border-b bg-background/95 px-3 backdrop-blur md:hidden">
+        <Button
+          variant="ghost"
+          size="icon"
+          aria-label="Menu"
+          onClick={() => setDrawerOpen(true)}
+        >
+          <MenuIcon className="h-5 w-5" />
+        </Button>
+        <Link to="/" className="flex items-center">
+          <img
+            src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
+            alt="Casbin Gateway"
+            className="h-6 w-auto"
+          />
+        </Link>
+      </header>
+
+      {drawerOpen ? (
+        <div className="fixed inset-0 z-50 md:hidden">
+          <div
+            className="absolute inset-0 bg-black/50"
+            onClick={() => setDrawerOpen(false)}
+            aria-hidden
+          />
+          <div className="absolute inset-y-0 left-0 flex w-64 flex-col 
border-r bg-background">
+            <Button
+              variant="ghost"
+              size="icon"
+              aria-label="Close"
+              className="absolute right-2 top-2 z-10"
+              onClick={() => setDrawerOpen(false)}
+            >
+              <X className="h-5 w-5" />
+            </Button>
+            {renderPanel(false)}
+          </div>
+        </div>
+      ) : null}
+    </>
+  );
+}
diff --git a/web2/src/lib/agents.ts b/web2/src/lib/agents.ts
new file mode 100644
index 0000000..f8d411d
--- /dev/null
+++ b/web2/src/lib/agents.ts
@@ -0,0 +1,196 @@
+// 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 {Agent, AgentSession} from "@/types";
+
+/** 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 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("");
+  // 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 ?? []);
+          } else {
+            setError(res.msg || i18next.t("agent:Failed to scan agents"));
+          }
+        })
+        .catch(err => setError(err.message || String(err)))
+        .then(() => {
+          setLoading(false);
+          setScanned(true);
+        });
+    },
+    [enabled],
+  );
+
+  React.useEffect(() => {
+    scan();
+  }, [scan]);
+
+  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],
+  );
+
+  return {agents, loading, error, busyKey, scanned, scan, togglePatch};
+}
+
+/** 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) {
+  return (
+    {
+      attempted: "processing",
+      denied: "warning",
+      failure: "error",
+      success: "success",
+    } as const
+  )[outcome as "attempted" | "denied" | "failure" | "success"];
+}
diff --git a/web2/src/locales/en/data.json b/web2/src/locales/en/data.json
index cd69f4b..42de594 100644
--- a/web2/src/locales/en/data.json
+++ b/web2/src/locales/en/data.json
@@ -19,15 +19,19 @@
   },
   "agent": {
     "Agent": "Agent",
-    "Agent Records": "Agent Records",
-    "Agent Sessions": "Agent Sessions",
+    "Agent Records": "Activity",
+    "Agent Sessions": "Sessions",
+    "Agent installation not found": "Agent installation not found",
     "Agent path": "Agent path",
     "Agents": "Agents",
+    "Agents on this machine": "Agents on this machine",
     "All agents": "All agents",
     "All event types": "All event types",
     "All outcomes": "All outcomes",
     "Auto refresh": "Auto refresh",
+    "Copy path": "Copy path",
     "Detail": "Detail",
+    "Details": "Details",
     "Duration": "Duration",
     "Event": "Event",
     "Failed to get agent records": "Failed to get agent records",
@@ -37,36 +41,46 @@
     "Filter": "Filter",
     "First activity": "First activity",
     "Install Method": "Install Method",
+    "Install an AI agent on this machine, then scan again": "Install an AI 
agent on this machine, then scan again",
+    "Installation": "Installation",
+    "Installed": "Installed",
     "Last activity": "Last activity",
     "Last {count} records": "Last {count} records",
     "MCP target": "MCP target",
     "Model": "Model",
+    "Monitored": "Monitored",
     "Monitoring disabled": "Monitoring disabled",
     "Monitoring enabled": "Monitoring enabled",
-    "No agent records yet - patch an agent to start collecting them": "No 
agent records yet - patch an agent to start collecting them",
-    "No agent sessions yet - patch an agent to start collecting them": "No 
agent sessions yet - patch an agent to start collecting them",
+    "Monitoring, no activity yet": "Monitoring, no activity yet",
+    "No agent records yet - patch an agent to start collecting them": "No 
activity yet - turn on monitoring for an agent to start collecting it",
+    "No agent sessions yet - patch an agent to start collecting them": "No 
sessions yet - turn on monitoring for an agent to start collecting them",
     "No supported agents found": "No supported agents found",
-    "Not patched": "Not patched",
-    "Not supported": "Not supported",
-    "Patch": "Patch",
-    "Patch Status": "Patch Status",
-    "Patched": "Patched",
+    "Not patched": "Not monitored",
+    "Not supported": "Not supported yet",
+    "Patch": "Turn on monitoring",
+    "Patch Status": "Monitoring",
+    "Patched": "Monitoring",
+    "Path copied to clipboard": "Path copied to clipboard",
     "Payload": "Payload",
     "Prompt ID": "Prompt ID",
     "Records": "Records",
     "Records are kept in memory only and are lost when Gateway restarts": 
"Records are kept in memory only and are lost when Gateway restarts",
     "Reported from": "Reported from",
-    "Scan": "Scan",
+    "Scan": "Rescan",
     "Session": "Session",
     "Session title": "Session title",
     "Target / Model": "Target / Model",
     "Time": "Time",
     "Tool": "Tool",
     "Tool use ID": "Tool use ID",
+    "Turn on monitoring to collect activity": "Turn on monitoring to collect 
activity",
     "Unknown": "Unknown",
+    "Unpatch": "Turn off monitoring",
     "User": "User",
     "Version": "Version",
-    "View Records": "View Records"
+    "View Records": "View Records",
+    "View all": "View all",
+    "{count} sessions": "{count} sessions"
   },
   "cert": {
     "Access key": "Access key",
@@ -120,6 +134,7 @@
   "general": {
     "Action": "Action",
     "Add": "Add",
+    "Advanced": "Advanced",
     "At Any Time": "At Any Time",
     "Back Home": "Back Home",
     "Cancel": "Cancel",
@@ -135,6 +150,7 @@
     "Display name": "Display name",
     "Edit": "Edit",
     "Edit Record": "Edit Record",
+    "Gateway Analytics": "Gateway Analytics",
     "Go to writable demo site?": "Go to writable demo site?",
     "Half A Hour": "Half A Hour",
     "Home": "Home",
@@ -160,6 +176,7 @@
     "Sorry, you do not have permission to access this page or logged in status 
invalid.": "Sorry, you do not have permission to access this page or logged in 
status invalid.",
     "Tag": "Tag",
     "This is a read-only demo site!": "This is a read-only demo site!",
+    "Toggle sidebar": "Toggle sidebar",
     "Top 10 IP Addresses": "Top 10 IP Addresses",
     "Top 10 User-Agents": "Top 10 User-Agents",
     "Total Request Count": "Total Request Count",
diff --git a/web2/src/locales/zh/data.json b/web2/src/locales/zh/data.json
index 43fd987..a31db89 100644
--- a/web2/src/locales/zh/data.json
+++ b/web2/src/locales/zh/data.json
@@ -18,16 +18,20 @@
     "Username": "用户名"
   },
   "agent": {
-    "Agent": "Agent",
-    "Agent Records": "Agent 记录",
-    "Agent Sessions": "Agent 会话",
+    "Agent": "智能体",
+    "Agent Records": "活动记录",
+    "Agent Sessions": "会话",
+    "Agent installation not found": "没有找到这个智能体的安装",
     "Agent path": "Agent 路径",
-    "Agents": "Agent 列表",
+    "Agents": "智能体",
+    "Agents on this machine": "本机的智能体",
     "All agents": "全部 Agent",
     "All event types": "全部事件类型",
     "All outcomes": "全部结果",
     "Auto refresh": "自动刷新",
+    "Copy path": "复制路径",
     "Detail": "详情",
+    "Details": "详情",
     "Duration": "耗时",
     "Event": "事件",
     "Failed to get agent records": "获取 Agent 记录失败",
@@ -37,36 +41,46 @@
     "Filter": "筛选",
     "First activity": "首次活动",
     "Install Method": "安装方式",
+    "Install an AI agent on this machine, then scan again": "在本机安装一个 AI 
智能体后,再扫描一次",
+    "Installation": "安装信息",
+    "Installed": "已安装",
     "Last activity": "最后活动",
     "Last {count} records": "最近 {count} 条记录",
     "MCP target": "MCP 目标",
     "Model": "模型",
+    "Monitored": "已开启监控",
     "Monitoring disabled": "已关闭监控",
     "Monitoring enabled": "已开启监控",
-    "No agent records yet - patch an agent to start collecting them": "暂无 
Agent 记录,请先 Patch Agent 开始采集",
-    "No agent sessions yet - patch an agent to start collecting them": "暂无 
Agent 会话,请先 Patch Agent 开始采集",
-    "No supported agents found": "未发现支持的 Agent",
-    "Not patched": "未 Patch",
-    "Not supported": "不支持",
-    "Patch": "Patch",
-    "Patch Status": "Patch 状态",
-    "Patched": "已 Patch",
+    "Monitoring, no activity yet": "监控中,暂无活动",
+    "No agent records yet - patch an agent to start collecting them": 
"还没有活动记录,开启某个智能体的监控后才会开始收集",
+    "No agent sessions yet - patch an agent to start collecting them": 
"还没有会话,开启某个智能体的监控后才会开始收集",
+    "No supported agents found": "没有发现本机安装的智能体",
+    "Not patched": "未开启",
+    "Not supported": "暂不支持",
+    "Patch": "开启监控",
+    "Patch Status": "监控状态",
+    "Patched": "监控中",
+    "Path copied to clipboard": "路径已复制到剪贴板",
     "Payload": "负载",
     "Prompt ID": "Prompt ID",
-    "Records": "记录",
+    "Records": "记录数",
     "Records are kept in memory only and are lost when Gateway restarts": 
"记录仅保存在内存中,Gateway 重启后会丢失",
     "Reported from": "上报来源",
-    "Scan": "探测",
+    "Scan": "重新扫描",
     "Session": "会话",
     "Session title": "会话标题",
     "Target / Model": "目标 / 模型",
     "Time": "时间",
     "Tool": "工具",
     "Tool use ID": "工具调用 ID",
+    "Turn on monitoring to collect activity": "开启监控后才会收集活动",
     "Unknown": "未知",
+    "Unpatch": "关闭监控",
     "User": "用户",
     "Version": "版本",
-    "View Records": "查看记录"
+    "View Records": "查看活动",
+    "View all": "查看全部",
+    "{count} sessions": "{count} 个会话"
   },
   "cert": {
     "Access key": "访问密钥",
@@ -120,6 +134,7 @@
   "general": {
     "Action": "操作",
     "Add": "添加",
+    "Advanced": "高级",
     "At Any Time": "At Any Time",
     "Back Home": "返回首页",
     "Cancel": "取消",
@@ -135,6 +150,7 @@
     "Display name": "显示名称",
     "Edit": "编辑",
     "Edit Record": "编辑日志",
+    "Gateway Analytics": "网关统计",
     "Go to writable demo site?": "跳转至可写演示站点?",
     "Half A Hour": "Half A Hour",
     "Home": "首页",
@@ -160,6 +176,7 @@
     "Sorry, you do not have permission to access this page or logged in status 
invalid.": "抱歉,您无权访问该页面或登录状态无效。",
     "Tag": "标签",
     "This is a read-only demo site!": "这是一个只读演示站点!",
+    "Toggle sidebar": "折叠/展开侧边栏",
     "Top 10 IP Addresses": "前10名IP地址",
     "Top 10 User-Agents": "前10名 User-Agent",
     "Total Request Count": "总请求数",
diff --git a/web2/src/pages/AgentDashboardPage.tsx 
b/web2/src/pages/AgentDashboardPage.tsx
new file mode 100644
index 0000000..e5af5f5
--- /dev/null
+++ b/web2/src/pages/AgentDashboardPage.tsx
@@ -0,0 +1,241 @@
+// 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, CircleX, Copy, RefreshCw} from "lucide-react";
+import copy from "copy-to-clipboard";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {PageHeader} from "@/components/FormRow";
+import {UnauthorizedResult} from "@/components/Result";
+import {StatisticCard} from "@/components/charts/ChartCards";
+import {Alert, AlertDescription} 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 {ConfirmButton} from "@/components/ui/confirm-button";
+import {Spinner} from "@/components/ui/spinner";
+import {Switch} from "@/components/ui/switch";
+import {Tooltip} from "@/components/ui/tooltip";
+import {activityOf, agentDetailPath, agentKey, useAgents, useAgentSessions} 
from "@/lib/agents";
+import type {Account, Agent} 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="shrink-0 text-xs text-muted-foreground">{label}</span>
+      <span className="min-w-0 flex-1 text-right">{children}</span>
+    </div>
+  );
+}
+
+function AgentCard({
+  agent,
+  busy,
+  activity,
+  onToggle,
+}: {
+  agent: Agent;
+  busy: boolean;
+  activity?: {sessionCount: number; recordCount: number; lastTime: string};
+  onToggle: () => 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="flex flex-col transition-colors hover:border-primary/50">
+      <CardHeader className="flex flex-row items-start gap-3 space-y-0 p-4">
+        <AgentIcon
+          agent={agent.agentId || agent.name}
+          size={40}
+          fallback={<Bot className="h-10 w-10 text-muted-foreground" />}
+        />
+        <div className="min-w-0 flex-1">
+          <Link
+            to={agentDetailPath(agent)}
+            className="block truncate font-semibold hover:text-primary 
hover:underline"
+          >
+            {agent.name}
+          </Link>
+          <div className="mt-1 flex flex-wrap items-center gap-1.5">
+            <Badge variant="secondary">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
+            {agent.installMethod ? <Badge 
variant="secondary">{agent.installMethod}</Badge> : null}
+          </div>
+        </div>
+        <div className="flex shrink-0 items-center gap-2">
+          {busy ? <Spinner /> : 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.
+            <ConfirmButton
+              title={`${action} ${agent.name}?`}
+              description={note || undefined}
+              okText={action}
+              destructive={agent.patched}
+              onConfirm={onToggle}
+            >
+              {toggle}
+            </ConfirmButton>
+          ) : (
+            <Tooltip title={agent.detail || i18next.t("agent:Not supported")}>
+              <span>{toggle}</span>
+            </Tooltip>
+          )}
+        </div>
+      </CardHeader>
+
+      <CardContent className="flex flex-1 flex-col gap-2 p-4 pt-0 text-sm">
+        <CardRow label={i18next.t("general:Path")}>
+          <span className="flex items-center justify-end gap-1">
+            <Tooltip title={agent.path}>
+              <code className="truncate text-xs">{agent.path}</code>
+            </Tooltip>
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-6 w-6 shrink-0"
+              aria-label={i18next.t("agent:Copy path")}
+              onClick={() => {
+                copy(agent.path);
+                Setting.showMessage("success", i18next.t("agent:Path copied to 
clipboard"));
+              }}
+            >
+              <Copy className="h-3.5 w-3.5" />
+            </Button>
+          </span>
+        </CardRow>
+        <CardRow label={i18next.t("general:Owner")}>
+          <span className="truncate text-xs">{agent.owner}</span>
+        </CardRow>
+
+        <div className="mt-auto flex items-center justify-between 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. 
*/}
+          <Tooltip title={agent.detail}>
+            <span className="min-w-0 truncate text-xs text-muted-foreground">
+              {!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>
+          </Tooltip>
+          <Link
+            to={agentDetailPath(agent)}
+            className="inline-flex shrink-0 items-center text-xs text-primary 
hover:underline"
+          >
+            {i18next.t("agent:Details")}
+            <ChevronRight className="h-3.5 w-3.5" />
+          </Link>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+export default function AgentDashboardPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const {agents, loading, error, busyKey, scanned, scan, togglePatch} = 
useAgents(isAdmin);
+  const {activity, recordCount} = useAgentSessions(isAdmin, "", 5000);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const patchedCount = agents.filter(agent => agent.patched).length;
+  const sessionCount = Object.values(activity).reduce(
+    (total, entry) => total + entry.sessionCount,
+    0,
+  );
+
+  return (
+    <div className="space-y-4 p-4 md:p-6">
+      <PageHeader title={i18next.t("agent:Agents on this machine")}>
+        <Button variant="outline" onClick={() => scan(true)} 
disabled={loading}>
+          <RefreshCw className={loading ? "animate-spin" : undefined} />
+          {i18next.t("agent:Scan")}
+        </Button>
+      </PageHeader>
+
+      {error && (
+        <Alert variant="destructive">
+          <CircleX />
+          <AlertDescription>{error}</AlertDescription>
+        </Alert>
+      )}
+
+      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+        <StatisticCard title={i18next.t("agent:Installed")} 
value={agents.length} />
+        <StatisticCard title={i18next.t("agent:Monitored")} 
value={patchedCount} />
+        <StatisticCard title={i18next.t("agent:Agent Sessions")} 
value={sessionCount} />
+        <StatisticCard title={i18next.t("agent:Records")} value={recordCount} 
/>
+      </div>
+
+      {!scanned ? (
+        <div className="flex justify-center p-10">
+          <Spinner />
+        </div>
+      ) : agents.length === 0 ? (
+        <Card>
+          <CardContent className="flex flex-col items-center gap-3 p-10 
text-center">
+            <Bot className="h-12 w-12 text-muted-foreground" />
+            <div className="font-medium">{i18next.t("agent:No supported agents 
found")}</div>
+            <div className="max-w-md text-sm text-muted-foreground">
+              {i18next.t("agent:Install an AI agent on this machine, then scan 
again")}
+            </div>
+            <Button variant="outline" onClick={() => scan(true)} 
disabled={loading}>
+              <RefreshCw className={loading ? "animate-spin" : undefined} />
+              {i18next.t("agent:Scan")}
+            </Button>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="grid 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)}
+              onToggle={() => togglePatch(agent)}
+            />
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/web2/src/pages/AgentDetailPage.tsx 
b/web2/src/pages/AgentDetailPage.tsx
new file mode 100644
index 0000000..15b27a7
--- /dev/null
+++ b/web2/src/pages/AgentDetailPage.tsx
@@ -0,0 +1,402 @@
+// 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, useParams, useSearchParams} from "react-router-dom";
+import {ArrowLeft, Bot, ChevronRight, CircleX, Copy, RefreshCw} from 
"lucide-react";
+import copy from "copy-to-clipboard";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {DataTable, type Column} from "@/components/DataTable";
+import {Result, UnauthorizedResult} from "@/components/Result";
+import {Alert, AlertDescription} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
+import {ConfirmButton} from "@/components/ui/confirm-button";
+import {Spinner} from "@/components/ui/spinner";
+import {Tooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {
+  agentKey,
+  getOutcomeVariant,
+  monitorAgentId,
+  useAgents,
+  useAgentSessions,
+} from "@/lib/agents";
+import type {Account, Agent, AgentRecord, AgentSession} from "@/types";
+
+const tabs = ["Agent Sessions", "Agent Records"] as const;
+type Tab = (typeof tabs)[number];
+
+const recordLimit = 20;
+
+function InfoRow({label, children}: {label: string; children: 
React.ReactNode}) {
+  return (
+    <div className="grid grid-cols-[140px_minmax(0,1fr)] gap-2 border-b py-2 
last:border-0">
+      <div className="text-sm text-muted-foreground">{label}</div>
+      <div className="min-w-0 break-all text-sm">{children}</div>
+    </div>
+  );
+}
+
+/** The monitoring card: what the patcher reported, plus the toggle itself. */
+function MonitoringCard({
+  agent,
+  busy,
+  onToggle,
+}: {
+  agent: Agent;
+  busy: boolean;
+  onToggle: () => void;
+}) {
+  const action = i18next.t(`agent:${agent.patched ? "Unpatch" : "Patch"}`);
+  const note = [agent.notice, agent.followup].filter(Boolean).join(" ");
+
+  return (
+    <Card>
+      <CardHeader className="p-4 pb-2">
+        <CardTitle className="text-base">{i18next.t("agent:Patch 
Status")}</CardTitle>
+      </CardHeader>
+      <CardContent className="space-y-3 p-4 pt-0">
+        <div>
+          {!agent.supported ? (
+            <Badge variant="secondary">{i18next.t("agent:Not 
supported")}</Badge>
+          ) : agent.patched ? (
+            <Badge variant="success">{i18next.t("agent:Patched")}</Badge>
+          ) : (
+            <Badge variant="secondary">{i18next.t("agent:Not patched")}</Badge>
+          )}
+        </div>
+
+        {agent.detail ? <p className="text-sm 
text-muted-foreground">{agent.detail}</p> : null}
+        {note ? <p className="text-sm text-muted-foreground">{note}</p> : null}
+
+        {agent.supported ? (
+          <ConfirmButton
+            title={`${action} ${agent.name}?`}
+            description={note || undefined}
+            okText={action}
+            destructive={agent.patched}
+            onConfirm={onToggle}
+          >
+            <Button variant={agent.patched ? "outline" : "default"} 
disabled={busy}>
+              {busy ? <Spinner /> : null}
+              {action}
+            </Button>
+          </ConfirmButton>
+        ) : (
+          <Button variant="outline" disabled>
+            {i18next.t("agent:Patch")}
+          </Button>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+export default function AgentDetailPage({account}: {account: Account}) {
+  const params = useParams();
+  const [searchParams] = useSearchParams();
+  const isAdmin = Setting.isAdminUser(account);
+  const {agents, error, busyKey, scanned, scan, togglePatch} = 
useAgents(isAdmin);
+  const [tab, setTab] = React.useState<Tab>("Agent Sessions");
+  const [records, setRecords] = React.useState<AgentRecord[]>([]);
+  const [recordError, setRecordError] = React.useState("");
+
+  const agentId = params.agentId ?? "";
+  const path = searchParams.get("path") ?? "";
+
+  // The same agent can be installed more than once, so the path from the link
+  // picks the installation; without one, the first match is close enough.
+  const agent = agents.find(
+    candidate =>
+      candidate.agentId === agentId && (path === "" || candidate.path === 
path),
+  );
+
+  const monitorId = agent ? monitorAgentId(agent.agentId) : "";
+  const watching = Boolean(agent?.patched);
+  const {sessions} = useAgentSessions(isAdmin && watching, monitorId, 5000);
+
+  React.useEffect(() => {
+    if (!isAdmin || !watching || monitorId === "") {
+      setRecords([]);
+      return undefined;
+    }
+
+    const load = () => {
+      AgentBackend.getAgentRecords(monitorId, "", "", "", recordLimit)
+        .then(res => {
+          if (res.status === "ok") {
+            setRecords(res.data ?? []);
+            setRecordError("");
+          } else {
+            setRecordError(res.msg || i18next.t("agent:Failed to get agent 
records"));
+          }
+        })
+        .catch(err => setRecordError(err.message || String(err)));
+    };
+
+    load();
+    const interval = setInterval(load, 5000);
+    return () => clearInterval(interval);
+  }, [isAdmin, watching, monitorId]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  if (!scanned) {
+    return (
+      <div className="flex justify-center p-10">
+        <Spinner />
+      </div>
+    );
+  }
+
+  if (!agent) {
+    return (
+      <Result
+        status="404"
+        title={i18next.t("agent:Agent installation not found")}
+        subTitle={error || path || agentId}
+        extra={
+          <Button asChild>
+            <Link to="/agents">{i18next.t("agent:Agents")}</Link>
+          </Button>
+        }
+      />
+    );
+  }
+
+  const sessionColumns: Column<AgentSession>[] = [
+    {
+      title: i18next.t("agent:Session"),
+      key: "session",
+      render: (_value, session) => (
+        <div className="flex min-w-0 flex-col">
+          <Link
+            
to={`/agent-records?agent=${encodeURIComponent(session.agent)}&session=${encodeURIComponent(session.sessionKey)}`}
+            className="truncate text-primary hover:underline"
+          >
+            {session.title || session.sessionKey}
+          </Link>
+          <span className="truncate text-xs 
text-muted-foreground">{session.sessionKey}</span>
+        </div>
+      ),
+    },
+    {
+      title: i18next.t("agent:Records"),
+      key: "recordCount",
+      dataIndex: "recordCount",
+      width: "100px",
+    },
+    {
+      title: i18next.t("agent:Last activity"),
+      key: "lastTime",
+      dataIndex: "lastTime",
+      width: "200px",
+      render: (value: string) => new Date(value).toLocaleString(),
+    },
+  ];
+
+  const recordColumns: Column<AgentRecord>[] = [
+    {
+      title: i18next.t("agent:Time"),
+      key: "createdTime",
+      dataIndex: "createdTime",
+      width: "180px",
+      render: (value: string) => new Date(value).toLocaleString(),
+    },
+    {
+      title: i18next.t("agent:Event"),
+      key: "event",
+      width: "220px",
+      render: (_value, record) => (
+        <div className="flex flex-wrap items-center gap-1">
+          <Badge variant="secondary">{record.eventType}</Badge>
+          {record.action && <code className="text-xs">{record.action}</code>}
+          {record.outcome && (
+            <Badge 
variant={getOutcomeVariant(record.outcome)}>{record.outcome}</Badge>
+          )}
+        </div>
+      ),
+    },
+    {
+      title: i18next.t("agent:Target / Model"),
+      key: "target",
+      render: (_value, record) => {
+        const target = record.mcpServer
+          ? `${record.mcpServer}${record.mcpTool ? ` / ${record.mcpTool}` : 
""}`
+          : record.toolName;
+        return (
+          <div className="flex min-w-0 flex-col">
+            {target && <code className="truncate text-xs">{target}</code>}
+            {record.model && (
+              <span className="truncate text-xs 
text-muted-foreground">{record.model}</span>
+            )}
+          </div>
+        );
+      },
+    },
+  ];
+
+  return (
+    <div className="space-y-4 p-4 md:p-6">
+      <Link
+        to="/agents"
+        className="inline-flex items-center gap-1 text-sm 
text-muted-foreground hover:text-foreground"
+      >
+        <ArrowLeft className="h-4 w-4" />
+        {i18next.t("agent:Agents")}
+      </Link>
+
+      <div className="flex flex-wrap items-center gap-3">
+        <AgentIcon
+          agent={agent.agentId || agent.name}
+          size={40}
+          fallback={<Bot className="h-10 w-10 text-muted-foreground" />}
+        />
+        <h1 className="text-xl font-semibold tracking-tight">{agent.name}</h1>
+        <Badge variant="secondary">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
+        <Button variant="outline" size="sm" className="ml-auto" onClick={() => 
scan(true)}>
+          <RefreshCw />
+          {i18next.t("agent:Scan")}
+        </Button>
+      </div>
+
+      {error && (
+        <Alert variant="destructive">
+          <CircleX />
+          <AlertDescription>{error}</AlertDescription>
+        </Alert>
+      )}
+
+      <div className="grid gap-4 lg:grid-cols-2">
+        <MonitoringCard
+          agent={agent}
+          busy={busyKey === agentKey(agent)}
+          onToggle={() => togglePatch(agent)}
+        />
+
+        <Card>
+          <CardHeader className="p-4 pb-2">
+            <CardTitle 
className="text-base">{i18next.t("agent:Installation")}</CardTitle>
+          </CardHeader>
+          <CardContent className="p-4 pt-0">
+            <InfoRow label={i18next.t("agent:Agent")}>
+              <code className="text-xs">{agent.agentId}</code>
+            </InfoRow>
+            <InfoRow label={i18next.t("agent:Version")}>
+              {agent.version || i18next.t("agent:Unknown")}
+            </InfoRow>
+            <InfoRow label={i18next.t("agent:Install Method")}>
+              {agent.installMethod || "-"}
+            </InfoRow>
+            <InfoRow label={i18next.t("general:Owner")}>{agent.owner}</InfoRow>
+            <InfoRow label={i18next.t("general:Path")}>
+              <span className="flex items-start gap-1">
+                <code className="min-w-0 flex-1 text-xs">{agent.path}</code>
+                <Tooltip title={i18next.t("agent:Copy path")}>
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    className="h-6 w-6 shrink-0"
+                    aria-label={i18next.t("agent:Copy path")}
+                    onClick={() => {
+                      copy(agent.path);
+                      Setting.showMessage("success", i18next.t("agent:Path 
copied to clipboard"));
+                    }}
+                  >
+                    <Copy className="h-3.5 w-3.5" />
+                  </Button>
+                </Tooltip>
+              </span>
+            </InfoRow>
+          </CardContent>
+        </Card>
+      </div>
+
+      {!watching ? (
+        <Alert variant="info">
+          <AlertDescription>
+            {i18next.t("agent:Turn on monitoring to collect activity")}
+          </AlertDescription>
+        </Alert>
+      ) : (
+        <div className="space-y-3">
+          <div className="flex flex-wrap items-center justify-between gap-2">
+            <div className="inline-flex rounded-md border p-0.5">
+              {tabs.map(name => (
+                <button
+                  key={name}
+                  onClick={() => setTab(name)}
+                  className={cn(
+                    "rounded px-3 py-1 text-sm transition-colors",
+                    tab === name
+                      ? "bg-primary text-primary-foreground"
+                      : "text-muted-foreground hover:bg-accent",
+                  )}
+                >
+                  {i18next.t(`agent:${name}`)}
+                </button>
+              ))}
+            </div>
+            {/* Only the records page filters by agent; the sessions page lists
+                every agent, so it is linked without one. */}
+            <Link
+              to={
+                tab === "Agent Sessions"
+                  ? "/agent-sessions"
+                  : `/agent-records?agent=${encodeURIComponent(monitorId)}`
+              }
+              className="inline-flex items-center text-sm text-primary 
hover:underline"
+            >
+              {i18next.t("agent:View all")}
+              <ChevronRight className="h-4 w-4" />
+            </Link>
+          </div>
+
+          {recordError && tab === "Agent Records" ? (
+            <Alert variant="destructive">
+              <CircleX />
+              <AlertDescription>{recordError}</AlertDescription>
+            </Alert>
+          ) : null}
+
+          {tab === "Agent Sessions" ? (
+            <DataTable
+              columns={sessionColumns}
+              data={sessions}
+              rowKey={session => session.sessionKey}
+              pageSize={0}
+              emptyText={i18next.t("agent:Monitoring, no activity yet")}
+            />
+          ) : (
+            <DataTable
+              columns={recordColumns}
+              data={records}
+              rowKey={record => record.id}
+              pageSize={0}
+              emptyText={i18next.t("agent:Monitoring, no activity yet")}
+            />
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/web2/src/pages/AgentsPage.tsx b/web2/src/pages/AgentsPage.tsx
index 98b36d7..36a0bf6 100644
--- a/web2/src/pages/AgentsPage.tsx
+++ b/web2/src/pages/AgentsPage.tsx
@@ -12,12 +12,10 @@
 // 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, CircleX, RefreshCw} from "lucide-react";
 import i18next from "i18next";
 
-import * as AgentBackend from "@/backend/AgentBackend";
 import * as Setting from "@/Setting";
 import {AgentIcon} from "@/components/AgentIcon";
 import {DataTable, type Column} from "@/components/DataTable";
@@ -29,72 +27,12 @@ import {Button} from "@/components/ui/button";
 import {ConfirmButton} from "@/components/ui/confirm-button";
 import {Spinner} from "@/components/ui/spinner";
 import {Tooltip} from "@/components/ui/tooltip";
+import {agentDetailPath, agentKey, monitorAgentId, useAgents} from 
"@/lib/agents";
 import type {Account, Agent} from "@/types";
 
-const rowKey = (record: Agent) => `${record.owner}:${record.path}`;
-
-/** The monitor keys the two Codex front ends under one agent id. */
-function monitorAgentId(agentId: string) {
-  return agentId === "codex_vscode" || agentId === "codex-vscode" ? 
"codex-cli" : agentId;
-}
-
 export default function AgentsPage({account}: {account: Account}) {
-  const [agents, setAgents] = React.useState<Agent[]>([]);
-  const [loading, setLoading] = React.useState(false);
-  const [error, setError] = React.useState("");
-  const [busyKey, setBusyKey] = React.useState("");
   const isAdmin = Setting.isAdminUser(account);
-
-  const scan = React.useCallback(
-    (forceRefresh = false) => {
-      if (!isAdmin) {
-        return;
-      }
-
-      setLoading(true);
-      setError("");
-      AgentBackend.getAgents(forceRefresh)
-        .then(res => {
-          if (res.status === "ok") {
-            setAgents(res.data ?? []);
-          } else {
-            setError(res.msg || i18next.t("agent:Failed to scan agents"));
-          }
-        })
-        .catch(err => setError(err.message || String(err)))
-        .then(() => setLoading(false));
-    },
-    [isAdmin],
-  );
-
-  React.useEffect(() => {
-    scan();
-  }, [scan]);
-
-  const togglePatch = (record: Agent) => {
-    const target = {agentId: record.agentId, path: record.path, owner: 
record.owner};
-    const patched = record.patched;
-
-    setBusyKey(rowKey(record));
-    (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 || record.followup;
-          Setting.showMessage(
-            "success",
-            followup ? `${done}: ${record.name}. ${followup}` : `${done}: 
${record.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(""));
-  };
+  const {agents, loading, error, busyKey, scan, togglePatch} = 
useAgents(isAdmin);
 
   if (!isAdmin) {
     return <UnauthorizedResult />;
@@ -106,13 +44,13 @@ export default function AgentsPage({account}: {account: 
Account}) {
       key: "name",
       dataIndex: "name",
       render: (value: string, record) => (
-        <span className="flex items-center gap-2">
+        <Link to={agentDetailPath(record)} className="flex items-center gap-2 
hover:text-primary">
           <AgentIcon
             agent={record.agentId || value}
             fallback={<Bot className="h-[18px] w-[18px] text-muted-foreground" 
/>}
           />
-          {value}
-        </span>
+          <span className="hover:underline">{value}</span>
+        </Link>
       ),
     },
     {
@@ -194,9 +132,9 @@ export default function AgentsPage({account}: {account: 
Account}) {
             <Button
               size="sm"
               variant={record.patched ? "outline" : "default"}
-              disabled={busyKey === rowKey(record)}
+              disabled={busyKey === agentKey(record)}
             >
-              {busyKey === rowKey(record) ? <Spinner /> : null}
+              {busyKey === agentKey(record) ? <Spinner /> : null}
               {action}
             </Button>
           </ConfirmButton>
@@ -224,7 +162,7 @@ export default function AgentsPage({account}: {account: 
Account}) {
       <DataTable
         columns={columns}
         data={agents}
-        rowKey={rowKey}
+        rowKey={agentKey}
         loading={loading}
         pageSize={0}
         emptyText={i18next.t("agent:No supported agents found")}
diff --git a/web2/src/pages/HomePage.tsx b/web2/src/pages/HomePage.tsx
deleted file mode 100644
index 39353c8..0000000
--- a/web2/src/pages/HomePage.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-// 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.
-
-export default function HomePage() {
-  return <div className="p-6">hello</div>;
-}
diff --git a/web2/tsconfig.app.tsbuildinfo b/web2/tsconfig.app.tsbuildinfo
index 004ce6f..bf90325 100644
--- a/web2/tsconfig.app.tsbuildinfo
+++ b/web2/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/conf.ts","./src/setting.tsx","./src/i18n.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/backend/accountbackend.ts","./src/backend/agentbackend.ts","./src/backend/certbackend.ts","./src/backend/channelbackend.ts","./src/backend/miscbackend.ts","./src/backend/nodebackend.ts","./src/backend/recordbackend.ts","./src/backend/rulebackend.ts","./src/backend/sitebackend.ts","./src/backend/request.ts","./src/components/agenticon.tsx","./src/comp
 [...]
\ No newline at end of file
+{"root":["./src/app.tsx","./src/conf.ts","./src/setting.tsx","./src/i18n.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/backend/accountbackend.ts","./src/backend/agentbackend.ts","./src/backend/certbackend.ts","./src/backend/channelbackend.ts","./src/backend/miscbackend.ts","./src/backend/nodebackend.ts","./src/backend/recordbackend.ts","./src/backend/rulebackend.ts","./src/backend/sitebackend.ts","./src/backend/request.ts","./src/components/agenticon.tsx","./src/comp
 [...]
\ No newline at end of file


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

Reply via email to