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