Recent Activity feed - #75
Conversation
?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 <anna.effort@ibm.com>
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>
e4b76dd to
3ffb8e3
Compare
marekdano
left a comment
There was a problem hiding this comment.
🔴 Blocking
1. Tab count renders with no space — "Errors2", not "Errors 2"
In ActivityFilters.tsx:
<TabsTrigger …>
{intl.formatMessage({ id: FILTER_LABEL[id] })}
{id !== "all" && <span>{counts[id]}</span>}
</TabsTrigger>These two expressions are separated only by a newline in JSX, which gets stripped — there's no literal text node between them. The rendered/announced text is literally "Errors2", not "Errors 2". The gap-1.5 flex gutter creates a visual gap, but the actual DOM text (and the accessible name a screen reader announces) has none.
The test doesn't catch this because within(...).getByText("2") matches the count <span> independently of what's next to it.
Fix: add an explicit space, e.g.:
{intl.formatMessage({ id: FILTER_LABEL[id] })}{" "}
{id !== "all" && <span>{counts[id]}</span>}🟡 Functionally impacting
2. A transient poll failure discards an already-loaded feed
In ActivityView.tsx:
if (isLoading) return <Skeleton .../>;
if (error) {
return isPermissionDenied(error) ? <PermissionDenied /> : <EmptyStatePlaceholder .../>;
}
if (items.length === 0) return <EmptyStatePlaceholder .../>;error is checked before items.length, so once the feed has successfully loaded, a later 30s poll tick that fails (network blip, transient 5xx) replaces the whole populated list with the generic error placeholder — even though items still holds the last-good data.
Worth aligning with the sibling card in this same area, McpHealthCard.tsx, which handles exactly this case and documents the reasoning:
"Only surface the error card when there is no roster to show. Once servers have loaded, a transient (non-403) refetch failure keeps the last-known roster on screen rather than replacing it with an error."
Suggest gating the non-403 branch on !items.length (a 403 should still take over immediately — losing permission mid-session shouldn't keep showing stale data, same reasoning McpHealthCard uses). Might also be worth a test for "error arrives after items are already loaded."
3. isPermissionDenied's doc comment is stale for this new caller
PermissionDenied.tsx says:
"useQuery sanitizes thrown ApiErrors into plain
{ message, status, ... }objects … so an instanceof test would never match a query error"
That's true for useQuery-based callers, but useRecentActivity is a hand-rolled hook that now stores the live ApiError instance directly (post-PR). The structural check ("status" in err && err.status === 403) still works correctly here since ApiError exposes status — this isn't a functional bug — but the comment now reads as if an instanceof check would be wrong in general, which could mislead the next person touching this. Worth a one-line note in ActivityView (or an update to the shared comment) clarifying that the structural check is intentionally shape-agnostic across both callers.
🔵 Suggestions / minor
4. Search isn't debounced
Every keystroke re-filters via useMemo. At the documented cap of 100 items this is cheap, so not a real perf concern, but other search boxes in the app debounce — could be a useDeferredValue wrap for consistency, low priority.
5. Dead PLACEHOLDER_MESSAGE.activity entry in Dashboard.tsx
const PLACEHOLDER_MESSAGE: Record<HomeViewId, string> = {
default: "dashboard.home.emptyState",
activity: "dashboard.home.emptyState", // ← unreachable
…if (active === "activity") return <ActivityView />; above MainContent means this key is never read. Worth removing (and narrowing the Record type) so a future reader doesn't think it's live.
6. ActivityRow renders item.title/item.description as plain text — worth a one-line comment
These are server-rendered strings passed straight through JSX, so React's escaping covers it — this is safe as-is. A short comment noting why no sanitization is needed would save a future reviewer from flagging it again.
✅ What's well done
- Defense-in-depth 403 handling: page-level gate and a runtime check for the stale-permission/team-switch race, each covered by its own test.
- No raw server error text ever reaches the DOM (verified by the
psycopg2.OperationalErrortest). aria-hiddenicon +sr-onlystatus text pattern inActivityRowis correct and consistent with the rest of the dashboard.- Tab counts are derived from
items, not the filtered/searched result, so they stay stable while typing — a good, clearly-documented UX call. - i18n parity across
en-US,es-ES,pt-BRin the same commit.
- The tab label and count were separated only by a JSX newline, so the rendered text was "Errors2". The gap-1.5 gutter made it look right. - A failed poll replaced an already-loaded feed with the error placeholder. The hook leaves items untouched on failure, so the last good feed now stays up and the next tick recovers. A 403 still takes over immediately. - Drop the unreachable activity key from PLACEHOLDER_MESSAGE; the view returns above the lookup. The tab-count test asserted the count span in isolation and could not see the missing space. The relative-timestamp test pinned an absolute date against the real clock and started failing once it aged past "ago". Signed-off-by: Anna Effort <anna.effort@ibm.com>
|
Thanks, this was a good catch list. Pushed Fixed:
Also, unrelated to the review: the "renders relative timestamps" test was failing on this branch before any of the above. It pinned --> Reasoning why updates to these things may not be needed:
|
gcgoncalves
left a comment
There was a problem hiding this comment.
Solid PR, just raised a few performance/A11y improvement suggestions.
| const intl = useIntl(); | ||
| const { items, isLoading, error } = useRecentActivity({ limit: ACTIVITY_FEED_LIMIT }); | ||
| const [filter, setFilter] = useState<ActivityFilter>("all"); | ||
| const [search, setSearch] = useState(""); |
There was a problem hiding this comment.
Issue: No maximum length constraint on search input.
const [search, setSearch] = useState(""); // ❌ UnboundedRisk: While unlikely to be exploited, extremely long search strings could cause performance degradation or memory issues.
Recommendation:
// ✅ Add reasonable limit
const MAX_SEARCH_LENGTH = 200;
<ListSearch
value={search}
onChange={(value) => setSearch(value.slice(0, MAX_SEARCH_LENGTH))}
// ...
/>| {visible.length === 0 ? ( | ||
| <div className="border-t border-border px-4 py-8 text-sm text-muted-foreground"> | ||
| {intl.formatMessage({ id: "dashboard.home.activity.noMatches" })} | ||
| </div> | ||
| ) : ( |
There was a problem hiding this comment.
Issue: Empty filter results are not announced to screen readers when filters change.
// ❌ No live region for dynamic content
<div className="border-t border-border px-4 py-8 text-sm text-muted-foreground">
{intl.formatMessage({ id: "dashboard.home.activity.noMatches" })}
</div>Impact: Screen reader users won't be notified when their filter/search produces no results.
Recommendation:
// ✅ Add live region
<div
className="border-t border-border px-4 py-8 text-sm text-muted-foreground"
role="status"
aria-live="polite"
>
{intl.formatMessage({ id: "dashboard.home.activity.noMatches" })}
</div>| <TabsTrigger | ||
| key={id} | ||
| value={id} | ||
| className="inline-flex items-center gap-1.5 text-xs font-medium" | ||
| > | ||
| {intl.formatMessage({ id: FILTER_LABEL[id] })}{" "} | ||
| {id !== "all" && <span>{counts[id]}</span>} | ||
| </TabsTrigger> |
There was a problem hiding this comment.
Issue: Tab counts are not explicitly associated with their labels for screen readers.
// ❌ Count is separate text node
{intl.formatMessage({ id: FILTER_LABEL[id] })}{" "}
{id !== "all" && <span>{counts[id]}</span>}Recommendation:
// ✅ Use aria-label for complete context
<TabsTrigger
key={id}
value={id}
aria-label={`${intl.formatMessage({ id: FILTER_LABEL[id] })}${
id !== "all" ? ` (${counts[id]})` : ""
}`}
className="inline-flex items-center gap-1.5 text-xs font-medium"
>
{intl.formatMessage({ id: FILTER_LABEL[id] })}{" "}
{id !== "all" && <span aria-hidden="true">{counts[id]}</span>}
</TabsTrigger>| const intl = useIntl(); | ||
| const { items, isLoading, error } = useRecentActivity({ limit: ACTIVITY_FEED_LIMIT }); | ||
| const [filter, setFilter] = useState<ActivityFilter>("all"); | ||
| const [search, setSearch] = useState(""); |
There was a problem hiding this comment.
Issue: Search triggers re-filtering on every keystroke without debouncing.
// ❌ No debouncing
const [search, setSearch] = useState("");Impact: For large feeds (100 items), rapid typing could cause unnecessary re-renders.
Recommendation:
// ✅ Add debouncing
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
const [searchInput, setSearchInput] = useState("");
const search = useDebouncedValue(searchInput, 300);
// Use searchInput for controlled input, search for filtering|
|
||
| return ( | ||
| <div className="flex items-center justify-between gap-3"> | ||
| <Tabs value={filter} onValueChange={(value) => onFilterChange(value as ActivityFilter)}> |
There was a problem hiding this comment.
Issue: Inline arrow function creates new reference on every render.
// ❌ New function on every render
onValueChange={(value) => onFilterChange(value as ActivityFilter)}Impact: Minor - TabsList likely doesn't memo its children, but it's a best practice.
Recommendation:
// ✅ Memoize callback
const handleFilterChange = useCallback(
(value: string) => onFilterChange(value as ActivityFilter),
[onFilterChange]
);
<Tabs value={filter} onValueChange={handleFilterChange}>
marekdano
left a comment
There was a problem hiding this comment.
Functionally-impacting
-
src/components/dashboard/ActivityRow.tsx:23—ACTIVITY_STATUS_STYLE[item.status]has no fallback for an unrecognized status. Since the backend endpoint is still unimplemented andapi.get()does a raw cast with no runtime validation, any status string outsidesuccess/info/warning/errorthrows during render and crashes the panel. -
src/hooks/useRecentActivity.ts:91— the 30s-interval poll call omits theAbortSignalthat the initial fetch gets (line 84), socleanup'scontroller.abort()doesn't cancel it. A poll racing a mount/unmount cycle can resolve late and clobber a fresh instance's state. -
src/components/dashboard/ActivityFilters.tsx:6— doc comment claims tab counts and mini-card counts "never disagree," but they read different windows/cadences (useMiniCardStatusesuseslimit: 10, one-shot;ActivityViewuseslimit: 100, 30s poll). Counts will visibly diverge once there are >10 errors/warnings. -
src/components/dashboard/ActivityView.tsx:47— mounts a second, independentuseRecentActivityfetch on top of the oneuseMiniCardStatusesalready keeps running at the Dashboard level — two uncoordinated GETs to the same endpoint with no shared cache.
Suggestions
-
src/components/dashboard/ActivityView.tsx:39— hand-rolledmatchesSearchreimplements the existinguseLocalSearchhook, but without its 300ms debounce — every keystroke re-filters up to 100 rows synchronously. -
src/components/dashboard/activityStatus.ts:36—ACTIVITY_STATUS_STYLEduplicates the same 4 tone colors already defined inStatusDot.tsx'sTONE_CLASS, independently — a future rebrand won't propagate here.
Minor
-
src/components/dashboard/ActivityRow.tsx:34— whenformatLastSeenreturnsnull, the time indicator is silently omitted with no fallback (other call sites in the codebase use an explicit "Not available" label). -
src/components/dashboard/ActivityFilters.tsx:18—ACTIVITY_FILTERSandFILTER_LABELare two separate declarations of the same filter set that must be kept in sync by hand. -
src/pages/Dashboard.tsx:243— the newExclude<HomeViewId, "activity">narrowing is inconsistent (mcp/system are equally unreachable via the map but weren't excluded), and the added comment overstates what the map actually guarantees.

Builds

ActivityView, the Recent Activity feed.?view=activitypreviously fell through to the "coming soon" placeholder: the earlier spike shipped the plumbing (types, api client, hook, fixture, MSW handler) but never the list itself.Dependencies (merged)
#74 (BFF
/apiprefix) and #69 (theenabledoption this hook needs) have merged. This branch is rebased ontomain, sod967052is gone from the diff and the view loads real data without any branch juggling. Both of this PR's own commits are unchanged by the rebase (identical patch-ids).Included
activityStatus.tsholds the single status to {icon, tone} map, using the setui/sonner.tsxalready ships.limit: 100rather than the hook's default of 10, because search filters the fetched window client-side.HOME_STATES.activityalready declaresrequiredPermission: "audit:read", so the page renders the skeleton while permissions load andPermissionDeniedwhen the caller lacks it.useRecentActivitynow keeps the original error instead of flattening it to{ message }.ApiErrorcarries the status andisPermissionDeniedneeds the instance, so a 403 that slips past the page gate (stale or coarser client permissions, team-switch race) renders as denied rather than as a generic failure.Notes
_audit_to_activitytestsrow.action in ("read", "execute"), but no write site emits either string, so read-type rows (view_server,view_prompt) render assuccess. The same mismatch mangles titles: 30 of the gateway's 31 audit write sites miss_ACTION_VERBS, so a deactivate renders as "Tool set tool state" and a delete as "Server delete server". Filed against IBM/mcp-context-forge#6342 (see comment for the full enumeration).Both are server-side.
title,descriptionandstatusare server-rendered by contract and the UI must not re-derive them, so nothing here changes until #6342 lands. The awkward wording in a live feed will be addressed by that work.Set
AUDIT_TRAIL_ENABLED=truein the.envfile when testing (off by default).Tests
10 new tests in
ActivityView.test.tsxcovering rows, relative timestamps, the screen-reader status label, tab counts, severity filtering, search, empty-feed versus empty-filter, the 403 path, the non-403 path and the loading skeleton.Full suite on the rebased branch: 3230 passed, 1 skipped, 198 files. tsc, eslint and the production build are clean.