update
This commit is contained in:
5197
data-layer/console/package-lock.json
generated
Normal file
5197
data-layer/console/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
314
data-layer/console/src/pages/Dashboard.tsx
Normal file
314
data-layer/console/src/pages/Dashboard.tsx
Normal 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
9
data-layer/console/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ANALYTICS_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user