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
commit 0a3405c6efa4bc0642176405885a921690e71f41 Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 12:46:09 2026 +0800 feat(nav): give the sidebar a second level --- web/src/App.tsx | 2 +- web/src/components/shared/app-sidebar.tsx | 48 ++++++++++++------- web/src/components/shared/page-header.tsx | 16 ++++++- web/src/locales/en/data.json | 4 +- web/src/locales/zh/data.json | 4 +- web/src/nav.ts | 80 +++++++++++++++++++++++++++---- web/src/pages/AgentConfigsPage.tsx | 13 ++++- web/src/pages/AuthenticityPage.tsx | 13 ++++- web/src/pages/SettingPage.tsx | 35 ++++++++++---- web/src/pages/UsagePage.tsx | 18 +++++-- 10 files changed, 188 insertions(+), 45 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4d32a24..80e442a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -69,7 +69,7 @@ export default function App() { const isDesktop = useIsDesktop(); const location = useLocation(); - const selectedKey = selectedKeyOf(location.pathname); + 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") { diff --git a/web/src/components/shared/app-sidebar.tsx b/web/src/components/shared/app-sidebar.tsx index 556028f..80554c4 100644 --- a/web/src/components/shared/app-sidebar.tsx +++ b/web/src/components/shared/app-sidebar.tsx @@ -23,7 +23,9 @@ import {navGroups, type NavGroup} from "@/nav"; import {SimpleTooltip} from "@/components/ui/tooltip"; const OPEN_KEYS_STORAGE = "siderMenuOpenKeys"; -const DEFAULT_OPEN_KEYS = navGroups.filter(group => group.children).map(group => group.key); +// 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 { @@ -199,7 +201,7 @@ export function AppSidebar({ return ( <NavLink key={group.key} - to={group.children[0].path} + to={group.path ?? group.children[0].path} active={hasActiveChild} collapsed icon={group.icon} @@ -208,22 +210,36 @@ export function AppSidebar({ ); } + 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}> - <button - type="button" - onClick={() => toggleGroup(group.key)} - className={cn( - "flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors", - hasActiveChild ? "text-sidebar-foreground font-medium" : "text-sidebar-foreground/70", - "hover:bg-sidebar-accent/60 hover:text-sidebar-foreground", - )} - aria-expanded={isOpen} - > - {group.icon ? <group.icon className="size-4 shrink-0" /> : null} - <span className="flex-1 truncate text-left">{groupLabel}</span> - <ChevronDown className={cn("size-3.5 shrink-0 transition-transform", isOpen && "rotate-180")} /> - </button> + {/* 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 => ( diff --git a/web/src/components/shared/page-header.tsx b/web/src/components/shared/page-header.tsx index d7bbdbe..b4a5ebe 100644 --- a/web/src/components/shared/page-header.tsx +++ b/web/src/components/shared/page-header.tsx @@ -13,6 +13,7 @@ // limitations under the License. import * as React from "react"; +import {useLocation} from "react-router-dom"; import {ChevronDown} from "lucide-react"; import {cn} from "@/lib/utils"; @@ -49,6 +50,7 @@ export function PageHeader({ * long form reads as a few short sections rather than one tall column. */ export function Section({ + id, title, description, columns = 3, @@ -56,6 +58,8 @@ export function Section({ collapsible = false, children, }: { + /** Names the section for the rail, which links to it by hash. */ + id?: string; title?: React.ReactNode; description?: React.ReactNode; columns?: 1 | 2 | 3; @@ -65,9 +69,19 @@ export function Section({ children: React.ReactNode; }) { const [open, setOpen] = React.useState(!collapsible); + const {hash} = useLocation(); + const targeted = id !== undefined && hash === `#${id}`; + + // Arriving at a folded section by link has to unfold it, or the jump lands on + // a title with nothing under it. + React.useEffect(() => { + if (targeted) { + setOpen(true); + } + }, [targeted]); return ( - <Card className={cn("gap-4 py-5", className)}> + <Card id={id} className={cn("scroll-mt-20 gap-4 py-5", className)}> {title || description ? ( <CardHeader className="px-5"> {title ? ( diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 9bc08d4..15aa73e 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -1189,6 +1189,7 @@ }, "usage": { "Add a price": "Add a price", + "Agent spend": "Agent spend", "Agents description": "What the agents on this machine spent, read from the transcripts they write themselves. This is the only account of a request that never went through Gateway.", "Automatic sync": "Automatic sync", "Automatic sync description": "Read the models.dev catalogue on a schedule and reprice the models this machine has been seen running. A price you edited by hand is left alone.", @@ -1237,6 +1238,7 @@ "Read from a catalogue of {count} models": "Read from a models.dev catalogue of {count} models.", "Recording is off detail": "llmRecordMode is \"off\", so nothing relayed is being counted. The agents' own transcripts are counted either way.", "Relayed description": "What Gateway relayed on behalf of the agents. This is the only account that knows which provider answered, and whether it failed.", + "Relayed spend": "Relayed spend", "Search the catalogue": "Search by model name", "Set their prices": "Set their prices", "Source": "Source", @@ -1249,8 +1251,6 @@ "Trend": "Trend", "Updated": "Updated:", "Usage": "Usage", - "What Gateway relayed": "What Gateway relayed", - "What the agents spent": "What the agents spent", "models.dev": "models.dev", "{count} providers list it": "{count} providers list it" } diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 04a2a8e..0feaaba 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -1261,6 +1261,7 @@ }, "usage": { "Add a price": "新增价格", + "Agent spend": "Agent 账单", "Agents description": "这台机器上的 agent 花掉的量,读自它们自己写的会话记录。没有经过 Gateway 的请求,只有这一份账。", "Automatic sync": "自动同步", "Automatic sync description": "定期读取 models.dev 目录,为本机跑过的模型重新定价。你手改过的价格不会被动。", @@ -1309,6 +1310,7 @@ "Read from a catalogue of {count} models": "读自 models.dev 的 {count} 个模型目录。", "Recording is off detail": "llmRecordMode 是 \"off\",转发的请求没有被统计。Agent 自己的会话记录不受影响,照常统计。", "Relayed description": "Gateway 代为转发的量。只有这一份账知道是哪个 provider 应答的,以及是否失败。", + "Relayed spend": "转发账单", "Search the catalogue": "按模型名搜索", "Set their prices": "去设置价格", "Source": "来源", @@ -1321,8 +1323,6 @@ "Trend": "趋势", "Updated": "已更新:", "Usage": "用量", - "What Gateway relayed": "Gateway 转发的账", - "What the agents spent": "Agent 自己的账", "models.dev": "models.dev", "{count} providers list it": "{count} 家收录" } diff --git a/web/src/nav.ts b/web/src/nav.ts index c13fed3..b26f738 100644 --- a/web/src/nav.ts +++ b/web/src/nav.ts @@ -59,6 +59,8 @@ 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. {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. @@ -70,8 +72,23 @@ export const navGroups: NavGroup[] = [ icon: Blocks, path: "/agent-configs", adminOnly: true, + children: [ + {key: "/agent-configs?tab=skill", label: "agentConfig:Skills", path: "/agent-configs?tab=skill"}, + {key: "/agent-configs?tab=mcp", label: "agentConfig:MCP servers", path: "/agent-configs?tab=mcp"}, + {key: "/agent-configs?tab=prompt", label: "agentConfig:Prompts", path: "/agent-configs?tab=prompt"}, + ], + }, + { + key: "/authenticity", + label: "audit:Authenticity", + icon: ShieldCheck, + path: "/authenticity", + adminOnly: true, + children: [ + {key: "/authenticity?tab=report", label: "audit:Report", path: "/authenticity?tab=report"}, + {key: "/authenticity?tab=cases", label: "audit:Test cases", path: "/authenticity?tab=cases"}, + ], }, - {key: "/authenticity", label: "audit:Authenticity", icon: ShieldCheck, path: "/authenticity", adminOnly: true}, {key: "/permissions", label: "agent:Permissions", icon: ShieldHalf, path: "/permissions", adminOnly: true}, { key: "/agent-sessions", @@ -82,9 +99,37 @@ export const navGroups: NavGroup[] = [ }, {key: "/agent-records", label: "agent:Agent Records", icon: FileSearch, path: "/agent-records", adminOnly: true}, {key: "/llm-records", label: "llm:LLM Records", icon: Logs, path: "/llm-records", adminOnly: true}, - {key: "/usage", label: "usage:Usage", icon: ChartColumn, path: "/usage", adminOnly: true}, + { + key: "/usage", + label: "usage:Usage", + icon: ChartColumn, + path: "/usage", + adminOnly: true, + children: [ + {key: "/usage?tab=agents", label: "usage:Agent spend", path: "/usage?tab=agents"}, + {key: "/usage?tab=relayed", label: "usage:Relayed spend", path: "/usage?tab=relayed"}, + ], + }, {key: "/pricing", label: "usage:Model pricing", icon: CircleDollarSign, path: "/pricing", adminOnly: true}, - {key: "/settings", label: "setting:Settings", icon: Settings, path: "/settings", adminOnly: true}, + { + key: "/settings", + label: "setting:Settings", + icon: Settings, + path: "/settings", + adminOnly: true, + // One long page, so the children are its sections rather than tabs. + children: [ + {key: "/settings#llm-records", label: "setting:LLM records", path: "/settings#llm-records"}, + {key: "/settings#probes", label: "setting:Channel probes", path: "/settings#probes"}, + {key: "/settings#agents", label: "setting:Agents", path: "/settings#agents"}, + {key: "/settings#signin", label: "setting:Sign-in", path: "/settings#signin"}, + {key: "/settings#security", label: "setting:Security", path: "/settings#security"}, + {key: "/settings#network", label: "setting:Network", path: "/settings#network"}, + {key: "/settings#backups", label: "setting:Backups", path: "/settings#backups"}, + {key: "/settings#cloud-sync", label: "setting:Cloud sync", path: "/settings#cloud-sync"}, + {key: "/settings#import-export", label: "setting:Import and export", path: "/settings#import-export"}, + ], + }, ]; /** All leaf entries, flattened, for lookups by first path segment. */ @@ -92,16 +137,35 @@ export const navLeaves: NavLeaf[] = navGroups.flatMap(group => group.children ? group.children : [{...group, path: group.path ?? group.key} as NavLeaf], ); -export function findLeaf(segmentKey: string) { - return navLeaves.find(leaf => leaf.key === segmentKey); +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; } export function findGroupOf(segmentKey: string) { return navGroups.find(group => group.children?.some(child => child.key === segmentKey)); } -/** The key the sidebar treats as selected: the first path segment, or "/" at home. */ -export function selectedKeyOf(pathname: string) { +/** + * 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 = "") { const firstSegment = pathname.split("/").filter(Boolean)[0]; - return firstSegment === undefined ? "/" : `/${firstSegment}`; + 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; } diff --git a/web/src/pages/AgentConfigsPage.tsx b/web/src/pages/AgentConfigsPage.tsx index afbaa11..2930979 100644 --- a/web/src/pages/AgentConfigsPage.tsx +++ b/web/src/pages/AgentConfigsPage.tsx @@ -13,6 +13,7 @@ // limitations under the License. import * as React from "react"; +import {useSearchParams} from "react-router-dom"; import { AlertTriangle, ArrowDownToLine, @@ -160,7 +161,17 @@ export default function AgentConfigsPage({account}: {account: Account}) { const isAdmin = Setting.isAdminUser(account); const {inventories, loading, error, scanned, refresh} = useAgentConfigs(); - const [kind, setKind] = React.useState<AgentConfigKind>("skill"); + // The tab lives in the URL so the rail can link straight to one of them. + const [searchParams, setSearchParams] = useSearchParams(); + const kind = (["skill", "mcp", "prompt"] as const).find(one => one === searchParams.get("tab")) ?? "skill"; + const setKind = (next: AgentConfigKind) => + setSearchParams( + previous => { + previous.set("tab", next); + return previous; + }, + {replace: true}, + ); const [sourceKey, setSourceKey] = React.useState(""); const [selected, setSelected] = React.useState<string[]>([]); const [detail, setDetail] = React.useState<DetailTarget | null>(null); diff --git a/web/src/pages/AuthenticityPage.tsx b/web/src/pages/AuthenticityPage.tsx index c64ff1c..1c6b637 100644 --- a/web/src/pages/AuthenticityPage.tsx +++ b/web/src/pages/AuthenticityPage.tsx @@ -13,6 +13,7 @@ // limitations under the License. import * as React from "react"; +import {useSearchParams} from "react-router-dom"; import i18next from "i18next"; import * as ProviderBackend from "@/backend/ProviderBackend"; @@ -35,7 +36,17 @@ type AuthenticityTab = "report" | "cases"; */ export default function AuthenticityPage({account}: {account: Account}) { const isAdmin = Setting.isAdminUser(account); - const [tab, setTab] = React.useState<AuthenticityTab>("report"); + // The tab lives in the URL so the rail can link straight to one of them. + const [searchParams, setSearchParams] = useSearchParams(); + const tab: AuthenticityTab = searchParams.get("tab") === "cases" ? "cases" : "report"; + const setTab = (next: AuthenticityTab) => + setSearchParams( + previous => { + previous.set("tab", next); + return previous; + }, + {replace: true}, + ); const [providers, setProviders] = React.useState<Provider[]>([]); React.useEffect(() => { diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx index 50ad9e0..7b36215 100644 --- a/web/src/pages/SettingPage.tsx +++ b/web/src/pages/SettingPage.tsx @@ -13,6 +13,7 @@ // limitations under the License. import * as React from "react"; +import {useLocation} from "react-router-dom"; import i18next from "i18next"; import * as SettingBackend from "@/backend/SettingBackend"; @@ -61,6 +62,17 @@ export default function SettingPage({account}: {account: Account}) { React.useEffect(load, [load]); + // The rail links to a section by hash, and the browser cannot honour that on + // its own while the page is still the loading placeholder. + const {hash} = useLocation(); + const loaded = setting !== null; + React.useEffect(() => { + if (!loaded || !hash) { + return; + } + document.getElementById(hash.slice(1))?.scrollIntoView({block: "start"}); + }, [hash, loaded]); + if (!isAdmin) { return <UnauthorizedResult />; } @@ -137,7 +149,7 @@ export default function SettingPage({account}: {account: Account}) { } /> - <Section columns={2} title={i18next.t("setting:LLM records")} description={i18next.t("setting:LLM records description")}> + <Section id="llm-records" columns={2} title={i18next.t("setting:LLM records")} description={i18next.t("setting:LLM records description")}> <Field label={i18next.t("setting:Record mode")}> <SimpleSelect value={setting.llmRecordMode} @@ -157,6 +169,7 @@ export default function SettingPage({account}: {account: Account}) { </Section> <Section + id="probes" columns={2} title={i18next.t("setting:Channel probes")} description={i18next.t("setting:Channel probes description")} @@ -181,13 +194,13 @@ export default function SettingPage({account}: {account: Account}) { )} </Section> - <Section columns={2} title={i18next.t("setting:Agents")}> + <Section id="agents" columns={2} title={i18next.t("setting:Agents")}> {textField("agentPatchStateDir", i18next.t("setting:Agent state dir"), i18next.t("setting:Agent state dir hint"))} {numberField("agentRecordCapacity", i18next.t("setting:Agent record capacity"), i18next.t("setting:Agent record capacity hint"), 1)} {numberField("agentMonitorPollSeconds", i18next.t("setting:Agent poll seconds"), undefined, 1)} </Section> - <Section columns={2} collapsible title={i18next.t("setting:Sign-in")} description={i18next.t("setting:Sign-in description")}> + <Section id="signin" columns={2} collapsible title={i18next.t("setting:Sign-in")} description={i18next.t("setting:Sign-in description")}> {textField("casdoorEndpoint", i18next.t("setting:Casdoor endpoint"))} {textField("clientId", i18next.t("setting:Client ID"))} {secretField("clientSecret", i18next.t("setting:Client secret"))} @@ -195,20 +208,26 @@ export default function SettingPage({account}: {account: Account}) { {textField("casdoorApplication", i18next.t("setting:Casdoor application"))} </Section> - <Section columns={2} title={i18next.t("setting:Security")}> + <Section id="security" columns={2} title={i18next.t("setting:Security")}> {secretField("apiKeyEncryptionKey", i18next.t("setting:API key encryption key"), i18next.t("setting:API key encryption key hint"))} {textField("relayToken", i18next.t("setting:Relay token"), i18next.t("setting:Relay token hint"))} </Section> - <Section columns={2} collapsible title={i18next.t("setting:Network")}> + <Section id="network" columns={2} collapsible title={i18next.t("setting:Network")}> {textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"), i18next.t("setting:Outbound SOCKS5 proxy hint"))} </Section> - <BackupPanel onSettingChanged={load} reloadToken={synced} /> + <div id="backups" className="scroll-mt-20"> + <BackupPanel onSettingChanged={load} reloadToken={synced} /> + </div> - <CloudSyncPanel onSynced={() => setSynced(count => count + 1)} /> + <div id="cloud-sync" className="scroll-mt-20"> + <CloudSyncPanel onSynced={() => setSynced(count => count + 1)} /> + </div> - <ImportExportPanel onSettingChanged={load} /> + <div id="import-export" className="scroll-mt-20"> + <ImportExportPanel onSettingChanged={load} /> + </div> </PageContainer> ); } diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx index b38aa96..56f80e6 100644 --- a/web/src/pages/UsagePage.tsx +++ b/web/src/pages/UsagePage.tsx @@ -13,7 +13,7 @@ // limitations under the License. import * as React from "react"; -import {Link} from "react-router-dom"; +import {Link, useSearchParams} from "react-router-dom"; import {Logs, RefreshCw} from "lucide-react"; import i18next from "i18next"; @@ -84,7 +84,9 @@ function rangeLabel(days: number) { export default function UsagePage({account}: {account: Account}) { const isAdmin = Setting.isAdminUser(account); - const [source, setSource] = React.useState<UsageSource>("agents"); + // The tab lives in the URL so the rail can link straight to one of them. + const [searchParams, setSearchParams] = useSearchParams(); + const source: UsageSource = searchParams.get("tab") === "relayed" ? "relayed" : "agents"; const [days, setDays] = React.useState(7); const [agent, setAgent] = React.useState(""); const [refreshMs, setRefreshMs] = React.useState(30000); @@ -247,7 +249,13 @@ export default function UsagePage({account}: {account: Account}) { } const changeSource = (next: UsageSource) => { - setSource(next); + setSearchParams( + previous => { + previous.set("tab", next); + return previous; + }, + {replace: true}, + ); // Each source offers the windows its own granularity can draw, so a window // the new one does not have falls back to its default. if (!RANGES[next].includes(days)) { @@ -308,8 +316,8 @@ export default function UsagePage({account}: {account: Account}) { <Tabs value={source} onValueChange={value => changeSource(value as UsageSource)}> <TabsList> - <TabsTrigger value="agents">{i18next.t("usage:What the agents spent")}</TabsTrigger> - <TabsTrigger value="relayed">{i18next.t("usage:What Gateway relayed")}</TabsTrigger> + <TabsTrigger value="agents">{i18next.t("usage:Agent spend")}</TabsTrigger> + <TabsTrigger value="relayed">{i18next.t("usage:Relayed spend")}</TabsTrigger> </TabsList> </Tabs> --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
