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