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 d06bfbbeb713e41dc399dc592bf7ca8fcb0128ea Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 13:56:57 2026 +0800 feat(audit): link authenticity tiles to their provider card --- web/src/components/AuthenticityOverview.tsx | 351 ++++++++++++++-------------- web/src/components/ProviderAuditPanel.tsx | 62 +++-- web/src/pages/AuthenticityPage.tsx | 4 +- 3 files changed, 228 insertions(+), 189 deletions(-) diff --git a/web/src/components/AuthenticityOverview.tsx b/web/src/components/AuthenticityOverview.tsx index 8d8cabd..3580fc9 100644 --- a/web/src/components/AuthenticityOverview.tsx +++ b/web/src/components/AuthenticityOverview.tsx @@ -1,174 +1,177 @@ -// 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 {ShieldCheck} from "lucide-react"; -import i18next from "i18next"; - -import * as ProviderBackend from "@/backend/ProviderBackend"; -import {ScoreBadge, ScoreDial} from "@/components/AuthenticityScore"; -import {ProviderIcon} from "@/components/ProviderIcon"; -import {Badge} from "@/components/ui/badge"; -import {Button} from "@/components/ui/button"; -import {Card, CardContent} from "@/components/ui/card"; -import {cn} from "@/lib/utils"; -import {gradeStyleOf, probeFindings, summarizeAuthenticity} from "@/lib/authenticity"; -import {providerIdOf} from "@/lib/providers"; -import type {Provider, ProviderProbe, ProviderProbeMode} from "@/types"; - -/** How often the scores are read again while the page is open. */ -const refreshMs = 20000; - -/** Fills {name} placeholders, which is all the interpolation these strings need. */ -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), - ); -} - -/** One provider's line: who it is, what it scored, and what failed if anything. */ -function ProviderScore({provider, probe}: {provider: Provider; probe?: ProviderProbe}) { - const {alerts, warnings} = probeFindings(probe); - const name = provider.displayName || provider.name; - - return ( - <div className="hover:border-foreground/20 flex min-w-0 items-center gap-2 rounded-lg border px-2.5 py-1.5 transition-colors"> - <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={name} size={16} /> - <span className="truncate text-xs">{name}</span> - <div className="ml-auto flex shrink-0 items-center gap-1.5"> - {alerts.length > 0 ? ( - <Badge variant="danger">{fill("audit:Failed cases", {count: alerts.length})}</Badge> - ) : warnings.length > 0 ? ( - <Badge variant="warning">{fill("audit:Flagged cases", {count: warnings.length})}</Badge> - ) : null} - <ScoreBadge probe={probe} showLabel={false} /> - </div> - </div> - ); -} - -/** - * Whether the APIs on this machine are what they are sold as, in one card. It - * is measured without being asked for: every provider is probed when it is - * added and again once its report goes stale, so this says something before - * anyone thinks to check. - */ -export function AuthenticityOverview({ - providers, - showLink = true, - className, -}: { - providers: Provider[]; - /** False on the page this links to, where the link would point at itself. */ - showLink?: boolean; - className?: string; -}) { - const [probes, setProbes] = React.useState<ProviderProbe[]>([]); - const [mode, setMode] = React.useState<ProviderProbeMode>("auto"); - const [loaded, setLoaded] = React.useState(false); - - React.useEffect(() => { - const load = () => { - ProviderBackend.getProviderProbes() - .then(res => { - if (res.status !== "ok") { - return; - } - setProbes(res.data ?? []); - setMode(res.data2 ?? "auto"); - }) - .catch(() => undefined) - .then(() => setLoaded(true)); - }; - - load(); - const interval = setInterval(load, refreshMs); - return () => clearInterval(interval); - }, []); - - const configured = providers.filter(provider => provider.status !== "disabled"); - const byProvider = new Map(probes.map(probe => [probe.provider, probe])); - const scored = configured - .map(provider => ({provider: provider, probe: byProvider.get(providerIdOf(provider))})) - .sort((left, right) => { - const score = (entry: {probe?: ProviderProbe}) => - entry.probe?.ok && entry.probe.grade !== "unknown" ? entry.probe.score : Number.POSITIVE_INFINITY; - return score(left) - score(right); - }); - - const summary = summarizeAuthenticity( - scored.filter(entry => entry.probe).map(entry => entry.probe as ProviderProbe), - configured.length, - ); - // The headline grade is the worst one held, not the average: a machine with - // four honest providers and one that is not is not four fifths fine. - const worst = scored.find(entry => entry.probe?.ok && entry.probe.grade !== "unknown")?.probe; - const style = gradeStyleOf(worst?.grade); - - if (configured.length === 0) { - return null; - } - - return ( - <Card className={cn("gap-0 py-0 shadow-xs", className)}> - <CardContent className="flex flex-col gap-3 p-4"> - <div className="flex flex-wrap items-center gap-3"> - <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="text-muted-foreground size-3.5" /> - <span className="text-sm font-medium">{i18next.t("audit:Authenticity")}</span> - </div> - <span className="text-xs"> - {summary.graded === 0 - ? i18next.t(mode === "off" ? "audit:Probing is off" : "audit:Measuring now") - : i18next.t(style.verdict)} - </span> - <span className="text-muted-foreground text-[11px]"> - {fill("audit:Graded providers", { - graded: summary.graded, - total: configured.length, - })} - {summary.alerting > 0 ? ` · ${fill("audit:Providers alerting", {count: summary.alerting})}` : ""} - {summary.ungraded > 0 ? ` · ${fill("audit:Providers unmeasured", {count: summary.ungraded})}` : ""} - </span> - </div> - - {showLink ? ( - <Button asChild variant="outline" size="sm" className="ml-auto"> - <Link to="/authenticity"> - <ShieldCheck /> - {i18next.t("audit:See the evidence")} - </Link> - </Button> - ) : null} - </div> - - {loaded ? ( - <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> - {scored.map(entry => ( - <ProviderScore - key={providerIdOf(entry.provider)} - provider={entry.provider} - probe={entry.probe} - /> - ))} - </div> - ) : null} - </CardContent> - </Card> - ); -} +// 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 {ShieldCheck} from "lucide-react"; +import i18next from "i18next"; + +import * as ProviderBackend from "@/backend/ProviderBackend"; +import {ScoreBadge, ScoreDial} from "@/components/AuthenticityScore"; +import {ProviderIcon} from "@/components/ProviderIcon"; +import {Badge} from "@/components/ui/badge"; +import {Button} from "@/components/ui/button"; +import {Card, CardContent} from "@/components/ui/card"; +import {cn} from "@/lib/utils"; +import {gradeStyleOf, probeFindings, summarizeAuthenticity} from "@/lib/authenticity"; +import {providerIdOf} from "@/lib/providers"; +import type {Provider, ProviderProbe, ProviderProbeMode} from "@/types"; + +/** How often the scores are read again while the page is open. */ +const refreshMs = 20000; + +/** Fills {name} placeholders, which is all the interpolation these strings need. */ +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), + ); +} + +/** One provider's line: who it is, what it scored, and what failed if anything. */ +function ProviderScore({provider, probe}: {provider: Provider; probe?: ProviderProbe}) { + const {alerts, warnings} = probeFindings(probe); + const name = provider.displayName || provider.name; + + return ( + <Link + to={`/authenticity?tab=report&provider=${encodeURIComponent(providerIdOf(provider))}`} + className="hover:border-foreground/20 hover:bg-accent/40 flex min-w-0 items-center gap-2 rounded-lg border px-2.5 py-1.5 transition-colors" + > + <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={name} size={16} /> + <span className="truncate text-xs">{name}</span> + <div className="ml-auto flex shrink-0 items-center gap-1.5"> + {alerts.length > 0 ? ( + <Badge variant="danger">{fill("audit:Failed cases", {count: alerts.length})}</Badge> + ) : warnings.length > 0 ? ( + <Badge variant="warning">{fill("audit:Flagged cases", {count: warnings.length})}</Badge> + ) : null} + <ScoreBadge probe={probe} showLabel={false} /> + </div> + </Link> + ); +} + +/** + * Whether the APIs on this machine are what they are sold as, in one card. It + * is measured without being asked for: every provider is probed when it is + * added and again once its report goes stale, so this says something before + * anyone thinks to check. + */ +export function AuthenticityOverview({ + providers, + showLink = true, + className, +}: { + providers: Provider[]; + /** False on the page this links to, where the link would point at itself. */ + showLink?: boolean; + className?: string; +}) { + const [probes, setProbes] = React.useState<ProviderProbe[]>([]); + const [mode, setMode] = React.useState<ProviderProbeMode>("auto"); + const [loaded, setLoaded] = React.useState(false); + + React.useEffect(() => { + const load = () => { + ProviderBackend.getProviderProbes() + .then(res => { + if (res.status !== "ok") { + return; + } + setProbes(res.data ?? []); + setMode(res.data2 ?? "auto"); + }) + .catch(() => undefined) + .then(() => setLoaded(true)); + }; + + load(); + const interval = setInterval(load, refreshMs); + return () => clearInterval(interval); + }, []); + + const configured = providers.filter(provider => provider.status !== "disabled"); + const byProvider = new Map(probes.map(probe => [probe.provider, probe])); + const scored = configured + .map(provider => ({provider: provider, probe: byProvider.get(providerIdOf(provider))})) + .sort((left, right) => { + const score = (entry: {probe?: ProviderProbe}) => + entry.probe?.ok && entry.probe.grade !== "unknown" ? entry.probe.score : Number.POSITIVE_INFINITY; + return score(left) - score(right); + }); + + const summary = summarizeAuthenticity( + scored.filter(entry => entry.probe).map(entry => entry.probe as ProviderProbe), + configured.length, + ); + // The headline grade is the worst one held, not the average: a machine with + // four honest providers and one that is not is not four fifths fine. + const worst = scored.find(entry => entry.probe?.ok && entry.probe.grade !== "unknown")?.probe; + const style = gradeStyleOf(worst?.grade); + + if (configured.length === 0) { + return null; + } + + return ( + <Card className={cn("gap-0 py-0 shadow-xs", className)}> + <CardContent className="flex flex-col gap-3 p-4"> + <div className="flex flex-wrap items-center gap-3"> + <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="text-muted-foreground size-3.5" /> + <span className="text-sm font-medium">{i18next.t("audit:Authenticity")}</span> + </div> + <span className="text-xs"> + {summary.graded === 0 + ? i18next.t(mode === "off" ? "audit:Probing is off" : "audit:Measuring now") + : i18next.t(style.verdict)} + </span> + <span className="text-muted-foreground text-[11px]"> + {fill("audit:Graded providers", { + graded: summary.graded, + total: configured.length, + })} + {summary.alerting > 0 ? ` · ${fill("audit:Providers alerting", {count: summary.alerting})}` : ""} + {summary.ungraded > 0 ? ` · ${fill("audit:Providers unmeasured", {count: summary.ungraded})}` : ""} + </span> + </div> + + {showLink ? ( + <Button asChild variant="outline" size="sm" className="ml-auto"> + <Link to="/authenticity"> + <ShieldCheck /> + {i18next.t("audit:See the evidence")} + </Link> + </Button> + ) : null} + </div> + + {loaded ? ( + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> + {scored.map(entry => ( + <ProviderScore + key={providerIdOf(entry.provider)} + provider={entry.provider} + probe={entry.probe} + /> + ))} + </div> + ) : null} + </CardContent> + </Card> + ); +} diff --git a/web/src/components/ProviderAuditPanel.tsx b/web/src/components/ProviderAuditPanel.tsx index 1ca9dfb..007abb5 100644 --- a/web/src/components/ProviderAuditPanel.tsx +++ b/web/src/components/ProviderAuditPanel.tsx @@ -13,7 +13,7 @@ // limitations under the License. import * as React from "react"; -import {Link} from "react-router-dom"; +import {Link, useLocation} from "react-router-dom"; import {Logs, RefreshCw, Stethoscope} from "lucide-react"; import i18next from "i18next"; @@ -28,6 +28,7 @@ import {SimpleSelect} from "@/components/shared/simple-select"; import {MessageAlert} from "@/components/ui/alert"; import {Button} from "@/components/ui/button"; import {Card} from "@/components/ui/card"; +import {cn} from "@/lib/utils"; import {providerIdOf} from "@/lib/providers"; import type { LlmAuditReport, @@ -116,7 +117,7 @@ function buildRows( * documented, which is the only way to learn something a real request never * happened to show — and spends a few cents of that provider's own credit. */ -export function ProviderAuditPanel({owner}: {owner: string}) { +export function ProviderAuditPanel({owner, focus = ""}: {owner: string; focus?: string}) { const [hours, setHours] = React.useState(24 * 7); const [report, setReport] = React.useState<LlmAuditReport | null>(null); // Null until a listing lands. An empty list and "not asked yet" must not @@ -204,6 +205,31 @@ export function ProviderAuditPanel({owner}: {owner: string}) { const rows = buildRows(providers ?? [], report?.providers ?? [], probes); + // A card linked to from the overview is scrolled to and lit for a moment, + // otherwise the click lands on a page that looks unchanged. + const [lit, setLit] = React.useState(""); + // Keyed on the navigation, so clicking the same tile twice scrolls again. + const navigation = useLocation().key; + + React.useEffect(() => { + if (focus === "") { + return; + } + const scroll = (behavior: ScrollBehavior) => + document + .querySelector(`[data-audit-provider="${CSS.escape(focus)}"]`) + ?.scrollIntoView({behavior: behavior, block: "start"}); + scroll("smooth"); + // Landed on exactly, once the panels above have loaded and moved the card. + const again = setTimeout(() => scroll("auto"), 600); + setLit(focus); + const off = setTimeout(() => setLit(""), 2500); + return () => { + clearTimeout(again); + clearTimeout(off); + }; + }, [focus, navigation, rows.length, loading]); + return ( <div className="space-y-3"> <div className="flex flex-wrap items-start justify-between gap-2"> @@ -272,19 +298,27 @@ export function ProviderAuditPanel({owner}: {owner: string}) { <> <div className="space-y-3"> {rows.map(row => ( - <ProviderAuditCard + <div key={row.id} - audit={row.audit} - probe={row.probe} - cases={cases} - provider={row.provider} - providersKnown={providers !== null} - probeMode={probeMode} - probing={probing.includes(row.id)} - // A provider the records still name but nothing is configured - // for has no endpoint left to ask. - onProbe={row.provider && probeMode !== "off" ? () => runProbe(row.id) : undefined} - /> + data-audit-provider={row.id} + className={cn( + "scroll-mt-20 rounded-xl transition-shadow", + lit === row.id ? "ring-primary/60 ring-2" : "", + )} + > + <ProviderAuditCard + audit={row.audit} + probe={row.probe} + cases={cases} + provider={row.provider} + providersKnown={providers !== null} + probeMode={probeMode} + probing={probing.includes(row.id)} + // A provider the records still name but nothing is configured + // for has no endpoint left to ask. + onProbe={row.provider && probeMode !== "off" ? () => runProbe(row.id) : undefined} + /> + </div> ))} </div> <p className="text-muted-foreground text-xs"> diff --git a/web/src/pages/AuthenticityPage.tsx b/web/src/pages/AuthenticityPage.tsx index 1c6b637..6b6121a 100644 --- a/web/src/pages/AuthenticityPage.tsx +++ b/web/src/pages/AuthenticityPage.tsx @@ -39,6 +39,8 @@ export default function AuthenticityPage({account}: {account: Account}) { // 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"; + // Set when the overview above was clicked, so the report opens at that card. + const focus = searchParams.get("provider") ?? ""; const setTab = (next: AuthenticityTab) => setSearchParams( previous => { @@ -78,7 +80,7 @@ export default function AuthenticityPage({account}: {account: Account}) { </TabsList> </Tabs> - {tab === "report" ? <ProviderAuditPanel owner={account.name} /> : <ProbeCaseList />} + {tab === "report" ? <ProviderAuditPanel owner={account.name} focus={focus} /> : <ProbeCaseList />} </PageContainer> ); } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
