init ingestion

This commit is contained in:
2026-05-24 22:59:24 +07:00
commit 4e8c11d545
80 changed files with 5639 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CDP Console</title>
</head>
<body class="h-full bg-background text-foreground antialiased">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Proxy to backends so the SPA can hit /api/ingest, /api/bulker, /api/rotor.
location /api/ingest/ {
rewrite ^/api/ingest/(.*)$ /$1 break;
proxy_pass http://ingest:3049;
}
location /api/bulker/ {
rewrite ^/api/bulker/(.*)$ /$1 break;
proxy_pass http://bulker:3042;
}
location /api/rotor/ {
rewrite ^/api/rotor/(.*)$ /$1 break;
proxy_pass http://rotor:3401;
}
}

View File

@@ -0,0 +1,44 @@
{
"name": "cdp-console",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --port 3000",
"lint": "eslint ."
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@tanstack/react-query": "^5.59.16",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.451.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-router-dom": "^6.27.0",
"recharts": "^2.13.0",
"tailwind-merge": "^2.5.4",
"zod": "^3.23.8",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.12.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.3",
"vite": "^5.4.9"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#0f172a"/><text x="16" y="21" text-anchor="middle" font-family="Inter, sans-serif" font-size="14" font-weight="700" fill="#fff">cdp</text></svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@@ -0,0 +1,32 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppShell } from '@/components/AppShell';
import { DashboardPage } from '@/pages/Dashboard';
import { SourcesPage } from '@/pages/Sources';
import { DestinationsPage } from '@/pages/Destinations';
import { FunctionsPage } from '@/pages/Functions';
import { LivePage } from '@/pages/Live';
import { SettingsPage } from '@/pages/Settings';
const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
});
export function App() {
return (
<QueryClientProvider client={qc}>
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/sources" element={<SourcesPage />} />
<Route path="/destinations" element={<DestinationsPage />} />
<Route path="/functions" element={<FunctionsPage />} />
<Route path="/live" element={<LivePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,90 @@
// Thin fetch wrapper. Throws on non-2xx with a structured ApiError.
export class ApiError extends Error {
status: number;
field?: string;
constructor(status: number, message: string, field?: string) {
super(message);
this.status = status;
this.field = field;
}
}
const INGEST_BASE = import.meta.env.VITE_API_BASE_URL ?? '/api/ingest';
const ROTOR_BASE = import.meta.env.VITE_ROTOR_BASE_URL ?? '/api/rotor';
const BULKER_BASE = import.meta.env.VITE_BULKER_BASE_URL ?? '/api/bulker';
async function request<T>(base: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
...init,
headers: {
'content-type': 'application/json',
...(init?.headers ?? {}),
},
});
const text = await res.text();
const data = text ? safeJSON(text) : undefined;
if (!res.ok) {
const msg = (data as { error?: string })?.error ?? res.statusText;
const field = (data as { field?: string })?.field;
throw new ApiError(res.status, msg, field);
}
return data as T;
}
function safeJSON(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
// ---------------------------------------------------------------------------
// Ingest API
// ---------------------------------------------------------------------------
export const ingest = {
health: () => request<{ status: string }>(INGEST_BASE, '/health'),
ready: () => request<{ status: string }>(INGEST_BASE, '/ready'),
track: (writeKey: string, body: Record<string, unknown>) =>
request<{ ok: boolean }>(INGEST_BASE, '/track', {
method: 'POST',
headers: { Authorization: `Bearer ${writeKey}` },
body: JSON.stringify(body),
}),
};
// ---------------------------------------------------------------------------
// Rotor API
// ---------------------------------------------------------------------------
export interface RunRequest {
code: string;
event: Record<string, unknown>;
}
export interface RunResponse {
result: unknown;
}
export const rotor = {
run: (body: RunRequest) =>
request<RunResponse>(ROTOR_BASE, '/v1/run', {
method: 'POST',
body: JSON.stringify(body),
}),
upsert: (body: { workspace_id: string; slug: string; code: string }) =>
request<{ ok: boolean }>(ROTOR_BASE, '/v1/functions', {
method: 'POST',
body: JSON.stringify(body),
}),
};
// ---------------------------------------------------------------------------
// Bulker API
// ---------------------------------------------------------------------------
export const bulker = {
health: () => request<{ status: string }>(BULKER_BASE, '/health'),
};

View File

@@ -0,0 +1,50 @@
import { NavLink, Outlet } from 'react-router-dom';
import {
Activity, BarChart3, Code2, Database, Settings, Workflow,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const nav = [
{ to: '/', label: 'Dashboard', icon: BarChart3 },
{ to: '/sources', label: 'Sources', icon: Workflow },
{ to: '/destinations', label: 'Destinations', icon: Database },
{ to: '/functions', label: 'Functions', icon: Code2 },
{ to: '/live', label: 'Live events', icon: Activity },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export function AppShell() {
return (
<div className="flex h-full">
<aside className="w-60 shrink-0 border-r bg-muted/30 p-4">
<div className="mb-6 px-2">
<div className="text-lg font-semibold">CDP Console</div>
<div className="text-xs text-muted-foreground">Ingestion</div>
</div>
<nav className="space-y-1">
{nav.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium',
isActive
? 'bg-primary text-primary-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
))}
</nav>
</aside>
<main className="flex-1 overflow-auto p-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-muted text-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
success: 'border-transparent bg-emerald-500 text-white',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { buttonVariants };

View File

@@ -0,0 +1,44 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)} {...props} />
),
);
Card.displayName = 'Card';
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
export const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,46 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* { @apply border-border; }
body { @apply bg-background text-foreground; }
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { bulker, ingest } from '@/api/client';
export function DashboardPage() {
const ingestHealth = useQuery({
queryKey: ['health', 'ingest'],
queryFn: ingest.health,
refetchInterval: 5_000,
});
const bulkerHealth = useQuery({
queryKey: ['health', 'bulker'],
queryFn: bulker.health,
refetchInterval: 5_000,
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">Operational status of the ingestion stack.</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<ServiceCard name="Ingest" status={statusFromQuery(ingestHealth)} port={3049} />
<ServiceCard name="Bulker" status={statusFromQuery(bulkerHealth)} port={3042} />
<ServiceCard name="Rotor" status="unknown" port={3401} />
</div>
<Card>
<CardHeader>
<CardTitle>Getting started</CardTitle>
<CardDescription>Send a test event with the dev write key.</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-md bg-muted p-4 text-xs">
{`curl -X POST http://localhost:3049/track \\
-H 'Authorization: Bearer cdp_dev_writekey_1234567890' \\
-H 'Content-Type: application/json' \\
-d '{
"type": "track",
"messageId": "m_${'${'}Date.now()${'}'}",
"anonymousId": "anon_1",
"event": "Signed Up",
"properties": { "plan": "pro" }
}'`}
</pre>
</CardContent>
</Card>
</div>
);
}
function ServiceCard({ name, status, port }: { name: string; status: ServiceStatus; port: number }) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{name}</CardTitle>
<StatusBadge status={status} />
</div>
<CardDescription>localhost:{port}</CardDescription>
</CardHeader>
</Card>
);
}
type ServiceStatus = 'ok' | 'down' | 'unknown';
function statusFromQuery(q: { isLoading: boolean; isError: boolean; data?: { status: string } }): ServiceStatus {
if (q.isLoading) return 'unknown';
if (q.isError) return 'down';
return q.data?.status === 'ok' ? 'ok' : 'down';
}
function StatusBadge({ status }: { status: ServiceStatus }) {
if (status === 'ok') return <Badge variant="success">healthy</Badge>;
if (status === 'down') return <Badge variant="destructive">down</Badge>;
return <Badge variant="secondary">unknown</Badge>;
}

View File

@@ -0,0 +1,43 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Database, Plus } from 'lucide-react';
const destinations = [
{ id: '1', name: 'ClickHouse (warehouse)', kind: 'clickhouse', enabled: true },
{ id: '2', name: 'BigQuery (BI)', kind: 'bigquery', enabled: false },
];
export function DestinationsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Destinations</h1>
<p className="text-sm text-muted-foreground">Where events end up.</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" /> New destination
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
{destinations.map((d) => (
<Card key={d.id}>
<CardHeader className="flex flex-row items-start justify-between space-y-0">
<div className="flex items-center gap-3">
<div className="rounded-md bg-muted p-2"><Database className="h-5 w-5" /></div>
<div>
<CardTitle>{d.name}</CardTitle>
<CardDescription>{d.kind}</CardDescription>
</div>
</div>
{d.enabled ? <Badge variant="success">on</Badge> : <Badge variant="secondary">off</Badge>}
</CardHeader>
<CardContent />
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ApiError, rotor } from '@/api/client';
const DEFAULT_CODE = `// Transform an event before it's stored.
// Return the (possibly modified) event, null to drop, or an array to fan out.
function transform(event) {
event.properties = event.properties || {};
event.properties.tagged_at = new Date().toISOString();
return event;
}`;
const DEFAULT_EVENT = JSON.stringify({
workspace_id: 'ws-1',
source_id: 'src-1',
message_id: 'm-1',
type: 'track',
event: 'Signed Up',
properties: { plan: 'pro' },
}, null, 2);
export function FunctionsPage() {
const [code, setCode] = useState(DEFAULT_CODE);
const [eventText, setEventText] = useState(DEFAULT_EVENT);
const [output, setOutput] = useState<string>('');
const run = useMutation({
mutationFn: async () => {
let event: Record<string, unknown>;
try {
event = JSON.parse(eventText);
} catch (err) {
throw new ApiError(400, `event is not valid JSON: ${(err as Error).message}`);
}
return rotor.run({ code, event });
},
onSuccess: (data) => setOutput(JSON.stringify(data.result, null, 2)),
onError: (err: ApiError) => setOutput(`ERROR (${err.status}): ${err.message}`),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Functions</h1>
<p className="text-sm text-muted-foreground">Author and test transformation functions.</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Code</CardTitle>
<CardDescription>Define <code>transform(event)</code>.</CardDescription>
</CardHeader>
<CardContent>
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
spellCheck={false}
className="h-72 w-full rounded-md border bg-muted p-3 font-mono text-xs"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Sample event</CardTitle>
<CardDescription>The input passed to <code>transform()</code>.</CardDescription>
</CardHeader>
<CardContent>
<textarea
value={eventText}
onChange={(e) => setEventText(e.target.value)}
spellCheck={false}
className="h-72 w-full rounded-md border bg-muted p-3 font-mono text-xs"
/>
</CardContent>
</Card>
</div>
<div className="flex items-center gap-3">
<Button onClick={() => run.mutate()} disabled={run.isPending}>
{run.isPending ? 'Running…' : 'Run'}
</Button>
<span className="text-sm text-muted-foreground">
rotor will execute in a V8 isolate with a 2s timeout
</span>
</div>
<Card>
<CardHeader>
<CardTitle>Output</CardTitle>
</CardHeader>
<CardContent>
<pre className="min-h-32 overflow-auto rounded-md bg-muted p-3 text-xs">{output || '— run to see output —'}</pre>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ApiError, ingest } from '@/api/client';
interface LogEntry {
ts: string;
ok: boolean;
message: string;
}
export function LivePage() {
const [writeKey, setWriteKey] = useState('cdp_dev_writekey_1234567890');
const [logs, setLogs] = useState<LogEntry[]>([]);
const send = useMutation({
mutationFn: async () =>
ingest.track(writeKey, {
type: 'track',
messageId: 'm_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
anonymousId: 'anon_console',
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)),
});
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>
<Card>
<CardHeader>
<CardTitle>Send test event</CardTitle>
<CardDescription>Uses the dev write key by default.</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>
</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>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useWorkspace } from '@/stores/workspace';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
export function SettingsPage() {
const { currentWorkspace, setCurrentWorkspace } = useWorkspace();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground">Workspace configuration.</p>
</div>
<Card>
<CardHeader>
<CardTitle>Workspace</CardTitle>
<CardDescription>Identifier used by the local console state.</CardDescription>
</CardHeader>
<CardContent>
<Input
value={currentWorkspace}
onChange={(e) => setCurrentWorkspace(e.target.value)}
placeholder="workspace slug"
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
// Placeholder data -- swap to a TanStack Query call against the control-plane
// API when /sources endpoints land.
const sources = [
{ id: '1', name: 'Web tracker', kind: 'web', enabled: true, events_24h: 12_482 },
{ id: '2', name: 'iOS app', kind: 'mobile', enabled: true, events_24h: 4_201 },
{ id: '3', name: 'Server', kind: 'server', enabled: false, events_24h: 0 },
];
export function SourcesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sources</h1>
<p className="text-sm text-muted-foreground">Where events come from.</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" /> New source
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sources.map((s) => (
<Card key={s.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{s.name}</CardTitle>
{s.enabled ? <Badge variant="success">on</Badge> : <Badge variant="secondary">off</Badge>}
</div>
<CardDescription>{s.kind}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">last 24h</div>
<div className="text-2xl font-semibold">{s.events_24h.toLocaleString()}</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface WorkspaceState {
// For the scaffold we keep this purely local. Replace with API-loaded list
// when the control-plane endpoints are wired up.
currentWorkspace: string;
setCurrentWorkspace: (id: string) => void;
}
export const useWorkspace = create<WorkspaceState>((set) => ({
currentWorkspace: 'dev',
setCurrentWorkspace: (id) => set({ currentWorkspace: id }),
}));

View File

@@ -0,0 +1,50 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '1rem',
screens: { '2xl': '1400px' },
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api/ingest': { target: 'http://localhost:3049', changeOrigin: true, rewrite: (p) => p.replace(/^\/api\/ingest/, '') },
'/api/bulker': { target: 'http://localhost:3042', changeOrigin: true, rewrite: (p) => p.replace(/^\/api\/bulker/, '') },
'/api/rotor': { target: 'http://localhost:3401', changeOrigin: true, rewrite: (p) => p.replace(/^\/api\/rotor/, '') },
},
},
});