testable
This commit is contained in:
@@ -1,21 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ApiError, ingest } from '@/api/client';
|
||||
|
||||
interface LogEntry {
|
||||
ts: string;
|
||||
ok: boolean;
|
||||
message: string;
|
||||
interface LiveEvent {
|
||||
topic: string;
|
||||
partition: number;
|
||||
offset: number;
|
||||
timestamp: string;
|
||||
event: {
|
||||
workspace_id: string;
|
||||
source_id: string;
|
||||
message_id: string;
|
||||
type: string;
|
||||
user_id?: string;
|
||||
anonymous_id?: string;
|
||||
event?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
traits?: Record<string, unknown>;
|
||||
received_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
const STREAM_URL = (import.meta.env.VITE_API_BASE_URL ?? '/api/ingest') + '/live/events';
|
||||
const MAX_ROWS = 200;
|
||||
|
||||
export function LivePage() {
|
||||
const [writeKey, setWriteKey] = useState('cdp_dev_writekey_1234567890');
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [filterType, setFilterType] = useState('');
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [events, setEvents] = useState<LiveEvent[]>([]);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
const send = useMutation({
|
||||
useEffect(() => {
|
||||
// Tear down any previous connection before opening a new one (handles
|
||||
// filter changes via the dropdown below).
|
||||
esRef.current?.close();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (filterType) params.set('type', filterType);
|
||||
const url = STREAM_URL + (params.toString() ? `?${params}` : '');
|
||||
|
||||
const es = new EventSource(url);
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener('ingest', (e) => {
|
||||
try {
|
||||
const payload = JSON.parse((e as MessageEvent).data) as LiveEvent;
|
||||
setEvents((prev) => [payload, ...prev].slice(0, MAX_ROWS));
|
||||
} catch {
|
||||
/* drop */
|
||||
}
|
||||
});
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onerror = () => setConnected(false);
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
setConnected(false);
|
||||
};
|
||||
}, [filterType]);
|
||||
|
||||
const sendTest = useMutation({
|
||||
mutationFn: async () =>
|
||||
ingest.track(writeKey, {
|
||||
type: 'track',
|
||||
@@ -24,49 +73,119 @@ export function LivePage() {
|
||||
event: 'Console Test',
|
||||
properties: { source: 'console', at: new Date().toISOString() },
|
||||
}),
|
||||
onSuccess: () =>
|
||||
setLogs((prev) => [{ ts: new Date().toLocaleTimeString(), ok: true, message: 'event accepted' }, ...prev].slice(0, 50)),
|
||||
onError: (err: ApiError) =>
|
||||
setLogs((prev) => [{ ts: new Date().toLocaleTimeString(), ok: false, message: `${err.status} ${err.message}` }, ...prev].slice(0, 50)),
|
||||
// Surface as a fake live row so the user sees feedback even before
|
||||
// the SSE round-trip lands.
|
||||
setEvents((prev) => [
|
||||
{
|
||||
topic: '(error)',
|
||||
partition: -1,
|
||||
offset: -1,
|
||||
timestamp: new Date().toISOString(),
|
||||
event: {
|
||||
workspace_id: '',
|
||||
source_id: '',
|
||||
message_id: '',
|
||||
type: 'error',
|
||||
event: `${err.status} ${err.message}`,
|
||||
received_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
...prev,
|
||||
].slice(0, MAX_ROWS)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Live events</h1>
|
||||
<p className="text-sm text-muted-foreground">Send a synthetic event and watch the response.</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Live events</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Streaming from Kafka <code>events.ingest</code> via SSE.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={connected ? 'success' : 'secondary'}>
|
||||
{connected ? 'connected' : 'disconnected'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send test event</CardTitle>
|
||||
<CardDescription>Uses the dev write key by default.</CardDescription>
|
||||
<CardDescription>Posts to /v1/track; the resulting event will appear below.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input value={writeKey} onChange={(e) => setWriteKey(e.target.value)} placeholder="write key" />
|
||||
<Button onClick={() => send.mutate()} disabled={send.isPending}>
|
||||
{send.isPending ? 'Sending…' : 'Send'}
|
||||
<Button onClick={() => sendTest.mutate()} disabled={sendTest.isPending}>
|
||||
{sendTest.isPending ? 'Sending…' : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Log</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{logs.length === 0 && <div className="text-muted-foreground">— no events yet —</div>}
|
||||
{logs.map((l, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<span className="text-muted-foreground">{l.ts}</span>
|
||||
<span className={l.ok ? 'text-emerald-600' : 'text-destructive'}>{l.ok ? 'OK' : 'ERR'}</span>
|
||||
<span>{l.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Stream</CardTitle>
|
||||
<CardDescription>Newest first. Buffer max {MAX_ROWS} rows.</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
>
|
||||
<option value="">all types</option>
|
||||
<option value="track">track</option>
|
||||
<option value="identify">identify</option>
|
||||
<option value="page">page</option>
|
||||
<option value="group">group</option>
|
||||
<option value="alias">alias</option>
|
||||
<option value="screen">screen</option>
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={() => setEvents([])}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-auto">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">— waiting for events —</div>
|
||||
) : (
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="px-2 py-1">time</th>
|
||||
<th className="px-2 py-1">type</th>
|
||||
<th className="px-2 py-1">event</th>
|
||||
<th className="px-2 py-1">user / anon</th>
|
||||
<th className="px-2 py-1">partition</th>
|
||||
<th className="px-2 py-1">message_id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((row, i) => (
|
||||
<tr key={`${row.event.message_id}-${i}`} className="border-b hover:bg-muted/30">
|
||||
<td className="px-2 py-1 whitespace-nowrap">
|
||||
{new Date(row.timestamp).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<Badge variant={row.event.type === 'error' ? 'destructive' : 'secondary'}>
|
||||
{row.event.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1">{row.event.event ?? ''}</td>
|
||||
<td className="px-2 py-1 max-w-[220px] truncate">
|
||||
{row.event.user_id ?? row.event.anonymous_id ?? ''}
|
||||
</td>
|
||||
<td className="px-2 py-1">{row.partition === -1 ? '-' : `${row.partition}@${row.offset}`}</td>
|
||||
<td className="px-2 py-1 max-w-[260px] truncate">{row.event.message_id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user