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 2c2a1b5  feat(web): redesign the UI as a new-style checkup console
2c2a1b5 is described below

commit 2c2a1b59031c25b30aaf6a9aa14114adcc93f15e
Author: Yang Luo <[email protected]>
AuthorDate: Mon Sep 28 16:46:33 2026 +0800

    feat(web): redesign the UI as a new-style checkup console
---
 web/src/App.tsx                               | 280 ++++--------
 web/src/components/CheckupHero.tsx            | 449 +++++++++++++++++++
 web/src/components/HomeSummary.tsx            | 170 -------
 web/src/components/OnboardingChecklist.tsx    | 175 --------
 web/src/components/shared/app-header.tsx      | 613 +++++++++++++++-----------
 web/src/components/shared/app-sidebar.tsx     | 266 -----------
 web/src/components/shared/breadcrumb-bar.tsx  |  78 ----
 web/src/components/shared/command-palette.tsx |  33 +-
 web/src/components/shared/version-panel.tsx   |  24 +-
 web/src/components/ui/breadcrumb.tsx          | 103 -----
 web/src/index.css                             | 103 ++---
 web/src/locales/en/data.json                  |  39 +-
 web/src/locales/zh/data.json                  |  39 +-
 web/src/nav.ts                                |  91 ++--
 web/src/pages/ChatPage.tsx                    |   2 +-
 web/src/pages/HomePage.tsx                    | 183 ++++----
 web/src/pages/ToolsPage.tsx                   |  71 +++
 17 files changed, 1204 insertions(+), 1515 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index 690bc0b..2756de6 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -20,14 +20,10 @@ import i18next from "i18next";
 import * as AccountBackend from "@/backend/AccountBackend";
 import * as Conf from "@/Conf";
 import * as Setting from "@/Setting";
-import {findGroupOf, selectedKeyOf} from "@/nav";
 import {AppHeader} from "@/components/shared/app-header";
-import {AppSidebar, persistOpenKeys, readSavedOpenKeys, useIsDesktop} from 
"@/components/shared/app-sidebar";
 import {CommandPalette, useCommandPalette} from 
"@/components/shared/command-palette";
 import {Loading} from "@/components/shared/loading";
-import {Logo} from "@/components/shared/logo";
 import {TooltipProvider} from "@/components/ui/tooltip";
-import {cn} from "@/lib/utils";
 import AccountPage from "@/pages/AccountPage";
 import AgentConfigsPage from "@/pages/AgentConfigsPage";
 import AgentDetailPage from "@/pages/AgentDetailPage";
@@ -52,12 +48,11 @@ import UsagePage from "@/pages/UsagePage";
 import PricingPage from "@/pages/PricingPage";
 import SettingPage from "@/pages/SettingPage";
 import SigninPage from "@/pages/SigninPage";
+import ToolsPage from "@/pages/ToolsPage";
 import type {Account, Palette, ThemeAlgorithm} from "@/types";
 
 Setting.initCasdoorSdk(Conf.AuthConfig);
 
-const collapsedKey = "siderCollapsed";
-
 export default function App() {
   // Pages call i18next.t() directly, so re-render the whole tree on a 
language change.
   useTranslation();
@@ -75,72 +70,9 @@ export default function App() {
     Setting.applyPalette(stored);
     return stored;
   });
-  const [collapsed, setCollapsed] = React.useState(() => 
localStorage.getItem(collapsedKey) === "true");
-  // Below md the rail is a drawer, so the same header button opens it instead
-  // of narrowing a column that is not on screen.
-  const [mobileNavOpen, setMobileNavOpen] = React.useState(false);
-  const isDesktop = useIsDesktop();
   const location = useLocation();
   const palette = useCommandPalette();
 
-  const selectedKey = selectedKeyOf(location.pathname, location.search, 
location.hash);
-  const wasCollapsedRef = React.useRef(false);
-  const [openKeys, setOpenKeys] = React.useState<string[]>(() => {
-    if (localStorage.getItem(collapsedKey) === "true") {
-      return [];
-    }
-    const saved = new Set(readSavedOpenKeys());
-    const group = findGroupOf(selectedKey);
-    if (group) {
-      saved.add(group.key);
-    }
-    return [...saved];
-  });
-
-  // Navigating into a collapsed group opens it, and expanding the rail 
restores
-  // whatever the reader last had open rather than the defaults.
-  React.useEffect(() => {
-    if (collapsed) {
-      wasCollapsedRef.current = true;
-      setOpenKeys([]);
-      return;
-    }
-    const justExpanded = wasCollapsedRef.current;
-    wasCollapsedRef.current = false;
-    const group = findGroupOf(selectedKey);
-
-    setOpenKeys(previous => {
-      if (justExpanded) {
-        const restored = new Set(readSavedOpenKeys());
-        if (group) {
-          restored.add(group.key);
-        }
-        return [...restored];
-      }
-      if (group && !previous.includes(group.key)) {
-        return [...previous, group.key];
-      }
-      return previous;
-    });
-  }, [selectedKey, collapsed]);
-
-  React.useEffect(() => {
-    if (!collapsed) {
-      persistOpenKeys(openKeys);
-    }
-  }, [openKeys, collapsed]);
-
-  const toggleSidebar = () => {
-    if (!isDesktop) {
-      setMobileNavOpen(value => !value);
-      return;
-    }
-    setCollapsed(value => {
-      localStorage.setItem(collapsedKey, String(!value));
-      return !value;
-    });
-  };
-
   const changeTheme = (next: ThemeAlgorithm) => {
     setThemeAlgorithm(next);
     Setting.saveThemeAlgorithm(next);
@@ -230,131 +162,107 @@ export default function App() {
 
   return (
     <TooltipProvider>
-      <div className="bg-muted/30 min-h-screen">
-        <AppSidebar
-          collapsed={collapsed}
-          selectedKey={selectedKey}
-          openKeys={openKeys}
-          onOpenKeysChange={setOpenKeys}
+      <div className="bg-background flex min-h-screen min-w-0 flex-col">
+        <AppHeader
+          uri={location.pathname}
+          account={account}
           isAdmin={Setting.isAdminUser(account)}
-          mobileOpen={mobileNavOpen}
-          onMobileOpenChange={setMobileNavOpen}
+          themeAlgorithm={themeAlgorithm}
+          onThemeChange={changeTheme}
+          colorPalette={colorPalette}
+          onColorPaletteChange={changeColorPalette}
+          onSignout={signout}
+          onOpenPalette={() => palette.setOpen(true)}
         />
 
-        <div
-          className={cn(
-            "flex min-h-screen min-w-0 flex-col transition-[margin] 
duration-200",
-            collapsed ? "md:ml-16" : "md:ml-64",
-          )}
-        >
-          <AppHeader
-            collapsed={collapsed}
-            onToggleSidebar={toggleSidebar}
-            uri={location.pathname}
+        {account ? (
+          <CommandPalette
+            open={palette.open}
+            onOpenChange={palette.setOpen}
             account={account}
             isAdmin={Setting.isAdminUser(account)}
-            themeAlgorithm={themeAlgorithm}
-            onThemeChange={changeTheme}
-            colorPalette={colorPalette}
-            onColorPaletteChange={changeColorPalette}
-            onSignout={signout}
-            onOpenPalette={() => palette.setOpen(true)}
           />
+        ) : null}
 
-          {account ? (
-            <CommandPalette
-              open={palette.open}
-              onOpenChange={palette.setOpen}
-              account={account}
-              isAdmin={Setting.isAdminUser(account)}
-            />
-          ) : null}
-
-          {/* min-w-0 keeps a wide table from stretching the whole layout. */}
-          <main className="flex min-w-0 flex-1 flex-col">
-            <React.Suspense fallback={<Loading type="page" />}>
-              <Routes>
-                <Route
-                  path="/"
-                  element={requireSignin(user =>
-                    Setting.isAdminUser(user) ? (
-                      <HomePage account={user} />
-                    ) : (
-                      // 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="/providers" replace />
-                    ),
-                  )}
-                />
-                <Route path="/account" element={requireSignin(user => 
<AccountPage account={user} />)} />
-                <Route path="/chat" element={requireSignin(user => <ChatPage 
account={user} />)} />
-                <Route path="/channels" element={requireSignin(user => 
<ChannelsPage account={user} />)} />
-                <Route path="/agents" element={requireSignin(user => 
<AgentsPage account={user} />)} />
-                <Route
-                  path="/agent-versions"
-                  element={requireSignin(user => <AgentVersionsPage 
account={user} />)}
-                />
-                <Route
-                  path="/agents/:agentId"
-                  element={requireSignin(user => <AgentDetailPage 
account={user} />)}
-                />
-                <Route
-                  path="/agent-configs"
-                  element={requireSignin(user => <AgentConfigsPage 
account={user} />)}
-                />
-                <Route
-                  path="/agent-records"
-                  element={requireSignin(user => <AgentRecordsPage 
account={user} />)}
-                />
-                <Route
-                  path="/agent-sessions"
-                  element={requireSignin(user => <AgentSessionsPage 
account={user} />)}
-                />
-                <Route
-                  path="/agent-sessions/:sessionKey"
-                  element={requireSignin(user => <AgentSessionPage 
account={user} />)}
-                />
-                <Route
-                  path="/connections"
-                  element={requireSignin(user => <ConnectionsPage 
account={user} />)}
-                />
-                <Route path="/import" element={requireSignin(() => 
<ImportLinkPage />)} />
-                <Route path="/settings" element={requireSignin(user => 
<SettingPage account={user} />)} />
-                <Route
-                  path="/permissions"
-                  element={requireSignin(user => <PermissionsPage 
account={user} />)}
-                />
-                <Route path="/providers" element={requireSignin(user => 
<ProviderListPage account={user} />)} />
-                <Route
-                  path="/authenticity"
-                  element={requireSignin(user => <AuthenticityPage 
account={user} />)}
-                />
-                <Route
-                  path="/providers/:owner/:providerName"
-                  element={requireSignin(() => <ProviderEditPage />)}
-                />
-                <Route
-                  path="/llm-records"
-                  element={requireSignin(user => <LlmRecordsPage 
account={user} />)}
-                />
-                <Route path="/usage" element={requireSignin(user => <UsagePage 
account={user} />)} />
-                <Route path="/pricing" element={requireSignin(user => 
<PricingPage account={user} />)} />
-                <Route
-                  path="/model-routes"
-                  element={requireSignin(user => <ModelRoutesPage 
account={user} />)}
-                />
-              </Routes>
-            </React.Suspense>
-          </main>
+        {/* min-w-0 keeps a wide table from stretching the whole layout. */}
+        <main className="flex min-w-0 flex-1 flex-col">
+          <React.Suspense fallback={<Loading type="page" />}>
+            <Routes>
+              <Route
+                path="/"
+                element={requireSignin(user =>
+                  Setting.isAdminUser(user) ? (
+                    <HomePage account={user} />
+                  ) : (
+                  // 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="/providers" replace />
+                  ),
+                )}
+              />
+              <Route path="/account" element={requireSignin(user => 
<AccountPage account={user} />)} />
+              <Route path="/chat" element={requireSignin(user => <ChatPage 
account={user} />)} />
+              <Route path="/channels" element={requireSignin(user => 
<ChannelsPage account={user} />)} />
+              <Route path="/agents" element={requireSignin(user => <AgentsPage 
account={user} />)} />
+              <Route
+                path="/agent-versions"
+                element={requireSignin(user => <AgentVersionsPage 
account={user} />)}
+              />
+              <Route
+                path="/agents/:agentId"
+                element={requireSignin(user => <AgentDetailPage account={user} 
/>)}
+              />
+              <Route
+                path="/agent-configs"
+                element={requireSignin(user => <AgentConfigsPage 
account={user} />)}
+              />
+              <Route
+                path="/agent-records"
+                element={requireSignin(user => <AgentRecordsPage 
account={user} />)}
+              />
+              <Route
+                path="/agent-sessions"
+                element={requireSignin(user => <AgentSessionsPage 
account={user} />)}
+              />
+              <Route
+                path="/agent-sessions/:sessionKey"
+                element={requireSignin(user => <AgentSessionPage 
account={user} />)}
+              />
+              <Route
+                path="/connections"
+                element={requireSignin(user => <ConnectionsPage account={user} 
/>)}
+              />
+              <Route path="/import" element={requireSignin(() => 
<ImportLinkPage />)} />
+              <Route path="/settings" element={requireSignin(user => 
<SettingPage account={user} />)} />
+              <Route
+                path="/permissions"
+                element={requireSignin(user => <PermissionsPage account={user} 
/>)}
+              />
+              <Route path="/providers" element={requireSignin(user => 
<ProviderListPage account={user} />)} />
+              <Route
+                path="/authenticity"
+                element={requireSignin(user => <AuthenticityPage 
account={user} />)}
+              />
+              <Route
+                path="/providers/:owner/:providerName"
+                element={requireSignin(() => <ProviderEditPage />)}
+              />
+              <Route
+                path="/llm-records"
+                element={requireSignin(user => <LlmRecordsPage account={user} 
/>)}
+              />
+              <Route path="/usage" element={requireSignin(user => <UsagePage 
account={user} />)} />
+              <Route path="/tools" element={requireSignin(user => <ToolsPage 
account={user} />)} />
+              <Route path="/pricing" element={requireSignin(user => 
<PricingPage account={user} />)} />
+              <Route
+                path="/model-routes"
+                element={requireSignin(user => <ModelRoutesPage account={user} 
/>)}
+              />
+            </Routes>
+          </React.Suspense>
+        </main>
 
-          {/* Fixed height: ChatPage sizes its pane against the header and 
this. */}
-          <footer className="flex h-18 items-center justify-center border-t">
-            <a target="_blank" rel="noreferrer" 
href="https://github.com/apache/casbin-gateway";>
-              <Logo className="h-[30px] w-auto" />
-            </a>
-          </footer>
-        </div>
       </div>
     </TooltipProvider>
   );
diff --git a/web/src/components/CheckupHero.tsx 
b/web/src/components/CheckupHero.tsx
new file mode 100644
index 0000000..b768453
--- /dev/null
+++ b/web/src/components/CheckupHero.tsx
@@ -0,0 +1,449 @@
+// 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 {
+  CheckCircle2,
+  ChevronRight,
+  FileSearch,
+  HeartPulse,
+  PackageCheck,
+  Plug,
+  Radar,
+  Route,
+  ScanSearch,
+  ShieldAlert,
+  ShieldCheck,
+  Bot,
+  type LucideIcon,
+} from "lucide-react";
+import i18next from "i18next";
+
+import {authenticityOf, useProviderProbes} from 
"@/components/AuthenticityOverview";
+import {Fold} from "@/components/shared/fold";
+import {Button} from "@/components/ui/button";
+import {Card} from "@/components/ui/card";
+import {Progress} from "@/components/ui/progress";
+import {agentDetailPath, type AgentSpend} from "@/lib/agents";
+import {formatCost} from "@/lib/usage";
+import {cn} from "@/lib/utils";
+import type {Agent, Provider, ProviderHealth} from "@/types";
+
+/** How long each check lingers on screen while a checkup runs. */
+const stepMs = 240;
+
+function fill(key: string, count: number) {
+  return i18next.t(key).split("{count}").join(String(count));
+}
+
+interface Check {
+  key: string;
+  icon: LucideIcon;
+  /** Points off the score of 100 while this check fails. */
+  penalty: number;
+  title: string;
+  /** Where the fix is, a page or a place further down this one. */
+  to?: string;
+  onFix?: () => void;
+}
+
+function scrollToId(id: string) {
+  document.getElementById(id)?.scrollIntoView({behavior: "smooth", block: 
"start"});
+}
+
+/**
+ * What a checkup looks at, each from state the home screen already holds. A
+ * check that cannot be judged yet is left out rather than passed.
+ */
+function runChecks({
+  agents,
+  providers,
+  providersLoaded,
+  health,
+  alerting,
+  graded,
+  outdated,
+  recording,
+}: {
+  agents: Agent[];
+  providers: Provider[];
+  providersLoaded: boolean;
+  health: ProviderHealth[];
+  alerting: number;
+  graded: number;
+  outdated: number;
+  recording: boolean;
+}) {
+  const passed: Check[] = [];
+  const failed: Check[] = [];
+  const judge = (ok: boolean, check: Check, okTitle: string) =>
+    ok ? passed.push({...check, title: okTitle}) : failed.push(check);
+
+  if (providersLoaded) {
+    judge(
+      providers.length > 0,
+      {key: "providers", icon: Plug, penalty: 30, title: 
i18next.t("provider:No providers yet"), to: "/providers"},
+      fill("general:Checkup providers ok", providers.length),
+    );
+  }
+
+  judge(
+    agents.length > 0,
+    {
+      key: "agents",
+      icon: Bot,
+      penalty: 20,
+      title: i18next.t("agent:No supported agents found"),
+      onFix: () => scrollToId("agent-catalog"),
+    },
+    fill("general:Checkup agents ok", agents.length),
+  );
+
+  if (agents.length > 0) {
+    const routed = agents.filter(agent => agent.provider !== "").length;
+    judge(
+      routed > 0,
+      {
+        key: "routed",
+        icon: Route,
+        penalty: 20,
+        title: i18next.t("general:Checkup routed failed"),
+        onFix: () => scrollToId("agent-cards"),
+      },
+      fill("general:Checkup routed ok", routed),
+    );
+  }
+
+  if (providers.length > 0) {
+    const failing = health.filter(entry => !entry.healthy).length;
+    judge(
+      failing === 0,
+      {
+        key: "health",
+        icon: HeartPulse,
+        penalty: Math.min(20, failing * 10),
+        title: fill("general:Checkup health failed", failing),
+        to: "/providers",
+      },
+      i18next.t("general:Checkup health ok"),
+    );
+  }
+
+  if (graded > 0) {
+    judge(
+      alerting === 0,
+      {
+        key: "authenticity",
+        icon: ShieldAlert,
+        penalty: 15,
+        title: fill("general:Checkup authenticity failed", alerting),
+        to: "/authenticity",
+      },
+      i18next.t("general:Checkup authenticity ok"),
+    );
+  }
+
+  const uploading = agents.filter(agent => (agent.egressFindings ?? 0) > 0);
+  // The watch is not always on, so a clean result is not a pass worth 
claiming.
+  if (uploading.length > 0) {
+    failed.push({
+      key: "egress",
+      icon: Radar,
+      penalty: 15,
+      title: fill("general:Checkup egress failed", uploading.length),
+      to: agentDetailPath(uploading[0], agents),
+    });
+  }
+
+  if (agents.length > 0) {
+    judge(
+      outdated === 0,
+      {
+        key: "updates",
+        icon: PackageCheck,
+        penalty: 5,
+        title: fill("general:Checkup updates failed", outdated),
+        to: "/agent-versions",
+      },
+      i18next.t("general:Checkup updates ok"),
+    );
+  }
+
+  const monitorable = agents.filter(agent => agent.supported);
+  if (monitorable.length > 0) {
+    const unmonitored = monitorable.filter(agent => !agent.patched).length;
+    judge(
+      unmonitored === 0,
+      {
+        key: "monitoring",
+        icon: ScanSearch,
+        penalty: 5,
+        title: fill("general:Checkup monitoring failed", unmonitored),
+        to: "/agents",
+      },
+      i18next.t("general:Checkup monitoring ok"),
+    );
+  }
+
+  judge(
+    recording,
+    {
+      key: "recording",
+      icon: FileSearch,
+      penalty: 5,
+      title: i18next.t("general:Checkup recording failed"),
+      to: "/settings#llm-records",
+    },
+    i18next.t("general:Checkup recording ok"),
+  );
+
+  failed.sort((left, right) => right.penalty - left.penalty);
+  const score = Math.max(0, 100 - failed.reduce((sum, check) => sum + 
check.penalty, 0));
+  return {passed, failed, score};
+}
+
+function toneOf(score: number) {
+  return score >= 90 ? "text-success" : score >= 60 ? "text-warning" : 
"text-destructive";
+}
+
+function ScoreRing({score, running}: {score: number | null; running: boolean}) 
{
+  const radius = 52;
+  const circumference = 2 * Math.PI * radius;
+  const shown = running || score === null ? 0.25 : score / 100;
+
+  return (
+    <div className={cn("relative size-36 shrink-0 md:size-40", score === null 
? "text-primary" : toneOf(score))}>
+      <svg viewBox="0 0 120 120" className={cn("size-full -rotate-90", running 
&& "animate-spin [animation-duration:1.2s]")}>
+        <circle cx="60" cy="60" r={radius} fill="none" strokeWidth="10" 
className="stroke-muted" />
+        <circle
+          cx="60"
+          cy="60"
+          r={radius}
+          fill="none"
+          strokeWidth="10"
+          strokeLinecap="round"
+          stroke="currentColor"
+          strokeDasharray={circumference}
+          strokeDashoffset={circumference * (1 - shown)}
+          className="transition-[stroke-dashoffset] duration-700"
+        />
+      </svg>
+      <div className="absolute inset-0 flex flex-col items-center 
justify-center">
+        {running || score === null ? (
+          <ShieldCheck className="size-12" strokeWidth={1.5} />
+        ) : (
+          <>
+            <span className="text-5xl font-bold tabular-nums">{score}</span>
+            <span className="text-muted-foreground 
text-xs">{i18next.t("general:Score unit")}</span>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}
+
+function CheckRow({check, ok}: {check: Check; ok: boolean}) {
+  const Icon = check.icon;
+  const severe = check.penalty >= 15;
+  const fix = i18next.t("general:Fix");
+
+  return (
+    <div className="flex items-center gap-3 px-5 py-3 md:px-8">
+      <span
+        className={cn(
+          "flex size-9 shrink-0 items-center justify-center rounded-full",
+          ok ? "bg-success/12 text-success" : severe ? "bg-destructive/12 
text-destructive" : "bg-warning/15 text-warning",
+        )}
+      >
+        <Icon className="size-4.5" />
+      </span>
+      <span className={cn("min-w-0 flex-1 text-sm", ok && 
"text-muted-foreground")}>{check.title}</span>
+      {ok ? (
+        <CheckCircle2 className="text-success size-4.5 shrink-0" />
+      ) : check.to ? (
+        <Button asChild variant="outline" size="sm" className="shrink-0 
rounded-full">
+          <Link to={check.to}>
+            {fix}
+            <ChevronRight />
+          </Link>
+        </Button>
+      ) : (
+        <Button variant="outline" size="sm" className="shrink-0 rounded-full" 
onClick={check.onFix}>
+          {fix}
+          <ChevronRight />
+        </Button>
+      )}
+    </div>
+  );
+}
+
+/**
+ * The home screen's headline: one score out of a hundred, the button that
+ * checks again, and what is wrong with a way to each fix.
+ */
+export function CheckupHero({
+  hostname,
+  agents,
+  scanned,
+  providers,
+  providersLoaded,
+  health,
+  outdated,
+  recording,
+  total,
+  running,
+  usageLoaded,
+  busy,
+  onCheck,
+}: {
+  hostname?: string;
+  agents: Agent[];
+  scanned: boolean;
+  providers: Provider[];
+  providersLoaded: boolean;
+  health: ProviderHealth[];
+  outdated: number;
+  recording: boolean;
+  total: Pick<AgentSpend, "requests" | "cost">;
+  running: number;
+  usageLoaded: boolean;
+  /** True while the reloads a checkup asked for are still out. */
+  busy: boolean;
+  onCheck: () => void;
+}) {
+  const {probes} = useProviderProbes();
+  const {summary} = authenticityOf(providers, probes);
+  const [step, setStep] = React.useState<number | null>(null);
+  const [checkedAt, setCheckedAt] = React.useState<Date>();
+
+  const ready = scanned && providersLoaded;
+  const {passed, failed, score} = runChecks({
+    agents,
+    providers,
+    providersLoaded,
+    health,
+    alerting: summary.alerting,
+    graded: summary.graded,
+    outdated,
+    recording,
+  });
+  const count = passed.length + failed.length;
+  const checking = step !== null || !ready;
+
+  React.useEffect(() => {
+    if (step === null) {
+      return undefined;
+    }
+    if (step >= count && !busy) {
+      setStep(null);
+      setCheckedAt(new Date());
+      return undefined;
+    }
+    const timer = window.setTimeout(() => setStep(Math.min(step + 1, count)), 
stepMs);
+    return () => window.clearTimeout(timer);
+  }, [step, count, busy]);
+
+  const start = () => {
+    setStep(0);
+    onCheck();
+  };
+
+  const headline = checking
+    ? i18next.t("general:Checking up")
+    : failed.length === 0
+      ? i18next.t("general:Checkup all clear")
+      : fill("general:Checkup found {count} issues", failed.length);
+
+  const dash = "-";
+  const stats = [
+    {label: i18next.t("llm:Cost"), value: usageLoaded ? formatCost(total.cost) 
: dash},
+    {label: i18next.t("llm:Requests"), value: usageLoaded ? 
total.requests.toLocaleString() : dash},
+    {
+      label: i18next.t("agent:Agents"),
+      value: `${agents.length}`,
+      note: fill("agent:{count} running", running),
+    },
+  ];
+
+  return (
+    <Card className="gap-0 overflow-hidden py-0 shadow-sm">
+      <div className="from-primary/10 via-card to-card flex flex-col 
items-center gap-6 bg-gradient-to-br px-6 py-7 md:flex-row md:px-10">
+        <ScoreRing score={ready ? score : null} running={checking} />
+
+        <div className="flex min-w-0 flex-1 flex-col items-center gap-2 
text-center md:items-start md:text-left">
+          <h1 className={cn("text-xl font-semibold md:text-2xl", !checking && 
failed.length > 0 && toneOf(score))}>{headline}</h1>
+          <p className="text-muted-foreground text-sm">
+            {[hostname, checkedAt ? i18next.t("general:Checked at 
{time}").replace("{time}", checkedAt.toLocaleTimeString()) : ""]
+              .filter(Boolean)
+              .join(" · ")}
+          </p>
+          <div className="mt-2 flex flex-wrap justify-center gap-x-8 gap-y-2 
md:justify-start">
+            {stats.map(stat => (
+              <div key={stat.label} className="flex flex-col">
+                <span className="text-lg font-semibold 
tabular-nums">{stat.value}</span>
+                <span className="text-muted-foreground text-xs">
+                  {stat.label}
+                  {stat.note ? <span className="opacity-70"> · 
{stat.note}</span> : null}
+                </span>
+              </div>
+            ))}
+          </div>
+        </div>
+
+        <Button
+          size="lg"
+          className="h-12 shrink-0 rounded-full px-10 text-base shadow-md"
+          onClick={start}
+          disabled={checking}
+        >
+          {i18next.t(checkedAt ? "general:Check again" : "general:Check up 
now")}
+        </Button>
+      </div>
+
+      {step !== null ? (
+        <Progress value={count === 0 ? 0 : (Math.min(step, count) / count) * 
100} className="h-1 rounded-none" />
+      ) : null}
+
+      {ready && step === null ? (
+        <div className="border-t">
+          {failed.length > 0 ? (
+            <div className="divide-y">
+              {failed.map(check => (
+                <CheckRow key={check.key} check={check} ok={false} />
+              ))}
+            </div>
+          ) : (
+            <div className="text-success flex items-center gap-2 px-5 py-3 
text-sm md:px-8">
+              <CheckCircle2 className="size-4.5" />
+              {i18next.t("general:No issues found")}
+            </div>
+          )}
+          {passed.length > 0 ? (
+            <Fold
+              className="border-t px-5 py-3 md:px-8"
+              title={fill("general:{count} checks passed", passed.length)}
+            >
+              <div className="-mx-5 divide-y md:-mx-8">
+                {passed.map(check => (
+                  <CheckRow key={check.key} check={check} ok />
+                ))}
+              </div>
+            </Fold>
+          ) : null}
+        </div>
+      ) : null}
+    </Card>
+  );
+}
diff --git a/web/src/components/HomeSummary.tsx 
b/web/src/components/HomeSummary.tsx
deleted file mode 100644
index 00f81ab..0000000
--- a/web/src/components/HomeSummary.tsx
+++ /dev/null
@@ -1,170 +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 {ChevronRight} from "lucide-react";
-import i18next from "i18next";
-
-import {authenticityOf, useProviderProbes} from 
"@/components/AuthenticityOverview";
-import {Card} from "@/components/ui/card";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import {formatScore, gradeLetter, gradeStyleOf} from "@/lib/authenticity";
-import {cn} from "@/lib/utils";
-import {formatCost} from "@/lib/usage";
-import type {AgentSpend} from "@/lib/agents";
-import type {Provider} from "@/types";
-
-/** Fills the {name} placeholders these strings carry. */
-function fill(key: string, values: Record<string, string | number>) {
-  return Object.entries(values).reduce(
-    (text, [name, value]) => text.split(`{${name}}`).join(String(value)),
-    i18next.t(key),
-  );
-}
-
-interface Cell {
-  key: string;
-  label: React.ReactNode;
-  value: React.ReactNode;
-  note?: React.ReactNode;
-  tone?: string;
-  to?: string;
-  hint?: React.ReactNode;
-}
-
-/** One figure of the strip: what it reads, then what it counts. */
-function Figure({cell, className}: {cell: Cell; className?: string}) {
-  const body = (
-    <>
-      <span
-        className={cn(
-          "flex items-baseline gap-1 truncate text-xl font-semibold 
tabular-nums",
-          cell.tone,
-        )}
-      >
-        {cell.value}
-        {cell.to ? <ChevronRight className="text-muted-foreground size-4 
shrink-0 self-center" /> : null}
-      </span>
-      <span className="text-muted-foreground truncate text-xs">
-        {cell.label}
-        {cell.note ? <span className="opacity-70"> · {cell.note}</span> : null}
-      </span>
-    </>
-  );
-
-  const inner = cell.to ? (
-    <Link to={cell.to} className="flex min-w-0 flex-col gap-0.5">
-      {body}
-    </Link>
-  ) : (
-    <div className="flex min-w-0 flex-col gap-0.5">{body}</div>
-  );
-
-  return (
-    <div className={cn("min-w-0 px-4 py-3", cell.to && "hover:bg-accent/40 
transition-colors", className)}>
-      {cell.hint ? <SimpleTooltip title={cell.hint}>{inner}</SimpleTooltip> : 
inner}
-    </div>
-  );
-}
-
-/**
- * The whole machine in one line: what the agents on it have spent, how many of
- * them there are, and whether the keys behind them are what they were sold as.
- * Everything here is otherwise only readable by adding up the cards below it,
- * which is the reading the home screen should not ask anyone to do.
- */
-export function HomeSummary({
-  total,
-  agents,
-  running,
-  providers,
-  loaded,
-}: {
-  /** The spend of every agent added up, by the same rule each card uses. */
-  total: Pick<AgentSpend, "requests" | "cost">;
-  agents: number;
-  running: number;
-  providers: Provider[];
-  /** False until the first listing lands, when a zero would be a guess. */
-  loaded: boolean;
-}) {
-  const {probes} = useProviderProbes();
-  const {configured, summary, worst} = authenticityOf(providers, probes);
-  const style = gradeStyleOf(worst?.grade);
-  const dash = <span className="text-muted-foreground">-</span>;
-
-  const cells: Cell[] = [
-    {
-      key: "cost",
-      label: i18next.t("llm:Cost"),
-      note: i18next.t("llm:All time"),
-      value: loaded ? formatCost(total.cost) : dash,
-      hint: i18next.t("agent:Read from the agent's own logs"),
-    },
-    {
-      key: "requests",
-      label: i18next.t("llm:Requests"),
-      value: loaded ? total.requests.toLocaleString() : dash,
-      to: "/usage",
-    },
-    {
-      key: "agents",
-      label: i18next.t("agent:Agents"),
-      note: fill("agent:{count} running", {count: running}),
-      value: agents,
-    },
-  ];
-
-  if (configured.length > 0) {
-    cells.push({
-      key: "authenticity",
-      label: i18next.t("audit:Authenticity"),
-      note:
-        summary.alerting > 0
-          ? fill("audit:Providers alerting", {count: summary.alerting})
-          : fill("audit:Graded providers", {graded: summary.graded, total: 
configured.length}),
-      value: (
-        <span className="tabular-nums">
-          {gradeLetter(worst?.grade)}
-          {worst ? ` · ${formatScore(worst)}` : ""}
-        </span>
-      ),
-      tone: style.text,
-      to: "/authenticity",
-      hint: i18next.t(style.verdict),
-    });
-  }
-
-  return (
-    <Card className="gap-0 overflow-hidden py-0 shadow-xs">
-      <div className={cn("grid grid-cols-2", cells.length > 3 ? 
"sm:grid-cols-4" : "sm:grid-cols-3")}>
-        {cells.map((cell, index) => (
-          <Figure
-            key={cell.key}
-            cell={cell}
-            // Two columns on a phone rule a cross through the middle; one row 
on
-            // a wide screen rules only between the figures.
-            className={cn(
-              index % 2 === 1 && "border-l",
-              index >= 2 && "border-t",
-              "sm:border-t-0",
-              index > 0 && "sm:border-l",
-            )}
-          />
-        ))}
-      </div>
-    </Card>
-  );
-}
diff --git a/web/src/components/OnboardingChecklist.tsx 
b/web/src/components/OnboardingChecklist.tsx
deleted file mode 100644
index 24d6ba9..0000000
--- a/web/src/components/OnboardingChecklist.tsx
+++ /dev/null
@@ -1,175 +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 {CheckCircle2, ChevronRight, Circle, ListChecks, X} from "lucide-react";
-import i18next from "i18next";
-
-import {Button} from "@/components/ui/button";
-import {Card, CardContent} from "@/components/ui/card";
-import {Progress} from "@/components/ui/progress";
-import {cn} from "@/lib/utils";
-import type {Agent, LlmAgentStat, Provider} from "@/types";
-
-const dismissedStorageKey = "onboardingChecklistDismissed";
-
-function readDismissed(): boolean {
-  try {
-    return localStorage.getItem(dismissedStorageKey) === "true";
-  } catch {
-    return false;
-  }
-}
-
-function writeDismissed(dismissed: boolean) {
-  try {
-    if (dismissed) {
-      localStorage.setItem(dismissedStorageKey, "true");
-    } else {
-      localStorage.removeItem(dismissedStorageKey);
-    }
-  } catch {
-    // Private-mode storage failures must not take the page down.
-  }
-}
-
-interface Step {
-  done: boolean;
-  title: string;
-  description: string;
-  to: string;
-}
-
-export interface Onboarding {
-  steps: Step[];
-  /** Whether the card is on screen, which is what the reopen entry hangs off. 
*/
-  open: boolean;
-  dismiss: () => void;
-  reopen: () => void;
-}
-
-/**
- * The three-step path from an empty machine to seeing traffic flow, taken from
- * the README's "Send an agent's traffic through Gateway" section. It hides
- * itself once every step is done and stays hidden once dismissed, so the state
- * is held here rather than inside the card: something has to offer it back.
- */
-export function useOnboarding({
-  providers,
-  agents,
-  stats,
-}: {
-  providers: Provider[];
-  agents: Agent[];
-  stats: LlmAgentStat[];
-}): Onboarding {
-  const [dismissed, setDismissed] = React.useState(readDismissed);
-  // Set by the reopen entry, which shows the card again even where every step
-  // is done: someone asking for the guide is asking to read it, not to be told
-  // there is nothing left to do.
-  const [reopened, setReopened] = React.useState(false);
-
-  const steps: Step[] = [
-    {
-      done: providers.length > 0,
-      title: i18next.t("agent:Onboarding add provider"),
-      description: i18next.t("agent:Onboarding add provider detail"),
-      to: "/providers",
-    },
-    {
-      done: agents.some(agent => agent.provider !== ""),
-      title: i18next.t("agent:Onboarding connect agent"),
-      description: i18next.t("agent:Onboarding connect agent detail"),
-      to: "/",
-    },
-    {
-      done: stats.some(stat => stat.requests > 0),
-      title: i18next.t("agent:Onboarding see it work"),
-      description: i18next.t("agent:Onboarding see it work detail"),
-      to: "/llm-records",
-    },
-  ];
-
-  return {
-    steps: steps,
-    open: reopened || (!dismissed && !steps.every(step => step.done)),
-    dismiss: () => {
-      writeDismissed(true);
-      setDismissed(true);
-      setReopened(false);
-    },
-    reopen: () => {
-      writeDismissed(false);
-      setDismissed(false);
-      setReopened(true);
-    },
-  };
-}
-
-export function OnboardingChecklist({onboarding}: {onboarding: Onboarding}) {
-  const steps = onboarding.steps;
-  const doneCount = steps.filter(step => step.done).length;
-
-  return (
-    <Card className="gap-0 py-0 shadow-xs">
-      <CardContent className="flex flex-col gap-3 p-4">
-        <div className="flex items-center gap-3">
-          <ListChecks className="text-muted-foreground size-4 shrink-0" />
-          <div className="flex min-w-0 flex-1 flex-col gap-1">
-            <div className="flex items-center justify-between gap-2">
-              <span className="text-sm font-medium">{i18next.t("agent:Getting 
started")}</span>
-              <span className="text-muted-foreground text-xs">
-                {doneCount}/{steps.length}
-              </span>
-            </div>
-            <Progress value={(doneCount / steps.length) * 100} 
className="h-1.5" />
-          </div>
-          <Button
-            variant="ghost"
-            size="icon-sm"
-            onClick={onboarding.dismiss}
-            aria-label={i18next.t("general:Dismiss")}
-          >
-            <X />
-          </Button>
-        </div>
-
-        <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
-          {steps.map(step => (
-            <Link
-              key={step.title}
-              to={step.to}
-              className={cn(
-                "hover:border-foreground/20 flex items-start gap-2 rounded-lg 
border px-3 py-2 transition-colors",
-                step.done && "opacity-60",
-              )}
-            >
-              {step.done ? (
-                <CheckCircle2 className="text-muted-foreground mt-0.5 size-4 
shrink-0" />
-              ) : (
-                <Circle className="text-muted-foreground mt-0.5 size-4 
shrink-0" />
-              )}
-              <div className="flex min-w-0 flex-col">
-                <span className="text-xs font-medium">{step.title}</span>
-                <span className="text-muted-foreground 
text-[11px]">{step.description}</span>
-              </div>
-              {!step.done ? <ChevronRight className="text-muted-foreground 
ml-auto size-3.5 shrink-0" /> : null}
-            </Link>
-          ))}
-        </div>
-      </CardContent>
-    </Card>
-  );
-}
diff --git a/web/src/components/shared/app-header.tsx 
b/web/src/components/shared/app-header.tsx
index fd648e5..1519fa4 100644
--- a/web/src/components/shared/app-header.tsx
+++ b/web/src/components/shared/app-header.tsx
@@ -1,267 +1,346 @@
-// 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 {useNavigate} from "react-router-dom";
-import {
-  Check,
-  ChevronDown,
-  Languages,
-  LogOut,
-  Moon,
-  PanelLeft,
-  PanelLeftClose,
-  Palette as PaletteIcon,
-  Settings,
-  Sun,
-} from "lucide-react";
-import i18next from "i18next";
-
-import * as Setting from "@/Setting";
-import type {Account, Palette, ThemeAlgorithm} from "@/types";
-import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
-import {Button} from "@/components/ui/button";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuLabel,
-  DropdownMenuSeparator,
-  DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-import {cn} from "@/lib/utils";
-import {BreadcrumbBar} from "@/components/shared/breadcrumb-bar";
-import {CommandPaletteTrigger} from "@/components/shared/command-palette";
-import {VersionPanel} from "@/components/shared/version-panel";
-
-function ThemeToggle({
-  themeAlgorithm,
-  onChange,
-}: {
-  themeAlgorithm: ThemeAlgorithm;
-  onChange: (next: ThemeAlgorithm) => void;
-}) {
-  const isDark = Setting.isDarkTheme(themeAlgorithm);
-  return (
-    <SimpleTooltip title={isDark ? i18next.t("general:Light") : 
i18next.t("general:Dark")}>
-      <Button
-        variant="ghost"
-        size="icon-sm"
-        onClick={() => onChange(isDark ? ["default"] : ["dark"])}
-        aria-label="Toggle theme"
-      >
-        {isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
-      </Button>
-    </SimpleTooltip>
-  );
-}
-
-// The swatches are literal rather than var(--primary): the menu has to show 
the
-// palette you are not in, and both colours have to read on either background.
-const paletteSwatch: Record<Palette, string> = {
-  amber: "oklch(0.64 0.16 52)",
-  terminal: "oklch(0.66 0.13 200)",
-  indigo: "oklch(0.58 0.19 275)",
-  neutral: "oklch(0.62 0 0)",
-};
-
-const paletteLabel: Record<Palette, string> = {
-  amber: "general:Amber & Ink",
-  terminal: "general:Terminal",
-  indigo: "general:Indigo",
-  neutral: "general:Neutral",
-};
-
-function PaletteSelect({palette, onChange}: {palette: Palette; onChange: 
(next: Palette) => void}) {
-  return (
-    <DropdownMenu>
-      <DropdownMenuTrigger asChild>
-        <Button variant="ghost" size="icon-sm" 
aria-label={i18next.t("general:Palette")}>
-          <PaletteIcon />
-        </Button>
-      </DropdownMenuTrigger>
-      <DropdownMenuContent align="end">
-        {Setting.Palettes.map(option => (
-          <DropdownMenuItem
-            key={option}
-            className={cn(palette === option && "font-semibold")}
-            onSelect={() => onChange(option)}
-          >
-            <span className="size-3 shrink-0 rounded-full" 
style={{backgroundColor: paletteSwatch[option]}} />
-            {i18next.t(paletteLabel[option])}
-            {palette === option ? <Check className="ml-auto size-3.5" /> : 
null}
-          </DropdownMenuItem>
-        ))}
-      </DropdownMenuContent>
-    </DropdownMenu>
-  );
-}
-
-function LanguageSelect() {
-  const [, forceRender] = React.useReducer((count: number) => count + 1, 0);
-  const current = Setting.getLanguage();
-  return (
-    <DropdownMenu>
-      <DropdownMenuTrigger asChild>
-        <Button variant="ghost" size="icon-sm" aria-label="Language">
-          <Languages />
-        </Button>
-      </DropdownMenuTrigger>
-      <DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
-        {Setting.Countries.map(country => (
-          <DropdownMenuItem
-            key={country.key}
-            className={cn(current === country.key && "font-semibold")}
-            onSelect={() => {
-              Setting.setLanguage(country.key);
-              // The shell reads i18next.t directly, which does not re-render 
on
-              // a language change by itself. Forcing a render here is what 
makes
-              // the switch take effect immediately.
-              forceRender();
-            }}
-          >
-            <img
-              
src={`${Setting.StaticBaseUrl}/flag-icons/${country.country}.svg`}
-              alt={country.alt}
-              className="h-4 w-5 rounded-[2px] object-cover"
-            />
-            {country.label}
-          </DropdownMenuItem>
-        ))}
-      </DropdownMenuContent>
-    </DropdownMenu>
-  );
-}
-
-function AccountMenu({
-  account,
-  onSignout,
-}: {
-  account: Account | null | undefined;
-  onSignout: () => void;
-}) {
-  const navigate = useNavigate();
-
-  if (account === undefined) {
-    return null;
-  }
-  if (account === null) {
-    return (
-      <Button asChild size="sm">
-        <a href={Setting.getSigninUrl() || "/signin"}>{i18next.t("account:Sign 
In")}</a>
-      </Button>
-    );
-  }
-
-  const name = account.displayName || account.name || "";
-  const openProfile = () => {
-    const profileUrl = Setting.getMyProfileUrl(account);
-    if (profileUrl === "") {
-      navigate("/account");
-    } else {
-      Setting.openLink(profileUrl);
-    }
-  };
-
-  return (
-    <DropdownMenu>
-      <DropdownMenuTrigger asChild>
-        <button type="button" className="hover:bg-accent ml-1 flex 
items-center gap-2 rounded-md p-1 transition-colors">
-          <Avatar>
-            {account.avatar ? <AvatarImage src={account.avatar} alt={name} /> 
: null}
-            <AvatarFallback style={{backgroundColor: 
Setting.getAvatarColor(account.name), color: "#fff"}}>
-              {Setting.getShortName(account.name).slice(0, 2).toUpperCase()}
-            </AvatarFallback>
-          </Avatar>
-          <span className="hidden max-w-[160px] truncate text-sm 
md:inline">{name}</span>
-          <ChevronDown className="h-3.5 w-3.5 opacity-60" />
-        </button>
-      </DropdownMenuTrigger>
-      <DropdownMenuContent align="end" className="w-56">
-        <DropdownMenuLabel className="truncate font-normal">
-          <div className="text-sm font-medium">{name}</div>
-          <div className="text-muted-foreground truncate text-xs">
-            {account.owner}/{account.name}
-          </div>
-        </DropdownMenuLabel>
-        <DropdownMenuSeparator />
-        <DropdownMenuItem onSelect={openProfile}>
-          <Settings />
-          {i18next.t("account:My Account")}
-        </DropdownMenuItem>
-        <DropdownMenuItem onSelect={onSignout}>
-          <LogOut />
-          {i18next.t("account:Sign Out")}
-        </DropdownMenuItem>
-      </DropdownMenuContent>
-    </DropdownMenu>
-  );
-}
-
-/**
- * The top bar of the content column: where you are on the left, and everything
- * about the viewer themselves on the right. It sticks so the account menu 
stays
- * reachable on a long list page.
- */
-export function AppHeader({
-  collapsed,
-  onToggleSidebar,
-  uri,
-  account,
-  isAdmin,
-  themeAlgorithm,
-  onThemeChange,
-  colorPalette,
-  onColorPaletteChange,
-  onSignout,
-  onOpenPalette,
-}: {
-  collapsed: boolean;
-  onToggleSidebar: () => void;
-  uri: string;
-  account: Account | null | undefined;
-  isAdmin: boolean;
-  themeAlgorithm: ThemeAlgorithm;
-  onThemeChange: (next: ThemeAlgorithm) => void;
-  colorPalette: Palette;
-  onColorPaletteChange: (next: Palette) => void;
-  onSignout: () => void;
-  onOpenPalette: () => void;
-}) {
-  return (
-    <header className="bg-background/95 
supports-[backdrop-filter]:bg-background/80 sticky top-0 z-30 flex h-13 
shrink-0 items-center justify-between gap-2 border-b px-2 backdrop-blur">
-      <div className="flex min-w-0 items-center gap-1">
-        <Button
-          variant="ghost"
-          size="icon-sm"
-          onClick={onToggleSidebar}
-          aria-label={i18next.t("general:Toggle navigation")}
-        >
-          {collapsed ? <PanelLeft className="size-4" /> : <PanelLeftClose 
className="size-4" />}
-        </Button>
-        <BreadcrumbBar uri={uri} />
-      </div>
-
-      <div className="flex items-center gap-1.5 pr-1">
-        {account ? <CommandPaletteTrigger onOpen={onOpenPalette} /> : null}
-        <VersionPanel isAdmin={isAdmin} signedIn={account !== null && account 
!== undefined} />
-        <ThemeToggle themeAlgorithm={themeAlgorithm} onChange={onThemeChange} 
/>
-        <PaletteSelect palette={colorPalette} onChange={onColorPaletteChange} 
/>
-        <LanguageSelect />
-        <AccountMenu account={account} onSignout={onSignout} />
-      </div>
-    </header>
-  );
-}
+// 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, useNavigate} from "react-router-dom";
+import {Check, ChevronDown, Languages, LogOut, Menu, Moon, Palette as 
PaletteIcon, Search, Settings, Sun} from "lucide-react";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import type {Account, Palette, ThemeAlgorithm} from "@/types";
+import {pageKeyOf, sectionOf, sectionPages, visibleSections} from "@/nav";
+import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
+import {Button} from "@/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuLabel,
+  DropdownMenuSeparator,
+  DropdownMenuSub,
+  DropdownMenuSubContent,
+  DropdownMenuSubTrigger,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {LogoMark} from "@/components/shared/logo";
+import {VersionPanel} from "@/components/shared/version-panel";
+
+const bandButton = "text-band-foreground hover:bg-band-foreground/15 
hover:text-band-foreground";
+
+// The swatches are literal rather than var(--primary): the menu has to show 
the
+// palette you are not in, and both colours have to read on either background.
+const paletteSwatch: Record<Palette, string> = {
+  amber: "oklch(0.64 0.16 52)",
+  terminal: "oklch(0.66 0.13 200)",
+  indigo: "oklch(0.58 0.19 275)",
+  neutral: "oklch(0.62 0 0)",
+};
+
+const paletteLabel: Record<Palette, string> = {
+  amber: "general:Amber & Ink",
+  terminal: "general:Terminal",
+  indigo: "general:Indigo",
+  neutral: "general:Neutral",
+};
+
+/** Theme, palette, language and settings behind one button, as a desktop 
app's menu. */
+function AppMenu({
+  isAdmin,
+  themeAlgorithm,
+  onThemeChange,
+  palette,
+  onPaletteChange,
+}: {
+  isAdmin: boolean;
+  themeAlgorithm: ThemeAlgorithm;
+  onThemeChange: (next: ThemeAlgorithm) => void;
+  palette: Palette;
+  onPaletteChange: (next: Palette) => void;
+}) {
+  const navigate = useNavigate();
+  // The shell reads i18next.t directly, which does not re-render on a language
+  // change by itself.
+  const [, forceRender] = React.useReducer((count: number) => count + 1, 0);
+  const isDark = Setting.isDarkTheme(themeAlgorithm);
+  const language = Setting.getLanguage();
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant="ghost" size="icon-sm" className={bandButton} 
aria-label={i18next.t("general:More")}>
+          <Menu className="size-4.5" />
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end" className="w-48">
+        <DropdownMenuItem onSelect={() => onThemeChange(isDark ? ["default"] : 
["dark"])}>
+          {isDark ? <Sun /> : <Moon />}
+          {i18next.t(isDark ? "general:Light" : "general:Dark")}
+        </DropdownMenuItem>
+        <DropdownMenuSub>
+          <DropdownMenuSubTrigger>
+            <PaletteIcon className="text-muted-foreground size-4" />
+            {i18next.t("general:Palette")}
+          </DropdownMenuSubTrigger>
+          <DropdownMenuSubContent>
+            {Setting.Palettes.map(option => (
+              <DropdownMenuItem
+                key={option}
+                className={cn(palette === option && "font-semibold")}
+                onSelect={() => onPaletteChange(option)}
+              >
+                <span className="size-3 shrink-0 rounded-full" 
style={{backgroundColor: paletteSwatch[option]}} />
+                {i18next.t(paletteLabel[option])}
+                {palette === option ? <Check className="ml-auto size-3.5" /> : 
null}
+              </DropdownMenuItem>
+            ))}
+          </DropdownMenuSubContent>
+        </DropdownMenuSub>
+        <DropdownMenuSub>
+          <DropdownMenuSubTrigger>
+            <Languages className="text-muted-foreground size-4" />
+            {Setting.Countries.find(country => country.key === 
language)?.label ?? "Language"}
+          </DropdownMenuSubTrigger>
+          <DropdownMenuSubContent className="max-h-80 overflow-y-auto">
+            {Setting.Countries.map(country => (
+              <DropdownMenuItem
+                key={country.key}
+                className={cn(language === country.key && "font-semibold")}
+                onSelect={() => {
+                  Setting.setLanguage(country.key);
+                  forceRender();
+                }}
+              >
+                <img
+                  
src={`${Setting.StaticBaseUrl}/flag-icons/${country.country}.svg`}
+                  alt={country.alt}
+                  className="h-4 w-5 rounded-[2px] object-cover"
+                />
+                {country.label}
+              </DropdownMenuItem>
+            ))}
+          </DropdownMenuSubContent>
+        </DropdownMenuSub>
+        {isAdmin ? (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem onSelect={() => navigate("/settings")}>
+              <Settings />
+              {i18next.t("setting:Settings")}
+            </DropdownMenuItem>
+          </>
+        ) : null}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}
+
+function AccountMenu({
+  account,
+  onSignout,
+}: {
+  account: Account | null | undefined;
+  onSignout: () => void;
+}) {
+  const navigate = useNavigate();
+
+  if (account === undefined) {
+    return null;
+  }
+  if (account === null) {
+    return (
+      <Button asChild size="sm" variant="secondary">
+        <a href={Setting.getSigninUrl() || "/signin"}>{i18next.t("account:Sign 
In")}</a>
+      </Button>
+    );
+  }
+
+  const name = account.displayName || account.name || "";
+  const openProfile = () => {
+    const profileUrl = Setting.getMyProfileUrl(account);
+    if (profileUrl === "") {
+      navigate("/account");
+    } else {
+      Setting.openLink(profileUrl);
+    }
+  };
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <button
+          type="button"
+          className="hover:bg-band-foreground/15 ml-1 flex items-center gap-2 
rounded-full p-0.5 pr-2 transition-colors"
+        >
+          <Avatar className="ring-band-foreground/40 ring-2">
+            {account.avatar ? <AvatarImage src={account.avatar} alt={name} /> 
: null}
+            <AvatarFallback style={{backgroundColor: 
Setting.getAvatarColor(account.name), color: "#fff"}}>
+              {Setting.getShortName(account.name).slice(0, 2).toUpperCase()}
+            </AvatarFallback>
+          </Avatar>
+          <span className="hidden max-w-[120px] truncate text-sm 
xl:inline">{name}</span>
+          <ChevronDown className="size-3.5 opacity-70" />
+        </button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end" className="w-56">
+        <DropdownMenuLabel className="truncate font-normal">
+          <div className="text-sm font-medium">{name}</div>
+          <div className="text-muted-foreground truncate text-xs">
+            {account.owner}/{account.name}
+          </div>
+        </DropdownMenuLabel>
+        <DropdownMenuSeparator />
+        <DropdownMenuItem onSelect={openProfile}>
+          <Settings />
+          {i18next.t("account:My Account")}
+        </DropdownMenuItem>
+        <DropdownMenuItem onSelect={onSignout}>
+          <LogOut />
+          {i18next.t("account:Sign Out")}
+        </DropdownMenuItem>
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}
+
+/** The pages of the current tab, side by side under the band. */
+function SectionStrip({uri, isAdmin}: {uri: string; isAdmin: boolean}) {
+  const section = sectionOf(uri);
+  const current = pageKeyOf(uri);
+  // The home screen is the one page that stands alone.
+  if (!section || uri === "/") {
+    return null;
+  }
+  const pages = sectionPages(section, isAdmin);
+  if (pages.length < 2) {
+    return null;
+  }
+
+  return (
+    <div className="bg-card border-b">
+      <nav className="scrollbar-thin flex gap-1 overflow-x-auto px-4 md:px-6">
+        {pages.map(page => {
+          const Icon = page.icon;
+          const active = page.key === current;
+          return (
+            <Link
+              key={page.key}
+              to={page.path ?? page.key}
+              className={cn(
+                "-mb-px flex h-11 shrink-0 items-center gap-1.5 border-b-2 
px-3 text-sm transition-colors",
+                active
+                  ? "border-primary text-primary font-medium"
+                  : "text-muted-foreground hover:text-foreground 
border-transparent",
+              )}
+            >
+              {Icon ? <Icon className="size-4" /> : null}
+              {i18next.t(page.label)}
+            </Link>
+          );
+        })}
+      </nav>
+    </div>
+  );
+}
+
+/**
+ * The top of every page: a coloured band carrying the big tabs, and under it
+ * the pages of whichever tab is open.
+ */
+export function AppHeader({
+  uri,
+  account,
+  isAdmin,
+  themeAlgorithm,
+  onThemeChange,
+  colorPalette,
+  onColorPaletteChange,
+  onSignout,
+  onOpenPalette,
+}: {
+  uri: string;
+  account: Account | null | undefined;
+  isAdmin: boolean;
+  themeAlgorithm: ThemeAlgorithm;
+  onThemeChange: (next: ThemeAlgorithm) => void;
+  colorPalette: Palette;
+  onColorPaletteChange: (next: Palette) => void;
+  onSignout: () => void;
+  onOpenPalette: () => void;
+}) {
+  const active = sectionOf(uri);
+  const sections = account ? visibleSections(isAdmin).filter(section => 
!section.hidden) : [];
+
+  return (
+    <>
+      <header className="from-band to-band-end text-band-foreground 
bg-gradient-to-br">
+        <div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 pt-2 
md:h-22 md:flex-nowrap md:px-6 md:pt-0">
+          <Link to="/" className="flex shrink-0 items-center gap-2">
+            <span className="bg-band-foreground/95 flex size-9 items-center 
justify-center rounded-xl shadow-sm">
+              <LogoMark className="size-7" />
+            </span>
+            <span className="hidden text-base leading-tight font-semibold 
lg:block">Casbin Gateway</span>
+          </Link>
+
+          <nav className="order-3 -mx-1 flex w-full [scrollbar-width:none] 
gap-1 overflow-x-auto pb-2 md:order-none md:mx-0 md:w-auto md:flex-1 
md:justify-center md:pb-0">
+            {sections.map(section => {
+              const Icon = section.icon;
+              const selected = active?.key === section.key;
+              return (
+                <Link
+                  key={section.key}
+                  to={sectionPages(section, isAdmin)[0].path ?? 
section.pages[0]}
+                  className={cn(
+                    "flex min-w-[68px] shrink-0 flex-col items-center gap-1 
rounded-xl px-3 py-2 transition-colors md:min-w-[84px]",
+                    selected ? "bg-band-foreground/20 font-semibold 
shadow-inner" : "hover:bg-band-foreground/10",
+                  )}
+                >
+                  <Icon className="size-6 md:size-7" strokeWidth={1.75} />
+                  <span className="text-xs whitespace-nowrap 
md:text-[13px]">{i18next.t(section.label)}</span>
+                </Link>
+              );
+            })}
+          </nav>
+
+          <div className="ml-auto flex shrink-0 items-center gap-1 md:ml-0">
+            {account ? (
+              <SimpleTooltip title={`${i18next.t("general:Search")} (Ctrl+K)`}>
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
+                  className={bandButton}
+                  onClick={onOpenPalette}
+                  aria-label={i18next.t("general:Search")}
+                >
+                  <Search className="size-4.5" />
+                </Button>
+              </SimpleTooltip>
+            ) : null}
+            <VersionPanel isAdmin={isAdmin} signedIn={account !== null && 
account !== undefined} onBand />
+            <AppMenu
+              isAdmin={isAdmin}
+              themeAlgorithm={themeAlgorithm}
+              onThemeChange={onThemeChange}
+              palette={colorPalette}
+              onPaletteChange={onColorPaletteChange}
+            />
+            <AccountMenu account={account} onSignout={onSignout} />
+          </div>
+        </div>
+      </header>
+      {account ? <SectionStrip uri={uri} isAdmin={isAdmin} /> : null}
+    </>
+  );
+}
diff --git a/web/src/components/shared/app-sidebar.tsx 
b/web/src/components/shared/app-sidebar.tsx
deleted file mode 100644
index f4f8f78..0000000
--- a/web/src/components/shared/app-sidebar.tsx
+++ /dev/null
@@ -1,266 +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 {ChevronDown, type LucideIcon} from "lucide-react";
-import i18next from "i18next";
-
-import {cn} from "@/lib/utils";
-import {navGroups, type NavGroup} from "@/nav";
-import {Logo, LogoMark} from "@/components/shared/logo";
-import {SimpleTooltip} from "@/components/ui/tooltip";
-
-const OPEN_KEYS_STORAGE = "siderMenuOpenKeys";
-// Nothing is expanded until the reader is inside a group: nine sections of
-// Settings unfolded on every page would bury the rest of the rail.
-const DEFAULT_OPEN_KEYS: string[] = [];
-
-export function readSavedOpenKeys(): string[] {
-  try {
-    const raw = localStorage.getItem(OPEN_KEYS_STORAGE);
-    if (!raw) {
-      return DEFAULT_OPEN_KEYS;
-    }
-    const parsed = JSON.parse(raw);
-    return Array.isArray(parsed) ? parsed.filter((key: unknown) => typeof key 
=== "string") : DEFAULT_OPEN_KEYS;
-  } catch {
-    return DEFAULT_OPEN_KEYS;
-  }
-}
-
-export function persistOpenKeys(keys: string[]) {
-  try {
-    localStorage.setItem(OPEN_KEYS_STORAGE, JSON.stringify(keys));
-  } catch {
-    // Private-mode storage failures must not take the navigation down.
-  }
-}
-
-/** Tailwind's md breakpoint, in JS, for the decisions CSS cannot make. */
-export function useIsDesktop() {
-  const [isDesktop, setIsDesktop] = React.useState(
-    () => typeof window === "undefined" || window.matchMedia("(min-width: 
768px)").matches,
-  );
-
-  React.useEffect(() => {
-    const query = window.matchMedia("(min-width: 768px)");
-    const update = () => setIsDesktop(query.matches);
-    update();
-    query.addEventListener("change", update);
-    return () => query.removeEventListener("change", update);
-  }, []);
-
-  return isDesktop;
-}
-
-function NavLink({
-  to,
-  active,
-  collapsed,
-  icon: Icon,
-  label,
-  nested = false,
-}: {
-  to: string;
-  active: boolean;
-  collapsed: boolean;
-  icon?: LucideIcon;
-  label: string;
-  nested?: boolean;
-}) {
-  const content = (
-    <Link
-      to={to}
-      className={cn(
-        "relative flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm 
transition-colors",
-        collapsed && "justify-center px-0",
-        nested && !collapsed && "pl-9 text-[13px]",
-        active
-          // The amber rule is what marks the page you are on; the tinted fill
-          // alone is too quiet to find at a glance down a rail this long.
-          ? "bg-sidebar-accent text-sidebar-accent-foreground 
before:bg-sidebar-primary font-medium before:absolute before:top-1.5 
before:bottom-1.5 before:left-0 before:w-[3px] before:rounded-full 
before:content-['']"
-          : "text-sidebar-foreground/70 hover:bg-sidebar-accent/60 
hover:text-sidebar-foreground",
-      )}
-    >
-      {Icon ? <Icon className="size-4 shrink-0" /> : null}
-      {!collapsed ? <span className="truncate">{label}</span> : null}
-    </Link>
-  );
-
-  return collapsed ? (
-    <SimpleTooltip title={label} side="right">
-      {content}
-    </SimpleTooltip>
-  ) : (
-    content
-  );
-}
-
-/**
- * Fixed navigation rail. When collapsed it drops to icons only and the groups
- * stop expanding in place — an accordion inside a 64px column is unreadable, 
so
- * the icon links straight to the group's first page instead.
- */
-export function AppSidebar({
-  collapsed,
-  selectedKey,
-  openKeys,
-  onOpenKeysChange,
-  isAdmin,
-  mobileOpen,
-  onMobileOpenChange,
-}: {
-  collapsed: boolean;
-  selectedKey: string;
-  openKeys: string[];
-  onOpenKeysChange: (keys: string[]) => void;
-  isAdmin: boolean;
-  /** On a narrow screen the rail is a drawer instead, and this is whether it 
is out. */
-  mobileOpen: boolean;
-  onMobileOpenChange: (open: boolean) => void;
-}) {
-  // Below md the rail is a full-width drawer, where icons without labels would
-  // be a worse menu than the one there is room for.
-  const isDesktop = useIsDesktop();
-  const railCollapsed = collapsed && isDesktop;
-
-  const toggleGroup = (key: string) => {
-    onOpenKeysChange(openKeys.includes(key) ? openKeys.filter(item => item !== 
key) : [...openKeys, key]);
-  };
-
-  const visible = (entry: {adminOnly?: boolean; hidden?: boolean}) =>
-    !entry.hidden && (!entry.adminOnly || isAdmin);
-  const groups: NavGroup[] = navGroups
-    .filter(visible)
-    .map(group => (group.children ? {...group, children: 
group.children.filter(visible)} : group))
-    .filter(group => !group.children || group.children.length > 0);
-
-  return (
-    <>
-      {/* Below md the rail slides over the page, so it needs something to
-          dismiss it that is not one of its own links. */}
-      <div
-        className={cn(
-          "fixed inset-0 z-30 bg-black/40 md:hidden",
-          mobileOpen ? "block" : "hidden",
-        )}
-        onClick={() => onMobileOpenChange(false)}
-      />
-      {/* Slid in and out on "left" rather than a transform: the rail is a
-          tooltip anchor, and a transformed ancestor moves those with it. */}
-      <aside
-        className={cn(
-          "bg-sidebar fixed inset-y-0 z-40 flex w-64 flex-col border-r 
transition-[left,width] duration-200 md:left-0",
-          collapsed ? "md:w-16" : "md:w-64",
-          mobileOpen ? "left-0" : "-left-64",
-        )}
-      >
-        <div className={cn("flex h-13 shrink-0 items-center border-b px-5", 
railCollapsed && "justify-center px-0")}>
-          <Link to="/" className="flex items-center overflow-hidden">
-            {railCollapsed ? (
-              <LogoMark className="h-7 w-auto object-contain transition-all" />
-            ) : (
-              <Logo className="h-7 w-auto max-w-[150px] object-contain 
transition-all" />
-            )}
-          </Link>
-        </div>
-
-        <nav
-          className="scrollbar-thin flex-1 space-y-0.5 overflow-y-auto p-2"
-          onClick={() => onMobileOpenChange(false)}
-        >
-          {groups.map(group => {
-            if (!group.children) {
-              return (
-                <NavLink
-                  key={group.key}
-                  to={group.path ?? group.key}
-                  active={selectedKey === group.key}
-                  collapsed={railCollapsed}
-                  icon={group.icon}
-                  label={i18next.t(group.label)}
-                />
-              );
-            }
-
-            const groupLabel = i18next.t(group.label);
-            const isOpen = openKeys.includes(group.key);
-            const hasActiveChild = group.children.some(child => child.key === 
selectedKey);
-
-            if (railCollapsed) {
-              return (
-                <NavLink
-                  key={group.key}
-                  to={group.path ?? group.children[0].path}
-                  active={hasActiveChild}
-                  collapsed
-                  icon={group.icon}
-                  label={groupLabel}
-                />
-              );
-            }
-
-            const header = cn(
-              "flex items-center gap-2.5 text-sm transition-colors",
-              hasActiveChild ? "text-sidebar-foreground font-medium" : 
"text-sidebar-foreground/70",
-              "hover:text-sidebar-foreground",
-            );
-
-            return (
-              <div key={group.key}>
-                {/* The label goes to the page, the chevron only unfolds it —
-                    a group is a destination as well as a heading. */}
-                <div className="hover:bg-sidebar-accent/60 flex items-center 
rounded-md pr-1 transition-colors">
-                  <Link to={group.path ?? group.children[0].path} 
className={cn(header, "min-w-0 flex-1 px-2.5 py-2")}>
-                    {group.icon ? <group.icon className="size-4 shrink-0" /> : 
null}
-                    <span className="truncate text-left">{groupLabel}</span>
-                  </Link>
-                  <button
-                    type="button"
-                    onClick={event => {
-                      // The rail closes itself on any link tap while it is a
-                      // drawer, and unfolding a group is not leaving it.
-                      event.stopPropagation();
-                      toggleGroup(group.key);
-                    }}
-                    className={cn(header, "rounded-md p-1.5")}
-                    aria-expanded={isOpen}
-                    aria-label={groupLabel}
-                  >
-                    <ChevronDown className={cn("size-3.5 shrink-0 
transition-transform", isOpen && "rotate-180")} />
-                  </button>
-                </div>
-                {isOpen ? (
-                  <div className="mt-0.5 space-y-0.5">
-                    {group.children.map(child => (
-                      <NavLink
-                        key={child.key}
-                        to={child.path}
-                        active={selectedKey === child.key}
-                        collapsed={false}
-                        label={i18next.t(child.label)}
-                        nested
-                      />
-                    ))}
-                  </div>
-                ) : null}
-              </div>
-            );
-          })}
-        </nav>
-      </aside>
-    </>
-  );
-}
diff --git a/web/src/components/shared/breadcrumb-bar.tsx 
b/web/src/components/shared/breadcrumb-bar.tsx
deleted file mode 100644
index 5551420..0000000
--- a/web/src/components/shared/breadcrumb-bar.tsx
+++ /dev/null
@@ -1,78 +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 {Link} from "react-router-dom";
-import i18next from "i18next";
-
-import {findLeaf} from "@/nav";
-import {
-  Breadcrumb,
-  BreadcrumbItem,
-  BreadcrumbLink,
-  BreadcrumbList,
-  BreadcrumbPage,
-  BreadcrumbSeparator,
-} from "@/components/ui/breadcrumb";
-
-/**
- * Derives the trail from the URL against the shared nav tree, so a page never
- * has to declare its own breadcrumb. A path whose first segment is not in the
- * tree renders nothing rather than an invented label.
- */
-export function BreadcrumbBar({uri}: {uri: string}) {
-  const segments = (uri || "").split("/").filter(Boolean);
-  if (segments.length === 0) {
-    return null;
-  }
-
-  const rootLeaf = findLeaf(`/${segments[0]}`);
-  if (!rootLeaf) {
-    return null;
-  }
-  const rootLabel = i18next.t(rootLeaf.label);
-
-  const lastSegment = segments[segments.length - 1];
-  const lastLeaf = segments.length > 1 ? findLeaf(`/${lastSegment}`) : null;
-  const lastLabel = lastLeaf ? i18next.t(lastLeaf.label) : 
decodeURIComponent(lastSegment);
-
-  return (
-    <Breadcrumb>
-      <BreadcrumbList className="text-xs sm:gap-1.5">
-        <BreadcrumbItem>
-          <BreadcrumbLink asChild>
-            <Link to="/">{i18next.t("general:Home")}</Link>
-          </BreadcrumbLink>
-        </BreadcrumbItem>
-        <BreadcrumbSeparator />
-        {segments.length === 1 ? (
-          <BreadcrumbItem>
-            <BreadcrumbPage>{rootLabel}</BreadcrumbPage>
-          </BreadcrumbItem>
-        ) : (
-          <>
-            <BreadcrumbItem>
-              <BreadcrumbLink asChild>
-                <Link to={`/${segments[0]}`}>{rootLabel}</Link>
-              </BreadcrumbLink>
-            </BreadcrumbItem>
-            <BreadcrumbSeparator />
-            <BreadcrumbItem>
-              <BreadcrumbPage className="max-w-[240px] 
truncate">{lastLabel}</BreadcrumbPage>
-            </BreadcrumbItem>
-          </>
-        )}
-      </BreadcrumbList>
-    </Breadcrumb>
-  );
-}
diff --git a/web/src/components/shared/command-palette.tsx 
b/web/src/components/shared/command-palette.tsx
index f7f96f4..dd66b3c 100644
--- a/web/src/components/shared/command-palette.tsx
+++ b/web/src/components/shared/command-palette.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {useNavigate} from "react-router-dom";
-import {Bot, CornerDownLeft, Search} from "lucide-react";
+import {Bot, CornerDownLeft} from "lucide-react";
 import i18next from "i18next";
 
 import * as AgentBackend from "@/backend/AgentBackend";
@@ -23,7 +23,6 @@ import {agentDetailPath} from "@/lib/agents";
 import {navGroups} from "@/nav";
 import type {Account, Agent, Provider} from "@/types";
 import {AgentIcon} from "@/components/AgentIcon";
-import {Button} from "@/components/ui/button";
 import {ProviderIcon} from "@/components/ProviderIcon";
 import {
   CommandDialog,
@@ -197,33 +196,3 @@ export function CommandPalette({
     </CommandDialog>
   );
 }
-
-/** The header's search box, which only ever opens the palette. */
-export function CommandPaletteTrigger({onOpen}: {onOpen: () => void}) {
-  const isMac = typeof navigator !== "undefined" && 
/Mac|iPhone|iPad/.test(navigator.platform);
-  return (
-    <>
-      <button
-        type="button"
-        onClick={onOpen}
-        className="border-input text-muted-foreground hover:bg-accent 
hover:text-foreground hidden h-8 w-56 items-center gap-2 rounded-md border 
px-2.5 text-sm transition-colors md:flex lg:w-72"
-      >
-        <Search className="size-3.5 shrink-0" />
-        <span className="flex-1 truncate 
text-left">{i18next.t("general:Search")}</span>
-        <kbd className="bg-muted rounded border px-1.5 py-0.5 text-[10px] 
leading-none font-medium">
-          {isMac ? "⌘" : "Ctrl+"}K
-        </kbd>
-      </button>
-      {/* No shortcut to press on a phone, so the bar becomes a button. */}
-      <Button
-        variant="ghost"
-        size="icon-sm"
-        className="md:hidden"
-        onClick={onOpen}
-        aria-label={i18next.t("general:Search")}
-      >
-        <Search className="size-4" />
-      </Button>
-    </>
-  );
-}
diff --git a/web/src/components/shared/version-panel.tsx 
b/web/src/components/shared/version-panel.tsx
index d6277cc..4b7b60e 100644
--- a/web/src/components/shared/version-panel.tsx
+++ b/web/src/components/shared/version-panel.tsx
@@ -167,10 +167,13 @@ function CommandLine({command}: {command: string}) {
 export function VersionPanel({
   isAdmin,
   signedIn,
+  onBand = false,
 }: {
   isAdmin: boolean;
   /** The version is behind the session, so asking before there is one answers 
nothing. */
   signedIn: boolean;
+  /** Sits on the coloured top band rather than on paper. */
+  onBand?: boolean;
 }) {
   const [info, setInfo] = React.useState<VersionInfo | null>(null);
   const [checking, setChecking] = React.useState(false);
@@ -339,17 +342,26 @@ export function VersionPanel({
           // No transition-colors: switching theme leaves an element that has 
one
           // stuck on the palette it was rendered in until the page reloads.
           "flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 
font-mono text-xs",
-          updateAvailable
-            ? "border-primary/40 bg-primary/10 text-primary 
hover:bg-primary/20"
-            : "text-muted-foreground hover:bg-accent hover:text-foreground 
border-transparent",
+          onBand
+            ? updateAvailable
+              ? "border-band-foreground/40 bg-band-foreground/15 
text-band-foreground hover:bg-band-foreground/25"
+              : "text-band-foreground/80 hover:bg-band-foreground/15 
hover:text-band-foreground border-transparent"
+            : updateAvailable
+              ? "border-primary/40 bg-primary/10 text-primary 
hover:bg-primary/20"
+              : "text-muted-foreground hover:bg-accent hover:text-foreground 
border-transparent",
         )}
       >
         {updateAvailable ? <ArrowUpCircle className="size-3.5 shrink-0" /> : 
null}
         {/* Narrow screens keep the date, which is the half that ages. */}
-        <span className="hidden lg:inline">{label}</span>
-        <span className="lg:hidden">{day === "" ? label : day}</span>
+        <span className={cn("hidden", onBand ? "2xl:inline" : 
"lg:inline")}>{label}</span>
+        <span className={onBand ? "2xl:hidden" : "lg:hidden"}>{day === "" ? 
label : day}</span>
         {updateAvailable ? (
-          <span className="bg-primary text-primary-foreground shrink-0 
rounded-full px-1.5 text-[10px] font-medium">
+          <span
+            className={cn(
+              "shrink-0 rounded-full px-1.5 text-[10px] font-medium",
+              onBand ? "bg-destructive text-destructive-foreground" : 
"bg-primary text-primary-foreground",
+            )}
+          >
             {i18next.t("general:New")}
           </span>
         ) : null}
diff --git a/web/src/components/ui/breadcrumb.tsx 
b/web/src/components/ui/breadcrumb.tsx
deleted file mode 100644
index e75b82c..0000000
--- a/web/src/components/ui/breadcrumb.tsx
+++ /dev/null
@@ -1,103 +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 {Slot} from "@radix-ui/react-slot";
-import {ChevronRight, MoreHorizontal} from "lucide-react";
-
-import {cn} from "@/lib/utils";
-
-function Breadcrumb({...props}: React.ComponentProps<"nav">) {
-  return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
-}
-
-function BreadcrumbList({className, ...props}: React.ComponentProps<"ol">) {
-  return (
-    <ol
-      data-slot="breadcrumb-list"
-      className={cn(
-        "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm 
break-words sm:gap-2.5",
-        className,
-      )}
-      {...props}
-    />
-  );
-}
-
-function BreadcrumbItem({className, ...props}: React.ComponentProps<"li">) {
-  return <li data-slot="breadcrumb-item" className={cn("inline-flex 
items-center gap-1.5", className)} {...props} />;
-}
-
-function BreadcrumbLink({
-  asChild,
-  className,
-  ...props
-}: React.ComponentProps<"a"> & {asChild?: boolean}) {
-  const Comp = asChild ? Slot : "a";
-  return (
-    <Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground 
transition-colors", className)} {...props} />
-  );
-}
-
-function BreadcrumbPage({className, ...props}: React.ComponentProps<"span">) {
-  return (
-    <span
-      data-slot="breadcrumb-page"
-      role="link"
-      aria-disabled="true"
-      aria-current="page"
-      className={cn("text-foreground font-normal", className)}
-      {...props}
-    />
-  );
-}
-
-function BreadcrumbSeparator({children, className, ...props}: 
React.ComponentProps<"li">) {
-  return (
-    <li
-      data-slot="breadcrumb-separator"
-      role="presentation"
-      aria-hidden="true"
-      className={cn("[&>svg]:size-3.5", className)}
-      {...props}
-    >
-      {children ?? <ChevronRight />}
-    </li>
-  );
-}
-
-function BreadcrumbEllipsis({className, ...props}: 
React.ComponentProps<"span">) {
-  return (
-    <span
-      data-slot="breadcrumb-ellipsis"
-      role="presentation"
-      aria-hidden="true"
-      className={cn("flex size-9 items-center justify-center", className)}
-      {...props}
-    >
-      <MoreHorizontal className="size-4" />
-      <span className="sr-only">More</span>
-    </span>
-  );
-}
-
-export {
-  Breadcrumb,
-  BreadcrumbList,
-  BreadcrumbItem,
-  BreadcrumbLink,
-  BreadcrumbPage,
-  BreadcrumbSeparator,
-  BreadcrumbEllipsis,
-};
diff --git a/web/src/index.css b/web/src/index.css
index 280276e..853b77c 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -50,16 +50,11 @@
   --chart-4: oklch(0.500 0.115 150);
   --chart-5: oklch(0.520 0.130 300);
 
-  /* The rail is a deeper sheet of the same paper, so it reads as a separate
-     surface without needing a heavy border. */
-  --sidebar: oklch(0.950 0.016 80);
-  --sidebar-foreground: oklch(0.245 0.016 60);
-  --sidebar-primary: oklch(0.545 0.155 48);
-  --sidebar-primary-foreground: oklch(0.99 0.012 90);
-  --sidebar-accent: oklch(0.922 0.032 68);
-  --sidebar-accent-foreground: oklch(0.355 0.085 45);
-  --sidebar-border: oklch(0.886 0.018 75);
-  --sidebar-ring: oklch(0.545 0.155 48);
+  /* The top band: a deep sweep of the primary, dark enough that its white tab
+     labels clear 4.5:1. */
+  --band: oklch(0.530 0.150 52);
+  --band-end: oklch(0.455 0.135 40);
+  --band-foreground: oklch(0.990 0.012 90);
 }
 
 .dark {
@@ -101,14 +96,9 @@
   --chart-4: oklch(0.680 0.115 152);
   --chart-5: oklch(0.700 0.125 302);
 
-  --sidebar: oklch(0.198 0.011 62);
-  --sidebar-foreground: oklch(0.930 0.010 82);
-  --sidebar-primary: oklch(0.735 0.152 58);
-  --sidebar-primary-foreground: oklch(0.200 0.045 50);
-  --sidebar-accent: oklch(0.312 0.038 55);
-  --sidebar-accent-foreground: oklch(0.900 0.045 72);
-  --sidebar-border: oklch(0.86 0.030 70 / 14%);
-  --sidebar-ring: oklch(0.735 0.152 58);
+  --band: oklch(0.305 0.060 52);
+  --band-end: oklch(0.235 0.035 45);
+  --band-foreground: oklch(0.955 0.012 85);
 }
 
 /* "Terminal": a console, so the greys go cool, the corners go square and the
@@ -153,14 +143,9 @@ html[data-palette="terminal"]:not(.dark) {
   --chart-4: oklch(0.540 0.135 300);
   --chart-5: oklch(0.580 0.170 25);
 
-  --sidebar: oklch(0.955 0.010 235);
-  --sidebar-foreground: oklch(0.240 0.020 245);
-  --sidebar-primary: oklch(0.520 0.105 210);
-  --sidebar-primary-foreground: oklch(0.985 0.004 230);
-  --sidebar-accent: oklch(0.920 0.026 215);
-  --sidebar-accent-foreground: oklch(0.320 0.060 212);
-  --sidebar-border: oklch(0.893 0.012 235);
-  --sidebar-ring: oklch(0.520 0.105 210);
+  --band: oklch(0.500 0.095 212);
+  --band-end: oklch(0.410 0.080 235);
+  --band-foreground: oklch(0.985 0.004 230);
 }
 
 /* The one this palette is really for. */
@@ -199,14 +184,9 @@ html[data-palette="terminal"].dark {
   --chart-4: oklch(0.700 0.130 300);
   --chart-5: oklch(0.700 0.165 25);
 
-  --sidebar: oklch(0.175 0.013 250);
-  --sidebar-foreground: oklch(0.925 0.008 230);
-  --sidebar-primary: oklch(0.760 0.115 200);
-  --sidebar-primary-foreground: oklch(0.170 0.030 220);
-  --sidebar-accent: oklch(0.290 0.038 215);
-  --sidebar-accent-foreground: oklch(0.890 0.045 205);
-  --sidebar-border: oklch(0.80 0.040 220 / 15%);
-  --sidebar-ring: oklch(0.760 0.115 200);
+  --band: oklch(0.270 0.040 222);
+  --band-end: oklch(0.195 0.022 245);
+  --band-foreground: oklch(0.950 0.006 230);
 }
 
 /* "Indigo": the infrastructure-console reading. 274° is the one hue on the
@@ -247,14 +227,9 @@ html[data-palette="indigo"]:not(.dark) {
   --chart-4: oklch(0.540 0.120 152);
   --chart-5: oklch(0.660 0.130 70);
 
-  --sidebar: oklch(0.958 0.014 280);
-  --sidebar-foreground: oklch(0.250 0.024 275);
-  --sidebar-primary: oklch(0.480 0.180 274);
-  --sidebar-primary-foreground: oklch(0.985 0.006 280);
-  --sidebar-accent: oklch(0.925 0.030 278);
-  --sidebar-accent-foreground: oklch(0.340 0.085 274);
-  --sidebar-border: oklch(0.900 0.016 280);
-  --sidebar-ring: oklch(0.480 0.180 274);
+  --band: oklch(0.490 0.175 276);
+  --band-end: oklch(0.400 0.160 286);
+  --band-foreground: oklch(0.985 0.006 280);
 }
 
 html[data-palette="indigo"].dark {
@@ -292,14 +267,9 @@ html[data-palette="indigo"].dark {
   --chart-4: oklch(0.720 0.120 152);
   --chart-5: oklch(0.800 0.125 70);
 
-  --sidebar: oklch(0.195 0.026 275);
-  --sidebar-foreground: oklch(0.925 0.012 280);
-  --sidebar-primary: oklch(0.680 0.165 272);
-  --sidebar-primary-foreground: oklch(0.180 0.050 274);
-  --sidebar-accent: oklch(0.315 0.055 274);
-  --sidebar-accent-foreground: oklch(0.900 0.040 280);
-  --sidebar-border: oklch(0.80 0.050 280 / 15%);
-  --sidebar-ring: oklch(0.680 0.165 272);
+  --band: oklch(0.290 0.080 276);
+  --band-end: oklch(0.215 0.045 280);
+  --band-foreground: oklch(0.955 0.008 280);
 }
 
 /* The palette Gateway shipped before "Amber & Ink", kept as an opt-out for
@@ -340,14 +310,9 @@ html[data-palette="neutral"]:not(.dark) {
   --chart-4: oklch(0.828 0.189 84.429);
   --chart-5: oklch(0.769 0.188 70.08);
 
-  --sidebar: oklch(0.985 0 0);
-  --sidebar-foreground: oklch(0.145 0 0);
-  --sidebar-primary: oklch(0.205 0 0);
-  --sidebar-primary-foreground: oklch(0.985 0 0);
-  --sidebar-accent: oklch(0.97 0 0);
-  --sidebar-accent-foreground: oklch(0.205 0 0);
-  --sidebar-border: oklch(0.922 0 0);
-  --sidebar-ring: oklch(0.708 0 0);
+  --band: oklch(0.300 0 0);
+  --band-end: oklch(0.225 0 0);
+  --band-foreground: oklch(0.985 0 0);
 }
 
 html[data-palette="neutral"].dark {
@@ -385,14 +350,9 @@ html[data-palette="neutral"].dark {
   --chart-4: oklch(0.627 0.265 303.9);
   --chart-5: oklch(0.645 0.246 16.439);
 
-  --sidebar: oklch(0.205 0 0);
-  --sidebar-foreground: oklch(0.985 0 0);
-  --sidebar-primary: oklch(0.985 0 0);
-  --sidebar-primary-foreground: oklch(0.205 0 0);
-  --sidebar-accent: oklch(0.269 0 0);
-  --sidebar-accent-foreground: oklch(0.985 0 0);
-  --sidebar-border: oklch(1 0 0 / 12%);
-  --sidebar-ring: oklch(0.556 0 0);
+  --band: oklch(0.255 0 0);
+  --band-end: oklch(0.195 0 0);
+  --band-foreground: oklch(0.985 0 0);
 }
 
 @theme inline {
@@ -433,14 +393,9 @@ html[data-palette="neutral"].dark {
   --color-chart-4: var(--chart-4);
   --color-chart-5: var(--chart-5);
 
-  --color-sidebar: var(--sidebar);
-  --color-sidebar-foreground: var(--sidebar-foreground);
-  --color-sidebar-primary: var(--sidebar-primary);
-  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
-  --color-sidebar-accent: var(--sidebar-accent);
-  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
-  --color-sidebar-border: var(--sidebar-border);
-  --color-sidebar-ring: var(--sidebar-ring);
+  --color-band: var(--band);
+  --color-band-end: var(--band-end);
+  --color-band-foreground: var(--band-foreground);
 
   /* Inter is never fetched, so the CJK faces are what a zh reader actually
      gets; without them Windows falls back to a mismatched SimSun. */
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 3e8c941..1c30230 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -162,7 +162,6 @@
     "Gemini CLI config hint": "The Gemini CLI reads no base URL from a 
settings file: the endpoint is GOOGLE_GEMINI_BASE_URL in ~/.gemini/.env, with 
GEMINI_API_KEY beside it and \"security.auth.selectedType\": \"gemini-api-key\" 
in ~/.gemini/settings.json. A project of its own .env is read first, and that 
one wins.",
     "Grok Build config hint": "Grok Build keeps the endpoint in 
~/.grok/config.toml: one [model.\"casbin-gateway/*\"] table per model, each 
with its own base_url, api_key and api_backend \"chat_completions\", and 
[models] default and session_summary naming the one it starts on. Grok's own 
models keep going to xAI on its sign-in. GROK_DEFAULT_MODEL beats that default, 
and GROK_HOME moves the whole directory.",
     "Gemini gateway only hint": "The Gemini CLI speaks only Google's 
generateContent API, which no provider here serves. Gateway serves it by 
translating to the API the provider answers, so the CLI cannot be pointed at a 
provider directly.",
-    "Getting started": "Getting started",
     "Home folder": "Home",
     "Hook enforced hint": "This agent does not route through Gateway, so the 
switches below are enforced by the hook Gateway installed in it: a tool call 
they do not allow is refused before it runs. The model and provider rules only 
hold requests that come through Gateway.",
     "Image": "Image",
@@ -244,12 +243,6 @@
     "Not supported": "Not supported yet",
     "Not written": "Not written",
     "Object storage": "Object storage",
-    "Onboarding add provider": "Add a provider",
-    "Onboarding add provider detail": "Point Gateway at a model vendor with a 
base URL and API key.",
-    "Onboarding connect agent": "Connect an agent",
-    "Onboarding connect agent detail": "Pick that provider on one of the agent 
cards above.",
-    "Onboarding see it work": "See it work",
-    "Onboarding see it work detail": "Watch requests land in LLM Records once 
traffic flows through.",
     "Only known hosts": "Only known hosts",
     "Only known hosts hint": "A URL a tool fetches, and a URL or curl/wget 
target in a command, has to be on the list: code hosting, package registries 
and their common mirrors, and this machine. Add your own on an agent's advanced 
view as \"host, *.example.com, allow\".",
     "Only the ones I pick": "Only the ones I pick",
@@ -1018,13 +1011,36 @@
   "general": {
     "Action": "Action",
     "Advanced": "Advanced",
+    "All tools": "All tools",
     "Amber & Ink": "Amber & Ink",
     "Back Home": "Back Home",
     "Built": "Built",
     "Cancel": "Cancel",
     "Cannot connect to the server, please check that the backend is running": 
"Cannot connect to the server, please check that the backend is running",
     "Casbin Gateway installs updates from the nightly build of master": 
"Casbin Gateway installs updates from the nightly build of master",
+    "Check again": "Check again",
     "Check for updates": "Check for updates",
+    "Check up now": "Check up now",
+    "Checked at {time}": "Checked at {time}",
+    "Checking up": "Checking up…",
+    "Checkup": "Checkup",
+    "Checkup agents ok": "{count} agents found",
+    "Checkup all clear": "All clear, keep it up",
+    "Checkup authenticity failed": "{count} providers failed an authenticity 
case",
+    "Checkup authenticity ok": "Every graded provider passed authenticity",
+    "Checkup egress failed": "{count} agents made suspicious uploads",
+    "Checkup found {count} issues": "{count} issues found, fix them now",
+    "Checkup health failed": "{count} providers are failing requests",
+    "Checkup health ok": "Every provider is answering",
+    "Checkup monitoring failed": "{count} agents are not monitored",
+    "Checkup monitoring ok": "Monitoring is on for every agent",
+    "Checkup providers ok": "{count} providers configured",
+    "Checkup recording failed": "Request recording is off",
+    "Checkup recording ok": "Requests are being recorded",
+    "Checkup routed failed": "No agent is routed through Gateway yet",
+    "Checkup routed ok": "{count} agents routed through Gateway",
+    "Checkup updates failed": "{count} agents have a newer release",
+    "Checkup updates ok": "Every agent is up to date",
     "Client ip": "Client ip",
     "Close": "Close",
     "Copied": "Copied",
@@ -1041,13 +1057,14 @@
     "Display name": "Display name",
     "Downloading": "Downloading",
     "Edit": "Edit",
+    "Extensions": "Extensions",
     "Failed to delete": "Failed to delete",
     "Failed to get data": "Failed to get data",
     "Failed to save": "Failed to save",
     "Failed to sign out": "Failed to sign out",
+    "Fix": "Fix",
     "GitHub could not be reached, if this machine needs a proxy to reach the 
internet, set one and try again": "GitHub could not be reached, if this machine 
needs a proxy to reach the internet, set one and try again",
     "Go to writable demo site?": "Go to writable demo site?",
-    "Home": "Home",
     "ID": "ID",
     "Indigo": "Indigo",
     "Installing": "Installing",
@@ -1057,6 +1074,7 @@
     "Loading": "Loading...",
     "Logged in successfully": "Logged in successfully",
     "Login Error": "Login Error",
+    "Models": "Models",
     "More": "More",
     "Name": "Name",
     "Name cannot be empty": "Name cannot be empty",
@@ -1065,6 +1083,7 @@
     "Next": "Next",
     "No build is published for this platform, so the update has to be done by 
hand": "No build is published for this platform, so the update has to be done 
by hand",
     "No data": "No data",
+    "No issues found": "No issues found",
     "OK": "OK",
     "Owner": "Owner",
     "Pages": "Pages",
@@ -1080,8 +1099,10 @@
     "Restarting into the new version": "Restarting into the new version",
     "Retry": "Retry",
     "Save": "Save",
+    "Score unit": "points",
     "Search": "Search",
     "Search pages, agents and providers": "Search pages, agents and providers",
+    "Security": "Security",
     "Select": "Select",
     "Set a proxy": "Set a proxy",
     "Signed out successfully": "Signed out successfully",
@@ -1096,13 +1117,13 @@
     "This executable sits where it cannot be replaced, so the update has to be 
done by hand": "This executable sits where it cannot be replaced, so the update 
has to be done by hand",
     "This is a read-only demo site!": "This is a read-only demo site!",
     "This is the latest version": "This is the latest version",
-    "Toggle navigation": "Toggle navigation",
     "Toggle sidebar": "Toggle sidebar",
     "Unauthorized": "Unauthorized",
     "Update available": "Update available",
     "Update complete, reloading": "Update complete, reloading",
     "Update now": "Update now",
     "Version": "Version",
+    "{count} checks passed": "{count} checks passed",
     "{total} in total": "{total} in total"
   },
   "link": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 0bbd244..9f0aacc 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -162,7 +162,6 @@
     "Gemini CLI config hint": "Gemini CLI不从配置文件读取Base 
URL:端点是~/.gemini/.env中的GOOGLE_GEMINI_BASE_URL,旁边是GEMINI_API_KEY,同时~/.gemini/settings.json中写入\"security.auth.selectedType\":
 \"gemini-api-key\"。项目自带的.env会被优先读取并覆盖它。",
     "Grok Build config hint": "Grok 
Build的端点写在~/.grok/config.toml里:每个模型一个[model.\"casbin-gateway/*\"]表,各自带base_url、api_key和api_backend
 
\"chat_completions\",再由[models]下的default和session_summary选中启动用的那个。Grok自带的模型仍凭它的登录直连xAI。GROK_DEFAULT_MODEL会覆盖这个默认值,GROK_HOME会把整个目录挪到别处。",
     "Gemini gateway only hint": "Gemini CLI只会说Google的generateContent 
API,这里没有供应商提供该API。Gateway会把它翻译成供应商应答的API,所以Gemini CLI无法直连供应商。",
-    "Getting started": "快速上手",
     "Home folder": "主目录",
     "Hook enforced hint": 
"该Agent客户端不经网关转发,因此下面的开关由网关装在它里面的钩子来拦截:不允许的工具调用在执行前就被拒绝。模型和供应商规则只对经网关转发的请求生效。",
     "Image": "图片",
@@ -244,12 +243,6 @@
     "Not supported": "暂不支持",
     "Not written": "未写入",
     "Object storage": "对象存储",
-    "Onboarding add provider": "添加一个Provider",
-    "Onboarding add provider detail": "填入模型厂商的地址和API Key,让Gateway接管它。",
-    "Onboarding connect agent": "绑定一个Agent",
-    "Onboarding connect agent detail": "在上方的Agent卡片中选择该Provider。",
-    "Onboarding see it work": "查看效果",
-    "Onboarding see it work detail": "流量接入后,可在LLM Records中看到请求记录。",
     "Only known hosts": "只允许访问白名单域名",
     "Only known hosts hint": 
"工具抓取的网址,以及命令里出现的网址或curl/wget目标,都必须在白名单里:代码托管平台、各包仓库及其常用镜像,以及本机。要加自己的域名,在Agent的高级视图里写一条\"host,
 *.example.com, allow\"。",
     "Only the ones I pick": "只允许勾选的",
@@ -1204,13 +1197,36 @@
   "general": {
     "Action": "操作",
     "Advanced": "高级",
+    "All tools": "功能大全",
     "Amber & Ink": "琥珀墨",
     "Back Home": "返回首页",
     "Built": "构建于",
     "Cancel": "取消",
     "Cannot connect to the server, please check that the backend is running": 
"无法连接到服务器,请检查后端是否已启动",
     "Casbin Gateway installs updates from the nightly build of master": 
"Casbin Gateway从master的nightly构建升级",
+    "Check again": "重新体检",
     "Check for updates": "检查更新",
+    "Check up now": "立即体检",
+    "Checked at {time}": "体检于{time}",
+    "Checking up": "正在体检…",
+    "Checkup": "体检",
+    "Checkup agents ok": "发现{count}个Agent客户端",
+    "Checkup all clear": "状态良好,继续保持",
+    "Checkup authenticity failed": "{count}个供应商未通过真实性检测",
+    "Checkup authenticity ok": "模型真实性检测通过",
+    "Checkup egress failed": "{count}个Agent记录到可疑上传",
+    "Checkup found {count} issues": "发现{count}个问题,建议立即处理",
+    "Checkup health failed": "{count}个供应商请求失败",
+    "Checkup health ok": "供应商连接正常",
+    "Checkup monitoring failed": "{count}个Agent未开启监控",
+    "Checkup monitoring ok": "所有Agent已开启监控",
+    "Checkup providers ok": "已配置{count}个供应商",
+    "Checkup recording failed": "请求记录未开启",
+    "Checkup recording ok": "请求记录已开启",
+    "Checkup routed failed": "还没有Agent接入Gateway",
+    "Checkup routed ok": "{count}个Agent已接入Gateway",
+    "Checkup updates failed": "{count}个Agent有新版本",
+    "Checkup updates ok": "Agent均为最新版本",
     "Client ip": "客户端IP",
     "Close": "关闭",
     "Copied": "已复制",
@@ -1227,13 +1243,14 @@
     "Display name": "显示名称",
     "Downloading": "正在下载",
     "Edit": "编辑",
+    "Extensions": "扩展",
     "Failed to delete": "删除失败",
     "Failed to get data": "获取数据失败",
     "Failed to save": "保存失败",
     "Failed to sign out": "退出登录失败",
+    "Fix": "去处理",
     "GitHub could not be reached, if this machine needs a proxy to reach the 
internet, set one and try again": "无法连接GitHub。如果本机需要代理才能访问外网,请先配置代理后重试。",
     "Go to writable demo site?": "跳转至可写演示站点?",
-    "Home": "首页",
     "ID": "ID",
     "Indigo": "靛蓝",
     "Installing": "正在安装",
@@ -1243,6 +1260,7 @@
     "Loading": "加载中...",
     "Logged in successfully": "登录成功",
     "Login Error": "登录失败",
+    "Models": "模型",
     "More": "更多",
     "Name": "名称",
     "Name cannot be empty": "名称不能为空",
@@ -1251,6 +1269,7 @@
     "Next": "下一页",
     "No build is published for this platform, so the update has to be done by 
hand": "该平台没有发布构建产物,需要手动升级",
     "No data": "暂无数据",
+    "No issues found": "未发现问题",
     "OK": "确定",
     "Owner": "所有者",
     "Pages": "页面",
@@ -1266,8 +1285,10 @@
     "Restarting into the new version": "正在重启到新版本",
     "Retry": "重试",
     "Save": "保存",
+    "Score unit": "分",
     "Search": "搜索",
     "Search pages, agents and providers": "搜索页面、Agent客户端和供应商",
+    "Security": "安全",
     "Select": "请选择",
     "Set a proxy": "去配置代理",
     "Signed out successfully": "已退出登录",
@@ -1282,13 +1303,13 @@
     "This executable sits where it cannot be replaced, so the update has to be 
done by hand": "当前可执行文件所在目录不可写,无法被替换,需要手动升级",
     "This is a read-only demo site!": "这是一个只读演示站点!",
     "This is the latest version": "已是最新版本",
-    "Toggle navigation": "收起/展开导航",
     "Toggle sidebar": "折叠/展开侧边栏",
     "Unauthorized": "无权限",
     "Update available": "有新版本可用",
     "Update complete, reloading": "升级完成,正在重新加载",
     "Update now": "立即升级",
     "Version": "版本",
+    "{count} checks passed": "已通过{count}项检查",
     "{total} in total": "共{total}条"
   },
   "link": {
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 9482e63..08bd05e 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -20,6 +20,8 @@ import {
   ChartColumn,
   CircleDollarSign,
   FileSearch,
+  Gauge,
+  LayoutGrid,
   Logs,
   MessageSquare,
   PackageCheck,
@@ -40,7 +42,7 @@ export interface NavLeaf {
   path: string;
   icon?: LucideIcon;
   adminOnly?: boolean;
-  /** Reached from a page rather than the rail, but still named by the 
breadcrumb. */
+  /** Reached from a page rather than the palette. */
   hidden?: boolean;
 }
 
@@ -55,9 +57,7 @@ export interface NavGroup {
 }
 
 /**
- * One description of the navigation, read by both the sidebar and the
- * breadcrumb. Keeping them on the same tree is what stops the two from
- * disagreeing about which section a page belongs to.
+ * Every page, read by the band, the "All tools" grid and the command palette.
  *
  * `label` is an i18next key, resolved at render time so a language switch does
  * not need the tree rebuilt.
@@ -65,11 +65,10 @@ export interface NavGroup {
 export const navGroups: NavGroup[] = [
   // The setup pages come first — agents, the models behind them, and the gates
   // they pass through — then what agents ran and what it cost, settings last.
-  // A group's children are places inside its page, so the rail can land on the
-  // right tab or section instead of only on the page's top.
+  // A group's children are places inside its page, so the palette can land on
+  // the right tab or section instead of only on the page's top.
   {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.
+  // The table of installations is the home screen's own advanced view.
   {key: "/agents", label: "agent:Advanced view", icon: Table2, path: 
"/agents", adminOnly: true, hidden: true},
   // Driving an agent is what somebody does with one, so it sits beside the
   // list of them rather than down among the pages that only report.
@@ -140,6 +139,7 @@ export const navGroups: NavGroup[] = [
     ],
   },
   {key: "/pricing", label: "usage:Model pricing", icon: CircleDollarSign, 
path: "/pricing", adminOnly: true},
+  {key: "/tools", label: "general:All tools", icon: LayoutGrid, path: 
"/tools"},
   {
     key: "/settings",
     label: "setting:Settings",
@@ -162,40 +162,55 @@ export const navGroups: NavGroup[] = [
   },
 ];
 
-/** All leaf entries, flattened, for lookups by first path segment. */
-export const navLeaves: NavLeaf[] = navGroups.flatMap(group =>
-  group.children ? group.children : [{...group, path: group.path ?? group.key} 
as NavLeaf],
-);
+/**
+ * The tabs of the top band. Each gathers a few pages, which sit side by side 
in
+ * the strip under it; everything is also on the "All tools" page.
+ */
+export interface NavSection {
+  key: string;
+  label: string;
+  icon: LucideIcon;
+  /** Keys of `navGroups`, in strip order; the first is where the tab lands. */
+  pages: string[];
+  adminOnly?: boolean;
+  /** Reached from the menu rather than the band. */
+  hidden?: boolean;
+}
+
+export const navSections: NavSection[] = [
+  {key: "home", label: "general:Checkup", icon: Gauge, pages: ["/", "/agents", 
"/agent-versions"], adminOnly: true},
+  {key: "chat", label: "chat:Chat", icon: SendHorizontal, pages: ["/chat", 
"/channels"]},
+  {key: "models", label: "general:Models", icon: Plug, pages: ["/providers", 
"/model-routes", "/pricing", "/import"]},
+  {key: "extensions", label: "general:Extensions", icon: Blocks, pages: 
["/agent-configs", "/connections"]},
+  {key: "security", label: "general:Security", icon: ShieldHalf, pages: 
["/permissions", "/authenticity"]},
+  {
+    key: "records",
+    label: "general:Records",
+    icon: ChartColumn,
+    pages: ["/usage", "/agent-sessions", "/agent-records", "/llm-records"],
+  },
+  {key: "tools", label: "general:All tools", icon: LayoutGrid, pages: 
["/tools"]},
+  {key: "system", label: "setting:Settings", icon: Settings, pages: 
["/settings"], hidden: true},
+];
 
-export function findLeaf(segmentKey: string): NavLeaf | undefined {
-  const leaf = navLeaves.find(entry => entry.key === segmentKey);
-  if (leaf) {
-    return leaf;
-  }
-  // A grouped page contributes its children to the leaves, not itself, so the
-  // breadcrumb still has to be able to name the page they live on.
-  const group = navGroups.find(entry => entry.key === segmentKey);
-  return group ? {key: group.key, label: group.label, path: group.path ?? 
group.key} : undefined;
+/** The pages of a section this viewer may open. */
+export function sectionPages(section: NavSection, isAdmin: boolean) {
+  return section.pages
+    .map(key => navGroups.find(group => group.key === key))
+    .filter((group): group is NavGroup => group !== undefined && 
(!group.adminOnly || isAdmin));
 }
 
-export function findGroupOf(segmentKey: string) {
-  return navGroups.find(group => group.children?.some(child => child.key === 
segmentKey));
+export function visibleSections(isAdmin: boolean) {
+  return navSections.filter(section => (!section.adminOnly || isAdmin) && 
sectionPages(section, isAdmin).length > 0);
 }
 
-/**
- * The key the sidebar treats as selected. A page with children is selected at
- * one of them: the tab named by `?tab=`, the section named by the hash, or the
- * first child, which is what the page shows when neither is set.
- */
-export function selectedKeyOf(pathname: string, search = "", hash = "") {
+/** The first path segment, as a nav key: "/agents/x" is "/agents". */
+export function pageKeyOf(pathname: string) {
   const firstSegment = pathname.split("/").filter(Boolean)[0];
-  const base = firstSegment === undefined ? "/" : `/${firstSegment}`;
-  const group = navGroups.find(entry => entry.key === base);
-  if (!group?.children?.length) {
-    return base;
-  }
-  const tab = new URLSearchParams(search).get("tab");
-  const suffix = hash || (tab ? `?tab=${tab}` : "");
-  const match = group.children.find(child => child.key === `${base}${suffix}`);
-  return (match ?? group.children[0]).key;
+  return firstSegment === undefined ? "/" : `/${firstSegment}`;
+}
+
+export function sectionOf(pathname: string) {
+  const key = pageKeyOf(pathname);
+  return navSections.find(section => section.pages.includes(key));
 }
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
index 9c346c9..40c57da 100644
--- a/web/src/pages/ChatPage.tsx
+++ b/web/src/pages/ChatPage.tsx
@@ -321,7 +321,7 @@ export default function ChatPage({account}: {account: 
Account}) {
   const turns = toTurns(events);
 
   return (
-    <PageContainer className="lg:h-[calc(100svh-3.25rem-4.5rem)]">
+    <PageContainer className="lg:h-[calc(100svh-5.5rem-2.75rem-1px)]">
       <PageHeader
         title={i18next.t("chat:Chat")}
         description={i18next.t("chat:Ask an agent on this machine something, 
and watch it work")}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 30d7385..14f4de5 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, Container, ListChecks, MoreHorizontal, Plus, RefreshCw, Table2} 
from "lucide-react";
+import {Bot, Container, MoreHorizontal, PackageCheck, RefreshCw, Table2} from 
"lucide-react";
 import i18next from "i18next";
 
 import * as AgentBackend from "@/backend/AgentBackend";
@@ -24,13 +24,12 @@ import * as Setting from "@/Setting";
 import {AgentCatalog} from "@/components/AgentCatalog";
 import {CcSwitchBanner} from "@/components/CcSwitchBanner";
 import {AgentGridCard} from "@/components/AgentGridCard";
-import {HomeSummary} from "@/components/HomeSummary";
-import {OnboardingChecklist, useOnboarding} from 
"@/components/OnboardingChecklist";
+import {CheckupHero} from "@/components/CheckupHero";
 import {EmptyState, ErrorState} from "@/components/shared/empty-state";
 import {Fold} from "@/components/shared/fold";
 import {Loading} from "@/components/shared/loading";
 import {UnauthorizedResult} from "@/components/shared/misc";
-import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {PageContainer} from "@/components/shared/page-header";
 import {MessageAlert} from "@/components/ui/alert";
 import {Button} from "@/components/ui/button";
 import {Card} from "@/components/ui/card";
@@ -147,47 +146,41 @@ export default function HomePage({account}: {account: 
Account}) {
 
   // What the proxy has seen changes as requests are relayed, so the health and
   // the per-agent totals are polled rather than read once.
-  React.useEffect(() => {
+  const loadLive = React.useCallback(() => {
     if (!isAdmin) {
-      return undefined;
+      return;
     }
+    ProviderBackend.getProviderHealth()
+      .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => setHealth([]));
+    LlmRecordBackend.getLlmAgentStats()
+      .then(res => setStats(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => setStats([]));
+  }, [isAdmin]);
 
-    const load = () => {
-      ProviderBackend.getProviderHealth()
-        .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
-        .catch(() => setHealth([]));
-      LlmRecordBackend.getLlmAgentStats()
-        .then(res => setStats(res.status === "ok" ? (res.data ?? []) : []))
-        .catch(() => setStats([]));
-    };
-
-    load();
-    const interval = setInterval(load, refreshMs);
+  React.useEffect(() => {
+    loadLive();
+    const interval = setInterval(loadLive, refreshMs);
     return () => clearInterval(interval);
-  }, [isAdmin]);
+  }, [loadLive]);
 
   // What the agents wrote down themselves, which is the only account of the
   // requests that never went through Gateway - and the only one there is at 
all
   // before a provider is bound.
-  React.useEffect(() => {
+  const loadUsage = React.useCallback(() => {
     if (!isAdmin) {
-      return undefined;
+      return;
     }
-
-    const load = () => {
-      AgentBackend.getAgentUsage()
-        .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : 
undefined))
-        .catch(() => undefined);
-    };
-
-    load();
-    const interval = setInterval(load, usageRefreshMs);
-    return () => clearInterval(interval);
+    AgentBackend.getAgentUsage()
+      .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : 
undefined))
+      .catch(() => undefined);
   }, [isAdmin]);
 
-  // Held here rather than inside the card, so the header can offer the guide
-  // back to whoever closed it.
-  const onboarding = useOnboarding({providers: providers, agents: agents, 
stats: stats});
+  React.useEffect(() => {
+    loadUsage();
+    const interval = setInterval(loadUsage, usageRefreshMs);
+    return () => clearInterval(interval);
+  }, [loadUsage]);
 
   // One reading of each agent, shared by the cards, the strip above them and 
the
   // split below: the totals cannot disagree with the cards they add up.
@@ -222,6 +215,13 @@ export default function HomePage({account}: {account: 
Account}) {
     instances.reload(true);
   };
 
+  const checkup = () => {
+    refresh();
+    loadLive();
+    loadUsage();
+    updates.reload(true);
+  };
+
   const rescan = (
     <Button variant="outline" onClick={refresh} loading={loading}>
       <RefreshCw />
@@ -255,59 +255,27 @@ export default function HomePage({account}: {account: 
Account}) {
   );
 
   return (
-    <PageContainer>
-      <PageHeader
-        title={i18next.t("agent:Agents on this machine")}
-        description={account.hostname}
-        actions={
-          <>
-            {/* Two controls, not four: the rescan is the one anybody presses,
-                and the rest are places rather than actions. */}
-            <DropdownMenu>
-              <DropdownMenuTrigger asChild>
-                <Button variant="ghost" size="icon" 
aria-label={i18next.t("general:More")}>
-                  <MoreHorizontal />
-                </Button>
-              </DropdownMenuTrigger>
-              <DropdownMenuContent align="end">
-                <DropdownMenuItem asChild>
-                  <Link to="/agents">
-                    <Table2 />
-                    {i18next.t("agent:Advanced view")}
-                  </Link>
-                </DropdownMenuItem>
-                <DropdownMenuItem onSelect={onboarding.reopen}>
-                  <ListChecks />
-                  {i18next.t("agent:Getting started")}
-                </DropdownMenuItem>
-              </DropdownMenuContent>
-            </DropdownMenu>
-            {rescan}
-          </>
-        }
+    <PageContainer className="mx-auto w-full max-w-7xl">
+      <CheckupHero
+        hostname={account.hostname}
+        agents={agents}
+        scanned={scanned}
+        providers={providers}
+        providersLoaded={loaded && providerError === ""}
+        health={health}
+        outdated={updates.outdated}
+        recording={recording}
+        total={total}
+        running={running}
+        usageLoaded={usage !== undefined}
+        busy={loading || updates.checking}
+        onCheck={checkup}
       />
 
-      {loaded && onboarding.open ? <OnboardingChecklist 
onboarding={onboarding} /> : null}
-
       {/* Somebody arriving from CC Switch has all of this set up already, and
           the import page is the first thing they want rather than a form. */}
       <CcSwitchBanner />
 
-      {/* What the machine adds up to, including whether the keys behind it are
-          what they were sold as. Every figure here is otherwise only reachable
-          by reading every card below. */}
-      {scanned && agents.length > 0 ? (
-        <HomeSummary
-          total={total}
-          agents={agents.length}
-          running={running}
-          providers={providers}
-          loaded={usage !== undefined}
-        />
-      ) : null}
-
-      {/* With no agents to show, the failure is the whole page and is rendered
-          below instead; here it sits above the cards that are still on 
screen. */}
       {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : 
null}
 
       {providerError !== "" ? (
@@ -321,22 +289,33 @@ export default function HomePage({account}: {account: 
Account}) {
             </Button>
           }
         />
-      ) : loaded && providers.length === 0 ? (
-        <MessageAlert
-          variant="info"
-          title={i18next.t("provider:No providers yet")}
-          description={i18next.t("provider:No providers yet detail")}
-          action={
-            <Button asChild size="sm">
-              <Link to="/providers">
-                <Plus />
-                {i18next.t("provider:New Provider")}
-              </Link>
-            </Button>
-          }
-        />
       ) : null}
 
+      <div id="agent-cards" className="flex scroll-mt-4 items-center 
justify-between gap-2 pt-2">
+        <h2 className="text-base font-semibold">{i18next.t("agent:Agents on 
this machine")}</h2>
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button variant="ghost" size="icon-sm" 
aria-label={i18next.t("general:More")}>
+              <MoreHorizontal />
+            </Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent align="end">
+            <DropdownMenuItem asChild>
+              <Link to="/agents">
+                <Table2 />
+                {i18next.t("agent:Advanced view")}
+              </Link>
+            </DropdownMenuItem>
+            <DropdownMenuItem asChild>
+              <Link to="/agent-versions">
+                <PackageCheck />
+                {i18next.t("agent:Agent versions")}
+              </Link>
+            </DropdownMenuItem>
+          </DropdownMenuContent>
+        </DropdownMenu>
+      </div>
+
       {!scanned ? (
         <Loading tip={i18next.t("agent:Scan")} />
       ) : error !== "" && agents.length === 0 ? (
@@ -377,13 +356,15 @@ export default function HomePage({account}: {account: 
Account}) {
       )}
 
       {/* What is not here yet, so an empty machine has somewhere to go. */}
-      <AgentCatalog
-        agents={agents}
-        enabled={scanned}
-        defaultOpen={agents.length === 0}
-        installer={installer}
-        onLocated={() => scan(true)}
-      />
+      <div id="agent-catalog" className="scroll-mt-4">
+        <AgentCatalog
+          agents={agents}
+          enabled={scanned}
+          defaultOpen={agents.length === 0}
+          installer={installer}
+          onLocated={() => scan(true)}
+        />
+      </div>
     </PageContainer>
   );
 }
diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx
new file mode 100644
index 0000000..d58303e
--- /dev/null
+++ b/web/src/pages/ToolsPage.tsx
@@ -0,0 +1,71 @@
+// 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 i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import {navSections, sectionPages} from "@/nav";
+import {PageContainer} from "@/components/shared/page-header";
+import {Card} from "@/components/ui/card";
+import type {Account} from "@/types";
+
+const tone: Record<string, string> = {
+  home: "bg-primary/12 text-primary",
+  chat: "bg-info/12 text-info",
+  models: "bg-chart-2/15 text-chart-2",
+  extensions: "bg-chart-5/12 text-chart-5",
+  security: "bg-success/12 text-success",
+  records: "bg-warning/15 text-warning",
+  system: "bg-muted text-muted-foreground",
+};
+
+/** Every page as a tile, grouped by the tab it lives under. */
+export default function ToolsPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const sections = navSections
+    .filter(section => section.key !== "tools" && (!section.adminOnly || 
isAdmin))
+    .map(section => ({section: section, pages: sectionPages(section, 
isAdmin)}))
+    .filter(entry => entry.pages.length > 0);
+
+  return (
+    <PageContainer className="mx-auto w-full max-w-6xl">
+      <h1 className="text-xl font-semibold">{i18next.t("general:All 
tools")}</h1>
+      {sections.map(({section, pages}) => (
+        <Card key={section.key} className="gap-3 px-5 py-4 shadow-xs">
+          <h2 className="text-muted-foreground text-sm 
font-medium">{i18next.t(section.label)}</h2>
+          <div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 
lg:grid-cols-8">
+            {pages.map(page => {
+              const Icon = page.icon ?? section.icon;
+              return (
+                <Link
+                  key={page.key}
+                  to={page.path ?? page.key}
+                  className="hover:bg-accent group flex flex-col items-center 
gap-2 rounded-xl px-1 py-3 text-center transition-colors"
+                >
+                  <span
+                    className={`flex size-12 items-center justify-center 
rounded-2xl transition-transform group-hover:scale-105 ${tone[section.key]}`}
+                  >
+                    <Icon className="size-6" strokeWidth={1.75} />
+                  </span>
+                  <span className="text-xs 
leading-tight">{i18next.t(page.label)}</span>
+                </Link>
+              );
+            })}
+          </div>
+        </Card>
+      ))}
+    </PageContainer>
+  );
+}


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

Reply via email to