data layer
This commit is contained in:
34
data-layer/console/src/App.tsx
Normal file
34
data-layer/console/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
data-layer/console/src/api/client.ts
Normal file
122
data-layer/console/src/api/client.ts
Normal 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' }),
|
||||
});
|
||||
51
data-layer/console/src/components/AppShell.tsx
Normal file
51
data-layer/console/src/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
data-layer/console/src/components/ui/badge.tsx
Normal file
25
data-layer/console/src/components/ui/badge.tsx
Normal 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} />;
|
||||
}
|
||||
48
data-layer/console/src/components/ui/button.tsx
Normal file
48
data-layer/console/src/components/ui/button.tsx
Normal 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 };
|
||||
44
data-layer/console/src/components/ui/card.tsx
Normal file
44
data-layer/console/src/components/ui/card.tsx
Normal 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';
|
||||
19
data-layer/console/src/components/ui/input.tsx
Normal file
19
data-layer/console/src/components/ui/input.tsx
Normal 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';
|
||||
0
data-layer/console/src/hooks/.gitkeep
Normal file
0
data-layer/console/src/hooks/.gitkeep
Normal file
46
data-layer/console/src/index.css
Normal file
46
data-layer/console/src/index.css
Normal 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; }
|
||||
}
|
||||
6
data-layer/console/src/lib/utils.ts
Normal file
6
data-layer/console/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
10
data-layer/console/src/main.tsx
Normal file
10
data-layer/console/src/main.tsx
Normal 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>,
|
||||
);
|
||||
139
data-layer/console/src/pages/Explore.tsx
Normal file
139
data-layer/console/src/pages/Explore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
data-layer/console/src/pages/Funnels.tsx
Normal file
10
data-layer/console/src/pages/Funnels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
data-layer/console/src/pages/Profiles.tsx
Normal file
10
data-layer/console/src/pages/Profiles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
data-layer/console/src/pages/Retention.tsx
Normal file
10
data-layer/console/src/pages/Retention.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
data-layer/console/src/pages/SQL.tsx
Normal file
85
data-layer/console/src/pages/SQL.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
data-layer/console/src/pages/Segments.tsx
Normal file
10
data-layer/console/src/pages/Segments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
data-layer/console/src/pages/Traits.tsx
Normal file
10
data-layer/console/src/pages/Traits.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
data-layer/console/src/stores/workspace.ts
Normal file
15
data-layer/console/src/stores/workspace.ts
Normal 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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user