This commit is contained in:
2026-05-25 11:23:18 +07:00
parent 81ba67f346
commit 5a1829bc0f
8 changed files with 5715 additions and 5 deletions

5197
data-layer/console/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
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 { ExplorePage } from '@/pages/Explore';
import { SQLPage } from '@/pages/SQL';
import { ProfilesPage } from '@/pages/Profiles';
@@ -19,7 +20,8 @@ export function App() {
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<ExplorePage />} />
<Route path="/" element={<DashboardPage />} />
<Route path="/explore" element={<ExplorePage />} />
<Route path="/sql" element={<SQLPage />} />
<Route path="/profiles" element={<ProfilesPage />} />
<Route path="/funnels" element={<FunnelsPage />} />

View File

@@ -1,11 +1,12 @@
import { NavLink, Outlet } from 'react-router-dom';
import {
Activity, Code2, LineChart, Search, Settings, Tags, Users,
Activity, BarChart3, Code2, LineChart, Search, Settings, Tags, Users,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const nav = [
{ to: '/', label: 'Explore', icon: Search },
{ to: '/', label: 'Dashboard', icon: BarChart3 },
{ to: '/explore', label: 'Explore', icon: Search },
{ to: '/sql', label: 'Custom SQL', icon: Code2 },
{ to: '/profiles', label: 'Profiles', icon: Users },
{ to: '/funnels', label: 'Funnels', icon: LineChart },

View File

@@ -0,0 +1,314 @@
import { useQuery } from '@tanstack/react-query';
import {
Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import { analytics, type QueryResult } from '@/api/client';
import { useWorkspace } from '@/stores/workspace';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
/**
* Dashboard runs a handful of canned SELECTs against ClickHouse via
* POST /query/sql and renders them. Every card refreshes every 10 seconds.
*
* We bias toward small queries with explicit time bounds so the workspace's
* Redis cache rejects them and the dashboard stays live.
*/
const REFRESH_MS = 10_000;
// ---------------------------------------------------------------------------
// SQL bundles
// ---------------------------------------------------------------------------
const SQL = {
totalLastHour: `
SELECT count() AS total
FROM analytics.events_track
WHERE received_at >= now() - INTERVAL 1 HOUR`,
uniqueUsersLastHour: `
SELECT uniqExact(user_id) AS uniq_users
FROM analytics.events_track
WHERE received_at >= now() - INTERVAL 1 HOUR
AND user_id != ''`,
uniqueAnonLastHour: `
SELECT uniqExact(anonymous_id) AS uniq_anon
FROM analytics.events_track
WHERE received_at >= now() - INTERVAL 1 HOUR
AND anonymous_id != ''`,
// Last 5 minutes, grouped by second. ClickHouse fills gaps via WITH FILL.
throughputPerSecond: `
SELECT
toStartOfSecond(received_at) AS sec,
count() AS events
FROM analytics.events_track
WHERE received_at >= now() - INTERVAL 5 MINUTE
GROUP BY sec
ORDER BY sec WITH FILL STEP INTERVAL 1 SECOND`,
topEvents: `
SELECT
event AS name,
count() AS events
FROM analytics.events_track
WHERE received_at >= now() - INTERVAL 1 HOUR
AND event != ''
GROUP BY name
ORDER BY events DESC
LIMIT 10`,
latencyPercentiles: `
SELECT
quantile(0.5)(dateDiff('millisecond', sent_at, received_at)) AS p50_ms,
quantile(0.95)(dateDiff('millisecond', sent_at, received_at)) AS p95_ms,
quantile(0.99)(dateDiff('millisecond', sent_at, received_at)) AS p99_ms,
max(dateDiff('millisecond', sent_at, received_at)) AS max_ms
FROM analytics.events_track
WHERE received_at >= now() - INTERVAL 1 HOUR
AND sent_at > toDateTime64('1971-01-01', 3)`,
recent: `
SELECT
received_at,
event,
user_id,
anonymous_id,
message_id
FROM analytics.events_track
ORDER BY received_at DESC
LIMIT 20`,
dlqLastHour: `
SELECT count() AS failed
FROM analytics.events_dlq
WHERE received_at >= now() - INTERVAL 1 HOUR`,
};
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
function useSQL(key: string, sql: string) {
const workspace = useWorkspace((s) => s.currentWorkspace);
return useQuery<QueryResult>({
queryKey: ['dashboard', key, workspace],
queryFn: () => analytics(workspace).querySQL({ sql }),
refetchInterval: REFRESH_MS,
staleTime: REFRESH_MS / 2,
});
}
// Convenience: read a single scalar cell out of a 1-row query.
function scalar<T = number>(res: QueryResult | undefined, col = 0, fallback: T = 0 as T): T {
if (!res || res.rows.length === 0) return fallback;
const v = res.rows[0][col];
return (v ?? fallback) as T;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function DashboardPage() {
const total = useSQL('total', SQL.totalLastHour);
const users = useSQL('users', SQL.uniqueUsersLastHour);
const anon = useSQL('anon', SQL.uniqueAnonLastHour);
const dlq = useSQL('dlq', SQL.dlqLastHour);
const tput = useSQL('tput', SQL.throughputPerSecond);
const top = useSQL('top', SQL.topEvents);
const lat = useSQL('lat', SQL.latencyPercentiles);
const recent = useSQL('recent', SQL.recent);
const tputData = (tput.data?.rows ?? []).map(([sec, events]) => ({
sec: new Date(String(sec)).toLocaleTimeString(),
events: Number(events ?? 0),
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Overview</h1>
<p className="text-sm text-muted-foreground">
Live view of <code>analytics.events_track</code>. Auto-refresh every {REFRESH_MS / 1000}s.
</p>
</div>
{/* KPI strip */}
<div className="grid grid-cols-4 gap-3">
<Kpi title="Events (last 1h)" value={fmt(scalar<number>(total.data))} loading={total.isPending} />
<Kpi title="Unique users (1h)" value={fmt(scalar<number>(users.data))} loading={users.isPending} />
<Kpi title="Unique anonymous (1h)" value={fmt(scalar<number>(anon.data))} loading={anon.isPending} />
<Kpi
title="DLQ (1h)"
value={fmt(scalar<number>(dlq.data))}
loading={dlq.isPending}
tone={scalar<number>(dlq.data) > 0 ? 'destructive' : 'default'}
/>
</div>
{/* Throughput chart */}
<Card>
<CardHeader>
<CardTitle>Throughput (events/sec)</CardTitle>
<CardDescription>Last 5 minutes, bucketed per second.</CardDescription>
</CardHeader>
<CardContent className="h-64">
{tput.isPending ? (
<Skeleton />
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={tputData} margin={{ left: 0, right: 0, top: 8, bottom: 0 }}>
<defs>
<linearGradient id="gFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis dataKey="sec" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} width={32} />
<Tooltip contentStyle={{ fontSize: 12 }} />
<Area type="monotone" dataKey="events" stroke="hsl(var(--primary))" fill="url(#gFill)" />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Two-up row: top events + latency */}
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Top events (1h)</CardTitle>
<CardDescription>Top 10 by count.</CardDescription>
</CardHeader>
<CardContent>
{top.isPending ? (
<Skeleton />
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-1">event</th>
<th className="py-1 text-right">count</th>
</tr>
</thead>
<tbody>
{(top.data?.rows ?? []).map(([name, n], i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{String(name)}</td>
<td className="py-1 text-right">{fmt(Number(n))}</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Sent received latency (1h)</CardTitle>
<CardDescription>Client clock vs server clock, milliseconds.</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-4 gap-3">
<LatStat label="p50" v={scalar<number>(lat.data, 0)} loading={lat.isPending} />
<LatStat label="p95" v={scalar<number>(lat.data, 1)} loading={lat.isPending} />
<LatStat label="p99" v={scalar<number>(lat.data, 2)} loading={lat.isPending} />
<LatStat label="max" v={scalar<number>(lat.data, 3)} loading={lat.isPending} />
</CardContent>
</Card>
</div>
{/* Recent events table */}
<Card>
<CardHeader>
<CardTitle>Recent events</CardTitle>
<CardDescription>20 most recent across the whole workspace.</CardDescription>
</CardHeader>
<CardContent className="overflow-auto">
{recent.isPending ? (
<Skeleton />
) : (
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b text-left">
<th className="px-2 py-1">received_at</th>
<th className="px-2 py-1">event</th>
<th className="px-2 py-1">user_id</th>
<th className="px-2 py-1">anonymous_id</th>
<th className="px-2 py-1">message_id</th>
</tr>
</thead>
<tbody>
{(recent.data?.rows ?? []).map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-2 py-1 whitespace-nowrap">{String(row[0])}</td>
<td className="px-2 py-1">{String(row[1])}</td>
<td className="px-2 py-1 max-w-[200px] truncate">{String(row[2] ?? '')}</td>
<td className="px-2 py-1 max-w-[200px] truncate">{String(row[3] ?? '')}</td>
<td className="px-2 py-1 max-w-[240px] truncate">{String(row[4])}</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</div>
);
}
// ---------------------------------------------------------------------------
// Small components
// ---------------------------------------------------------------------------
function Kpi({
title, value, loading, tone = 'default',
}: { title: string; value: string; loading: boolean; tone?: 'default' | 'destructive' }) {
return (
<Card>
<CardHeader className="pb-2">
<CardDescription>{title}</CardDescription>
</CardHeader>
<CardContent>
{loading
? <div className="h-7 w-20 animate-pulse rounded bg-muted" />
: <div className={`text-3xl font-semibold ${tone === 'destructive' ? 'text-destructive' : ''}`}>{value}</div>}
</CardContent>
</Card>
);
}
function LatStat({ label, v, loading }: { label: string; v: number; loading: boolean }) {
return (
<div>
<div className="text-xs text-muted-foreground">{label}</div>
{loading
? <div className="h-6 w-14 mt-1 animate-pulse rounded bg-muted" />
: <div className="text-xl font-mono">{Number(v).toFixed(1)} <span className="text-xs text-muted-foreground">ms</span></div>}
</div>
);
}
function Skeleton() {
return (
<div className="space-y-2">
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
</div>
);
}
function fmt(n: number) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return new Intl.NumberFormat().format(n);
}
// keep one Badge import alive for future use
void Badge;

9
data-layer/console/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ANALYTICS_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}