You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/04 05:57:07 UTC
(casbin-gateway) 02/02: feat(audit): link authenticity tiles to their provider card
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 <hs...@gmail.com>
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: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org