From c52acbc64f4dfc2e0e86ddb634e26a9018d76c50 Mon Sep 17 00:00:00 2001 From: Ahmad Ali Salim Date: Sat, 8 Aug 2026 10:21:14 +0300 Subject: [PATCH] fix: hydrate activity feed via TanStack Query on refresh. Resolves #1355 --- clients/dashboard/src/pages/activity.tsx | 118 ++++++++++++++++------- 1 file changed, 85 insertions(+), 33 deletions(-) diff --git a/clients/dashboard/src/pages/activity.tsx b/clients/dashboard/src/pages/activity.tsx index 067d2a30a2..cee14236df 100644 --- a/clients/dashboard/src/pages/activity.tsx +++ b/clients/dashboard/src/pages/activity.tsx @@ -1,6 +1,8 @@ -import { useMemo } from "react"; -import { Activity, Inbox } from "lucide-react"; -import { useSseEvents, useSseStatus, type SseEvent } from "@/sse/sse-context"; +import { useEffect, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Activity, Inbox, Loader2 } from "lucide-react"; +import { useSseEvents, useSseStatus } from "@/sse/sse-context"; +import { listAudits, auditPredicate, AUDIT_EVENT_TYPE_LABELS } from "@/api/audits"; import { Badge } from "@/components/ui/badge"; import { EntityEmpty, @@ -35,9 +37,6 @@ function payloadSummary(data: unknown, raw: string): string { return raw; } -// Map an event type to a status badge tone — failures pop red, successes -// green, warnings amber, everything else neutral. Mirrors the heuristic -// the legacy live-feed component used. function eventTone(type: string): EntityStatusTone { const t = type.toLowerCase(); if (t.includes("fail") || t.includes("error") || t.includes("revoke")) return "danger"; @@ -47,8 +46,6 @@ function eventTone(type: string): EntityStatusTone { return "default"; } -// Try to extract a friendlier "entity" label from the event payload — -// most domain events carry an aggregate id under a predictable field. function entityLabel(data: unknown): string { if (data && typeof data === "object") { const obj = data as Record; @@ -60,6 +57,14 @@ function entityLabel(data: unknown): string { return "—"; } +export type DisplayActivityItem = { + id: string; + type: string; + summary: string; + entity: string; + timestamp: number; +}; + // ─────────────────────────────────────────────────────────────────────── // Page // ─────────────────────────────────────────────────────────────────────── @@ -67,10 +72,56 @@ function entityLabel(data: unknown): string { const DESKTOP_GRID = "grid-cols-[1fr_240px_120px]"; export function ActivityPage() { + const queryClient = useQueryClient(); const { status, eventCount } = useSseStatus(); const { events } = useSseEvents(); - const items = useMemo(() => events.slice(0, 200), [events]); + // TanStack Query: Fetch current accurate activity state from the backend API on mount/refresh + const activityQuery = useQuery({ + queryKey: ["audits", "activity-feed"], + queryFn: () => listAudits({ pageSize: 50 }), + staleTime: 10_000, + }); + + // Real-time invalidation: when real-time SSE notifications arrive, invalidate the TanStack Query cache + // so the UI automatically refetches fresh data from the backend API instead of stale local state. + useEffect(() => { + if (events.length > 0) { + void queryClient.invalidateQueries({ queryKey: ["audits", "activity-feed"] }); + } + }, [events, queryClient]); + + const items = useMemo(() => { + const sseItems: DisplayActivityItem[] = events.map((ev) => ({ + id: ev.id, + type: ev.type, + summary: payloadSummary(ev.data, ev.rawData), + entity: entityLabel(ev.data), + timestamp: ev.receivedAt, + })); + + const apiItems: DisplayActivityItem[] = (activityQuery.data?.items ?? []).map((audit) => ({ + id: audit.id, + type: AUDIT_EVENT_TYPE_LABELS[audit.eventType] ?? audit.eventType, + summary: auditPredicate(audit), + entity: audit.userName ?? audit.source ?? "—", + timestamp: new Date(audit.occurredAtUtc).getTime(), + })); + + // Merge API query results with live SSE items, deduping by id + const seen = new Set(); + const merged: DisplayActivityItem[] = []; + + for (const item of [...sseItems, ...apiItems]) { + if (!seen.has(item.id)) { + seen.add(item.id); + merged.push(item); + } + } + + return merged.sort((a, b) => b.timestamp - a.timestamp).slice(0, 200); + }, [events, activityQuery.data]); + const isLive = status === "connected"; return ( @@ -78,9 +129,9 @@ export function ActivityPage() { {isLive ? ( streaming @@ -91,14 +142,19 @@ export function ActivityPage() { )} - {items.length === 0 ? ( + {activityQuery.isLoading ? ( +
+ + Loading activity log… +
+ ) : items.length === 0 ? ( ) : ( @@ -106,9 +162,6 @@ export function ActivityPage() {

{items.length} event{items.length === 1 ? "" : "s"} shown - - · {new Intl.NumberFormat("en-US").format(eventCount)} total -

@@ -120,8 +173,8 @@ export function ActivityPage() { aria-relevant="additions" aria-label="Activity events" > - {items.map((ev) => ( - + {items.map((item) => ( + ))} @@ -138,10 +191,10 @@ export function ActivityPage() { Entity Time - {items.map((ev, i) => ( + {items.map((item, i) => ( ))} @@ -152,42 +205,41 @@ export function ActivityPage() { ); } -// Mobile uses a static div (no navigation target — the activity feed is -// a stream of events, not a list of routable entities). -function MobileCard({ ev }: { ev: SseEvent }) { +function MobileCard({ item }: { item: DisplayActivityItem }) { return (
- {ev.type} + {item.type} - {formatTime(ev.receivedAt)} + {formatTime(item.timestamp)}

- {payloadSummary(ev.data, ev.rawData)} + {item.summary}

); } -function DesktopRow({ ev, isLast }: { ev: SseEvent; isLast: boolean }) { +function DesktopRow({ item, isLast }: { item: DisplayActivityItem; isLast: boolean }) { return (
- {ev.type} + {item.type} - {payloadSummary(ev.data, ev.rawData)} + {item.summary}
- {entityLabel(ev.data)} + {item.entity} - {formatTime(ev.receivedAt)} + {formatTime(item.timestamp)}
); } +