From d9670522bb6b283c4ccc119599e27f7b36b7072e Mon Sep 17 00:00:00 2001 From: Ahmad Al Tamimi Date: Fri, 21 Aug 2026 17:44:51 +0400 Subject: [PATCH 1/3] fix: gate the recent-activity fetch on audit:read /api/logs/activity requires audit:read, which no default non-admin role holds, so every non-admin home load fired a guaranteed 403. Add an `enabled` option to useRecentActivity, mirroring useSystemHealth's flag for the admin-only /version probe, and gate the home call on it. Signed-off-by: Ahmad Al Tamimi --- src/hooks/useMiniCardStatuses.test.tsx | 17 ++++++++++++++++- src/hooks/useMiniCardStatuses.ts | 9 +++++++-- src/hooks/useRecentActivity.test.tsx | 24 ++++++++++++++++++++++++ src/hooks/useRecentActivity.ts | 16 +++++++++++++--- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/hooks/useMiniCardStatuses.test.tsx b/src/hooks/useMiniCardStatuses.test.tsx index ae3f72c..a4d6130 100644 --- a/src/hooks/useMiniCardStatuses.test.tsx +++ b/src/hooks/useMiniCardStatuses.test.tsx @@ -41,7 +41,8 @@ function health(over: { data?: VersionInfo; error?: { message: string; status?: function admin(isAdmin = true) { mockUseAuth.mockReturnValue({ - hasPermission: (perm: string) => isAdmin && perm === "admin.system_config", + hasPermission: (perm: string) => + isAdmin && (perm === "admin.system_config" || perm === "audit:read"), } as unknown as ReturnType); } @@ -102,6 +103,20 @@ describe("useMiniCardStatuses — /version gating", () => { }); }); +describe("useMiniCardStatuses — activity gating", () => { + it("does not fetch activity for a caller without audit:read", () => { + admin(false); + renderHook(() => useMiniCardStatuses()); + expect(mockUseRecentActivity).toHaveBeenCalledWith({ pollIntervalMs: 0, enabled: false }); + }); + + it("fetches activity when the caller holds audit:read", () => { + admin(true); + renderHook(() => useMiniCardStatuses()); + expect(mockUseRecentActivity).toHaveBeenCalledWith({ pollIntervalMs: 0, enabled: true }); + }); +}); + describe("useMiniCardStatuses — headline health axis", () => { it("stays optimistic (reachable undefined) while health is loading / for a non-admin", () => { admin(false); // no /version -> no data, no error diff --git a/src/hooks/useMiniCardStatuses.ts b/src/hooks/useMiniCardStatuses.ts index c430d7b..f69ac53 100644 --- a/src/hooks/useMiniCardStatuses.ts +++ b/src/hooks/useMiniCardStatuses.ts @@ -12,7 +12,8 @@ * `/version` is admin-only, so it is fetched only when the caller can view * system diagnostics (`admin.system_config`); non-admins never poll a guaranteed * 403. It is polled once here (the hook is resolved at the page level) and feeds - * both the mini cards and the headline. + * both the mini cards and the headline. Activity is gated the same way on + * `audit:read`. */ import { useMemo } from "react"; @@ -75,7 +76,11 @@ export function useMiniCardStatuses(): HomeStatus { const { data: health, error: healthError } = systemHealth; const { data: mcpServers, error: mcpServersError } = useQuery(MCP_REACH_PATH); const { data: a2aAgents, error: a2aError } = useQuery(A2A_REACH_PATH); - const { items } = useRecentActivity({ pollIntervalMs: 0 }); + // /api/logs/activity requires audit:read, which no default non-admin role + // holds. security:read is not checked: that half of the feed is additive + // server-side, so an audit:read-only caller gets a narrower feed, not an error. + const canViewActivity = hasPermission("audit:read"); + const { items } = useRecentActivity({ pollIntervalMs: 0, enabled: canViewActivity }); const derived = useMemo(() => { const healthy = safeHealthy(health); diff --git a/src/hooks/useRecentActivity.test.tsx b/src/hooks/useRecentActivity.test.tsx index b105159..1d05ee1 100644 --- a/src/hooks/useRecentActivity.test.tsx +++ b/src/hooks/useRecentActivity.test.tsx @@ -41,6 +41,30 @@ describe("useRecentActivity", () => { expect(result.current.items).toEqual([]); }); + it("makes no request while disabled and fetches once enabled", async () => { + let callCount = 0; + server.use( + http.get("*/api/logs/activity", () => { + callCount += 1; + return HttpResponse.json({ items: RECENT_ACTIVITY_FIXTURE.slice(0, 2) }); + }), + ); + + const { result, rerender } = renderHook( + ({ enabled }) => useRecentActivity({ pollIntervalMs: 0, enabled }), + { initialProps: { enabled: false } }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(callCount).toBe(0); + expect(result.current.items).toEqual([]); + + rerender({ enabled: true }); + + await waitFor(() => expect(result.current.items).toHaveLength(2)); + expect(callCount).toBe(1); + }); + it("refetch re-hits the endpoint and clears the error", async () => { let callCount = 0; server.use( diff --git a/src/hooks/useRecentActivity.ts b/src/hooks/useRecentActivity.ts index 48d3836..d6fb584 100644 --- a/src/hooks/useRecentActivity.ts +++ b/src/hooks/useRecentActivity.ts @@ -26,6 +26,8 @@ interface UseRecentActivityOptions { limit?: number; /** Polling cadence override. Pass 0 to disable. */ pollIntervalMs?: number; + /** When false, no request is made and the feed stays empty. */ + enabled?: boolean; } function isMockEnabled(): boolean { @@ -33,7 +35,7 @@ function isMockEnabled(): boolean { } export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRecentActivityResult { - const { limit = 10, pollIntervalMs = RECENT_ACTIVITY_POLL_INTERVAL_MS } = options; + const { limit = 10, pollIntervalMs = RECENT_ACTIVITY_POLL_INTERVAL_MS, enabled = true } = options; const mock = isMockEnabled(); const [items, setItems] = useState([]); @@ -68,6 +70,13 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe ); useEffect(() => { + if (!enabled) { + setItems([]); + setError(null); + setIsLoading(false); + return; + } + const controller = new AbortController(); void fetchOnce(controller.signal); @@ -83,12 +92,13 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe controller.abort(); window.clearInterval(intervalId); }; - }, [fetchOnce, mock, pollIntervalMs]); + }, [fetchOnce, mock, pollIntervalMs, enabled]); const refetch = useCallback(async (): Promise => { + if (!enabled) return; setIsLoading(true); await fetchOnce(); - }, [fetchOnce]); + }, [fetchOnce, enabled]); return { items, isLoading, error, refetch }; } From 74a676f72658e70b3563ab97a0c266cd8fcf1ce8 Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Fri, 21 Aug 2026 16:06:56 -0700 Subject: [PATCH 2/3] feat: build the Recent Activity feed view ?view=activity fell through to the "coming soon" placeholder; the spike had shipped the plumbing (types, api client, hook, fixture, MSW handler) but never the list itself. Adds ActivityView and wires it into the Dashboard main-content switch. - activityStatus.ts holds the one status -> {icon, tone} map, using the set ui/sonner.tsx already ships. Keeping it in a single record is what makes the app-wide token rollout (#62) a one-file change here. `info` is unaccented, matching sonner, so high-volume read/execute rows recede while errors and warnings carry. - Filter tabs count `error`/`warning` the same way the mini cards do, so the two can't disagree. `info` gets no tab of its own. - The feed is requested at limit 100, not the hook's default of 10: search filters the fetched window client-side, so a 10-row window would make it near-useless. - No self-gating. HOME_STATES.activity already declares requiredPermission: "audit:read", so the page renders the skeleton while permissions load and PermissionDenied when the caller lacks it. useRecentActivity now keeps the original error instead of flattening it to { message }. ApiError carries the status, and isPermissionDenied needs the instance, so a 403 that slips past the page gate (stale or coarser client permissions, team-switch race) can render as denied rather than as a generic failure. Signed-off-by: Anna Effort --- src/components/dashboard/ActivityFilters.tsx | 65 +++++++ src/components/dashboard/ActivityRow.tsx | 45 +++++ .../dashboard/ActivityView.test.tsx | 171 ++++++++++++++++++ src/components/dashboard/ActivityView.tsx | 108 +++++++++++ src/components/dashboard/activityStatus.ts | 54 ++++++ src/hooks/useRecentActivity.ts | 11 +- src/i18n/locales/en-US/dashboard.json | 12 +- src/i18n/locales/es-ES/dashboard.json | 12 +- src/i18n/locales/pt-BR/dashboard.json | 12 +- src/pages/Dashboard.tsx | 4 +- 10 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 src/components/dashboard/ActivityFilters.tsx create mode 100644 src/components/dashboard/ActivityRow.tsx create mode 100644 src/components/dashboard/ActivityView.test.tsx create mode 100644 src/components/dashboard/ActivityView.tsx create mode 100644 src/components/dashboard/activityStatus.ts diff --git a/src/components/dashboard/ActivityFilters.tsx b/src/components/dashboard/ActivityFilters.tsx new file mode 100644 index 0000000..0d4ebbe --- /dev/null +++ b/src/components/dashboard/ActivityFilters.tsx @@ -0,0 +1,65 @@ +/** + * ActivityFilters — the feed's filter tabs (All / Errors / Warnings, each with a + * count) plus the search box. + * + * Counts key off `status === "error"` / `"warning"`, matching the mini-card + * counters in `useMiniCardStatuses`, so the two never disagree. `info` has no + * tab of its own: it is high-volume read/execute traffic that belongs under + * "All activity" rather than as a severity filter. + * + * Filtering itself is owned by the caller; this component is presentational. + */ + +import { useIntl } from "react-intl"; + +import { ListSearch } from "@/components/ui/list-search"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +export const ACTIVITY_FILTERS = ["all", "error", "warning"] as const; + +export type ActivityFilter = (typeof ACTIVITY_FILTERS)[number]; + +const FILTER_LABEL: Record = { + all: "dashboard.home.activity.filter.all", + error: "dashboard.home.activity.filter.errors", + warning: "dashboard.home.activity.filter.warnings", +}; + +interface ActivityFiltersProps { + filter: ActivityFilter; + onFilterChange: (filter: ActivityFilter) => void; + counts: Record; + search: string; + onSearchChange: (search: string) => void; +} + +export function ActivityFilters({ + filter, + onFilterChange, + counts, + search, + onSearchChange, +}: ActivityFiltersProps) { + const intl = useIntl(); + + return ( +
+ onFilterChange(value as ActivityFilter)}> + + {ACTIVITY_FILTERS.map((id) => ( + + {intl.formatMessage({ id: FILTER_LABEL[id] })} + {counts[id]} + + ))} + + + +
+ ); +} diff --git a/src/components/dashboard/ActivityRow.tsx b/src/components/dashboard/ActivityRow.tsx new file mode 100644 index 0000000..2f616b7 --- /dev/null +++ b/src/components/dashboard/ActivityRow.tsx @@ -0,0 +1,45 @@ +/** + * ActivityRow — one entry in the Recent Activity feed. + * + * `title` and `description` are server-rendered (see `types/activity.ts`): the + * UI must not re-derive either from the other fields. Timestamps render + * relative ("6 minutes ago") with the absolute ISO value in `title` for hover, + * per the feed spike. + * + * The status glyph is decorative; the status is exposed to assistive tech as + * visually-hidden text instead, so the row reads as "Error — ". + */ + +import { useIntl } from "react-intl"; + +import { cn } from "@/lib/utils"; +import type { ActivityItem } from "@/types/activity"; +import { formatLastSeen } from "@/utils/format"; + +import { ACTIVITY_STATUS_STYLE } from "./activityStatus"; + +export function ActivityRow({ item }: { item: ActivityItem }) { + const intl = useIntl(); + const { Icon, className, labelId } = ACTIVITY_STATUS_STYLE[item.status]; + const relative = formatLastSeen(item.timestamp, { locale: intl.locale }); + + return ( + <li className="flex items-start gap-3 px-4 py-3"> + <Icon className={cn("mt-0.5 size-4 shrink-0", className)} aria-hidden /> + <span className="sr-only">{intl.formatMessage({ id: labelId })}</span> + <div className="min-w-0 flex-1"> + <p className="text-sm font-medium text-foreground">{item.title}</p> + <p className="text-sm text-muted-foreground">{item.description}</p> + </div> + {relative && ( + <time + dateTime={item.timestamp} + title={item.timestamp} + className="shrink-0 pt-0.5 text-xs text-muted-foreground" + > + {relative} + </time> + )} + </li> + ); +} diff --git a/src/components/dashboard/ActivityView.test.tsx b/src/components/dashboard/ActivityView.test.tsx new file mode 100644 index 0000000..072a3ed --- /dev/null +++ b/src/components/dashboard/ActivityView.test.tsx @@ -0,0 +1,171 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { screen, within } from "@testing-library/react"; + +import { ApiError } from "@/api/client"; +import { useRecentActivity } from "@/hooks/useRecentActivity"; +import { renderWithProviders } from "@/test/test-utils"; +import type { ActivityItem } from "@/types/activity"; + +import { ActivityView } from "./ActivityView"; + +vi.mock("@/hooks/useRecentActivity", () => ({ useRecentActivity: vi.fn() })); + +const mockUseRecentActivity = vi.mocked(useRecentActivity); + +function item(over: Partial<ActivityItem> = {}): ActivityItem { + return { + id: "audit:1", + timestamp: "2026-08-21T12:00:00Z", + source: "audit", + title: "MCP server registered", + description: "A new MCP server github-tools was registered.", + status: "success", + resource_type: "mcp_server", + resource_name: "github-tools", + actor: "alice@acme.io", + correlation_id: "a1b2c3d4", + ...over, + }; +} + +function feed(items: ActivityItem[], over: { isLoading?: boolean; error?: Error | null } = {}) { + mockUseRecentActivity.mockReturnValue({ + items, + isLoading: false, + error: null, + refetch: vi.fn(), + ...over, + }); +} + +const ITEMS = [ + item({ id: "audit:1", title: "MCP server registered", status: "success" }), + item({ + id: "audit:2", + title: "Tool invoked", + description: "Tool search ran for 120ms.", + status: "info", + resource_name: "search", + }), + item({ + id: "sec:1", + title: "Rate limit reached", + description: "Threshold hit for payments.", + status: "warning", + resource_name: "payments", + }), + item({ + id: "sec:2", + title: "Health check failed", + description: "billing stopped responding.", + status: "error", + resource_name: "billing", + }), + item({ + id: "sec:3", + title: "Auth failure", + description: "Rejected credentials for billing.", + status: "error", + resource_name: "billing", + }), +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("ActivityView", () => { + it("renders a row per item with its server-rendered title and description", () => { + feed(ITEMS); + renderWithProviders(<ActivityView />); + + expect(screen.getAllByRole("listitem")).toHaveLength(5); + expect(screen.getByText("MCP server registered")).toBeInTheDocument(); + expect(screen.getByText("A new MCP server github-tools was registered.")).toBeInTheDocument(); + }); + + it("renders relative timestamps and keeps the absolute ISO value for hover", () => { + feed([item({ timestamp: "2026-08-21T12:00:00Z" })]); + renderWithProviders(<ActivityView />); + + const time = screen.getByRole("listitem").querySelector("time"); + expect(time).toHaveAttribute("dateTime", "2026-08-21T12:00:00Z"); + expect(time).toHaveAttribute("title", "2026-08-21T12:00:00Z"); + expect(time?.textContent).toMatch(/ago|now/); + }); + + it("exposes the status to assistive tech as text, not just an icon", () => { + feed([item({ status: "error" })]); + renderWithProviders(<ActivityView />); + + expect(within(screen.getByRole("listitem")).getByText("Error")).toBeInTheDocument(); + }); + + it("counts errors and warnings on the filter tabs, and info only under All", () => { + feed(ITEMS); + renderWithProviders(<ActivityView />); + + expect(within(screen.getByRole("tab", { name: /All activity/ })).getByText("5")).toBeVisible(); + expect(within(screen.getByRole("tab", { name: /Errors/ })).getByText("2")).toBeVisible(); + expect(within(screen.getByRole("tab", { name: /Warnings/ })).getByText("1")).toBeVisible(); + expect(screen.queryByRole("tab", { name: /Info/ })).not.toBeInTheDocument(); + }); + + it("narrows the list to the selected severity", async () => { + feed(ITEMS); + renderWithProviders(<ActivityView />); + + await userEvent.click(screen.getByRole("tab", { name: /Errors/ })); + + expect(screen.getAllByRole("listitem")).toHaveLength(2); + expect(screen.getByText("Health check failed")).toBeInTheDocument(); + expect(screen.queryByText("MCP server registered")).not.toBeInTheDocument(); + }); + + it("searches the fetched feed across title, description, resource and actor", async () => { + feed(ITEMS); + renderWithProviders(<ActivityView />); + + await userEvent.type(screen.getByRole("searchbox"), "billing"); + + expect(screen.getAllByRole("listitem")).toHaveLength(2); + expect(screen.getByText("Health check failed")).toBeInTheDocument(); + }); + + it("distinguishes an empty feed from an empty filter result", async () => { + feed(ITEMS); + const { rerender } = renderWithProviders(<ActivityView />); + + await userEvent.type(screen.getByRole("searchbox"), "nothing-matches-this"); + expect(screen.getByText("No activity matches your filters.")).toBeInTheDocument(); + + feed([]); + rerender(<ActivityView />); + expect(screen.getByText("No recent activity.")).toBeInTheDocument(); + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + }); + + it("renders PermissionDenied when the server 403s despite the page gate", () => { + feed([], { error: new ApiError(403, {}, "Forbidden") }); + renderWithProviders(<ActivityView />); + + expect(screen.getByText("You do not have permission to view this.")).toBeInTheDocument(); + }); + + it("renders generic copy for a non-403 failure, never the raw server message", () => { + feed([], { error: new ApiError(500, {}, "psycopg2.OperationalError: connection refused") }); + renderWithProviders(<ActivityView />); + + expect(screen.getByText("Recent activity could not be loaded.")).toBeInTheDocument(); + expect(screen.queryByText(/psycopg2/)).not.toBeInTheDocument(); + }); + + it("shows a skeleton while the first fetch is in flight", () => { + feed([], { isLoading: true }); + const { container } = renderWithProviders(<ActivityView />); + + expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/dashboard/ActivityView.tsx b/src/components/dashboard/ActivityView.tsx new file mode 100644 index 0000000..e9aa29c --- /dev/null +++ b/src/components/dashboard/ActivityView.tsx @@ -0,0 +1,108 @@ +/** + * ActivityView (#5531) — the active-state main content of the Activity feed + * home view: filter tabs, search, and the list of recent activity. + * + * Permission gating lives at the page, not here. `HOME_STATES.activity` declares + * `requiredPermission: "audit:read"`, so `NonDefaultState` renders a skeleton + * while permissions load and `PermissionDenied` when the caller lacks it — this + * component only mounts once the gate is open. That is also why the hook is + * called without `enabled`: by the time we render, the permission is held. + * + * The error path below is therefore not the ordinary denied case. It covers the + * stale/coarser-permission edge (client says yes, server says 403 — e.g. a + * team-switch race), which is why it still checks `isPermissionDenied`. + * + * Search filters the fetched window client-side, so the feed is requested at the + * server's max (`limit: 100`) rather than the hook's default of 10 — otherwise + * search would only ever see the ten newest rows. + */ + +import { useMemo, useState } from "react"; +import { useIntl } from "react-intl"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { useRecentActivity } from "@/hooks/useRecentActivity"; +import type { ActivityItem } from "@/types/activity"; + +import { ACTIVITY_FILTERS, ActivityFilters, type ActivityFilter } from "./ActivityFilters"; +import { ActivityRow } from "./ActivityRow"; +import { EmptyStatePlaceholder } from "./EmptyStatePlaceholder"; +import { isPermissionDenied, PermissionDenied } from "./PermissionDenied"; + +/** Server clamps to 100; search needs the widest window it will give us. */ +const ACTIVITY_FEED_LIMIT = 100; + +function matchesFilter(item: ActivityItem, filter: ActivityFilter): boolean { + return filter === "all" || item.status === filter; +} + +function matchesSearch(item: ActivityItem, needle: string): boolean { + if (!needle) return true; + const haystack = [item.title, item.description, item.resource_name, item.actor]; + return haystack.some((field) => field?.toLowerCase().includes(needle)); +} + +export function ActivityView() { + const intl = useIntl(); + const { items, isLoading, error } = useRecentActivity({ limit: ACTIVITY_FEED_LIMIT }); + const [filter, setFilter] = useState<ActivityFilter>("all"); + const [search, setSearch] = useState(""); + + // Counts reflect the fetched feed, not the search results: the tabs are a + // severity breakdown of what is loaded, so they stay stable while typing. + const counts = useMemo(() => { + const byFilter = Object.fromEntries(ACTIVITY_FILTERS.map((id) => [id, 0])) as Record< + ActivityFilter, + number + >; + for (const item of items) { + byFilter.all += 1; + if (item.status === "error") byFilter.error += 1; + if (item.status === "warning") byFilter.warning += 1; + } + return byFilter; + }, [items]); + + const visible = useMemo(() => { + const needle = search.trim().toLowerCase(); + return items.filter((item) => matchesFilter(item, filter) && matchesSearch(item, needle)); + }, [items, filter, search]); + + if (isLoading) return <Skeleton className="h-40 w-full rounded-lg" />; + + if (error) { + return isPermissionDenied(error) ? ( + <PermissionDenied /> + ) : ( + <EmptyStatePlaceholder messageId="dashboard.home.activity.error" /> + ); + } + + if (items.length === 0) { + return <EmptyStatePlaceholder messageId="dashboard.home.activity.empty" />; + } + + return ( + <div className="flex flex-col gap-4"> + <ActivityFilters + filter={filter} + onFilterChange={setFilter} + counts={counts} + search={search} + onSearchChange={setSearch} + /> + {visible.length === 0 ? ( + <EmptyStatePlaceholder messageId="dashboard.home.activity.noMatches" /> + ) : ( + <ul + className="divide-y divide-border rounded-lg ring-1 ring-foreground/10" + aria-label={intl.formatMessage({ id: "dashboard.home.card.activity" })} + > + {visible.map((item) => ( + <ActivityRow key={item.id} item={item} /> + ))} + </ul> + )} + </div> + ); +} diff --git a/src/components/dashboard/activityStatus.ts b/src/components/dashboard/activityStatus.ts new file mode 100644 index 0000000..4ca26c0 --- /dev/null +++ b/src/components/dashboard/activityStatus.ts @@ -0,0 +1,54 @@ +/** + * Activity status -> icon + tone. + * + * The canonical status set is the one `ui/sonner.tsx` already ships + * (CircleCheck / Info / TriangleAlert / OctagonX). sonner is the reference + * implementation; keeping the mapping in this single exported record is what + * makes the app-wide token rollout (#62) a one-file change here rather than a + * sweep through every row. + * + * `info` is deliberately unaccented. sonner gives it no per-status color (the + * glyph inherits the toast foreground), and `info` covers high-volume + * read/execute audit actions — muted-foreground lets those rows recede while + * errors and warnings still carry. + * + * NOTE: `OctagonX` (octagon + x) diverges from the Figma spec's `octagon-alert` + * (octagon + !) on purpose, matching the icon already shipping in sonner. Same + * shape family. Do not "correct" it toward Figma without changing both. + */ + +import { CircleCheckIcon, InfoIcon, OctagonXIcon, TriangleAlertIcon } from "lucide-react"; +import type { ComponentType } from "react"; + +import type { ActivityStatus } from "@/types/activity"; + +export interface ActivityStatusStyle { + Icon: ComponentType<{ className?: string }>; + /** Text-color utility for the icon. */ + className: string; + /** i18n message id for the screen-reader status label. */ + labelId: string; +} + +export const ACTIVITY_STATUS_STYLE: Record<ActivityStatus, ActivityStatusStyle> = { + success: { + Icon: CircleCheckIcon, + className: "text-green-500", + labelId: "dashboard.home.activity.status.success", + }, + info: { + Icon: InfoIcon, + className: "text-muted-foreground", + labelId: "dashboard.home.activity.status.info", + }, + warning: { + Icon: TriangleAlertIcon, + className: "text-yellow-500", + labelId: "dashboard.home.activity.status.warning", + }, + error: { + Icon: OctagonXIcon, + className: "text-destructive", + labelId: "dashboard.home.activity.status.error", + }, +}; diff --git a/src/hooks/useRecentActivity.ts b/src/hooks/useRecentActivity.ts index d6fb584..91beaf8 100644 --- a/src/hooks/useRecentActivity.ts +++ b/src/hooks/useRecentActivity.ts @@ -17,7 +17,8 @@ export const RECENT_ACTIVITY_POLL_INTERVAL_MS = 30_000; interface UseRecentActivityResult { items: ActivityItem[]; isLoading: boolean; - error: { message: string } | null; + /** The original error, so callers can inspect `ApiError.status` (e.g. 403). */ + error: Error | null; refetch: () => Promise<void>; } @@ -40,7 +41,7 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe const [items, setItems] = useState<ActivityItem[]>([]); const [isLoading, setIsLoading] = useState<boolean>(true); - const [error, setError] = useState<{ message: string } | null>(null); + const [error, setError] = useState<Error | null>(null); const fetchOnce = useCallback( async (signal?: AbortSignal): Promise<void> => { @@ -60,8 +61,10 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe setError(null); } catch (err) { if (err instanceof Error && err.name === "AbortError") return; - const message = err instanceof Error ? err.message : "Failed to load recent activity"; - setError({ message }); + // Keep the original error rather than flattening it to { message }: + // ApiError carries the status, and `isPermissionDenied` needs the + // instance to tell a 403 from any other failure. + setError(err instanceof Error ? err : new Error("Failed to load recent activity")); } finally { setIsLoading(false); } diff --git a/src/i18n/locales/en-US/dashboard.json b/src/i18n/locales/en-US/dashboard.json index 92c24c4..13bed68 100644 --- a/src/i18n/locales/en-US/dashboard.json +++ b/src/i18n/locales/en-US/dashboard.json @@ -17,7 +17,17 @@ "dashboard.home.card.rest": "REST API", "dashboard.home.card.grpc": "gRPC", "dashboard.home.placeholder.system": "System status card (coming soon)", - "dashboard.home.placeholder.activity": "Activity feed (coming soon)", + "dashboard.home.activity.filter.all": "All activity", + "dashboard.home.activity.filter.errors": "Errors", + "dashboard.home.activity.filter.warnings": "Warnings", + "dashboard.home.activity.search": "Search activity", + "dashboard.home.activity.empty": "No recent activity.", + "dashboard.home.activity.noMatches": "No activity matches your filters.", + "dashboard.home.activity.error": "Recent activity could not be loaded.", + "dashboard.home.activity.status.success": "Success", + "dashboard.home.activity.status.info": "Info", + "dashboard.home.activity.status.warning": "Warning", + "dashboard.home.activity.status.error": "Error", "dashboard.home.placeholder.mcp": "MCP health card (coming soon)", "dashboard.home.placeholder.a2a": "No agent (A2A) sources have been added yet.", "dashboard.home.placeholder.rest": "No REST API sources have been added yet.", diff --git a/src/i18n/locales/es-ES/dashboard.json b/src/i18n/locales/es-ES/dashboard.json index 3a6a35a..d8cd0da 100644 --- a/src/i18n/locales/es-ES/dashboard.json +++ b/src/i18n/locales/es-ES/dashboard.json @@ -17,7 +17,17 @@ "dashboard.home.card.rest": "API REST", "dashboard.home.card.grpc": "gRPC", "dashboard.home.placeholder.system": "Tarjeta de estado del sistema (próximamente)", - "dashboard.home.placeholder.activity": "Feed de actividad (próximamente)", + "dashboard.home.activity.filter.all": "Toda la actividad", + "dashboard.home.activity.filter.errors": "Errores", + "dashboard.home.activity.filter.warnings": "Advertencias", + "dashboard.home.activity.search": "Buscar actividad", + "dashboard.home.activity.empty": "No hay actividad reciente.", + "dashboard.home.activity.noMatches": "Ninguna actividad coincide con los filtros.", + "dashboard.home.activity.error": "No se pudo cargar la actividad reciente.", + "dashboard.home.activity.status.success": "Correcto", + "dashboard.home.activity.status.info": "Información", + "dashboard.home.activity.status.warning": "Advertencia", + "dashboard.home.activity.status.error": "Error", "dashboard.home.placeholder.mcp": "Tarjeta de estado del MCP (próximamente)", "dashboard.home.placeholder.a2a": "Aún no se han añadido fuentes de agente (A2A).", "dashboard.home.placeholder.rest": "Aún no se han añadido fuentes de API REST.", diff --git a/src/i18n/locales/pt-BR/dashboard.json b/src/i18n/locales/pt-BR/dashboard.json index 0977667..1c22d41 100644 --- a/src/i18n/locales/pt-BR/dashboard.json +++ b/src/i18n/locales/pt-BR/dashboard.json @@ -17,7 +17,17 @@ "dashboard.home.card.rest": "API REST", "dashboard.home.card.grpc": "gRPC", "dashboard.home.placeholder.system": "Cartão de status do sistema (em breve)", - "dashboard.home.placeholder.activity": "Feed de atividades (em breve)", + "dashboard.home.activity.filter.all": "Toda a atividade", + "dashboard.home.activity.filter.errors": "Erros", + "dashboard.home.activity.filter.warnings": "Avisos", + "dashboard.home.activity.search": "Pesquisar atividade", + "dashboard.home.activity.empty": "Nenhuma atividade recente.", + "dashboard.home.activity.noMatches": "Nenhuma atividade corresponde aos filtros.", + "dashboard.home.activity.error": "Não foi possível carregar a atividade recente.", + "dashboard.home.activity.status.success": "Sucesso", + "dashboard.home.activity.status.info": "Informação", + "dashboard.home.activity.status.warning": "Aviso", + "dashboard.home.activity.status.error": "Erro", "dashboard.home.placeholder.mcp": "Cartão de integridade do MCP (em breve)", "dashboard.home.placeholder.a2a": "Nenhuma fonte de agente (A2A) foi adicionada ainda.", "dashboard.home.placeholder.rest": "Nenhuma fonte de API REST foi adicionada ainda.", diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index c632fc8..c0c246a 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -5,6 +5,7 @@ import { SourceSelection } from "@/components/gateways/SourceSelection"; import type { ActionCard } from "@/components/gateways/types"; import { MCPIcon } from "@/components/icons/MCPIcon"; import { ActivityFeedButton } from "@/components/dashboard/ActivityFeedButton"; +import { ActivityView } from "@/components/dashboard/ActivityView"; import { ClearControl } from "@/components/dashboard/ClearControl"; import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder"; import { McpHealthCard } from "@/components/dashboard/McpHealthCard"; @@ -233,13 +234,14 @@ function MainContent({ }) { if (active === "system") return <SystemView />; if (active === "mcp") return <McpHealthCard health={systemHealth} />; + if (active === "activity") return <ActivityView />; return <EmptyStatePlaceholder messageId={PLACEHOLDER_MESSAGE[active]} />; } /** Placeholder copy per view until the real card lands. */ const PLACEHOLDER_MESSAGE: Record<HomeViewId, string> = { default: "dashboard.home.emptyState", - activity: "dashboard.home.placeholder.activity", + activity: "dashboard.home.emptyState", mcp: "dashboard.home.placeholder.mcp", a2a: "dashboard.home.placeholder.a2a", rest: "dashboard.home.placeholder.rest", From e4b76dd965b4fdbe61082115a73480bf8ae26a96 Mon Sep 17 00:00:00 2001 From: Anna Effort <anna.effort@ibm.com> Date: Fri, 21 Aug 2026 17:51:15 -0700 Subject: [PATCH 3/3] style: align the activity feed to the Figma frame Measured against frame 4979-39243. - Text style updates. - Adds a `text-xxs` theme token (10px/16) for the step below Tailwind's built-in scale, matching Figma's own `text-xxs`. - Wraps the feed in the single bordered panel the design shows, with a divider under the tab row, replacing the loose filters + ringed list. - Drops the count from "All activity" and lets the remaining counts inherit their label colour rather than rendering muted. - TabsTrigger has no display utility, so the `gap-*` between label and count was inert; set inline-flex here. Signed-off-by: Anna Effort <anna.effort@ibm.com> --- src/components/dashboard/ActivityFilters.tsx | 8 +++++-- src/components/dashboard/ActivityRow.tsx | 6 ++--- .../dashboard/ActivityView.test.tsx | 2 +- src/components/dashboard/ActivityView.tsx | 24 +++++++++++-------- src/index.css | 3 +++ 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/components/dashboard/ActivityFilters.tsx b/src/components/dashboard/ActivityFilters.tsx index 0d4ebbe..0b06175 100644 --- a/src/components/dashboard/ActivityFilters.tsx +++ b/src/components/dashboard/ActivityFilters.tsx @@ -47,9 +47,13 @@ export function ActivityFilters({ <Tabs value={filter} onValueChange={(value) => onFilterChange(value as ActivityFilter)}> <TabsList> {ACTIVITY_FILTERS.map((id) => ( - <TabsTrigger key={id} value={id} className="gap-1.5"> + <TabsTrigger + key={id} + value={id} + className="inline-flex items-center gap-1.5 text-xs font-medium" + > {intl.formatMessage({ id: FILTER_LABEL[id] })} - <span className="text-xs text-muted-foreground">{counts[id]}</span> + {id !== "all" && <span>{counts[id]}</span>} </TabsTrigger> ))} </TabsList> diff --git a/src/components/dashboard/ActivityRow.tsx b/src/components/dashboard/ActivityRow.tsx index 2f616b7..60bed2e 100644 --- a/src/components/dashboard/ActivityRow.tsx +++ b/src/components/dashboard/ActivityRow.tsx @@ -28,14 +28,14 @@ export function ActivityRow({ item }: { item: ActivityItem }) { <Icon className={cn("mt-0.5 size-4 shrink-0", className)} aria-hidden /> <span className="sr-only">{intl.formatMessage({ id: labelId })}</span> <div className="min-w-0 flex-1"> - <p className="text-sm font-medium text-foreground">{item.title}</p> - <p className="text-sm text-muted-foreground">{item.description}</p> + <p className="text-xs text-foreground">{item.title}</p> + <p className="text-xxs font-medium text-muted-foreground">{item.description}</p> </div> {relative && ( <time dateTime={item.timestamp} title={item.timestamp} - className="shrink-0 pt-0.5 text-xs text-muted-foreground" + className="shrink-0 text-xxs font-medium text-muted-foreground" > {relative} </time> diff --git a/src/components/dashboard/ActivityView.test.tsx b/src/components/dashboard/ActivityView.test.tsx index 072a3ed..18ab988 100644 --- a/src/components/dashboard/ActivityView.test.tsx +++ b/src/components/dashboard/ActivityView.test.tsx @@ -106,7 +106,7 @@ describe("ActivityView", () => { feed(ITEMS); renderWithProviders(<ActivityView />); - expect(within(screen.getByRole("tab", { name: /All activity/ })).getByText("5")).toBeVisible(); + expect(screen.getByRole("tab", { name: "All activity" })).toBeInTheDocument(); expect(within(screen.getByRole("tab", { name: /Errors/ })).getByText("2")).toBeVisible(); expect(within(screen.getByRole("tab", { name: /Warnings/ })).getByText("1")).toBeVisible(); expect(screen.queryByRole("tab", { name: /Info/ })).not.toBeInTheDocument(); diff --git a/src/components/dashboard/ActivityView.tsx b/src/components/dashboard/ActivityView.tsx index e9aa29c..37001a1 100644 --- a/src/components/dashboard/ActivityView.tsx +++ b/src/components/dashboard/ActivityView.tsx @@ -83,19 +83,23 @@ export function ActivityView() { } return ( - <div className="flex flex-col gap-4"> - <ActivityFilters - filter={filter} - onFilterChange={setFilter} - counts={counts} - search={search} - onSearchChange={setSearch} - /> + <div className="rounded-lg border border-border bg-card"> + <div className="px-4 py-3"> + <ActivityFilters + filter={filter} + onFilterChange={setFilter} + counts={counts} + search={search} + onSearchChange={setSearch} + /> + </div> {visible.length === 0 ? ( - <EmptyStatePlaceholder messageId="dashboard.home.activity.noMatches" /> + <div className="border-t border-border px-4 py-8 text-sm text-muted-foreground"> + {intl.formatMessage({ id: "dashboard.home.activity.noMatches" })} + </div> ) : ( <ul - className="divide-y divide-border rounded-lg ring-1 ring-foreground/10" + className="divide-y divide-border border-t border-border" aria-label={intl.formatMessage({ id: "dashboard.home.card.activity" })} > {visible.map((item) => ( diff --git a/src/index.css b/src/index.css index d9e6d6a..ba7c0a7 100644 --- a/src/index.css +++ b/src/index.css @@ -50,6 +50,9 @@ --radius-lg: var(--radius); --font-heading: var(--font-sans); --font-sans: "Inter Variable", sans-serif; + /* Figma's `text-xxs` step, below Tailwind's built-in scale. */ + --text-xxs: 10px; + --text-xxs--line-height: 16px; --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3);