Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8791996
feat(inspector): add Zustand store with persist, polling, and Page Vi…
JSisques May 22, 2026
69c802b
feat(inspector): add RequestCard, RequestDetailDialog, RequestList, I…
JSisques May 22, 2026
10bb4c2
feat(inspector): add /inspector RSC page and register inspector in to…
JSisques May 22, 2026
7c924c3
test(inspector): add durationMs, UUID id, and timestamp assertions to…
JSisques May 22, 2026
4ae94e4
feat(inspector): add i18n keys, server action, store tests, and no-op…
JSisques May 22, 2026
8f617f8
fix(inspector): use vi.hoisted for mock functions in inspector-client…
JSisques May 22, 2026
53fe334
feat(inspector): add i18n keys for view toggle, empty state, and retries
JSisques May 23, 2026
fcb95bd
feat(inspector): add InspectorEmpty and InspectorSkeleton components
JSisques May 23, 2026
b88c0c9
feat(inspector): add InspectorTimeline with descending sort and spine…
JSisques May 23, 2026
47101a5
feat(inspector): add retry badge with i18n to RequestCard
JSisques May 23, 2026
af2f656
feat(inspector): add view toggle and conditional rendering to Inspect…
JSisques May 23, 2026
26f60ed
fix(inspector): await rehydrate with async IIFE to handle void | Prom…
JSisques May 23, 2026
b0ba27e
fix(inspector): handle null from Select onValueChange in service filter
JSisques May 23, 2026
6328cf8
fix(inspector): narrow result to success before accessing data in poll()
JSisques May 23, 2026
d8e8efb
Merge pull request #213 from sisques-labs/feat/inspector-pr3
JSisques May 23, 2026
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
49 changes: 49 additions & 0 deletions app/[lang]/(dashboard)/inspector/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { getDictionary } from "@/features/shared/i18n/get-dictionary";
import { DEFAULT_LOCALE, isLocale, type Locale } from "@/features/shared/i18n/locale";
import { getEntries } from "@/lib/aws/inspector-buffer";
import { InspectorClient } from "@/features/inspector/components/inspector-client/inspector-client";

export const dynamic = "force-dynamic";

type Props = {
params: Promise<{ lang: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : DEFAULT_LOCALE;
const dict = getDictionary(locale);
return {
title: dict.inspector.title,
};
}

export default async function InspectorPage({ params }: Props) {
const { lang } = await params;
const locale: Locale = isLocale(lang) ? lang : DEFAULT_LOCALE;
const dict = getDictionary(locale);

// Read directly from buffer on RSC — no SDK call needed.
const initialEntries = [...getEntries()].sort((a, b) => b.timestamp - a.timestamp);

return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<h1 className="text-xl font-semibold">{dict.inspector.title}</h1>
<p className="text-sm text-muted-foreground">{dict.inspector.description}</p>
</div>
</div>
<InspectorClient
initialEntries={initialEntries}
dict={{
toolbar: dict.inspector.toolbar,
empty: dict.inspector.empty,
card: dict.inspector.card,
detail: dict.inspector.detail,
}}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { cleanup, render, screen, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RequestEntry } from "@/features/inspector/lib/types/types";
import type { InspectorDict } from "@/features/inspector/i18n/en";
import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals";

// ── Mocks ─────────────────────────────────────────────────────────────────────
// vi.mock() is hoisted to the top of the file, BEFORE any const declarations.
// To reference mock functions inside a vi.mock factory, they must be declared
// with vi.hoisted() which runs at the same "hoisted" phase as vi.mock itself.

const {
mockSeedEntries,
mockStartPolling,
mockStopPolling,
mockRehydrate,
mockStoreState,
} = vi.hoisted(() => {
const state = {
entries: [] as RequestEntry[],
isLoading: false,
view: "list" as "list" | "timeline",
};
return {
mockSeedEntries: vi.fn(),
mockStartPolling: vi.fn(),
mockStopPolling: vi.fn(),
mockRehydrate: vi.fn().mockResolvedValue(undefined),
mockStoreState: state,
};
});

vi.mock(
"@/features/inspector/stores/use-inspector-store/use-inspector-store",
() => {
const mockStore = vi.fn(() => ({
entries: mockStoreState.entries,
filters: { service: "", status: "all", text: "" },
status: mockStoreState.isLoading ? "polling" : "idle",
isLoading: mockStoreState.isLoading,
lastUpdatedAt: null,
isPolling: mockStoreState.isLoading,
view: mockStoreState.view,
seedEntries: mockSeedEntries,
startPolling: mockStartPolling,
stopPolling: mockStopPolling,
setFilter: vi.fn(),
clearBuffer: vi.fn(),
setView: vi.fn(),
}));
mockStore.persist = { rehydrate: mockRehydrate };
return { useInspectorStore: mockStore };
},
);

vi.mock("@/features/inspector/components/inspector-toolbar/inspector-toolbar", () => ({
InspectorToolbar: () => <div data-testid="inspector-toolbar" />,
}));

vi.mock("@/features/inspector/components/request-list/request-list", () => ({
RequestList: ({ entries }: { entries: RequestEntry[] }) => (
<div data-testid="request-list" data-count={entries.length} />
),
}));

vi.mock("@/features/inspector/components/inspector-timeline/inspector-timeline", () => ({
InspectorTimeline: ({ entries }: { entries: RequestEntry[] }) => (
<div data-testid="inspector-timeline" data-count={entries.length} />
),
}));

vi.mock("@/features/inspector/components/inspector-empty/inspector-empty", () => ({
InspectorEmpty: () => <div data-testid="inspector-empty" />,
}));

vi.mock("@/features/inspector/components/inspector-skeleton/inspector-skeleton", () => ({
InspectorSkeleton: ({ withSpine }: { withSpine: boolean }) => (
<div data-testid="inspector-skeleton" data-with-spine={withSpine} />
),
}));

import { InspectorClient } from "./inspector-client";

// ── Fixtures ──────────────────────────────────────────────────────────────────

type ClientDict = Pick<
WidenStringLiterals<InspectorDict>,
"toolbar" | "empty" | "card" | "detail"
>;

const dict: ClientDict = {
toolbar: {
filters: {
service: { label: "Service", all: "All services" },
status: { label: "Status", all: "All", success: "Success", error: "Error" },
text: { placeholder: "Search…" },
},
view: { label: "View", list: "List", timeline: "Timeline" },
clearBuffer: "Clear",
statusPolling: "Live",
statusError: "Error",
statusIdle: "Idle",
lastUpdated: "Updated {time} ago",
},
empty: { title: "No requests", body: "Make some AWS calls" },
card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" },
detail: {
title: "Detail",
input: "Input",
output: "Output",
attempts: "Attempts",
duration: "Duration",
timestamp: "Timestamp",
error: "Error",
closeLabel: "Close",
},
};

function makeEntry(id: string): RequestEntry {
return {
id,
timestamp: 1700000000000,
service: "SQS",
operation: "SendMessageCommand",
input: {},
output: {},
durationMs: 10,
status: "success",
attempts: 1,
};
}

afterEach(() => {
cleanup();
vi.clearAllMocks();
mockRehydrate.mockResolvedValue(undefined);
mockStoreState.entries = [];
mockStoreState.isLoading = false;
mockStoreState.view = "list";
});

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("InspectorClient", () => {
it("renders the toolbar", async () => {
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("inspector-toolbar")).toBeInTheDocument();
});

it("renders the request list when entries exist", async () => {
mockStoreState.entries = [makeEntry("e1")];
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("request-list")).toBeInTheDocument();
});

it("calls rehydrate on mount", async () => {
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(mockRehydrate).toHaveBeenCalledOnce();
});

it("calls seedEntries with initialEntries on mount", async () => {
const entries = [makeEntry("e1"), makeEntry("e2")];
await act(async () => {
render(<InspectorClient initialEntries={entries} dict={dict} />);
});
expect(mockSeedEntries).toHaveBeenCalledWith(entries);
});

it("calls startPolling on mount", async () => {
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(mockStartPolling).toHaveBeenCalledOnce();
});

// ── Task 3.12 / 3.13: view toggle ────────────────────────────────────────────

it("renders RequestList when view is 'list' and entries exist", async () => {
mockStoreState.entries = [makeEntry("e1")];
mockStoreState.view = "list";
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("request-list")).toBeInTheDocument();
expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument();
});

it("renders InspectorTimeline when view is 'timeline' and entries exist", async () => {
mockStoreState.entries = [makeEntry("e1")];
mockStoreState.view = "timeline";
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("inspector-timeline")).toBeInTheDocument();
expect(screen.queryByTestId("request-list")).not.toBeInTheDocument();
});

it("renders InspectorSkeleton when isLoading is true", async () => {
mockStoreState.isLoading = true;
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("inspector-skeleton")).toBeInTheDocument();
expect(screen.queryByTestId("request-list")).not.toBeInTheDocument();
expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument();
});

it("renders InspectorEmpty when no entries and not loading", async () => {
mockStoreState.entries = [];
mockStoreState.isLoading = false;
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("inspector-empty")).toBeInTheDocument();
expect(screen.queryByTestId("request-list")).not.toBeInTheDocument();
expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import { useEffect, useMemo } from "react";
import { useInspectorStore } from "@/features/inspector/stores/use-inspector-store/use-inspector-store";
import { InspectorToolbar } from "@/features/inspector/components/inspector-toolbar/inspector-toolbar";
import { RequestList } from "@/features/inspector/components/request-list/request-list";
import { InspectorTimeline } from "@/features/inspector/components/inspector-timeline/inspector-timeline";
import { InspectorEmpty } from "@/features/inspector/components/inspector-empty/inspector-empty";
import { InspectorSkeleton } from "@/features/inspector/components/inspector-skeleton/inspector-skeleton";
import type { RequestEntry } from "@/features/inspector/lib/types/types";
import type { InspectorDict } from "@/features/inspector/i18n/en";
import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals";

// ── Types ──────────────────────────────────────────────────────────────────────

type ClientDict = Pick<
WidenStringLiterals<InspectorDict>,
"toolbar" | "empty" | "card" | "detail"
>;

type InspectorClientProps = {
initialEntries: RequestEntry[];
dict: ClientDict;
};

// ── Helpers ────────────────────────────────────────────────────────────────────

const SERVICES = ["SQS", "SNS", "S3", "Lambda", "DynamoDB", "CloudWatchLogs"];

// ── Component ─────────────────────────────────────────────────────────────────

export function InspectorClient({ initialEntries, dict }: InspectorClientProps) {
const { entries, filters, view, isPolling, seedEntries, startPolling, stopPolling } =
useInspectorStore();

// Mount: rehydrate → seed RSC entries → start polling
useEffect(() => {
void (async () => {
await useInspectorStore.persist.rehydrate();
seedEntries(initialEntries);
startPolling();
})();

return () => {
stopPolling();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Client-side filter: pure derived state, no store mutation
const filtered = useMemo(() => {
return entries.filter((entry) => {
if (filters.service && entry.service !== filters.service) return false;
if (filters.status !== "all" && entry.status !== filters.status) return false;
if (filters.text) {
const needle = filters.text.toLowerCase();
const haystack = `${entry.operation} ${JSON.stringify(entry.input)} ${JSON.stringify(entry.output)}`.toLowerCase();
if (!haystack.includes(needle)) return false;
}
return true;
});
}, [entries, filters]);

// ── Render body ───────────────────────────────────────────────────────────────

function renderBody() {
if (isPolling && entries.length === 0) {
return <InspectorSkeleton withSpine={view === "timeline"} />;
}
if (filtered.length === 0) {
return <InspectorEmpty dict={{ empty: dict.empty }} />;
}
if (view === "timeline") {
return <InspectorTimeline entries={filtered} dict={{ card: dict.card }} />;
}
return <RequestList entries={filtered} dict={{ card: dict.card }} />;
}

return (
<div className="flex flex-col gap-0 rounded-lg border border-border overflow-hidden">
<InspectorToolbar dict={{ toolbar: dict.toolbar }} services={SERVICES} />
<div className="p-3">
{renderBody()}
</div>
</div>
);
}
Loading
Loading