Skip to content

Commit b210f37

Browse files
committed
feat: Add event filtering by endpoint and pagination to the console UI.
1 parent 653197d commit b210f37

File tree

8 files changed

+402
-51
lines changed

8 files changed

+402
-51
lines changed

apps/console-e2e/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default defineConfig({
3131
/* Increase timeout for webhook integration tests */
3232
timeout: 60000,
3333
expect: {
34-
timeout: 10000,
34+
timeout: 4000,
3535
},
3636
/* Run your local dev server before starting the tests */
3737
webServer: {

apps/console-e2e/src/events.spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,150 @@ test.describe('Events Viewing', () => {
100100
await mockServer.stop();
101101
}
102102
});
103+
104+
test('should filter events by endpoint', async ({ page, request }) => {
105+
const baseUrl = getBaseUrl();
106+
const mockServer = new MockTargetServer();
107+
await mockServer.start();
108+
109+
try {
110+
// Create two endpoints
111+
const endpoint1Name = generateTestName('Endpoint 1');
112+
const endpoint2Name = generateTestName('Endpoint 2');
113+
const endpoint1 = await apiCreateEndpoint(request, baseUrl, endpoint1Name);
114+
const endpoint2 = await apiCreateEndpoint(request, baseUrl, endpoint2Name);
115+
116+
// Create targets for both
117+
await apiCreateTarget(request, baseUrl, endpoint1.id, {
118+
name: 'Target 1',
119+
kind: 'http',
120+
url: `${mockServer.url}/ok`,
121+
});
122+
await apiCreateTarget(request, baseUrl, endpoint2.id, {
123+
name: 'Target 2',
124+
kind: 'http',
125+
url: `${mockServer.url}/ok`,
126+
});
127+
128+
// Send events to both endpoints
129+
const event1Title = generateTestName('Event 1');
130+
const event2Title = generateTestName('Event 2');
131+
132+
await sendWebhookToIngress(baseUrl, endpoint1.id, 'http', WebhookSimulator.http('Body 1', event1Title));
133+
await sendWebhookToIngress(baseUrl, endpoint2.id, 'http', WebhookSimulator.http('Body 2', event2Title));
134+
135+
// Wait for events to be created
136+
await waitFor(async () => {
137+
const events = await apiListEvents(request, baseUrl);
138+
return events.some((e: any) => e.title === event1Title) &&
139+
events.some((e: any) => e.title === event2Title);
140+
}, 10000, 200);
141+
142+
await page.goto('/console#/events');
143+
144+
// Initially both events should be visible
145+
await expect(page.locator('tr', { hasText: event1Title })).toBeVisible();
146+
await expect(page.locator('tr', { hasText: event2Title })).toBeVisible();
147+
148+
// Filter by endpoint 1
149+
await page.locator('button[role="combobox"]').click();
150+
await page.locator(`div[role="option"]:has-text("${endpoint1Name}")`).click();
151+
152+
// Wait for filter to apply
153+
await page.waitForTimeout(1000);
154+
155+
// Only event 1 should be visible
156+
await expect(page.locator('tr', { hasText: event1Title })).toBeVisible();
157+
await expect(page.locator('tr', { hasText: event2Title })).not.toBeVisible();
158+
159+
// Switch to endpoint 2
160+
await page.locator('button[role="combobox"]').click();
161+
await page.locator(`div[role="option"]:has-text("${endpoint2Name}")`).click();
162+
163+
// Wait for filter to apply
164+
await page.waitForTimeout(1000);
165+
166+
// Only event 2 should be visible
167+
await expect(page.locator('tr', { hasText: event2Title })).toBeVisible();
168+
await expect(page.locator('tr', { hasText: event1Title })).not.toBeVisible();
169+
170+
// Reset filter
171+
await page.locator('button[role="combobox"]').click();
172+
await page.locator('div[role="option"]:has-text("All Endpoints")').click();
173+
174+
// Wait for filter to apply
175+
await page.waitForTimeout(1000);
176+
177+
// Both events should be visible again
178+
await expect(page.locator('tr', { hasText: event1Title })).toBeVisible();
179+
await expect(page.locator('tr', { hasText: event2Title })).toBeVisible();
180+
} finally {
181+
await mockServer.stop();
182+
}
183+
});
184+
185+
test('should paginate events', async ({ page, request }) => {
186+
const baseUrl = getBaseUrl();
187+
const mockServer = new MockTargetServer();
188+
await mockServer.start();
189+
190+
try {
191+
const endpointName = generateTestName('Pagination Test');
192+
const endpoint = await apiCreateEndpoint(request, baseUrl, endpointName);
193+
194+
await apiCreateTarget(request, baseUrl, endpoint.id, {
195+
name: 'Test Target',
196+
kind: 'http',
197+
url: `${mockServer.url}/ok`,
198+
});
199+
200+
// Create 25 events (more than one page with page_size=20)
201+
const eventTitles: string[] = [];
202+
for (let i = 0; i < 25; i++) {
203+
const title = generateTestName(`Event ${i}`);
204+
eventTitles.push(title);
205+
await sendWebhookToIngress(baseUrl, endpoint.id, 'http', WebhookSimulator.http(`Body ${i}`, title));
206+
// Small delay to ensure different timestamps
207+
await new Promise(resolve => setTimeout(resolve, 50));
208+
}
209+
210+
// Wait for all events to be created
211+
await waitFor(async () => {
212+
const events = await apiListEvents(request, baseUrl);
213+
return events.length >= 25;
214+
}, 15000, 500);
215+
216+
await page.goto('/console#/events');
217+
218+
// Check that pagination controls are visible
219+
await expect(page.locator('button:has-text("Previous")')).toBeVisible();
220+
await expect(page.locator('button:has-text("Next")')).toBeVisible();
221+
222+
// Previous should be disabled on first page
223+
await expect(page.locator('button:has-text("Previous")')).toBeDisabled();
224+
225+
// Next should be enabled
226+
await expect(page.locator('button:has-text("Next")')).toBeEnabled();
227+
228+
// Click next to go to page 2
229+
await page.locator('button:has-text("Next")').click();
230+
await page.waitForTimeout(1000);
231+
232+
// Now previous should be enabled
233+
await expect(page.locator('button:has-text("Previous")')).toBeEnabled();
234+
235+
// Page indicator should show page 2
236+
await expect(page.locator('text=Page 2')).toBeVisible();
237+
238+
// Click previous to go back to page 1
239+
await page.locator('button:has-text("Previous")').click();
240+
await page.waitForTimeout(1000);
241+
242+
// Should be back on page 1
243+
await expect(page.locator('text=Page 1')).toBeVisible();
244+
await expect(page.locator('button:has-text("Previous")')).toBeDisabled();
245+
} finally {
246+
await mockServer.stop();
247+
}
248+
});
103249
});

apps/console/src/routes/events.tsx

Lines changed: 95 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import { useQuery } from '@tanstack/react-query';
22
import { listEvents, listEndpoints, EventRecord } from '@webhook-router/api-client';
3-
import { Loader2, FileText, X } from 'lucide-react';
3+
import { Loader2, FileText, X, ChevronLeft, ChevronRight } from 'lucide-react';
44
import { Button } from '@/components/ui/button';
55
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
66
import { Link } from '@tanstack/react-router';
77
import { useState } from 'react';
8+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
89

910
export function EventsPage() {
1011
const [previewEvent, setPreviewEvent] = useState<EventRecord | null>(null);
12+
const [page, setPage] = useState(1);
13+
const [selectedEndpoint, setSelectedEndpoint] = useState<string>('all');
14+
const pageSize = 20;
1115

1216
const { data: events, isLoading: isEventsLoading, error: eventsError } = useQuery({
13-
queryKey: ['events'],
17+
queryKey: ['events', page, selectedEndpoint],
1418
queryFn: async () => {
15-
const res = await listEvents();
19+
const res = await listEvents({
20+
query: {
21+
page,
22+
page_size: pageSize,
23+
endpoint_id: selectedEndpoint === 'all' ? undefined : selectedEndpoint,
24+
},
25+
});
1626
return res.data;
1727
},
1828
refetchInterval: 5000,
@@ -32,11 +42,31 @@ export function EventsPage() {
3242
if (eventsError) return <div className="p-4 text-destructive">Error loading events</div>;
3343

3444
const endpointMap = new Map(endpoints?.map(e => [e.id, e.name]));
45+
const hasNextPage = events && events.length === pageSize;
46+
const hasPrevPage = page > 1;
3547

3648
return (
3749
<div className="space-y-6">
3850
<div className="flex justify-between items-center">
3951
<h2 className="text-2xl font-bold tracking-tight">Events</h2>
52+
<div className="flex items-center gap-4">
53+
<Select value={selectedEndpoint} onValueChange={(value) => {
54+
setSelectedEndpoint(value);
55+
setPage(1);
56+
}}>
57+
<SelectTrigger className="w-[200px]">
58+
<SelectValue placeholder="All Endpoints" />
59+
</SelectTrigger>
60+
<SelectContent>
61+
<SelectItem value="all">All Endpoints</SelectItem>
62+
{endpoints?.map((endpoint) => (
63+
<SelectItem key={endpoint.id} value={endpoint.id}>
64+
{endpoint.name}
65+
</SelectItem>
66+
))}
67+
</SelectContent>
68+
</Select>
69+
</div>
4070
</div>
4171

4272
<div className="rounded-md border">
@@ -58,40 +88,41 @@ export function EventsPage() {
5888
const sentCount = deliveries.filter(d => d.status === 'sent').length;
5989
const failedCount = deliveries.filter(d => d.status !== 'sent').length;
6090
return (
61-
<TableRow key={event.id}>
62-
<TableCell className="font-mono text-xs">{event.id?.slice(0, 8)}</TableCell>
63-
<TableCell>
64-
<Link
65-
to="/endpoints/$endpointId"
66-
params={{ endpointId: event.endpoint_id }}
67-
className="text-primary hover:underline font-medium"
68-
>
69-
{endpointMap.get(event.endpoint_id) || event.endpoint_id.slice(0, 8)}
70-
</Link>
71-
</TableCell>
72-
<TableCell className="capitalize">{event.platform}</TableCell>
73-
<TableCell className="truncate max-w-[300px]">{event.title || '-'}</TableCell>
74-
<TableCell>
75-
<div className="text-xs">
76-
<span className="text-emerald-600">{sentCount} sent</span>
77-
<span className="mx-2 text-muted-foreground">/</span>
78-
<span className={failedCount > 0 ? "text-destructive" : "text-muted-foreground"}>
79-
{failedCount} failed
80-
</span>
81-
</div>
82-
</TableCell>
83-
<TableCell className="text-muted-foreground">{new Date(event.created_at * 1000).toLocaleString()}</TableCell>
84-
<TableCell>
85-
<Button
86-
variant="outline"
87-
size="icon-sm"
88-
onClick={() => setPreviewEvent(event)}
89-
>
90-
<FileText className="w-4 h-4" />
91-
</Button>
92-
</TableCell>
93-
</TableRow>
94-
)})}
91+
<TableRow key={event.id}>
92+
<TableCell className="font-mono text-xs">{event.id?.slice(0, 8)}</TableCell>
93+
<TableCell>
94+
<Link
95+
to="/endpoints/$endpointId"
96+
params={{ endpointId: event.endpoint_id }}
97+
className="text-primary hover:underline font-medium"
98+
>
99+
{endpointMap.get(event.endpoint_id) || event.endpoint_id.slice(0, 8)}
100+
</Link>
101+
</TableCell>
102+
<TableCell className="capitalize">{event.platform}</TableCell>
103+
<TableCell className="truncate max-w-[300px]">{event.title || '-'}</TableCell>
104+
<TableCell>
105+
<div className="text-xs">
106+
<span className="text-emerald-600">{sentCount} sent</span>
107+
<span className="mx-2 text-muted-foreground">/</span>
108+
<span className={failedCount > 0 ? "text-destructive" : "text-muted-foreground"}>
109+
{failedCount} failed
110+
</span>
111+
</div>
112+
</TableCell>
113+
<TableCell className="text-muted-foreground">{new Date(event.created_at * 1000).toLocaleString()}</TableCell>
114+
<TableCell>
115+
<Button
116+
variant="outline"
117+
size="icon-sm"
118+
onClick={() => setPreviewEvent(event)}
119+
>
120+
<FileText className="w-4 h-4" />
121+
</Button>
122+
</TableCell>
123+
</TableRow>
124+
)
125+
})}
95126
{events?.length === 0 && (
96127
<TableRow>
97128
<TableCell colSpan={7} className="text-center text-muted-foreground">No events found.</TableCell>
@@ -101,6 +132,33 @@ export function EventsPage() {
101132
</Table>
102133
</div>
103134

135+
{/* Pagination Controls */}
136+
<div className="flex items-center justify-between">
137+
<div className="text-sm text-muted-foreground">
138+
Page {page} {events && events.length > 0 && `(${events.length} events)`}
139+
</div>
140+
<div className="flex items-center gap-2">
141+
<Button
142+
variant="outline"
143+
size="sm"
144+
onClick={() => setPage(p => Math.max(1, p - 1))}
145+
disabled={!hasPrevPage}
146+
>
147+
<ChevronLeft className="w-4 h-4 mr-1" />
148+
Previous
149+
</Button>
150+
<Button
151+
variant="outline"
152+
size="sm"
153+
onClick={() => setPage(p => p + 1)}
154+
disabled={!hasNextPage}
155+
>
156+
Next
157+
<ChevronRight className="w-4 h-4 ml-1" />
158+
</Button>
159+
</div>
160+
</div>
161+
104162
{previewEvent && (
105163
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
106164
<div className="bg-background rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] flex flex-col">

apps/webhook_router/src/db.rs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -292,13 +292,44 @@ impl Db {
292292
Ok(())
293293
}
294294

295-
pub async fn list_events(&self) -> Result<Vec<EventRecord>, sqlx::Error> {
296-
let rows = sqlx::query(
297-
"SELECT id, endpoint_id, platform, title, markdown, raw, created_at
298-
FROM events ORDER BY created_at DESC LIMIT 100",
299-
)
300-
.fetch_all(&self.pool)
301-
.await?;
295+
pub async fn list_events(
296+
&self,
297+
endpoint_id: Option<&str>,
298+
page: Option<i64>,
299+
page_size: Option<i64>,
300+
) -> Result<Vec<EventRecord>, sqlx::Error> {
301+
let page = page.unwrap_or(1).max(1);
302+
let page_size = page_size.unwrap_or(50).clamp(1, 100);
303+
let offset = (page - 1) * page_size;
304+
305+
let (query_str, has_filter) = if let Some(_ep_id) = endpoint_id {
306+
(
307+
"SELECT id, endpoint_id, platform, title, markdown, raw, created_at
308+
FROM events WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
309+
true,
310+
)
311+
} else {
312+
(
313+
"SELECT id, endpoint_id, platform, title, markdown, raw, created_at
314+
FROM events ORDER BY created_at DESC LIMIT ? OFFSET ?",
315+
false,
316+
)
317+
};
318+
319+
let rows = if has_filter {
320+
sqlx::query(query_str)
321+
.bind(endpoint_id.unwrap())
322+
.bind(page_size)
323+
.bind(offset)
324+
.fetch_all(&self.pool)
325+
.await?
326+
} else {
327+
sqlx::query(query_str)
328+
.bind(page_size)
329+
.bind(offset)
330+
.fetch_all(&self.pool)
331+
.await?
332+
};
302333

303334
let mut events = Vec::new();
304335
let mut event_ids = Vec::new();
@@ -426,7 +457,7 @@ mod tests {
426457
.await
427458
.expect("insert delivery");
428459

429-
let events = db.list_events().await.expect("list events");
460+
let events = db.list_events(None, None, None).await.expect("list events");
430461
assert!(!events.is_empty());
431462

432463
let endpoints = db.list_endpoints().await.expect("list endpoints");

0 commit comments

Comments
 (0)