This commit is contained in:
2026-05-25 11:00:13 +07:00
parent c5e980aa52
commit 81ba67f346
12 changed files with 1534 additions and 77 deletions

View File

@@ -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>