diff --git a/src/components/dashboard/ActivityFilters.tsx b/src/components/dashboard/ActivityFilters.tsx new file mode 100644 index 0000000..0b06175 --- /dev/null +++ b/src/components/dashboard/ActivityFilters.tsx @@ -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 = { + 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] })} + {id !== "all" && {counts[id]}} + + ))} + + + +
+ ); +} diff --git a/src/components/dashboard/ActivityRow.tsx b/src/components/dashboard/ActivityRow.tsx new file mode 100644 index 0000000..60bed2e --- /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-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> + ); +} diff --git a/src/components/dashboard/ActivityView.test.tsx b/src/components/dashboard/ActivityView.test.tsx new file mode 100644 index 0000000..18ab988 --- /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(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(); + }); +}); diff --git a/src/components/dashboard/ActivityView.tsx b/src/components/dashboard/ActivityView.tsx new file mode 100644 index 0000000..37001a1 --- /dev/null +++ b/src/components/dashboard/ActivityView.tsx @@ -0,0 +1,112 @@ +/** + * 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="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 ? ( + <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 border-t border-border" + 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/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<typeof useAuth>); } @@ -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<ServersResponse>(MCP_REACH_PATH); const { data: a2aAgents, error: a2aError } = useQuery<Activatable[]>(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..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>; } @@ -26,6 +27,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,12 +36,12 @@ 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<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> => { @@ -58,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); } @@ -68,6 +73,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 +95,13 @@ export function useRecentActivity(options: UseRecentActivityOptions = {}): UseRe controller.abort(); window.clearInterval(intervalId); }; - }, [fetchOnce, mock, pollIntervalMs]); + }, [fetchOnce, mock, pollIntervalMs, enabled]); const refetch = useCallback(async (): Promise<void> => { + if (!enabled) return; setIsLoading(true); await fetchOnce(); - }, [fetchOnce]); + }, [fetchOnce, enabled]); return { items, isLoading, error, refetch }; } 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/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); 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",