data layer

This commit is contained in:
2026-05-25 08:38:26 +07:00
parent 4e8c11d545
commit a428170fef
81 changed files with 3941 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppShell } from '@/components/AppShell';
import { ExplorePage } from '@/pages/Explore';
import { SQLPage } from '@/pages/SQL';
import { ProfilesPage } from '@/pages/Profiles';
import { FunnelsPage } from '@/pages/Funnels';
import { RetentionPage } from '@/pages/Retention';
import { SegmentsPage } from '@/pages/Segments';
import { TraitsPage } from '@/pages/Traits';
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={<ExplorePage />} />
<Route path="/sql" element={<SQLPage />} />
<Route path="/profiles" element={<ProfilesPage />} />
<Route path="/funnels" element={<FunnelsPage />} />
<Route path="/retention" element={<RetentionPage />} />
<Route path="/segments" element={<SegmentsPage />} />
<Route path="/traits" element={<TraitsPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,122 @@
// Thin fetch wrapper for the analytics API. Throws ApiError on non-2xx.
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 BASE = import.meta.env.VITE_ANALYTICS_BASE_URL ?? '/api/analytics';
async function request<T>(path: string, workspaceID: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
'content-type': 'application/json',
'X-Workspace-Id': workspaceID,
...(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;
}
}
// ---------------------------------------------------------------------------
// Common shapes
// ---------------------------------------------------------------------------
export interface QueryResult {
columns: string[];
rows: unknown[][];
row_count: number;
duration_ms: number;
cache_hit: boolean;
meta?: Record<string, unknown>;
}
export interface Profile {
id: string;
workspace_id: string;
user_id?: string;
anonymous_ids?: string[];
traits?: Record<string, unknown>;
first_seen_at: string;
last_seen_at: string;
}
export interface SavedQuery {
id: string;
workspace_id: string;
owner_id?: string;
name: string;
kind: 'events' | 'sql' | 'funnel' | 'retention' | 'session';
spec: Record<string, unknown>;
created_at: string;
updated_at: string;
}
// ---------------------------------------------------------------------------
// Endpoints (current workspace passed by each caller)
// ---------------------------------------------------------------------------
export const analytics = (workspaceID: string) => ({
health: () => request<{ status: string }>('/health', workspaceID),
ready: () => request<{ status: string }>('/ready', workspaceID),
queryEvents: (body: {
table: 'events_track' | 'events_identify' | 'events_page' | 'events_group';
from: string; to: string;
user_id?: string; anonymous_id?: string; event?: string;
limit?: number; offset?: number;
}) => request<QueryResult>('/query/events', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
querySQL: (body: { sql: string }) =>
request<QueryResult>('/query/sql', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
queryFunnel: (body: { steps: string[]; from: string; to: string; window_seconds: number }) =>
request<QueryResult>('/query/funnel', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
queryRetention: (body: {
initial_event: string; return_event: string;
from: string; to: string; periods?: number;
}) => request<QueryResult>('/query/retention', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
querySession: (body: {
from: string; to: string;
timeout_seconds?: number; user_id?: string;
limit?: number; offset?: number;
}) => request<QueryResult>('/query/session', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
getProfile: (id: string) =>
request<Profile>(`/profiles/${id}`, workspaceID),
getProfileTimeline: (id: string, limit = 100, offset = 0) =>
request<QueryResult>(`/profiles/${id}/events?limit=${limit}&offset=${offset}`, workspaceID),
listSavedQueries: () =>
request<{ items: SavedQuery[]; limit: number; offset: number }>('/queries', workspaceID),
createSavedQuery: (body: { name: string; kind: SavedQuery['kind']; spec: Record<string, unknown> }) =>
request<SavedQuery>('/queries', workspaceID, { method: 'POST', body: JSON.stringify(body) }),
deleteSavedQuery: (id: string) =>
request<void>(`/queries/${id}`, workspaceID, { method: 'DELETE' }),
});

View File

@@ -0,0 +1,51 @@
import { NavLink, Outlet } from 'react-router-dom';
import {
Activity, Code2, LineChart, Search, Settings, Tags, Users,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const nav = [
{ to: '/', label: 'Explore', icon: Search },
{ to: '/sql', label: 'Custom SQL', icon: Code2 },
{ to: '/profiles', label: 'Profiles', icon: Users },
{ to: '/funnels', label: 'Funnels', icon: LineChart },
{ to: '/retention', label: 'Retention', icon: Activity },
{ to: '/segments', label: 'Segments', icon: Tags },
{ to: '/traits', label: 'Traits', 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 Analytics</div>
<div className="text-xs text-muted-foreground">Data Layer</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

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,139 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { analytics, ApiError, type QueryResult } from '@/api/client';
import { useWorkspace } from '@/stores/workspace';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
type Table = 'events_track' | 'events_identify' | 'events_page' | 'events_group';
const TABLE_OPTIONS: Table[] = ['events_track', 'events_identify', 'events_page', 'events_group'];
function isoNow(offsetHours = 0) {
return new Date(Date.now() + offsetHours * 3_600_000).toISOString();
}
export function ExplorePage() {
const workspace = useWorkspace((s) => s.currentWorkspace);
const [table, setTable] = useState<Table>('events_track');
const [from, setFrom] = useState(isoNow(-24));
const [to, setTo] = useState(isoNow(0));
const [userID, setUserID] = useState('');
const [eventName, setEventName] = useState('');
const [limit, setLimit] = useState(100);
const mutation = useMutation<QueryResult, ApiError>({
mutationFn: () =>
analytics(workspace).queryEvents({
table,
from, to,
user_id: userID || undefined,
event: eventName || undefined,
limit,
}),
});
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">Event Explorer</h1>
<p className="text-sm text-muted-foreground">
Filter raw events. Backed by <code>POST /query/events</code>.
</p>
</div>
<Card>
<CardHeader><CardTitle>Filter</CardTitle></CardHeader>
<CardContent className="grid grid-cols-2 gap-3">
<label className="text-sm flex flex-col gap-1">
Table
<select
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={table}
onChange={(e) => setTable(e.target.value as Table)}
>
{TABLE_OPTIONS.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<label className="text-sm flex flex-col gap-1">
Limit
<Input type="number" value={limit} onChange={(e) => setLimit(Number(e.target.value) || 100)} />
</label>
<label className="text-sm flex flex-col gap-1">
From (ISO)
<Input value={from} onChange={(e) => setFrom(e.target.value)} />
</label>
<label className="text-sm flex flex-col gap-1">
To (ISO)
<Input value={to} onChange={(e) => setTo(e.target.value)} />
</label>
<label className="text-sm flex flex-col gap-1">
User ID (optional)
<Input value={userID} onChange={(e) => setUserID(e.target.value)} />
</label>
{table === 'events_track' && (
<label className="text-sm flex flex-col gap-1">
Event name (optional)
<Input value={eventName} onChange={(e) => setEventName(e.target.value)} />
</label>
)}
<div className="col-span-2">
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Running…' : 'Run query'}
</Button>
</div>
</CardContent>
</Card>
{mutation.error && (
<Card>
<CardHeader><CardTitle>Error</CardTitle></CardHeader>
<CardContent>
<Badge variant="destructive">{mutation.error.status}</Badge>{' '}
<span className="text-sm">{mutation.error.message}</span>
</CardContent>
</Card>
)}
{mutation.data && <ResultsTable result={mutation.data} />}
</div>
);
}
function ResultsTable({ result }: { result: QueryResult }) {
return (
<Card>
<CardHeader>
<CardTitle>
Results
<span className="ml-3 text-sm font-normal text-muted-foreground">
{result.row_count} rows · {result.duration_ms} ms
{result.cache_hit ? ' · cache hit' : ''}
</span>
</CardTitle>
</CardHeader>
<CardContent className="overflow-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b">
{result.columns.map((c) => <th key={c} className="px-2 py-1 text-left">{c}</th>)}
</tr>
</thead>
<tbody>
{result.rows.map((row, ri) => (
<tr key={ri} className="border-b hover:bg-muted/30">
{row.map((cell, ci) => (
<td key={ci} className="px-2 py-1 align-top max-w-[400px] truncate">
{typeof cell === 'object' ? JSON.stringify(cell) : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,10 @@
export function FunnelsPage() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Funnels</h1>
<p className="text-sm text-muted-foreground">
Multi-step conversion analysis.
</p>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export function ProfilesPage() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Profiles</h1>
<p className="text-sm text-muted-foreground">
Unified profile lookup and event timeline.
</p>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export function RetentionPage() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Retention</h1>
<p className="text-sm text-muted-foreground">
Cohort retention curves.
</p>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { analytics, ApiError, type QueryResult } from '@/api/client';
import { useWorkspace } from '@/stores/workspace';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
export function SQLPage() {
const workspace = useWorkspace((s) => s.currentWorkspace);
const [sql, setSQL] = useState('SELECT count() FROM events_track');
const mutation = useMutation<QueryResult, ApiError>({
mutationFn: () => analytics(workspace).querySQL({ sql }),
});
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold">Custom SQL</h1>
<p className="text-sm text-muted-foreground">
<code>SELECT</code>-only. Runs as the analytics_ro ClickHouse user.
</p>
</div>
<Card>
<CardHeader><CardTitle>Query</CardTitle></CardHeader>
<CardContent className="space-y-3">
<textarea
className="h-48 w-full rounded-md border border-input bg-background p-3 font-mono text-sm"
value={sql}
onChange={(e) => setSQL(e.target.value)}
spellCheck={false}
/>
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Running…' : 'Run'}
</Button>
</CardContent>
</Card>
{mutation.error && (
<Card>
<CardHeader><CardTitle>Error</CardTitle></CardHeader>
<CardContent>
<Badge variant="destructive">{mutation.error.status}</Badge>{' '}
<span className="text-sm">{mutation.error.message}</span>
</CardContent>
</Card>
)}
{mutation.data && (
<Card>
<CardHeader>
<CardTitle>
Results
<span className="ml-3 text-sm font-normal text-muted-foreground">
{mutation.data.row_count} rows · {mutation.data.duration_ms} ms
</span>
</CardTitle>
</CardHeader>
<CardContent className="overflow-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b">
{mutation.data.columns.map((c) => <th key={c} className="px-2 py-1 text-left">{c}</th>)}
</tr>
</thead>
<tbody>
{mutation.data.rows.map((row, ri) => (
<tr key={ri} className="border-b hover:bg-muted/30">
{row.map((cell, ci) => (
<td key={ci} className="px-2 py-1 align-top max-w-[400px] truncate">
{typeof cell === 'object' ? JSON.stringify(cell) : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
export function SegmentsPage() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Segments</h1>
<p className="text-sm text-muted-foreground">
Audience segments computed by the worker.
</p>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export function TraitsPage() {
return (
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Computed Traits</h1>
<p className="text-sm text-muted-foreground">
Trait definitions and refresh schedules.
</p>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
// Matches the workspace seeded by cdp-ingestion's 000002_seed_dev migration.
// Replace with API-loaded list once the analytics auth/session lands.
const DEV_WORKSPACE = '00000000-0000-0000-0000-000000000001';
interface WorkspaceState {
currentWorkspace: string;
setCurrentWorkspace: (id: string) => void;
}
export const useWorkspace = create<WorkspaceState>((set) => ({
currentWorkspace: DEV_WORKSPACE,
setCurrentWorkspace: (id) => set({ currentWorkspace: id }),
}));