Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/components/dashboard/ActivityFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* 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<ActivityFilter, string> = {
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<ActivityFilter, number>;
search: string;
onSearchChange: (search: string) => void;
}

export function ActivityFilters({
filter,
onFilterChange,
counts,
search,
onSearchChange,
}: ActivityFiltersProps) {
const intl = useIntl();

return (
<div className="flex items-center justify-between gap-3">
<Tabs value={filter} onValueChange={(value) => onFilterChange(value as ActivityFilter)}>
<TabsList>
{ACTIVITY_FILTERS.map((id) => (
<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>
))}
</TabsList>
</Tabs>
<ListSearch
value={search}
onChange={onSearchChange}
ariaLabel={intl.formatMessage({ id: "dashboard.home.activity.search" })}
placeholder={intl.formatMessage({ id: "dashboard.home.activity.search" })}
/>
</div>
);
}
45 changes: 45 additions & 0 deletions src/components/dashboard/ActivityRow.tsx
Original file line number Diff line number Diff line change
@@ -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 — <title>".
*/

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-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 text-xxs font-medium text-muted-foreground"
>
{relative}
</time>
)}
</li>
);
}
171 changes: 171 additions & 0 deletions src/components/dashboard/ActivityView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(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();
});

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();
});
});
Loading