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 f41ff444ae8fdab151f33f88df82180a779232a9
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 13:06:52 2026 +0800

    feat(sessions): give the session list stats, filters and rows that fit
---
 web/src/components/ui/badge.tsx     |  24 ++--
 web/src/pages/AgentSessionsPage.tsx | 251 +++++++++++++++++++++++++++++++-----
 2 files changed, 232 insertions(+), 43 deletions(-)

diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
index f5a8a1e..0e6d924 100644
--- a/web/src/components/ui/badge.tsx
+++ b/web/src/components/ui/badge.tsx
@@ -42,14 +42,20 @@ const badgeVariants = cva(
 
 export type BadgeVariant = NonNullable<VariantProps<typeof 
badgeVariants>["variant"]>;
 
-function Badge({
-  className,
-  variant,
-  asChild = false,
-  ...props
-}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & 
{asChild?: boolean}) {
-  const Comp = asChild ? Slot : "span";
-  return <Comp data-slot="badge" className={cn(badgeVariants({variant}), 
className)} {...props} />;
-}
+export type BadgeProps = React.ComponentProps<"span"> & VariantProps<typeof 
badgeVariants> & {asChild?: boolean};
+
+/** forwardRef, like Button: a badge is a common tooltip and popover trigger. 
*/
+const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
+  {className, variant, asChild = false, ...props},
+  ref,
+) {
+  const classes = cn(badgeVariants({variant}), className);
+
+  if (asChild) {
+    return <Slot ref={ref} data-slot="badge" className={classes} {...props} />;
+  }
+
+  return <span ref={ref} data-slot="badge" className={classes} {...props} />;
+});
 
 export {Badge, badgeVariants};
diff --git a/web/src/pages/AgentSessionsPage.tsx 
b/web/src/pages/AgentSessionsPage.tsx
index 49f5468..9fccba7 100644
--- a/web/src/pages/AgentSessionsPage.tsx
+++ b/web/src/pages/AgentSessionsPage.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Bot, FileText, MessageSquare, RefreshCw} from "lucide-react";
+import {Activity, Bot, FileText, Folder, Logs, MessageSquare, Radio, 
RefreshCw} from "lucide-react";
 import i18next from "i18next";
 
 import * as AgentBackend from "@/backend/AgentBackend";
@@ -23,19 +23,83 @@ import {AgentIcon} from "@/components/AgentIcon";
 import {DataTable, type Column} from "@/components/shared/data-table";
 import {UnauthorizedResult} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {StatCard} from "@/components/shared/stat-card";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {MessageAlert} from "@/components/ui/alert";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {Label} from "@/components/ui/label";
 import {Switch} from "@/components/ui/switch";
+import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs";
 import type {Account, AgentSession} from "@/types";
 
+type SourceFilter = "all" | "monitored" | "historical";
+
+const dayMs = 24 * 60 * 60 * 1000;
+
+const relativeUnits: [Intl.RelativeTimeFormatUnit, number][] = [
+  ["year", 365 * 86400],
+  ["month", 30 * 86400],
+  ["day", 86400],
+  ["hour", 3600],
+  ["minute", 60],
+];
+
+const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
+
+function relativeFormatter() {
+  const language = i18next.language || "en";
+  let formatter = relativeFormatters.get(language);
+  if (!formatter) {
+    formatter = new Intl.RelativeTimeFormat(language, {numeric: "auto"});
+    relativeFormatters.set(language, formatter);
+  }
+  return formatter;
+}
+
+/** "3 minutes ago" - a wall of timestamps is unreadable at a glance. */
+function relativeTime(value?: string) {
+  const time = value ? new Date(value).getTime() : NaN;
+  if (!Number.isFinite(time)) {
+    return "";
+  }
+
+  const seconds = (time - Date.now()) / 1000;
+  for (const [unit, size] of relativeUnits) {
+    if (Math.abs(seconds) >= size) {
+      return relativeFormatter().format(Math.round(seconds / size), unit);
+    }
+  }
+  return relativeFormatter().format(Math.round(seconds), "second");
+}
+
+function shortTime(value?: string) {
+  const date = value ? new Date(value) : null;
+  if (!date || Number.isNaN(date.getTime())) {
+    return "";
+  }
+  return date.toLocaleString(i18next.language, {month: "short", day: 
"numeric", hour: "2-digit", minute: "2-digit"});
+}
+
+function fullTime(value?: string) {
+  const date = value ? new Date(value) : null;
+  return !date || Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
+}
+
+/** The folder alone: the same repo path repeated down a column says nothing. 
*/
+function baseName(path: string) {
+  const parts = path.split(/[\\/]/).filter(Boolean);
+  return parts[parts.length - 1] || path;
+}
+
 export default function AgentSessionsPage({account}: {account: Account}) {
   const [sessions, setSessions] = React.useState<AgentSession[]>([]);
   const [error, setError] = React.useState("");
   const [loading, setLoading] = React.useState(false);
   const [autoRefresh, setAutoRefresh] = React.useState(true);
+  const [source, setSource] = React.useState<SourceFilter>("all");
+  const [agent, setAgent] = React.useState("");
   const isAdmin = Setting.isAdminUser(account);
 
   // The poll below runs every few seconds and must not spin the refresh button
@@ -78,6 +142,32 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
     return () => clearInterval(interval);
   }, [autoRefresh, isAdmin, load]);
 
+  const ordered = React.useMemo(
+    () => [...sessions].sort((a, b) => new Date(b.lastTime).getTime() - new 
Date(a.lastTime).getTime()),
+    [sessions],
+  );
+
+  const agentOptions = React.useMemo(
+    () => Array.from(new Set(sessions.map(session => 
session.agent).filter(Boolean))).sort(),
+    [sessions],
+  );
+
+  const monitored = ordered.filter(session => !session.historical).length;
+  const historical = ordered.length - monitored;
+  const records = ordered.reduce((total, session) => total + 
(session.recordCount || 0), 0);
+  const activeToday = ordered.filter(session => Date.now() - new 
Date(session.lastTime).getTime() < dayMs).length;
+  const lastTime = ordered[0]?.lastTime;
+
+  const rows = React.useMemo(
+    () =>
+      ordered.filter(session => {
+        const matchesSource =
+          source === "all" || (source === "historical" ? 
Boolean(session.historical) : !session.historical);
+        return matchesSource && (agent === "" || session.agent === agent);
+      }),
+    [agent, ordered, source],
+  );
+
   if (!isAdmin) {
     return <UnauthorizedResult />;
   }
@@ -86,8 +176,14 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
     {
       title: i18next.t("agent:Session"),
       key: "session",
+      dataIndex: "title",
+      ellipsis: true,
+      // Without a floor the title column is the one that gives way when the
+      // fixed ones do not fit, and the only column worth reading goes first.
+      className: "min-w-[240px]",
+      sorter: (a, b) => (a.title || a.sessionKey).localeCompare(b.title || 
b.sessionKey),
       render: (_value, session) => (
-        <div className="flex min-w-0 flex-col">
+        <div className="flex min-w-0 flex-col gap-0.5">
           {/* A session read off disk has no monitoring records behind it, so 
it
               opens its own transcript rather than the Activity page. */}
           <Link
@@ -96,13 +192,18 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
                 ? 
`/agent-sessions/${encodeURIComponent(session.sessionKey)}?agent=${encodeURIComponent(session.agent)}`
                 : 
`/agent-records?agent=${encodeURIComponent(session.agent)}&session=${encodeURIComponent(session.sessionKey)}`
             }
-            className="text-primary truncate font-medium hover:underline"
+            className="hover:text-primary truncate font-medium 
transition-colors"
           >
             {session.title || session.sessionKey}
           </Link>
-          <span className="text-muted-foreground truncate text-xs">
-            {session.cwd || session.sessionKey}
-          </span>
+          <SimpleTooltip title={session.cwd || session.sessionKey}>
+            <span className="text-muted-foreground flex w-fit max-w-full 
items-center gap-1.5 text-xs">
+              {session.cwd ? <Folder className="size-3 shrink-0" /> : null}
+              <span className="truncate font-mono">
+                {session.cwd ? baseName(session.cwd) : session.sessionKey}
+              </span>
+            </span>
+          </SimpleTooltip>
         </div>
       ),
     },
@@ -110,70 +211,85 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
       title: i18next.t("agent:Agent"),
       key: "agent",
       dataIndex: "agent",
-      width: "180px",
+      width: "170px",
+      sorter: (a, b) => a.agent.localeCompare(b.agent),
       render: (value: string) => (
-        <Badge variant="info">
-          <AgentIcon agent={value} fallback={<Bot className="size-3" />} 
size={12} />
-          {value}
-        </Badge>
+        <span className="flex min-w-0 items-center gap-2">
+          <span className="bg-muted text-muted-foreground flex size-5 shrink-0 
items-center justify-center rounded">
+            <AgentIcon agent={value} fallback={<Bot className="size-3" />} 
size={12} />
+          </span>
+          <span className="truncate">{value}</span>
+        </span>
       ),
     },
     {
       title: i18next.t("agent:Source"),
       key: "historical",
-      width: "150px",
+      width: "140px",
+      className: "hidden xl:table-cell",
+      headerClassName: "hidden xl:table-cell",
       render: (_value, session) =>
         session.historical ? (
           <SimpleTooltip title={session.path}>
             <Badge variant="muted">
-              <FileText className="size-3" />
+              <FileText />
               {i18next.t("agent:From the transcript")}
             </Badge>
           </SimpleTooltip>
         ) : (
-          <Badge variant="success">{i18next.t("agent:Monitored")}</Badge>
+          <Badge variant="success">
+            <Radio />
+            {i18next.t("agent:Monitored")}
+          </Badge>
         ),
     },
     {
       title: i18next.t("agent:Records"),
       key: "recordCount",
       dataIndex: "recordCount",
-      width: "110px",
+      width: "100px",
+      align: "right",
+      sorter: (a, b) => a.recordCount - b.recordCount,
+      render: (value: number) => (
+        <span className={value ? "tabular-nums" : "text-muted-foreground 
tabular-nums"}>
+          {(value || 0).toLocaleString()}
+        </span>
+      ),
     },
     {
       title: i18next.t("agent:First activity"),
       key: "firstTime",
       dataIndex: "firstTime",
-      width: "200px",
-      render: (value: string) => new Date(value).toLocaleString(),
+      width: "150px",
+      className: "hidden 2xl:table-cell",
+      headerClassName: "hidden 2xl:table-cell",
+      sorter: (a, b) => new Date(a.firstTime).getTime() - new 
Date(b.firstTime).getTime(),
+      render: (value: string) => (
+        <SimpleTooltip title={fullTime(value)}>
+          <span className="text-muted-foreground 
whitespace-nowrap">{shortTime(value)}</span>
+        </SimpleTooltip>
+      ),
     },
     {
       title: i18next.t("agent:Last activity"),
       key: "lastTime",
       dataIndex: "lastTime",
-      width: "200px",
-      render: (value: string) => new Date(value).toLocaleString(),
+      width: "150px",
+      sorter: (a, b) => new Date(a.lastTime).getTime() - new 
Date(b.lastTime).getTime(),
+      render: (value: string) => (
+        <SimpleTooltip title={fullTime(value)}>
+          <span className="whitespace-nowrap">{relativeTime(value)}</span>
+        </SimpleTooltip>
+      ),
     },
   ];
 
   return (
     <PageContainer>
-      <PageHeader title={i18next.t("agent:Agent Sessions")} />
-
-      {error ? <MessageAlert title={error} /> : null}
-
-      <DataTable
+      <PageHeader
         title={i18next.t("agent:Agent Sessions")}
-        description={`${sessions.length} ${i18next.t("agent:Agent Sessions")}`}
-        columns={columns}
-        dataSource={sessions}
-        rowKey={session => `${session.agent}:${session.sessionKey}`}
-        loading={loading}
-        pageSize={20}
-        searchable
-        emptyIcon={MessageSquare}
-        emptyText={i18next.t("agent:No agent sessions yet")}
-        toolbar={
+        description={i18next.t("agent:Agent Sessions detail")}
+        actions={
           <>
             <Label className="text-sm font-normal">
               <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
@@ -186,6 +302,73 @@ export default function AgentSessionsPage({account}: 
{account: Account}) {
           </>
         }
       />
+
+      {error ? <MessageAlert title={error} /> : null}
+
+      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
+        <StatCard
+          label={i18next.t("agent:Agent Sessions")}
+          value={ordered.length.toLocaleString()}
+          icon={MessageSquare}
+          hint={i18next.t("agent:Across {count} agents").replace("{count}", 
String(agentOptions.length))}
+        />
+        <StatCard
+          label={i18next.t("agent:Active today")}
+          value={activeToday.toLocaleString()}
+          icon={Activity}
+          hint={lastTime ? `${i18next.t("agent:Last activity")} ยท 
${relativeTime(lastTime)}` : undefined}
+        />
+        <StatCard
+          label={i18next.t("agent:Monitored")}
+          value={monitored.toLocaleString()}
+          icon={Radio}
+          hint={i18next.t("agent:{count} from transcripts").replace("{count}", 
historical.toLocaleString())}
+        />
+        <StatCard label={i18next.t("agent:Records")} 
value={records.toLocaleString()} icon={Logs} />
+      </div>
+
+      <div className="flex flex-wrap items-center justify-between gap-3">
+        <Tabs value={source} onValueChange={value => setSource(value as 
SourceFilter)}>
+          <TabsList>
+            <TabsTrigger value="all">
+              {i18next.t("agent:Any source")}
+              <Badge variant="muted">{ordered.length}</Badge>
+            </TabsTrigger>
+            <TabsTrigger value="monitored">
+              {i18next.t("agent:Monitored")}
+              <Badge variant="muted">{monitored}</Badge>
+            </TabsTrigger>
+            <TabsTrigger value="historical">
+              {i18next.t("agent:From the transcript")}
+              <Badge variant="muted">{historical}</Badge>
+            </TabsTrigger>
+          </TabsList>
+        </Tabs>
+
+        <SimpleSelect
+          className="w-[200px]"
+          value={agent}
+          onChange={setAgent}
+          aria-label={i18next.t("agent:Agent")}
+          options={[{label: i18next.t("agent:All agents"), value: ""}, 
...agentOptions]}
+        />
+      </div>
+
+      <DataTable
+        description={
+          rows.length === ordered.length
+            ? `${ordered.length.toLocaleString()} ${i18next.t("agent:Agent 
Sessions")}`
+            : `${rows.length.toLocaleString()} / 
${ordered.length.toLocaleString()} ${i18next.t("agent:Agent Sessions")}`
+        }
+        columns={columns}
+        dataSource={rows}
+        rowKey={session => `${session.agent}:${session.sessionKey}`}
+        loading={loading}
+        pageSize={20}
+        searchable
+        emptyIcon={MessageSquare}
+        emptyText={i18next.t("agent:No agent sessions yet")}
+      />
     </PageContainer>
   );
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to