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 de5669d  feat(ui): switch an agent to another provider in one click
de5669d is described below

commit de5669d27c8ff5e675ad6d681443ef79c7ddff5f
Author: Yang Luo <[email protected]>
AuthorDate: Mon Aug 24 01:15:42 2026 +0800

    feat(ui): switch an agent to another provider in one click
---
 web/src/App.tsx                           |   6 +-
 web/src/components/ProviderCard.tsx       |  12 +-
 web/src/components/ProviderGridCard.tsx   |   6 +-
 web/src/components/shared/app-sidebar.tsx |   4 +-
 web/src/lib/agents.ts                     | 902 ++++++++++++++++--------------
 web/src/lib/providers.ts                  |   5 +
 web/src/locales/en/data.json              |  17 +-
 web/src/locales/zh/data.json              |  17 +-
 web/src/nav.ts                            |  11 +-
 web/src/pages/AgentDashboardPage.tsx      | 351 ------------
 web/src/pages/AgentDetailPage.tsx         |   4 +-
 web/src/pages/AgentsPage.tsx              | 499 +++++++++--------
 web/src/pages/HomePage.tsx                | 427 ++++++++++++++
 web/src/pages/ProviderListPage.tsx        |   3 +-
 14 files changed, 1207 insertions(+), 1057 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index 2694ea9..6ee0fc5 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -29,7 +29,6 @@ import {TooltipProvider} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
 import AccountPage from "@/pages/AccountPage";
 import AgentConfigsPage from "@/pages/AgentConfigsPage";
-import AgentDashboardPage from "@/pages/AgentDashboardPage";
 import AgentDetailPage from "@/pages/AgentDetailPage";
 import AgentRecordsPage from "@/pages/AgentRecordsPage";
 import AgentSessionPage from "@/pages/AgentSessionPage";
@@ -38,6 +37,7 @@ import AgentsPage from "@/pages/AgentsPage";
 import AuthCallback from "@/pages/AuthCallback";
 import CertEditPage from "@/pages/CertEditPage";
 import CertListPage from "@/pages/CertListPage";
+import HomePage from "@/pages/HomePage";
 import ProviderEditPage from "@/pages/ProviderEditPage";
 import ProviderListPage from "@/pages/ProviderListPage";
 import LlmRecordsPage from "@/pages/LlmRecordsPage";
@@ -272,9 +272,9 @@ export default function App() {
                   path="/"
                   element={requireSignin(user =>
                     Setting.isAdminUser(user) ? (
-                      <AgentDashboardPage account={user} />
+                      <HomePage account={user} />
                     ) : (
-                      // The dashboard is about the agents on this host, which
+                      // The home screen 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 />
diff --git a/web/src/components/ProviderCard.tsx 
b/web/src/components/ProviderCard.tsx
index 657f9e0..8a1340d 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -48,17 +48,19 @@ import {
   directMode,
   gatewayMode,
 } from "@/lib/agents";
-import {providerProtocol, servesResponsesApi, shellForPath, usesClientAuth} 
from "@/lib/providers";
+import {
+  providerIdOf,
+  providerProtocol,
+  servesResponsesApi,
+  shellForPath,
+  usesClientAuth,
+} from "@/lib/providers";
 import {cn} from "@/lib/utils";
 import type {Agent, AgentProviderFile, Provider, ProviderHealth} from 
"@/types";
 
 /** Radix rejects an empty item value, so "unbound" needs a stand-in. */
 const noProvider = "-";
 
-function providerIdOf(provider: Provider) {
-  return `${provider.owner}/${provider.name}`;
-}
-
 /** The file preview, loaded when the dialog opens rather than on every 
render. */
 function PreviewDialog({
   agent,
diff --git a/web/src/components/ProviderGridCard.tsx 
b/web/src/components/ProviderGridCard.tsx
index 7eb453b..28ca79f 100644
--- a/web/src/components/ProviderGridCard.tsx
+++ b/web/src/components/ProviderGridCard.tsx
@@ -30,14 +30,10 @@ import {
 } from "@/components/ui/dropdown-menu";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {agentSpeaks} from "@/lib/agents";
-import {providerProtocol, usesClientAuth} from "@/lib/providers";
+import {providerIdOf, providerProtocol, usesClientAuth} from "@/lib/providers";
 import {cn} from "@/lib/utils";
 import type {Agent, Provider, ProviderHealth, ProviderQuota} from "@/types";
 
-export function providerIdOf(provider: Provider) {
-  return `${provider.owner}/${provider.name}`;
-}
-
 /** How many model badges fit before the rest become a count. */
 const visibleModels = 3;
 
diff --git a/web/src/components/shared/app-sidebar.tsx 
b/web/src/components/shared/app-sidebar.tsx
index 61dbd8b..2cf283c 100644
--- a/web/src/components/shared/app-sidebar.tsx
+++ b/web/src/components/shared/app-sidebar.tsx
@@ -138,8 +138,8 @@ export function AppSidebar({
     onOpenKeysChange(openKeys.includes(key) ? openKeys.filter(item => item !== 
key) : [...openKeys, key]);
   };
 
-  const visible = (entry: {adminOnly?: boolean; gatewayOnly?: boolean}) =>
-    (!entry.adminOnly || isAdmin) && (!entry.gatewayOnly || gatewayEnabled);
+  const visible = (entry: {adminOnly?: boolean; gatewayOnly?: boolean; 
hidden?: boolean}) =>
+    !entry.hidden && (!entry.adminOnly || isAdmin) && (!entry.gatewayOnly || 
gatewayEnabled);
   const groups: NavGroup[] = navGroups
     .filter(visible)
     .map(group => (group.children ? {...group, children: 
group.children.filter(visible)} : group))
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 2cf760e..fd558b3 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -1,419 +1,483 @@
-// 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 wire format an agent's client speaks, as the server reports it. Empty 
for
- * an agent Gateway does not know, which is then not filtered on.
- */
-export function agentProtocol(agent: Agent) {
-  return agent.providerConfig?.protocol ?? "";
-}
-
-/**
- * Whether an agent can be pointed at a provider serving this wire format.
- * Through the gateway any provider will do: the proxy translates between the
- * APIs. A direct binding writes the provider's own URL into the agent config,
- * so there the two have to speak the same one.
- */
-export function agentSpeaks(agent: Agent, protocol: string) {
-  if ((agent.mode || gatewayMode) !== directMode) {
-    return true;
-  }
-  const spoken = agentProtocol(agent);
-  return spoken === "" || spoken === protocol;
-}
-
-/**
- * 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";
-}
+// 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 {providerProtocol, servesResponsesApi} from "@/lib/providers";
+import type {Agent, AgentRuntime, AgentSession, Provider} 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 wire format an agent's client speaks, as the server reports it. Empty 
for
+ * an agent Gateway does not know, which is then not filtered on.
+ */
+export function agentProtocol(agent: Agent) {
+  return agent.providerConfig?.protocol ?? "";
+}
+
+/**
+ * Whether an agent can be pointed at a provider serving this wire format.
+ * Through the gateway any provider will do: the proxy translates between the
+ * APIs. A direct binding writes the provider's own URL into the agent config,
+ * so there the two have to speak the same one.
+ */
+export function agentSpeaks(agent: Agent, protocol: string) {
+  if ((agent.mode || gatewayMode) !== directMode) {
+    return true;
+  }
+  const spoken = agentProtocol(agent);
+  return spoken === "" || spoken === protocol;
+}
+
+/**
+ * Whether this agent, routed the way it is now, can be pointed at this 
provider
+ * at all. Through the gateway anything goes, since the proxy translates; a
+ * direct binding hands the agent the provider's own URL, and there both the
+ * wire format and, for Codex, the Responses API have to line up.
+ */
+export function agentCanUse(agent: Agent, provider: Provider) {
+  if ((agent.mode || gatewayMode) !== directMode) {
+    return true;
+  }
+  if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)) {
+    return false;
+  }
+  return agentSpeaks(agent, providerProtocol(provider.type));
+}
+
+/**
+ * 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],
+  );
+
+  /**
+   * activateProvider is the one click the home page is built around: it stores
+   * the routing and, for an agent whose configuration file Gateway knows how 
to
+   * write, writes it, so the agent is on the new provider with nothing left to
+   * press.
+   */
+  const activateProvider = React.useCallback(
+    (agent: Agent, providerId: string) => {
+      const target = {agentId: agent.agentId, path: agent.path, owner: 
agent.owner};
+      const routing: AgentBackend.AgentRouting = {
+        provider: providerId,
+        fallbacks: [],
+        mode: agent.mode || gatewayMode,
+      };
+      const writes = providerId !== "" && (agent.providerConfig?.supported ?? 
false);
+
+      setBusyKey(agentKey(agent));
+      AgentBackend.updateAgentRouting(agent.agentId, routing)
+        .then(res => {
+          if (res.status !== "ok") {
+            throw new Error(res.msg || i18next.t("agent:Failed to update agent 
provider"));
+          }
+          return writes ? AgentBackend.applyAgentProvider(target) : res;
+        })
+        .then(res => {
+          if (res.status !== "ok") {
+            throw new Error(res.msg || i18next.t("agent:Failed to write the 
agent configuration"));
+          }
+          Setting.showMessage(
+            "success",
+            providerId === ""
+              ? i18next.t("agent:Provider cleared")
+              : `${i18next.t("agent:Provider enabled")}: ${providerId}`,
+          );
+        })
+        .catch(err => Setting.showMessage("error", err.message || String(err)))
+        // The routing may be stored even when the file write failed, so the
+        // scan runs either way and the page shows what actually happened.
+        .then(() => {
+          scan();
+          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,
+    activateProvider,
+    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/lib/providers.ts b/web/src/lib/providers.ts
index a679c40..ac02681 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -15,6 +15,11 @@
 import * as Setting from "@/Setting";
 import type {Provider} from "@/types";
 
+/** The "owner/name" id an agent's routing names a provider by. */
+export function providerIdOf(provider: Pick<Provider, "owner" | "name">) {
+  return `${provider.owner}/${provider.name}`;
+}
+
 /** Mirrors object.ProviderProtocol on the server. */
 export function providerProtocol(type: string) {
   return type === "anthropic" ? "anthropic" : "openai";
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 7ce106b..f161e39 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -18,8 +18,8 @@
     "Username": "Username"
   },
   "agent": {
-    "Add a provider": "Add a provider",
-    "Add a provider hint": "Providers → New Provider: pick a vendor, paste 
your API key, and list the models it serves.",
+    "Advanced view": "Advanced view",
+    "Advanced view hint": "Every installation found, with its processes, path, 
owner and install method.",
     "Agent": "Agent",
     "Agent Records": "Activity",
     "Agent Sessions": "Sessions",
@@ -35,8 +35,6 @@
     "All outcomes": "All outcomes",
     "Auto refresh": "Auto refresh",
     "Base URL hint": "Give the agent the settings below. Requests that arrive 
at this URL always go to the provider selected above, whatever model they 
name.",
-    "Bind the provider": "Bind the provider to an agent",
-    "Bind the provider hint": "Open an agent, pick the provider, and let 
Gateway write the agent's configuration for you.",
     "Bound provider cooling down": "The bound provider is out of rotation, 
requests go to the fallbacks",
     "Claude Code config hint": "The same variables can live in the \"env\" 
block of ~/.claude/settings.json, so every session picks them up without 
exporting anything.",
     "Client auth token hint": "This provider forwards the agent's own 
credentials, so no token is set: the agent keeps the sign-in it already has.",
@@ -52,6 +50,7 @@
     "Direct mode hint": "The provider's own base URL and key are written into 
the agent's configuration. Switching to another provider rewrites the file, and 
the agent has to be restarted to pick it up.",
     "Duration": "Duration",
     "Empty message": "Empty message",
+    "Enable": "Enable",
     "Event": "Event",
     "Failed to get agent records": "Failed to get agent records",
     "Failed to get agent sessions": "Failed to get agent sessions",
@@ -70,10 +69,10 @@
     "Gateway mode hint": "The agent is pointed at the local proxy, so 
switching to another provider takes effect on the next request without 
restarting the agent.",
     "Gateway only hint": "Codex speaks only the OpenAI Responses API. Gateway 
serves it by translating to the chat completions this provider answers, so 
Codex cannot be pointed at it directly.",
     "Image": "Image",
+    "In use": "In use",
     "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",
@@ -104,6 +103,7 @@
     "Prompt ID": "Prompt ID",
     "Provider": "Provider",
     "Provider cleared": "Provider cleared",
+    "Provider enabled": "Provider enabled",
     "Provider hint": "With no provider selected, this agent still talks to its 
own upstream and Gateway only watches it.",
     "Provider saved": "Provider saved",
     "Provider speaks another API": "This agent speaks the {protocol} API, 
which the bound provider does not. A direct binding needs both to speak the 
same one: pick another provider, or route this agent through the gateway, which 
translates.",
@@ -145,8 +145,6 @@
     "Unknown": "Unknown",
     "Unpatch": "Turn off monitoring",
     "Usage": "Usage",
-    "Use your own API": "Use your own API",
-    "Use your own API hint": "Gateway can relay an agent's requests to your 
own vendor account, keeping the API key out of the agent and every prompt in 
LLM Records.",
     "User": "User",
     "Version": "Version",
     "View Records": "View Records",
@@ -155,8 +153,7 @@
     "Write confirm hint": "Gateway writes the selected provider into this 
agent's own configuration file, replacing the file in one step. Restart any 
session that is already running.",
     "Write the configuration of {agent}?": "Write the configuration of 
{agent}?",
     "Written by Gateway": "Written by Gateway",
-    "{count} messages": "{count} messages",
-    "{count} sessions": "{count} sessions"
+    "{count} messages": "{count} messages"
   },
   "agentConfig": {
     "1 file": "1 file",
@@ -327,7 +324,6 @@
     "CreatedTime": "CreatedTime",
     "Current version": "Current version",
     "Dark": "Dark",
-    "Dashboard": "Dashboard",
     "Delete": "Delete",
     "Deleted successfully": "Deleted successfully",
     "Display name": "Display name",
@@ -365,7 +361,6 @@
     "No data": "No data",
     "Nodes": "Nodes",
     "OK": "OK",
-    "Open": "Open",
     "Other": "Other",
     "Owner": "Owner",
     "Password": "Password",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 8e7d368..3035c5a 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -18,8 +18,8 @@
     "Username": "用户名"
   },
   "agent": {
-    "Add a provider": "添加一个供应商",
-    "Add a provider hint": "供应商 → 新建供应商:选一个厂商,填上你的 API Key,列出它提供的模型。",
+    "Advanced view": "高级视图",
+    "Advanced view hint": "扫描到的每个安装,含进程、路径、所有者和安装方式。",
     "Agent": "智能体",
     "Agent Records": "活动记录",
     "Agent Sessions": "会话",
@@ -35,8 +35,6 @@
     "All outcomes": "全部结果",
     "Auto refresh": "自动刷新",
     "Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的供应商,与请求里的模型名无关。",
-    "Bind the provider": "把供应商绑定到 Agent",
-    "Bind the provider hint": "打开一个 Agent,选中该供应商,Gateway 会替你写好它的配置。",
     "Bound provider cooling down": "绑定的供应商已暂时移出轮转,请求将走备用供应商",
     "Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env 
段,这样不用每次 export,所有会话都会生效。",
     "Client auth token hint": "该供应商转发 Agent 自己的凭据,所以不设置 token,Agent 
继续用它已有的登录。",
@@ -52,6 +50,7 @@
     "Direct mode hint": "直接把该供应商的 Base URL 和密钥写入 Agent 配置文件。切换供应商会重写文件,且需要重启 
Agent 才能生效。",
     "Duration": "耗时",
     "Empty message": "空消息",
+    "Enable": "启用",
     "Event": "事件",
     "Failed to get agent records": "获取 Agent 记录失败",
     "Failed to get agent sessions": "获取 Agent 会话失败",
@@ -70,10 +69,10 @@
     "Gateway mode hint": "把 Agent 指向本地代理,切换供应商在下一次请求即生效,无需重启 Agent。",
     "Gateway only hint": "Codex 只会说 OpenAI Responses API。Gateway 会把它翻译成该供应商应答的 
chat completions,所以 Codex 无法直连这个供应商。",
     "Image": "图片",
+    "In use": "使用中",
     "Install Method": "安装方式",
     "Install an AI agent on this machine, then scan again": "在本机安装一个 AI 
智能体后,再扫描一次",
     "Installation": "安装信息",
-    "Installed": "已安装",
     "Last activity": "最后活动",
     "Last {count} records": "最近 {count} 条记录",
     "MCP target": "MCP 目标",
@@ -104,6 +103,7 @@
     "Prompt ID": "Prompt ID",
     "Provider": "供应商",
     "Provider cleared": "已清除供应商",
+    "Provider enabled": "已启用供应商",
     "Provider hint": "未选择供应商时,该 Agent 仍直连自己的上游,网关只做观测。",
     "Provider saved": "供应商已保存",
     "Provider speaks another API": "该 Agent 使用 {protocol} 
API,而绑定的供应商不支持。直连模式要求两者使用同一种 API:请改选其他供应商,或将该 Agent 改为经网关转发(网关会自动转换)。",
@@ -145,8 +145,6 @@
     "Unknown": "未知",
     "Unpatch": "关闭监控",
     "Usage": "用量",
-    "Use your own API": "使用你自己的 API",
-    "Use your own API hint": "Gateway 可以把 Agent 的请求转发到你自己的厂商账号,API Key 不会落到 
Agent 手里,每一次提示词都会进入 LLM Records。",
     "User": "用户",
     "Version": "版本",
     "View Records": "查看活动",
@@ -155,8 +153,7 @@
     "Write confirm hint": "Gateway 会把所选供应商以该 Agent 
自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
     "Write the configuration of {agent}?": "写入 {agent} 的配置?",
     "Written by Gateway": "已由 Gateway 写入",
-    "{count} messages": "{count} 条消息",
-    "{count} sessions": "{count} 个会话"
+    "{count} messages": "{count} 条消息"
   },
   "agentConfig": {
     "1 file": "1 个文件",
@@ -327,7 +324,6 @@
     "CreatedTime": "创建时间",
     "Current version": "当前版本",
     "Dark": "深色",
-    "Dashboard": "数据看板",
     "Delete": "删除",
     "Deleted successfully": "删除成功",
     "Display name": "显示名称",
@@ -365,7 +361,6 @@
     "No data": "暂无数据",
     "Nodes": "节点",
     "OK": "确定",
-    "Open": "打开",
     "Other": "其他",
     "Owner": "所有者",
     "Password": "密码",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 7b7fdfe..24e2069 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -18,7 +18,6 @@ import {
   ChartColumn,
   FileSearch,
   Globe,
-  LayoutDashboard,
   ListFilter,
   Logs,
   MessageSquare,
@@ -28,6 +27,7 @@ import {
   Settings,
   SlidersHorizontal,
   ShieldCheck,
+  Table2,
   type LucideIcon,
 } from "lucide-react";
 
@@ -37,6 +37,8 @@ export interface NavLeaf {
   path: string;
   icon?: LucideIcon;
   adminOnly?: boolean;
+  /** Reached from a page rather than the rail, but still named by the 
breadcrumb. */
+  hidden?: boolean;
 }
 
 export interface NavGroup {
@@ -45,6 +47,7 @@ export interface NavGroup {
   icon?: LucideIcon;
   path?: string;
   adminOnly?: boolean;
+  hidden?: boolean;
   /** Hidden until the reverse proxy is turned on, since nothing under it 
works before that. */
   gatewayOnly?: boolean;
   children?: NavLeaf[];
@@ -61,8 +64,10 @@ export interface NavGroup {
  * them, so they live in one group instead of competing with the agent pages.
  */
 export const navGroups: NavGroup[] = [
-  {key: "/", label: "general:Dashboard", icon: LayoutDashboard, path: "/"},
-  {key: "/agents", label: "agent:Agents", icon: Bot, path: "/agents", 
adminOnly: true},
+  {key: "/", label: "agent:Agents", icon: Bot, path: "/"},
+  // The table of installations is the home screen's own advanced view, linked
+  // from it rather than competing with it in the rail.
+  {key: "/agents", label: "agent:Advanced view", icon: Table2, path: 
"/agents", adminOnly: true, hidden: true},
   {
     key: "/agent-configs",
     label: "agentConfig:Skills & MCP",
diff --git a/web/src/pages/AgentDashboardPage.tsx 
b/web/src/pages/AgentDashboardPage.tsx
deleted file mode 100644
index 26a78a3..0000000
--- a/web/src/pages/AgentDashboardPage.tsx
+++ /dev/null
@@ -1,351 +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} 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/AgentDetailPage.tsx 
b/web/src/pages/AgentDetailPage.tsx
index 544e46b..229de0c 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -326,7 +326,7 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
         subTitle={error || path || agentId}
         extra={
           <Button asChild>
-            <Link to="/agents">{i18next.t("agent:Agents")}</Link>
+            <Link to="/">{i18next.t("agent:Agents")}</Link>
           </Button>
         }
       />
@@ -408,7 +408,7 @@ export default function AgentDetailPage({account}: 
{account: Account}) {
   return (
     <div className="space-y-4 p-4 md:p-6">
       <Link
-        to="/agents"
+        to="/"
         className="inline-flex items-center gap-1 text-sm 
text-muted-foreground hover:text-foreground"
       >
         <ArrowLeft className="h-4 w-4" />
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index bdeb9a2..12785ff 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -1,244 +1,255 @@
-// 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>
-  );
-}
+// 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 {ArrowLeft, 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>
+      <Link
+        to="/"
+        className="text-muted-foreground hover:text-foreground inline-flex 
items-center gap-1 text-sm"
+      >
+        <ArrowLeft className="size-4" />
+        {i18next.t("agent:Agents")}
+      </Link>
+
+      <PageHeader
+        title={i18next.t("agent:Advanced view")}
+        description={`${account.hostname} · ${i18next.t("agent:Advanced view 
hint")}`}
+      />
+
+      {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>
+  );
+}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
new file mode 100644
index 0000000..fb8e277
--- /dev/null
+++ b/web/src/pages/HomePage.tsx
@@ -0,0 +1,427 @@
+// 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, CircleX, Container, Plug, Plus, RefreshCw, 
Table2} 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 {ProviderIcon} from "@/components/ProviderIcon";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {EmptyState} from "@/components/shared/empty-state";
+import {Loading} from "@/components/shared/loading";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent} from "@/components/ui/card";
+import {Switch} from "@/components/ui/switch";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {
+  agentCanUse,
+  agentDetailPath,
+  agentKey,
+  agentNeedsResponsesApi,
+  agentProtocol,
+  runtimeOf,
+  useAgents,
+} from "@/lib/agents";
+import {
+  providerIdOf,
+  providerProtocol,
+  servesResponsesApi,
+  usesClientAuth,
+} from "@/lib/providers";
+import {cn} from "@/lib/utils";
+import type {Account, Agent, AgentRuntime, Provider, ProviderHealth} from 
"@/types";
+
+/** One row of the rail: the tool, and the provider it is on right now. */
+function AgentRow({
+  agent,
+  provider,
+  status,
+  active,
+  onSelect,
+}: {
+  agent: Agent;
+  provider: string;
+  status?: AgentRuntime;
+  active: boolean;
+  onSelect: () => void;
+}) {
+  return (
+    <SimpleTooltip title={agent.path} side="right">
+      <button
+        type="button"
+        onClick={onSelect}
+        className={cn(
+          "flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left 
transition-colors",
+          active ? "bg-accent text-accent-foreground" : "hover:bg-accent/60",
+        )}
+      >
+        <AgentIcon
+          agent={agent.agentId || agent.name}
+          size={22}
+          fallback={<Bot className="text-muted-foreground size-5" />}
+        />
+        <span className="min-w-0 flex-1">
+          <span className="flex items-center gap-1.5">
+            <span className="truncate text-sm font-medium">{agent.name}</span>
+            {status?.running ? <span className="bg-success size-1.5 shrink-0 
rounded-full" /> : null}
+          </span>
+          <span className="text-muted-foreground block truncate 
text-xs">{provider}</span>
+        </span>
+      </button>
+    </SimpleTooltip>
+  );
+}
+
+/** Why this agent cannot be put on this provider, empty when it can. */
+function blockedReason(agent: Agent, provider: Provider) {
+  if (agentCanUse(agent, provider)) {
+    return "";
+  }
+  return agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(provider)
+    ? i18next.t("agent:Gateway only hint")
+    : i18next.t("agent:Provider speaks another API").replace("{protocol}", 
agentProtocol(agent));
+}
+
+/** One provider the selected agent can be put on, with the button that does 
it. */
+function ProviderChoice({
+  provider,
+  health,
+  active,
+  blocked,
+  busy,
+  waiting,
+  onEnable,
+}: {
+  provider: Provider;
+  health?: ProviderHealth;
+  active: boolean;
+  /** Why this agent cannot reach it as routed now, empty when it can. */
+  blocked: string;
+  busy: boolean;
+  /** Another card of the same agent is being enabled. */
+  waiting: boolean;
+  onEnable: () => void;
+}) {
+  const protocol = providerProtocol(provider.type);
+
+  return (
+    <Card className={cn("gap-0 py-0", active && "border-primary 
ring-primary/20 ring-1")}>
+      <CardContent className="flex h-full flex-col gap-3 p-4">
+        <div className="flex items-start gap-2">
+          <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} 
alt={provider.name} size={22} />
+          <div className="min-w-0 flex-1">
+            <p className="truncate font-medium">{provider.displayName || 
provider.name}</p>
+            <SimpleTooltip title={provider.baseUrl}>
+              <p className="text-muted-foreground truncate font-mono 
text-xs">{provider.baseUrl || "-"}</p>
+            </SimpleTooltip>
+          </div>
+        </div>
+
+        <div className="flex flex-wrap items-center gap-1.5">
+          <Badge variant={protocol === "anthropic" ? "info" : 
"success"}>{protocol}</Badge>
+          {usesClientAuth(provider) ? (
+            <Badge variant="muted">{i18next.t("provider:Caller's own 
login")}</Badge>
+          ) : null}
+          {provider.status === "enabled" ? null : (
+            <Badge variant="muted">
+              <CircleX />
+              {i18next.t("provider:Disabled")}
+            </Badge>
+          )}
+          {health && !health.healthy ? (
+            <SimpleTooltip title={health.lastError}>
+              <span>
+                <Badge variant="warning">{i18next.t("agent:Cooling 
down")}</Badge>
+              </span>
+            </SimpleTooltip>
+          ) : null}
+        </div>
+
+        <div className="mt-auto flex items-center gap-2 border-t pt-3">
+          {active ? (
+            <Badge variant="success">
+              <Check />
+              {i18next.t("agent:In use")}
+            </Badge>
+          ) : blocked === "" ? (
+            <Button size="sm" loading={busy} disabled={waiting} 
onClick={onEnable}>
+              {i18next.t("agent:Enable")}
+            </Button>
+          ) : (
+            <SimpleTooltip title={blocked}>
+              <span>
+                <Button size="sm" variant="outline" disabled>
+                  {i18next.t("agent:Enable")}
+                </Button>
+              </span>
+            </SimpleTooltip>
+          )}
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
+
+/**
+ * The home screen: the agents installed on this machine on the left, and the
+ * providers the selected one can be put on to the right of it. One click on a
+ * card is the whole switch — the routing is stored and, where Gateway writes
+ * the agent's configuration file, that file is rewritten with it.
+ */
+export default function HomePage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const {
+    agents,
+    loading,
+    error,
+    busyKey,
+    scanned,
+    inContainer,
+    runtime,
+    runBusyKey,
+    scan,
+    loadRuntime,
+    toggleRunning,
+    togglePatch,
+    activateProvider,
+  } = useAgents(isAdmin);
+  const [providers, setProviders] = React.useState<Provider[]>([]);
+  const [health, setHealth] = React.useState<ProviderHealth[]>([]);
+  // False until the first listing lands, so "no providers" never flashes.
+  const [loaded, setLoaded] = React.useState(false);
+  const [selected, setSelected] = React.useState("");
+  // Which card was clicked, so only that one spins while the switch is made.
+  const [enabling, setEnabling] = React.useState("");
+
+  const loadProviders = React.useCallback(() => {
+    if (!isAdmin) {
+      return;
+    }
+    ProviderBackend.getProviders(account.name)
+      .then(res => setProviders(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => setProviders([]))
+      .then(() => setLoaded(true));
+  }, [isAdmin, account.name]);
+
+  React.useEffect(() => {
+    loadProviders();
+  }, [loadProviders]);
+
+  // What the proxy has seen of each provider changes as requests are relayed,
+  // so it is polled rather than read once.
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return undefined;
+    }
+
+    const load = () => {
+      ProviderBackend.getProviderHealth()
+        .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
+        .catch(() => setHealth([]));
+    };
+
+    load();
+    const interval = setInterval(load, 10000);
+    return () => clearInterval(interval);
+  }, [isAdmin]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  // The rail keeps its selection across a rescan; the first agent stands in
+  // until something is picked, so the right side is never blank.
+  const agent = agents.find(candidate => agentKey(candidate) === selected) ?? 
agents[0];
+  const status = agent ? runtimeOf(runtime, agent) : undefined;
+  const busy = agent !== undefined && busyKey === agentKey(agent);
+  const nameOf = (id: string) => {
+    const provider = providers.find(candidate => providerIdOf(candidate) === 
id);
+    return provider ? provider.displayName || provider.name : id;
+  };
+  const patchAction = agent ? i18next.t(`agent:${agent.patched ? "Unpatch" : 
"Patch"}`) : "";
+
+  const refresh = () => {
+    scan(true);
+    loadRuntime(true);
+    loadProviders();
+  };
+
+  const rescan = (
+    <Button variant="outline" onClick={refresh} loading={loading}>
+      <RefreshCw />
+      {i18next.t("agent:Scan")}
+    </Button>
+  );
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("agent:Agents on this machine")}
+        description={account.hostname}
+        actions={
+          <>
+            <Button asChild variant="ghost">
+              <Link to="/agents">
+                <Table2 />
+                {i18next.t("agent:Advanced view")}
+              </Link>
+            </Button>
+            {rescan}
+          </>
+        }
+      />
+
+      {error ? <MessageAlert title={error} /> : 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={rescan}
+          />
+        </Card>
+      ) : (
+        <div className="grid grid-cols-1 gap-4 
lg:grid-cols-[15rem_minmax(0,1fr)]">
+          <Card className="h-max gap-0 py-2">
+            <CardContent className="space-y-0.5 px-2">
+              {agents.map(item => (
+                <AgentRow
+                  key={agentKey(item)}
+                  agent={item}
+                  provider={item.provider === "" ? i18next.t("agent:No 
provider") : nameOf(item.provider)}
+                  status={runtimeOf(runtime, item)}
+                  active={agent !== undefined && agentKey(item) === 
agentKey(agent)}
+                  onSelect={() => setSelected(agentKey(item))}
+                />
+              ))}
+            </CardContent>
+          </Card>
+
+          <div className="min-w-0 space-y-3">
+            {agent === undefined ? null : (
+              <>
+                <div className="flex flex-wrap items-center gap-2 border-b 
pb-3">
+                  <AgentIcon
+                    agent={agent.agentId || agent.name}
+                    size={24}
+                    fallback={<Bot className="text-muted-foreground size-6" />}
+                  />
+                  <span className="font-semibold">{agent.name}</span>
+                  <Badge variant="muted">{agent.version || 
i18next.t("agent:Unknown")}</Badge>
+                  <RunBadge status={status} />
+
+                  <div className="ml-auto flex flex-wrap items-center gap-3">
+                    <label className="text-muted-foreground flex items-center 
gap-1.5 text-xs">
+                      {i18next.t("agent:Monitored")}
+                      {agent.supported ? (
+                        <ConfirmDialog
+                          title={`${patchAction} ${agent.name}?`}
+                          description={[agent.notice, 
agent.followup].filter(Boolean).join(" ") || undefined}
+                          confirmText={patchAction}
+                          variant={agent.patched ? "destructive" : "default"}
+                          onConfirm={() => togglePatch(agent)}
+                        >
+                          {/* The dialog owns the click, so the switch only 
ever
+                              mirrors what the last scan reported. */}
+                          <Switch
+                            checked={agent.patched}
+                            disabled={busy}
+                            aria-label={patchAction}
+                            onCheckedChange={() => undefined}
+                          />
+                        </ConfirmDialog>
+                      ) : (
+                        <SimpleTooltip title={agent.detail || 
i18next.t("agent:Not supported")}>
+                          <span>
+                            <Switch checked={false} disabled 
aria-label={i18next.t("agent:Patch")} />
+                          </span>
+                        </SimpleTooltip>
+                      )}
+                    </label>
+                    <RunButton
+                      agent={agent}
+                      status={status}
+                      busy={runBusyKey === agentKey(agent)}
+                      onToggle={toggleRunning}
+                    />
+                    <Link
+                      to={agentDetailPath(agent, agents)}
+                      className="text-primary inline-flex items-center text-sm 
hover:underline"
+                    >
+                      {i18next.t("agent:Details")}
+                      <ChevronRight className="size-4" />
+                    </Link>
+                  </div>
+                </div>
+
+                {loaded && providers.length === 0 ? (
+                  <Card className="py-0">
+                    <EmptyState
+                      icon={Plug}
+                      title={i18next.t("provider:No providers yet")}
+                      description={i18next.t("provider:No providers yet 
detail")}
+                      action={
+                        <Button asChild>
+                          <Link to="/providers">
+                            <Plus />
+                            {i18next.t("provider:New Provider")}
+                          </Link>
+                        </Button>
+                      }
+                    />
+                  </Card>
+                ) : (
+                  <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 
2xl:grid-cols-3">
+                    {providers.map(provider => (
+                      <ProviderChoice
+                        key={providerIdOf(provider)}
+                        provider={provider}
+                        health={health.find(item => item.provider === 
providerIdOf(provider))}
+                        active={agent.provider === providerIdOf(provider)}
+                        blocked={blockedReason(agent, provider)}
+                        busy={busy && enabling === providerIdOf(provider)}
+                        waiting={busy && enabling !== providerIdOf(provider)}
+                        onEnable={() => {
+                          setEnabling(providerIdOf(provider));
+                          activateProvider(agent, providerIdOf(provider));
+                        }}
+                      />
+                    ))}
+                  </div>
+                )}
+              </>
+            )}
+          </div>
+        </div>
+      )}
+    </PageContainer>
+  );
+}
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index 55f0880..7639dab 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -28,7 +28,7 @@ import * as AgentBackend from "@/backend/AgentBackend";
 import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
 import {ProviderIconField} from "@/components/ProviderIcon";
-import {ProviderGridCard, providerIdOf} from "@/components/ProviderGridCard";
+import {ProviderGridCard} from "@/components/ProviderGridCard";
 import {ProviderModelsField} from "@/components/ProviderModelsField";
 import {ProviderSourcePicker, sourceTitle} from 
"@/components/ProviderSourcePicker";
 import {ProviderTestField, useProviderTest} from 
"@/components/ProviderTestField";
@@ -48,6 +48,7 @@ import {
   baseUrlPlaceholder,
   baseUrlPresets,
   customSource,
+  providerIdOf,
   providerSlug,
   providerSources,
   usesClientAuth,


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

Reply via email to