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 c16138208e32c2b650d3deefba39e1aed0b05874 Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 13:04:31 2026 +0800 feat(nav): put a search palette in the header --- web/src/App.tsx | 12 ++ web/src/components/AgentGridCard.tsx | 2 +- web/src/components/AuthenticityOverview.tsx | 4 +- web/src/components/AuthenticityScore.tsx | 8 +- web/src/components/OnboardingChecklist.tsx | 8 +- web/src/components/ProviderAuditCard.tsx | 25 +-- web/src/components/shared/app-header.tsx | 6 +- web/src/components/shared/command-palette.tsx | 229 ++++++++++++++++++++++++++ web/src/components/shared/empty-state.tsx | 2 +- web/src/components/shared/stat-card.tsx | 2 +- web/src/components/ui/alert.tsx | 10 +- web/src/components/ui/badge.tsx | 14 +- web/src/components/ui/command.tsx | 14 +- web/src/components/usage/usage-hero.tsx | 23 +-- web/src/index.css | 18 +- web/src/lib/authenticity.ts | 15 +- web/src/locales/en/data.json | 7 + web/src/locales/zh/data.json | 7 + 18 files changed, 339 insertions(+), 67 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 80e442a..a5d03da 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,6 +23,7 @@ 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 {TooltipProvider} from "@/components/ui/tooltip"; import {cn} from "@/lib/utils"; @@ -68,6 +69,7 @@ export default function App() { 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); @@ -237,8 +239,18 @@ export default function App() { themeAlgorithm={themeAlgorithm} onThemeChange={changeTheme} onSignout={signout} + onOpenPalette={() => palette.setOpen(true)} /> + {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" />}> diff --git a/web/src/components/AgentGridCard.tsx b/web/src/components/AgentGridCard.tsx index adc9985..0c661d5 100644 --- a/web/src/components/AgentGridCard.tsx +++ b/web/src/components/AgentGridCard.tsx @@ -139,7 +139,7 @@ function PlanUsage({quota}: {quota?: ProviderQuota}) { {percent === null ? null : ( <Progress value={percent} - tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "success"} + tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "default"} className="h-1" /> )} diff --git a/web/src/components/AuthenticityOverview.tsx b/web/src/components/AuthenticityOverview.tsx index 921fcc6..8d8cabd 100644 --- a/web/src/components/AuthenticityOverview.tsx +++ b/web/src/components/AuthenticityOverview.tsx @@ -129,10 +129,10 @@ export function AuthenticityOverview({ <ScoreDial probe={worst} size={56} /> <div className="flex min-w-0 flex-col gap-0.5"> <div className="flex items-center gap-1.5"> - <ShieldCheck className={cn("size-3.5", style.text)} /> + <ShieldCheck className="text-muted-foreground size-3.5" /> <span className="text-sm font-medium">{i18next.t("audit:Authenticity")}</span> </div> - <span className={cn("text-xs", style.text)}> + <span className="text-xs"> {summary.graded === 0 ? i18next.t(mode === "off" ? "audit:Probing is off" : "audit:Measuring now") : i18next.t(style.verdict)} diff --git a/web/src/components/AuthenticityScore.tsx b/web/src/components/AuthenticityScore.tsx index 801c811..ca46aed 100644 --- a/web/src/components/AuthenticityScore.tsx +++ b/web/src/components/AuthenticityScore.tsx @@ -75,16 +75,18 @@ export function ScoreDial({ stroke="currentColor" /> </svg> + {/* The ring is the one thing here that takes the grade's colour; the + number inside it is ordinary text. */} <div className="absolute inset-0 flex flex-col items-center justify-center gap-1"> <span - className={cn("font-semibold tabular-nums leading-none", style.text)} + className="font-semibold tabular-nums leading-none" style={{fontSize: size / 3.8}} > {formatScore(probe)} </span> {spelled || measured ? ( <span - className={cn("font-medium tracking-wide uppercase leading-none", style.text)} + className="text-muted-foreground font-medium tracking-wide uppercase leading-none" style={{fontSize: labelSize}} > {spelled ? label : letter} @@ -117,7 +119,7 @@ export function ScoreBadge({ return ( <SimpleTooltip title={title}> - <Badge className={cn("gap-1.5 font-medium", style.badge, className)}> + <Badge variant="outline" className={cn("gap-1.5 font-medium", style.badge, className)}> <span className="tabular-nums"> {gradeLetter(grade)} {grade === "unknown" ? "" : ` · ${formatScore(probe)}`} diff --git a/web/src/components/OnboardingChecklist.tsx b/web/src/components/OnboardingChecklist.tsx index ebb403c..859fffb 100644 --- a/web/src/components/OnboardingChecklist.tsx +++ b/web/src/components/OnboardingChecklist.tsx @@ -14,7 +14,7 @@ import * as React from "react"; import {Link} from "react-router-dom"; -import {CheckCircle2, ChevronRight, Circle, Sparkles, X} from "lucide-react"; +import {CheckCircle2, ChevronRight, Circle, ListChecks, X} from "lucide-react"; import i18next from "i18next"; import {Button} from "@/components/ui/button"; @@ -100,7 +100,7 @@ export function OnboardingChecklist({ <Card className="gap-0 py-0 shadow-xs"> <CardContent className="flex flex-col gap-3 p-4"> <div className="flex items-center gap-3"> - <Sparkles className="text-primary size-4 shrink-0" /> + <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> @@ -108,7 +108,7 @@ export function OnboardingChecklist({ {doneCount}/{steps.length} </span> </div> - <Progress value={(doneCount / steps.length) * 100} tone="success" className="h-1.5" /> + <Progress value={(doneCount / steps.length) * 100} className="h-1.5" /> </div> <Button variant="ghost" size="icon-sm" onClick={dismiss} aria-label={i18next.t("general:Dismiss")}> <X /> @@ -126,7 +126,7 @@ export function OnboardingChecklist({ )} > {step.done ? ( - <CheckCircle2 className="text-success mt-0.5 size-4 shrink-0" /> + <CheckCircle2 className="text-muted-foreground mt-0.5 size-4 shrink-0" /> ) : ( <Circle className="text-muted-foreground mt-0.5 size-4 shrink-0" /> )} diff --git a/web/src/components/ProviderAuditCard.tsx b/web/src/components/ProviderAuditCard.tsx index bb95404..51acf71 100644 --- a/web/src/components/ProviderAuditCard.tsx +++ b/web/src/components/ProviderAuditCard.tsx @@ -13,7 +13,7 @@ // limitations under the License. import * as React from "react"; -import {CircleAlert, CircleCheck, CircleHelp, Stethoscope, TriangleAlert} from "lucide-react"; +import {Stethoscope} from "lucide-react"; import i18next from "i18next"; import * as Setting from "@/Setting"; @@ -43,11 +43,13 @@ const visibleModels = 4; /** Mirrors llmAuditMinSample on the server, for the sentence that explains it. */ const minSample = 20; -const levelStyles: Record<LlmAuditLevel, {text: string; border: string; icon: React.ElementType}> = { - ok: {text: "text-success", border: "border-success/25 bg-success/5", icon: CircleCheck}, - warn: {text: "text-warning", border: "border-warning/30 bg-warning/5", icon: TriangleAlert}, - alert: {text: "text-destructive", border: "border-destructive/30 bg-destructive/5", icon: CircleAlert}, - unknown: {text: "text-muted-foreground", border: "border-border", icon: CircleHelp}, +// A tile is a neutral surface. The level is a dot beside the title, and only a +// case that failed colours anything larger than that. +const levelStyles: Record<LlmAuditLevel, {dot: string; value: string; border: string}> = { + ok: {dot: "bg-success", value: "text-foreground", border: ""}, + warn: {dot: "bg-warning", value: "text-foreground", border: ""}, + alert: {dot: "bg-destructive", value: "text-destructive", border: "border-destructive/35"}, + unknown: {dot: "bg-muted-foreground/40", value: "text-muted-foreground", border: ""}, }; function percent(share: number) { @@ -89,12 +91,11 @@ function CheckTile({ weight?: number; }) { const style = levelStyles[level] ?? levelStyles.unknown; - const Icon = style.icon; return ( - <div className={cn("flex flex-col gap-1 rounded-lg border p-3", style.border)}> + <div className={cn("bg-card flex flex-col gap-1 rounded-lg border p-3", style.border)}> <div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium"> - <Icon className={cn("size-3.5 shrink-0", style.text)} /> + <span className={cn("size-1.5 shrink-0 rounded-full", style.dot)} /> <span className="truncate">{title}</span> {weight && level !== "unknown" ? ( <SimpleTooltip title={i18next.t("audit:Weight hint")}> @@ -102,7 +103,7 @@ function CheckTile({ </SimpleTooltip> ) : null} </div> - <span className={cn("text-lg font-semibold tabular-nums", style.text)}>{value}</span> + <span className={cn("text-lg font-semibold tabular-nums", style.value)}>{value}</span> <span className="text-muted-foreground text-xs leading-snug">{detail}</span> </div> ); @@ -339,10 +340,10 @@ function ScoreHeadline({probe}: {probe: ProviderProbe}) { const {alerts, warnings, measured} = probeFindings(probe); return ( - <div className="flex items-center gap-4 rounded-lg border p-3"> + <div className="bg-card flex items-center gap-4 rounded-lg border p-3"> <ScoreDial probe={probe} size={84} /> <div className="flex min-w-0 flex-col gap-1"> - <span className={cn("text-sm font-semibold", style.text)}>{i18next.t(style.label)}</span> + <span className="text-sm font-semibold">{i18next.t(style.label)}</span> <span className="text-muted-foreground text-xs leading-snug">{i18next.t(style.verdict)}</span> <span className="text-muted-foreground text-xs"> {fill("audit:Score from cases", { diff --git a/web/src/components/shared/app-header.tsx b/web/src/components/shared/app-header.tsx index 8326da6..f2af4e1 100644 --- a/web/src/components/shared/app-header.tsx +++ b/web/src/components/shared/app-header.tsx @@ -30,6 +30,7 @@ import { } from "@/components/ui/dropdown-menu"; import {SimpleTooltip} from "@/components/ui/tooltip"; import {BreadcrumbBar} from "@/components/shared/breadcrumb-bar"; +import {CommandPaletteTrigger} from "@/components/shared/command-palette"; import {VersionPanel} from "@/components/shared/version-panel"; function ThemeToggle({ @@ -163,6 +164,7 @@ export function AppHeader({ themeAlgorithm, onThemeChange, onSignout, + onOpenPalette, }: { collapsed: boolean; onToggleSidebar: () => void; @@ -172,6 +174,7 @@ export function AppHeader({ themeAlgorithm: ThemeAlgorithm; onThemeChange: (next: ThemeAlgorithm) => 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"> @@ -187,7 +190,8 @@ export function AppHeader({ <BreadcrumbBar uri={uri} /> </div> - <div className="flex items-center gap-0.5 pr-1"> + <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} /> <LanguageSelect /> diff --git a/web/src/components/shared/command-palette.tsx b/web/src/components/shared/command-palette.tsx new file mode 100644 index 0000000..f7f96f4 --- /dev/null +++ b/web/src/components/shared/command-palette.tsx @@ -0,0 +1,229 @@ +// 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 {Bot, CornerDownLeft, Search} from "lucide-react"; +import i18next from "i18next"; + +import * as AgentBackend from "@/backend/AgentBackend"; +import * as ProviderBackend from "@/backend/ProviderBackend"; +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, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** Owns the ⌘K / Ctrl+K shortcut, so the shell can hand `open` to both the palette and its trigger. */ +export function useCommandPalette() { + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + setOpen(previous => !previous); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + + return {open, setOpen}; +} + +/** + * The lists behind the object hits. Neither endpoint takes a search term, so + * they are fetched once the palette is first opened and filtered here — both + * lists are the size of one machine's setup, not of a directory. + */ +function useSearchable(open: boolean, account: Account | null | undefined, isAdmin: boolean) { + const [agents, setAgents] = React.useState<Agent[]>([]); + const [providers, setProviders] = React.useState<Provider[]>([]); + const loadedRef = React.useRef(false); + + React.useEffect(() => { + if (!open || !account || loadedRef.current) { + return; + } + loadedRef.current = true; + + ProviderBackend.getProviders(account.name) + .then(res => setProviders(res.status === "ok" ? (res.data ?? []) : [])) + .catch(() => setProviders([])); + + if (isAdmin) { + // The scan only an admin may run, so everyone else searches pages and + // providers alone. + AgentBackend.getAgents() + .then(res => setAgents(res.status === "ok" ? (res.data ?? []) : [])) + .catch(() => setAgents([])); + } + }, [open, account, isAdmin]); + + return {agents, providers}; +} + +function agentValue(agent: Agent, agents: Agent[]) { + const duplicated = agents.filter(candidate => candidate.agentId === agent.agentId).length > 1; + return duplicated ? `${agent.name} ${agent.agentId} ${agent.path}` : `${agent.name} ${agent.agentId}`; +} + +/** Every page in the rail, a group's sections included, as flat rows. */ +function pageHits(isAdmin: boolean) { + return navGroups + .filter(group => !group.hidden && (!group.adminOnly || isAdmin)) + .flatMap(group => { + const label = i18next.t(group.label); + const self = {key: group.key, label: label, hint: "", path: group.path ?? group.key}; + const children = (group.children ?? []).map(child => ({ + key: child.key, + label: i18next.t(child.label), + hint: label, + path: child.path, + })); + return [self, ...children]; + }); +} + +/** + * Every console page by name, and the agents and providers behind them — the + * way to reach one of thirty providers without paging through the list. + */ +export function CommandPalette({ + open, + onOpenChange, + account, + isAdmin, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + account: Account | null | undefined; + isAdmin: boolean; +}) { + const navigate = useNavigate(); + const {agents, providers} = useSearchable(open, account, isAdmin); + + // Recomputed per render: the labels come from i18next, which changes language + // without changing this component's inputs. + const pages = pageHits(isAdmin); + + const go = (to: string) => { + onOpenChange(false); + navigate(to); + }; + + return ( + <CommandDialog open={open} onOpenChange={onOpenChange} title={i18next.t("general:Search")}> + <CommandInput placeholder={i18next.t("general:Search pages, agents and providers")} /> + <CommandList className="max-h-[380px]"> + <CommandEmpty>{i18next.t("general:No data")}</CommandEmpty> + + <CommandGroup heading={i18next.t("general:Pages")}> + {pages.map(page => ( + <CommandItem + key={page.key} + value={`${page.hint} ${page.label}`} + onSelect={() => go(page.path)} + > + <CornerDownLeft className="opacity-50" /> + <span className="flex-1 truncate">{page.label}</span> + {page.hint === "" ? null : <span className="text-muted-foreground max-w-[50%] truncate text-xs">{page.hint}</span>} + </CommandItem> + ))} + </CommandGroup> + + {agents.length === 0 ? null : ( + <> + <CommandSeparator /> + <CommandGroup heading={i18next.t("agent:Agents")}> + {agents.map(agent => ( + <CommandItem + key={`${agent.agentId}/${agent.path}`} + // The path only joins the searched text for an agent installed + // twice, where the name alone would name two rows. + value={agentValue(agent, agents)} + onSelect={() => go(agentDetailPath(agent, agents))} + > + <AgentIcon agent={agent.agentId} size={16} fallback={<Bot className="size-4 opacity-50" />} /> + <span className="flex-1 truncate">{agent.name || agent.agentId}</span> + <span className="text-muted-foreground max-w-[50%] truncate text-xs">{agent.path}</span> + </CommandItem> + ))} + </CommandGroup> + </> + )} + + {providers.length === 0 ? null : ( + <> + <CommandSeparator /> + <CommandGroup heading={i18next.t("provider:Providers")}> + {providers.map(provider => ( + <CommandItem + key={`${provider.owner}/${provider.name}`} + value={`${provider.displayName} ${provider.name} ${provider.type}`} + onSelect={() => go(`/providers/${provider.owner}/${provider.name}`)} + > + <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={provider.type} size={16} /> + <span className="flex-1 truncate">{provider.displayName || provider.name}</span> + <span className="text-muted-foreground max-w-[50%] truncate text-xs">{provider.type}</span> + </CommandItem> + ))} + </CommandGroup> + </> + )} + </CommandList> + </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/empty-state.tsx b/web/src/components/shared/empty-state.tsx index 478b41f..441e297 100644 --- a/web/src/components/shared/empty-state.tsx +++ b/web/src/components/shared/empty-state.tsx @@ -64,7 +64,7 @@ export function ErrorState({ }) { return ( <div className={cn("flex flex-col items-center justify-center gap-2 px-6 py-14 text-center", className)}> - <div className="bg-destructive/10 text-destructive flex size-10 items-center justify-center rounded-full"> + <div className="bg-muted text-destructive flex size-10 items-center justify-center rounded-full"> <TriangleAlert className="size-5" /> </div> <p className="text-sm font-medium">{title ?? i18next.t("general:Could not load this")}</p> diff --git a/web/src/components/shared/stat-card.tsx b/web/src/components/shared/stat-card.tsx index 4f5527d..64c4ec6 100644 --- a/web/src/components/shared/stat-card.tsx +++ b/web/src/components/shared/stat-card.tsx @@ -73,7 +73,7 @@ export function StatCard({ {percent !== undefined && percent !== null ? ( <Progress value={Math.min(100, Math.max(0, Number(percent) || 0))} - tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "success"} + tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "default"} className="h-1.5" /> ) : null} diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx index 86f653e..ce5b3cc 100644 --- a/web/src/components/ui/alert.tsx +++ b/web/src/components/ui/alert.tsx @@ -23,11 +23,13 @@ const alertVariants = cva( { variants: { variant: { + // As shadcn ships it: the surface stays neutral. Something wrong + // colours its words; something merely worth knowing colours its icon. default: "bg-card text-card-foreground", - destructive: "border-destructive/30 bg-destructive/8 text-destructive [&>svg]:text-destructive", - warning: "border-warning/35 bg-warning/10 text-warning [&>svg]:text-warning", - success: "border-success/30 bg-success/8 text-success [&>svg]:text-success", - info: "border-info/30 bg-info/8 text-info [&>svg]:text-info", + destructive: "bg-card text-destructive [&>svg]:text-destructive", + warning: "bg-card text-warning [&>svg]:text-warning", + success: "bg-card text-card-foreground [&>svg]:text-success", + info: "bg-card text-card-foreground [&>svg]:text-info", }, }, defaultVariants: {variant: "default"}, diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 60bf062..f5a8a1e 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -27,13 +27,13 @@ const badgeVariants = cva( secondary: "border-transparent bg-secondary text-secondary-foreground", destructive: "border-transparent bg-destructive text-destructive-foreground", outline: "text-foreground", - // Semantic tones are tinted rather than solid, so a table row carrying - // several of them still reads as a row instead of a row of buttons. - success: "border-success/25 bg-success/12 text-success", - warning: "border-warning/30 bg-warning/15 text-warning", - info: "border-info/25 bg-info/12 text-info", - danger: "border-destructive/25 bg-destructive/12 text-destructive", - muted: "border-border bg-muted text-muted-foreground", + // Semantic tones are outlined rather than filled: the word carries the + // colour, so a table of them reads as text instead of a row of pills. + success: "border-success/35 text-success", + warning: "border-warning/35 text-warning", + info: "border-info/35 text-info", + danger: "border-destructive/35 text-destructive", + muted: "border-transparent bg-muted text-muted-foreground", }, }, defaultVariants: {variant: "default"}, diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx index 8a972bc..5c434dd 100644 --- a/web/src/components/ui/command.tsx +++ b/web/src/components/ui/command.tsx @@ -17,6 +17,7 @@ import {Command as CommandPrimitive} from "cmdk"; import {SearchIcon} from "lucide-react"; import {cn} from "@/lib/utils"; +import {Dialog, DialogContent, DialogTitle} from "@/components/ui/dialog"; function Command({className, ...props}: React.ComponentProps<typeof CommandPrimitive>) { return ( @@ -31,6 +32,17 @@ function Command({className, ...props}: React.ComponentProps<typeof CommandPrimi ); } +function CommandDialog({title, children, ...props}: React.ComponentProps<typeof Dialog> & {title: string}) { + return ( + <Dialog {...props}> + <DialogContent className="overflow-hidden p-0" showCloseButton={false}> + <DialogTitle className="sr-only">{title}</DialogTitle> + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2.5">{children}</Command> + </DialogContent> + </Dialog> + ); +} + function CommandInput({className, ...props}: React.ComponentProps<typeof CommandPrimitive.Input>) { return ( <div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3"> @@ -103,4 +115,4 @@ function CommandShortcut({className, ...props}: React.ComponentProps<"span">) { ); } -export {Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator}; +export {Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator}; diff --git a/web/src/components/usage/usage-hero.tsx b/web/src/components/usage/usage-hero.tsx index 6811356..9d9d3df 100644 --- a/web/src/components/usage/usage-hero.tsx +++ b/web/src/components/usage/usage-hero.tsx @@ -14,10 +14,9 @@ import * as React from "react"; import {Link} from "react-router-dom"; -import {Activity, ArrowDownToLine, ArrowUpFromLine, CircleDollarSign, Database, Sparkles} from "lucide-react"; +import {Activity, ArrowDownToLine, ArrowUpFromLine, CircleDollarSign, Database, DatabaseZap} from "lucide-react"; import i18next from "i18next"; -import {cn} from "@/lib/utils"; import {Card, CardContent} from "@/components/ui/card"; import {Progress} from "@/components/ui/progress"; import {Skeleton} from "@/components/ui/skeleton"; @@ -29,13 +28,11 @@ function MiniStat({ icon: Icon, label, value, - accent, hint, }: { icon: React.ComponentType<{className?: string}>; label: React.ReactNode; value: React.ReactNode; - accent: string; hint?: React.ReactNode; }) { // SimpleTooltip renders its child alone when there is no title, so the hint @@ -44,7 +41,7 @@ function MiniStat({ <SimpleTooltip title={hint}> <div className="bg-background/60 flex flex-col gap-1 rounded-lg border p-3"> <div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium"> - <Icon className={cn("size-3.5 shrink-0", accent)} /> + <Icon className="size-3.5 shrink-0" /> <span className="truncate">{label}</span> </div> <span className="text-sm font-semibold tabular-nums">{value}</span> @@ -107,7 +104,7 @@ export function UsageHero({ {i18next.t("llm:Requests")} </span> <span className="flex items-center gap-1.5 text-sm font-semibold tabular-nums"> - <Activity className="text-info size-3.5" /> + <Activity className="text-muted-foreground size-3.5" /> {totals.requests.toLocaleString()} {totals.failed ? ( <span className="text-warning text-xs font-normal"> @@ -119,8 +116,8 @@ export function UsageHero({ <div className="bg-border h-8 w-px" /> <div className="flex flex-col"> <span className="text-muted-foreground text-[11px] font-medium">{i18next.t("llm:Cost")}</span> - <span className="text-success flex items-center gap-1.5 text-sm font-semibold tabular-nums"> - <CircleDollarSign className="size-3.5" /> + <span className="flex items-center gap-1.5 text-sm font-semibold tabular-nums"> + <CircleDollarSign className="text-muted-foreground size-3.5" /> {formatCost(totals.cost)} </span> </div> @@ -132,33 +129,29 @@ export function UsageHero({ icon={ArrowDownToLine} label={i18next.t("llm:Fresh input")} value={formatTokens(totals.promptTokens)} - accent="text-info" /> <MiniStat icon={ArrowUpFromLine} label={i18next.t("llm:Output")} value={formatTokens(totals.completionTokens)} - accent="text-chart-4" /> <MiniStat icon={Database} label={i18next.t("llm:Cache write")} value={formatTokens(totals.cacheWriteTokens)} - accent="text-warning" hint={cacheWriteHint} /> <MiniStat - icon={Sparkles} + icon={DatabaseZap} label={i18next.t("llm:Cache read")} value={formatTokens(totals.cacheReadTokens)} - accent="text-success" /> <div className="bg-background/60 col-span-2 flex flex-col justify-center gap-2 rounded-lg border p-3 lg:col-span-1"> <div className="flex items-center justify-between text-xs"> <span className="text-muted-foreground font-medium">{i18next.t("llm:Cache hit rate")}</span> - <span className="text-success font-semibold tabular-nums">{hitRate.toFixed(hitRate >= 99.95 ? 0 : 1)}%</span> + <span className="font-semibold tabular-nums">{hitRate.toFixed(hitRate >= 99.95 ? 0 : 1)}%</span> </div> - <Progress value={hitRate} tone="success" className="h-1.5" /> + <Progress value={hitRate} className="h-1.5" /> </div> </div> diff --git a/web/src/index.css b/web/src/index.css index 307fb46..c3824e8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4,7 +4,9 @@ @custom-variant dark (&:is(.dark *)); /* shadcn's neutral base palette. --primary is a neutral near-black rather than - a hue, so the semantic colours below carry all the meaning a page needs. */ + a hue, so the semantic colours below carry all the meaning a page needs. + Those three are kept low-chroma on purpose: they are ink for a word or a + dot, never a wash behind a panel. */ :root { --radius: 0.625rem; @@ -24,11 +26,11 @@ --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --success: oklch(0.627 0.194 149.214); + --success: oklch(0.52 0.115 152); --success-foreground: oklch(0.985 0 0); - --warning: oklch(0.705 0.187 47.604); - --warning-foreground: oklch(0.145 0 0); - --info: oklch(0.588 0.158 241.966); + --warning: oklch(0.55 0.115 65); + --warning-foreground: oklch(0.985 0 0); + --info: oklch(0.52 0.115 248); --info-foreground: oklch(0.985 0 0); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); @@ -67,11 +69,11 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); - --success: oklch(0.696 0.17 162.48); + --success: oklch(0.72 0.105 158); --success-foreground: oklch(0.145 0 0); - --warning: oklch(0.769 0.188 70.08); + --warning: oklch(0.78 0.115 72); --warning-foreground: oklch(0.145 0 0); - --info: oklch(0.685 0.145 241.966); + --info: oklch(0.7 0.095 245); --info-foreground: oklch(0.145 0 0); --border: oklch(1 0 0 / 12%); --input: oklch(1 0 0 / 18%); diff --git a/web/src/lib/authenticity.ts b/web/src/lib/authenticity.ts index 23166e6..eafbf69 100644 --- a/web/src/lib/authenticity.ts +++ b/web/src/lib/authenticity.ts @@ -29,7 +29,8 @@ export const gradeFloors: {grade: ProbeGrade; floor: number}[] = [ * How each grade is painted and worded. The wording is deliberately about what * was measured rather than about the provider's character: an F says the answers * did not match what the API documents, which is a finding to send to whoever - * sold the key. + * sold the key. The paint is one accent per grade: an outlined badge and the + * ring on the dial, never a filled panel. */ export const gradeStyles: Record< ProbeGrade, @@ -37,42 +38,42 @@ export const gradeStyles: Record< > = { A: { text: "text-success", - badge: "border-success/30 bg-success/10 text-success", + badge: "border-success/35 text-success", ring: "text-success", label: "audit:Grade A", verdict: "audit:Grade A detail", }, B: { text: "text-success", - badge: "border-success/30 bg-success/10 text-success", + badge: "border-success/35 text-success", ring: "text-success", label: "audit:Grade B", verdict: "audit:Grade B detail", }, C: { text: "text-warning", - badge: "border-warning/30 bg-warning/10 text-warning", + badge: "border-warning/35 text-warning", ring: "text-warning", label: "audit:Grade C", verdict: "audit:Grade C detail", }, D: { text: "text-warning", - badge: "border-warning/30 bg-warning/10 text-warning", + badge: "border-warning/35 text-warning", ring: "text-warning", label: "audit:Grade D", verdict: "audit:Grade D detail", }, F: { text: "text-destructive", - badge: "border-destructive/30 bg-destructive/10 text-destructive", + badge: "border-destructive/35 text-destructive", ring: "text-destructive", label: "audit:Grade F", verdict: "audit:Grade F detail", }, unknown: { text: "text-muted-foreground", - badge: "border-border bg-muted text-muted-foreground", + badge: "border-border text-muted-foreground", ring: "text-muted-foreground", label: "audit:Grade unknown", verdict: "audit:Grade unknown detail", diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 15aa73e..ff6269c 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -19,12 +19,15 @@ }, "agent": { "Account": "Account", + "Across {count} agents": "Across {count} agents", + "Active today": "Active today", "Add instance": "Add instance", "Advanced view": "Advanced view", "Advanced view hint": "Every installation found, with its processes, path, owner and install method.", "Agent": "Agent", "Agent Records": "Activity", "Agent Sessions": "Sessions", + "Agent Sessions detail": "Every conversation your agents have had, from monitoring and from the transcripts they write themselves", "Agent configuration": "Agent configuration", "Agent installation not found": "Agent installation not found", "Agent installed": "Agent installed", @@ -45,6 +48,7 @@ "Any other tool that reads": "Any other tool that reads", "Any other tool that writes": "Any other tool that writes", "Any provider": "Any provider", + "Any source": "Any source", "Apply a patch": "Apply a patch", "Ask the user a question": "Ask the user a question", "Auto refresh": "Auto refresh", @@ -279,6 +283,7 @@ "blocked": "blocked", "opencode config hint": "opencode reads no base URL from the environment: the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, loaded through @ai-sdk/openai-compatible and selected with \"model\": \"<provider>/<model>\".", "switches": "switches", + "{count} from transcripts": "{count} from transcripts", "{count} messages": "{count} messages" }, "agentConfig": { @@ -778,7 +783,9 @@ "Restarting into the new version": "Restarting into the new version", "Retry": "Retry", "Save": "Save", + "Pages": "Pages", "Search": "Search", + "Search pages, agents and providers": "Search pages, agents and providers", "Select": "Select", "Set a proxy": "Set a proxy", "Signed out successfully": "Signed out successfully", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 0feaaba..f083e84 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -19,12 +19,15 @@ }, "agent": { "Account": "账户", + "Across {count} agents": "覆盖 {count} 个 Agent", + "Active today": "今日活跃", "Add instance": "添加实例", "Advanced view": "高级视图", "Advanced view hint": "扫描到的每个安装,含进程、路径、所有者和安装方式。", "Agent": "智能体", "Agent Records": "活动记录", "Agent Sessions": "会话", + "Agent Sessions detail": "Agent 的每一次会话,既来自监控,也来自它们自己写下的记录", "Agent configuration": "Agent 配置文件", "Agent installation not found": "没有找到这个智能体的安装", "Agent installed": "安装完成", @@ -45,6 +48,7 @@ "Any other tool that reads": "其他所有读取类工具", "Any other tool that writes": "其他所有写入类工具", "Any provider": "任意供应商", + "Any source": "全部来源", "Apply a patch": "应用补丁", "Ask the user a question": "向用户提问", "Auto refresh": "自动刷新", @@ -279,6 +283,7 @@ "blocked": "项被拦截", "opencode config hint": "opencode 不从环境变量读取 base URL:端点是 ~/.config/opencode/opencode.json 里的一个 \"provider\" 条目,用 @ai-sdk/openai-compatible 加载,再用 \"model\": \"<provider>/<model>\" 选中它。", "switches": "个开关", + "{count} from transcripts": "{count} 个来自会话记录", "{count} messages": "{count} 条消息" }, "agentConfig": { @@ -850,7 +855,9 @@ "Restarting into the new version": "正在重启到新版本", "Retry": "重试", "Save": "保存", + "Pages": "页面", "Search": "搜索", + "Search pages, agents and providers": "搜索页面、智能体和供应商", "Select": "请选择", "Set a proxy": "去配置代理", "Signed out successfully": "已退出登录", --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
