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]