();
+ const requireLogin = useCallback(() => {
+ clearSessionCookies();
+ setIdentity(undefined);
+ setNeedsLogin(true);
+ }, []);
+ const client = useMemo(
+ () => createClient({ mode, onUnauthorized: requireLogin }),
+ [mode, session, requireLogin],
+ );
+ useEffect(() => {
+ const controller = new AbortController();
+ client
+ .identity(controller.signal)
+ .then(async (value) => {
+ // Pages with only local content still need expiry recovery. A profile
+ // response proves a server session; cookie display hints do not.
+ if (mode === 'live' && value?.source !== 'server') {
+ try {
+ await client.listDatasets(controller.signal);
+ } catch (error) {
+ // The client reports 401 centrally. Other errors belong to the page.
+ if (error instanceof ApiError && error.status === 401) return;
+ }
+ }
+ if (!controller.signal.aborted) setIdentity(value);
+ })
+ .catch(() => {
+ /* Identity is optional; data request errors remain visible. */
+ });
+ return () => controller.abort();
+ }, [client, mode]);
+ const setMode = (value: Mode) => {
+ const allowedMode = value === 'demo' && demoEnabled ? 'demo' : 'live';
+ sessionStorage.setItem('parseable-mode', allowedMode);
+ setModeState(allowedMode);
+ setIdentity(undefined);
+ setNeedsLogin(false);
+ setSession((s) => s + 1);
+ };
+ return { mode, setMode, client, session, identity, needsLogin, demoEnabled };
+}
+export function AppProvider({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+export function useApp() {
+ const ctx = useContext(Context);
+ if (!ctx) throw new Error('Missing AppProvider');
+ return ctx;
+}
diff --git a/frontend/src/app/ConnectionDialog.tsx b/frontend/src/app/ConnectionDialog.tsx
new file mode 100644
index 000000000..311d472f1
--- /dev/null
+++ b/frontend/src/app/ConnectionDialog.tsx
@@ -0,0 +1,51 @@
+import { useLocation } from 'react-router-dom';
+import { Button, Dialog } from '../components/ui';
+import { useApp } from './AppProvider';
+import { LoginForm } from './LoginForm';
+export function ConnectionDialog({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+}) {
+ const { setMode, mode, client, demoEnabled } = useApp();
+ const location = useLocation();
+ return (
+
+ {open && (
+ {
+ setMode('live');
+ onOpenChange(false);
+ }}
+ />
+ )}
+
+ {demoEnabled && (
+ {
+ setMode('demo');
+ onOpenChange(false);
+ }}
+ >
+ Explore demo data
+
+ )}
+ {mode === 'live' && (
+ void client.logout()}>
+ Sign out of server
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/app/LoginForm.tsx b/frontend/src/app/LoginForm.tsx
new file mode 100644
index 000000000..c848f22f7
--- /dev/null
+++ b/frontend/src/app/LoginForm.tsx
@@ -0,0 +1,103 @@
+import { useState, type FormEvent } from 'react';
+import { Eye, EyeOff, KeyRound } from 'lucide-react';
+import { Button, Dialog, Input } from '../components/ui';
+import { createClient } from '../lib/client';
+import { startSso } from '../lib/auth';
+
+export function LoginForm({
+ returnPath,
+ onSuccess,
+ submitLabel = 'Login',
+}: {
+ returnPath: string;
+ onSuccess: () => void;
+ submitLabel?: string;
+}) {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [visible, setVisible] = useState(false);
+ const [forgot, setForgot] = useState(false);
+ const [error, setError] = useState('');
+ const [pending, setPending] = useState(false);
+ async function connect(event: FormEvent) {
+ event.preventDefault();
+ setError('');
+ setPending(true);
+ try {
+ await createClient({ mode: 'live' }).login(username, password, returnPath);
+ setPassword('');
+ onSuccess();
+ } catch (error) {
+ setError(error instanceof Error ? error.message : 'Unable to sign in');
+ } finally {
+ setPending(false);
+ }
+ }
+ return (
+ <>
+
+
+ setForgot(false)}>
+ Got it
+
+
+ >
+ );
+}
diff --git a/frontend/src/app/LoginPage.tsx b/frontend/src/app/LoginPage.tsx
new file mode 100644
index 000000000..7353222b8
--- /dev/null
+++ b/frontend/src/app/LoginPage.tsx
@@ -0,0 +1,105 @@
+import { Brain, Database, Globe2, Telescope, Zap } from 'lucide-react';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { Button } from '../components/ui';
+import { intendedReturnPath } from '../lib/auth';
+import { appPath } from '../lib/config';
+import { useApp } from './AppProvider';
+import { LoginForm } from './LoginForm';
+
+export function LoginPage({ oidcUnavailable = false }: { oidcUnavailable?: boolean }) {
+ const { setMode, demoEnabled } = useApp();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const next = intendedReturnPath(location.search);
+ return (
+
+
+
+
+
+
+
+ parseable
+
+
+
+ Observability
+
+ Simplified.
+
+
AI Native observability datalake.
+
+
+
+ Petascale ingestion
+
+
+
+ Natural language interface
+
+
+
+ Native OpenTelemetry support
+
+
+
+ Blazing fast query engine
+
+
+
+ Up to 90% compression with columnar design
+
+
+
+ Self-hosted workspace · Your infrastructure. Your data.
+
+
+
+ {oidcUnavailable ? 'Single sign-on is not configured' : 'Log in to your account'}
+
+ {oidcUnavailable && (
+
+ This server does not have an OAuth provider configured. Sign in with your native
+ account or contact your administrator.
+
+ )}
+ {
+ setMode('live');
+ navigate(next, { replace: true });
+ }}
+ />
+ {demoEnabled ? (
+ {
+ setMode('demo');
+ navigate(next, { replace: true });
+ }}
+ >
+ Explore demo data
+
+ ) : (
+ Need help? Contact your administrator
+ )}
+ {oidcUnavailable && (
+
+ Back to native sign in
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/app/OverviewPage.tsx b/frontend/src/app/OverviewPage.tsx
new file mode 100644
index 000000000..074e3caa9
--- /dev/null
+++ b/frontend/src/app/OverviewPage.tsx
@@ -0,0 +1,90 @@
+import { useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import { ArrowUpRight, Database, Logs, Terminal, Layers, Plug } from 'lucide-react';
+import { Button, Card, Badge } from '../components/ui';
+import { PageHeader } from '../components/explorer/PageHeader';
+import { QueryState } from '../components/explorer/QueryState';
+import { useAsync } from '../hooks/useAsync';
+import { useApp } from './AppProvider';
+export function OverviewPage({ onConnect }: { onConnect: () => void }) {
+ const { client, mode } = useApp();
+ const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client]));
+ return (
+
+
+
+ Connection
+
+ }
+ />
+
+
+
PARSEABLE WORKSPACE
+
From events to understanding.
+
+ Search your logs, ask questions with SQL, and build a shared language for your data.
+
+
+ Explore logs
+
+
+
+ {Array.from({ length: 22 }, (_, i) => (
+
+ ))}
+
+
+
+
+
+ Available datasets
+ {datasets.loading ? '…' : datasets.error ? '—' : datasets.data?.length}
+
+
+
+ Workspace
+ {mode === 'demo' ? 'Demo' : 'Self-hosted'}
+
+
+
+ Query language
+ SQL
+
+
+
+
+
Start exploring
+
+ View all datasets
+
+
+
+ {datasets.data?.map((d) => (
+
+
+
+
+
+
{d.name}
+
Explore events and inspect fields
+
+
+
+ ))}
+
+
+ This editable frontend starts with logs, SQL, datasets, local dashboards, and reusable
+ components. Metrics, traces, alerts, and other advanced views will follow in later
+ iterations.
+
+
+ );
+}
diff --git a/frontend/src/app/Sidebar.tsx b/frontend/src/app/Sidebar.tsx
new file mode 100644
index 000000000..cffdde9ff
--- /dev/null
+++ b/frontend/src/app/Sidebar.tsx
@@ -0,0 +1,169 @@
+import { useState } from 'react';
+import { NavLink } from 'react-router-dom';
+import {
+ Activity,
+ Database,
+ ExternalLink,
+ FileCode2,
+ LayoutDashboard,
+ Library,
+ Logs,
+ Plug,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Search,
+} from 'lucide-react';
+import { Button, Badge } from '../components/ui';
+import { useApp } from './AppProvider';
+import { classicOnlyPages, classicUiAvailable } from '../lib/classicUi';
+const groups = [
+ { label: '', links: [{ to: '/', label: 'Overview', id: 'home', icon: Activity }] },
+ {
+ label: 'Analyze',
+ links: [
+ { to: '/dashboards', label: 'Dashboards', id: 'dashboards', icon: LayoutDashboard },
+ { to: '/sql-editor', label: 'SQL editor', id: 'sql-editor', icon: FileCode2 },
+ ],
+ },
+ { label: 'Observe', links: [{ to: '/logs', label: 'Logs', id: 'logs', icon: Logs }] },
+ {
+ label: 'Data',
+ links: [{ to: '/datasets', label: 'Datasets', id: 'datasets', icon: Database }],
+ },
+ {
+ label: 'Develop',
+ links: [{ to: '/components', label: 'Component library', id: 'components', icon: Library }],
+ },
+];
+export function Sidebar({
+ collapsed,
+ onToggle,
+ onConnect,
+}: {
+ collapsed: boolean;
+ onToggle: () => void;
+ onConnect: () => void;
+}) {
+ const { mode, demoEnabled } = useApp();
+ const [search, setSearch] = useState('');
+ return (
+
+ {!collapsed && (
+
+
+ setSearch(event.target.value)}
+ />
+
+ )}
+
+ {groups.map((group) => {
+ const links = group.links.filter(
+ (link) =>
+ (link.to !== '/components' || demoEnabled) &&
+ (collapsed || link.label.toLowerCase().includes(search.toLowerCase())),
+ );
+ if (!links.length) return null;
+ return (
+
+ {group.label && !collapsed && (
+
+ {group.label}
+
+
+ )}
+ {links.map(({ to, label, id, icon: Icon }) => (
+
`nav-item ${isActive ? 'active' : ''}`}
+ >
+
+ {label}
+
+ ))}
+
+ );
+ })}
+ {search &&
+ !collapsed &&
+ !groups.some((group) =>
+ group.links.some(
+ (link) =>
+ (link.to !== '/components' || demoEnabled) &&
+ link.label.toLowerCase().includes(search.toLowerCase()),
+ ),
+ ) && (
+
+ No matching pages
+
+ )}
+ {classicUiAvailable && !search && (
+
+ {!collapsed && (
+
+ Classic UI
+
+
+ )}
+ {classicOnlyPages.map(({ href, label, id }) => (
+
+
+ {label}
+
+ ))}
+
+ )}
+
+
+ {!collapsed && mode === 'demo' && (
+
+
+ Demo workspace
+ DEMO
+
+ )}
+
+
+ {!collapsed && 'Connection'}
+
+
+ {collapsed ? (
+
+ ) : (
+ <>
+
+ Collapse sidebar
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/explorer/ColumnPicker.tsx b/frontend/src/components/explorer/ColumnPicker.tsx
new file mode 100644
index 000000000..4acacb908
--- /dev/null
+++ b/frontend/src/components/explorer/ColumnPicker.tsx
@@ -0,0 +1,60 @@
+import { useState } from 'react';
+import { Columns3 } from 'lucide-react';
+import { Button, Dialog, Input } from '../ui';
+export function ColumnPicker({
+ fields,
+ selected,
+ onChange,
+}: {
+ fields: string[];
+ selected: string[];
+ onChange: (fields: string[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ return (
+ <>
+ setOpen(true)}>
+
+ Add columns
+
+
+ setSearch(e.target.value)}
+ />
+
+ {fields
+ .filter((f) => f.toLowerCase().includes(search.toLowerCase()))
+ .map((field) => (
+
+
+ onChange(
+ selected.includes(field)
+ ? selected.filter((f) => f !== field)
+ : [...selected, field],
+ )
+ }
+ />
+ {field}
+
+ ))}
+
+ setOpen(false)}>
+ Done
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/explorer/DataTable.tsx b/frontend/src/components/explorer/DataTable.tsx
new file mode 100644
index 000000000..fe9f573c3
--- /dev/null
+++ b/frontend/src/components/explorer/DataTable.tsx
@@ -0,0 +1,224 @@
+import { useState } from 'react';
+import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react';
+import { Badge, Button, EmptyState } from '../ui';
+import type { LogRecord } from '../../lib/types';
+export function formatValue(value: unknown): string {
+ if (value == null) return '—';
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
+}
+export function exportRecords(rows: LogRecord[], format: 'json' | 'csv') {
+ const fields = [...new Set(rows.flatMap(Object.keys))];
+ // Prefix spreadsheet formulas in CSV so exported log text remains data.
+ const csvCell = (value: unknown) => {
+ let text = value == null ? '' : formatValue(value);
+ if (/^[=+@\-\t\r]/.test(text)) text = `'${text}`;
+ return `"${text.replaceAll('"', '""')}"`;
+ };
+ const content =
+ format === 'json'
+ ? JSON.stringify(rows, null, 2)
+ : [
+ fields.map(csvCell).join(','),
+ ...rows.map((row) => fields.map((f) => csvCell(row[f])).join(',')),
+ ].join('\r\n');
+ const url = URL.createObjectURL(
+ new Blob([content], {
+ type: format === 'json' ? 'application/json' : 'text/csv;charset=utf-8',
+ }),
+ );
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `query-results.${format}`;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+export function DataTable({
+ rows,
+ columns,
+ onSelect,
+ caption = 'Query results',
+ wrap = false,
+ summary = false,
+}: {
+ rows: LogRecord[];
+ columns?: string[];
+ onSelect?: (row: LogRecord) => void;
+ caption?: string;
+ wrap?: boolean;
+ summary?: boolean;
+}) {
+ const [page, setPage] = useState(0);
+ const [sort, setSort] = useState<{ field: string; descending: boolean }>();
+ const pageSize = 25;
+ const effectivePage = Math.min(page, Math.max(0, Math.ceil(rows.length / pageSize) - 1));
+ const fields = columns || [...new Set(rows.flatMap(Object.keys))];
+ const ordered = sort
+ ? [...rows].sort((a, b) => {
+ const left = a[sort.field],
+ right = b[sort.field];
+ const result =
+ typeof left === 'number' && typeof right === 'number'
+ ? left - right
+ : formatValue(left).localeCompare(formatValue(right), undefined, { numeric: true });
+ return sort.descending ? -result : result;
+ })
+ : rows;
+ if (!rows.length)
+ return (
+
+ );
+ return (
+
+
+
+ {caption}
+
+
+ {onSelect && (
+
+ Details
+
+ )}
+ {fields.map((field) => (
+
+ {
+ setSort({
+ field,
+ descending: sort?.field === field ? !sort.descending : false,
+ });
+ setPage(0);
+ }}
+ >
+ {field === 'p_timestamp'
+ ? summary
+ ? 'Ingestion Time (UTC)'
+ : 'Timestamp (UTC)'
+ : field}
+ {sort?.field === field ? (
+ sort.descending ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ ))}
+ {summary && Data }
+
+
+
+ {ordered
+ .slice(effectivePage * pageSize, (effectivePage + 1) * pageSize)
+ .map((row, index) => (
+
+ {onSelect && (
+
+ onSelect(row)}
+ >
+
+
+
+ )}
+ {fields.map((field) => (
+
+ {field === 'level' ? (
+
+ {formatValue(row[field])}
+
+ ) : field === 'p_timestamp' ? (
+ formatValue(row[field]).replace('T', ' ').replace('Z', '')
+ ) : (
+ formatValue(row[field])
+ )}
+
+ ))}
+ {summary && (
+
+ {Object.entries(row)
+ .filter(([key]) => !fields.includes(key))
+ .map(([key, value]) => (
+
+ {key} =
+ {formatValue(value)} {' '}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/explorer/LogHistogram.tsx b/frontend/src/components/explorer/LogHistogram.tsx
new file mode 100644
index 000000000..54b392a6c
--- /dev/null
+++ b/frontend/src/components/explorer/LogHistogram.tsx
@@ -0,0 +1,52 @@
+import { parseEventTimestamp } from './timestamp';
+import type { LogRecord } from '../../lib/types';
+export function LogHistogram({
+ rows,
+ bounds,
+}: {
+ rows: LogRecord[];
+ bounds?: { startTime: string; endTime: string };
+}) {
+ const timestamps = rows
+ .map((row) => parseEventTimestamp(row.p_timestamp))
+ .filter(Number.isFinite);
+ const min = bounds ? Date.parse(bounds.startTime) : Math.min(...timestamps);
+ const max = bounds ? Date.parse(bounds.endTime) : Math.max(...timestamps);
+ const bins = Array.from({ length: 48 }, () => 0);
+ timestamps.forEach((t) => {
+ bins[Math.max(0, Math.min(47, Math.floor(((t - min) / Math.max(1, max - min)) * 48)))]++;
+ });
+ const peak = Math.max(...bins, 1);
+ return (
+
+
+
Logs over time
+ {rows.length} returned events
+
+
+
+ {bins.map((count, i) => (
+
+ ))}
+
+
+
+ {timestamps.length ? new Date(min).toLocaleTimeString([], { timeZone: 'UTC' }) : 'Start'}
+
+ Returned rows · UTC
+
+ {timestamps.length ? new Date(max).toLocaleTimeString([], { timeZone: 'UTC' }) : 'End'}
+
+
+
+ );
+}
diff --git a/frontend/src/components/explorer/PageHeader.tsx b/frontend/src/components/explorer/PageHeader.tsx
new file mode 100644
index 000000000..25c60982d
--- /dev/null
+++ b/frontend/src/components/explorer/PageHeader.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from 'react';
+export function PageHeader({
+ title,
+ description,
+ actions,
+}: {
+ title: string;
+ description?: string;
+ actions?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ );
+}
diff --git a/frontend/src/components/explorer/QueryState.tsx b/frontend/src/components/explorer/QueryState.tsx
new file mode 100644
index 000000000..ada895a0a
--- /dev/null
+++ b/frontend/src/components/explorer/QueryState.tsx
@@ -0,0 +1,69 @@
+import { useLocation, useNavigate } from 'react-router-dom';
+import { Button, EmptyState, Spinner } from '../ui';
+import { useApp } from '../../app/AppProvider';
+import { ApiError } from '../../lib/client';
+export function QueryState({
+ loading,
+ error,
+ retry,
+}: {
+ loading: boolean;
+ error?: Error;
+ retry?: () => void;
+}) {
+ const { mode, setMode, demoEnabled } = useApp();
+ const navigate = useNavigate();
+ const location = useLocation();
+ if (loading)
+ return (
+
+
+ Loading data…
+
+ );
+ if (!error) return null;
+ const unauthorized = error instanceof ApiError && error.status === 401;
+ const forbidden = error instanceof ApiError && error.status === 403;
+ return (
+
+
+ {unauthorized ? (
+
+ navigate(
+ `/login?${new URLSearchParams({ next: `${location.pathname}${location.search}${location.hash}` })}`,
+ )
+ }
+ >
+ Sign in
+
+ ) : (
+ retry && (
+
+ Try again
+
+ )
+ )}
+ {mode === 'live' && demoEnabled && !unauthorized && !forbidden && (
+ setMode('demo')}>Explore demo data
+ )}
+
+ }
+ />
+
+ );
+}
diff --git a/frontend/src/components/explorer/RecordSheet.tsx b/frontend/src/components/explorer/RecordSheet.tsx
new file mode 100644
index 000000000..b85aaacb8
--- /dev/null
+++ b/frontend/src/components/explorer/RecordSheet.tsx
@@ -0,0 +1,149 @@
+import { useState } from 'react';
+import { Copy, Filter, FilterX } from 'lucide-react';
+import { Button, Sheet, Input, Tabs } from '../ui';
+import { formatValue } from './DataTable';
+import type { LogRecord } from '../../lib/types';
+export function RecordSheet({
+ row,
+ onClose,
+ onFilter,
+}: {
+ row?: LogRecord;
+ onClose: () => void;
+ onFilter?: (field: string, value: string, operator?: '=' | '!=') => void;
+}) {
+ const [search, setSearch] = useState('');
+ const [view, setView] = useState('fields');
+ // Each opening of the sheet is a new session, even for the same event. Status
+ // belongs to the session it was set in, so a copy that finishes after the
+ // sheet closed or reopened is never reported, nor clears the current status.
+ const [session, setSession] = useState<{ row?: LogRecord; id: object; status: string }>({
+ row,
+ id: {},
+ status: '',
+ });
+ if (session.row !== row) {
+ setSession({ row, id: {}, status: '' });
+ setSearch('');
+ }
+ const report = (id: object, status: string) =>
+ setSession((current) => (current.id === id ? { ...current, status } : current));
+ return (
+ {
+ if (!open) onClose();
+ }}
+ title="Event details"
+ description="Inspect fields and values for this event."
+ >
+
+
{
+ const { id } = session;
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(row, null, 2));
+ report(id, 'Event copied');
+ } catch {
+ report(id, 'Clipboard unavailable. Select the values to copy them.');
+ }
+ }}
+ >
+
+ Copy JSON
+
+
{session.status}
+
+ setSearch(e.target.value)}
+ />
+
+ {Object.entries(row || {})
+ .filter(([key, value]) =>
+ `${key} ${formatValue(value)}`.toLowerCase().includes(search.toLowerCase()),
+ )
+ .map(([key, value]) => (
+
+
{key}
+
+ {formatValue(value)}
+
+ {onFilter && typeof value === 'string' && (
+ <>
+ {
+ onFilter(key, value);
+ onClose();
+ }}
+ >
+
+
+ {
+ onFilter(key, value, '!=');
+ onClose();
+ }}
+ >
+
+
+ >
+ )}
+ {
+ const { id } = session;
+ try {
+ await navigator.clipboard.writeText(
+ `${key}: ${formatValue(value)}`,
+ );
+ report(id, 'Key value pair copied');
+ } catch {
+ report(
+ id,
+ 'Clipboard unavailable. Select the values to copy them.',
+ );
+ }
+ }}
+ >
+
+
+
+
+
+ ))}
+
+ >
+ ),
+ },
+ {
+ value: 'json',
+ label: 'JSON',
+ content: {JSON.stringify(row, null, 2)} ,
+ },
+ ]}
+ />
+
+
+ );
+}
diff --git a/frontend/src/components/explorer/TimeRangePicker.tsx b/frontend/src/components/explorer/TimeRangePicker.tsx
new file mode 100644
index 000000000..e21a6f2c2
--- /dev/null
+++ b/frontend/src/components/explorer/TimeRangePicker.tsx
@@ -0,0 +1,110 @@
+import { useState } from 'react';
+import { Clock, CalendarDays } from 'lucide-react';
+import { Button, Dialog, Input, Select } from '../ui';
+import { timeBounds } from '../../lib/query';
+import type { TimeRange } from '../../lib/types';
+export function TimeRangePicker({
+ value,
+ onChange,
+}: {
+ value: TimeRange;
+ onChange: (value: TimeRange) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [from, setFrom] = useState('');
+ const [to, setTo] = useState('');
+ const [error, setError] = useState('');
+ const begin = () => {
+ const bounds = timeBounds(value);
+ setFrom(bounds.startTime.slice(0, 19));
+ setTo(bounds.endTime.slice(0, 19));
+ setError('');
+ setOpen(true);
+ };
+ return (
+
+
+
+ e.target.value === 'custom' ? begin() : onChange(e.target.value as TimeRange)
+ }
+ >
+ Last 15 minutes
+ Last 1 hour
+ Last 6 hours
+ Last 24 hours
+ Last 7 days
+ Custom range
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/explorer/timestamp.test.ts b/frontend/src/components/explorer/timestamp.test.ts
new file mode 100644
index 000000000..13b6cb392
--- /dev/null
+++ b/frontend/src/components/explorer/timestamp.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { parseEventTimestamp } from './timestamp';
+describe('Arrow event timestamps', () => {
+ it('interprets timezone-less timestamps as UTC in a non-UTC browser timezone', () => {
+ const previous = process.env.TZ;
+ process.env.TZ = 'America/Chicago';
+ try {
+ expect(parseEventTimestamp('2026-10-07T14:00:44.985')).toBe(
+ Date.parse('2026-10-07T14:00:44.985Z'),
+ );
+ expect(parseEventTimestamp('2026-10-07 14:00:44.985')).toBe(
+ Date.parse('2026-10-07T14:00:44.985Z'),
+ );
+ expect(parseEventTimestamp('2026-10-07T09:00:44.985-05:00')).toBe(
+ Date.parse('2026-10-07T14:00:44.985Z'),
+ );
+ expect(parseEventTimestamp(null)).toBeNaN();
+ } finally {
+ if (previous === undefined) delete process.env.TZ;
+ else process.env.TZ = previous;
+ }
+ });
+});
diff --git a/frontend/src/components/explorer/timestamp.ts b/frontend/src/components/explorer/timestamp.ts
new file mode 100644
index 000000000..7eaf69012
--- /dev/null
+++ b/frontend/src/components/explorer/timestamp.ts
@@ -0,0 +1,10 @@
+/** Arrow timestamps without an offset represent UTC, not the browser's timezone. */
+export function parseEventTimestamp(value: unknown): number {
+ if (typeof value !== 'string') return NaN;
+ const text = value.trim();
+ return Date.parse(
+ /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text)
+ ? `${text.replace(' ', 'T')}Z`
+ : text,
+ );
+}
diff --git a/frontend/src/components/ui.stories.tsx b/frontend/src/components/ui.stories.tsx
new file mode 100644
index 000000000..d8d98f7cd
--- /dev/null
+++ b/frontend/src/components/ui.stories.tsx
@@ -0,0 +1,152 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Plus, RefreshCw } from 'lucide-react';
+import { Badge, Button, Card, Dialog, EmptyState, Input, Select, Sheet, Spinner, Tabs } from './ui';
+
+const meta = {
+ title: 'Prism/Component library',
+ parameters: { layout: 'padded' },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const Buttons: Story = {
+ render: () => (
+
+
+
+ Create dataset
+
+
Save view
+
Cancel
+
Delete dataset
+
Unavailable
+
Small action
+
+
+
+
+ ),
+};
+
+export const Inputs: Story = {
+ render: () => (
+
+
+
+
+
+ Last 15 minutes
+ Last hour
+
+
+ ),
+};
+
+export const Badges: Story = {
+ render: () => (
+
+ Logs
+ Healthy
+ Warning
+ Error
+
+ ),
+};
+
+function DialogExample() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>
+ Save view
+
+
+
+
+ setOpen(false)}>Cancel
+ setOpen(false)}>
+ Save view
+
+
+
+ >
+ );
+}
+export const Modal: Story = { render: () => };
+
+function SheetExample() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>Inspect record
+
+
+ {JSON.stringify(
+ { level: 'info', message: 'Request completed', service: 'gateway' },
+ null,
+ 2,
+ )}
+
+
+ >
+ );
+}
+export const DetailSheet: Story = { render: () => };
+
+export const Empty: Story = {
+ render: () => (
+
+ Clear filters}
+ />
+
+ ),
+};
+
+function TabsExample() {
+ const [value, setValue] = useState('table');
+ return (
+
+ );
+}
+export const TabViews: Story = { render: () => };
+export const Loading: Story = { render: () => };
diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx
new file mode 100644
index 000000000..2b7b4aede
--- /dev/null
+++ b/frontend/src/components/ui/Badge.tsx
@@ -0,0 +1,13 @@
+import { forwardRef, type HTMLAttributes } from 'react';
+import { cx } from './utils';
+
+export interface BadgeProps extends HTMLAttributes {
+ tone?: 'neutral' | 'success' | 'warning' | 'danger';
+}
+
+export const Badge = forwardRef(function Badge(
+ { tone = 'neutral', className, ...props },
+ ref,
+) {
+ return ;
+});
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
new file mode 100644
index 000000000..419ef6af7
--- /dev/null
+++ b/frontend/src/components/ui/Button.tsx
@@ -0,0 +1,23 @@
+import { forwardRef, type ButtonHTMLAttributes } from 'react';
+import { cx } from './utils';
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
+ size?: 'sm' | 'md' | 'icon';
+}
+
+export const Button = forwardRef(function Button(
+ { variant = 'secondary', size = 'md', className, type = 'button', ...props },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx
new file mode 100644
index 000000000..7ac4f202e
--- /dev/null
+++ b/frontend/src/components/ui/Card.tsx
@@ -0,0 +1,9 @@
+import { forwardRef, type HTMLAttributes } from 'react';
+import { cx } from './utils';
+
+export const Card = forwardRef>(function Card(
+ { className, ...props },
+ ref,
+) {
+ return
;
+});
diff --git a/frontend/src/components/ui/Dialog.tsx b/frontend/src/components/ui/Dialog.tsx
new file mode 100644
index 000000000..a987cf8c6
--- /dev/null
+++ b/frontend/src/components/ui/Dialog.tsx
@@ -0,0 +1,70 @@
+import { type ReactNode, useRef } from 'react';
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+import { Button } from './Button';
+import { cx } from './utils';
+
+export interface DialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description?: string;
+ children: ReactNode;
+ className?: string;
+}
+
+/** Shared modal anatomy; feature forms own submit and cancel actions. */
+export function ModalFrame({
+ open,
+ onOpenChange,
+ title,
+ description,
+ children,
+ className,
+ side,
+}: DialogProps & { side?: 'right' }) {
+ const returnFocus = useRef(null);
+ return (
+
+
+
+ {
+ returnFocus.current =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ }}
+ onCloseAutoFocus={(event) => {
+ if (returnFocus.current?.isConnected) {
+ event.preventDefault();
+ returnFocus.current.focus({ preventScroll: true });
+ }
+ }}
+ >
+
+ {children}
+
+
+
+ );
+}
+
+export function Dialog(props: DialogProps) {
+ return ;
+}
diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx
new file mode 100644
index 000000000..1be9f1fa4
--- /dev/null
+++ b/frontend/src/components/ui/EmptyState.tsx
@@ -0,0 +1,22 @@
+import { type ReactNode } from 'react';
+import { Search } from 'lucide-react';
+
+export interface EmptyStateProps {
+ title: string;
+ description?: string;
+ action?: ReactNode;
+ icon?: ReactNode;
+}
+
+export function EmptyState({ title, description, action, icon }: EmptyStateProps) {
+ return (
+
+
+ {icon ?? }
+
+
{title}
+ {description &&
{description}
}
+ {action &&
{action}
}
+
+ );
+}
diff --git a/frontend/src/components/ui/Field.tsx b/frontend/src/components/ui/Field.tsx
new file mode 100644
index 000000000..075aea4d2
--- /dev/null
+++ b/frontend/src/components/ui/Field.tsx
@@ -0,0 +1,52 @@
+import { type ReactNode, useId } from 'react';
+import { cx } from './utils';
+
+export interface FieldOptions {
+ label?: string;
+ hint?: string;
+ error?: string;
+}
+
+export function useField(
+ id: string | undefined,
+ describedBy: string | undefined,
+ { hint, error }: FieldOptions,
+) {
+ const generatedId = useId();
+ const inputId = id ?? generatedId;
+ const messageId = `${inputId}-message`;
+ return {
+ inputId,
+ messageId,
+ describedBy: cx(describedBy, (hint || error) && messageId) || undefined,
+ };
+}
+
+export function Field({
+ children,
+ label,
+ hint,
+ error,
+ inputId,
+ messageId,
+}: FieldOptions & {
+ children: ReactNode;
+ inputId: string;
+ messageId: string;
+}) {
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+ {children}
+ {(hint || error) && (
+
+ {error || hint}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx
new file mode 100644
index 000000000..b3a4663f8
--- /dev/null
+++ b/frontend/src/components/ui/Input.tsx
@@ -0,0 +1,33 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { Field, useField, type FieldOptions } from './Field';
+import { cx } from './utils';
+
+export interface InputProps extends InputHTMLAttributes, FieldOptions {}
+
+export const Input = forwardRef(function Input(
+ {
+ label,
+ hint,
+ error,
+ id,
+ className,
+ 'aria-describedby': describedBy,
+ 'aria-invalid': invalid,
+ ...props
+ },
+ ref,
+) {
+ const field = useField(id, describedBy, { hint, error });
+ return (
+
+
+
+ );
+});
diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx
new file mode 100644
index 000000000..e32067dcb
--- /dev/null
+++ b/frontend/src/components/ui/Select.tsx
@@ -0,0 +1,37 @@
+import { forwardRef, type SelectHTMLAttributes } from 'react';
+import { ChevronDown } from 'lucide-react';
+import { Field, useField, type FieldOptions } from './Field';
+import { cx } from './utils';
+
+export interface SelectProps extends SelectHTMLAttributes, FieldOptions {}
+
+export const Select = forwardRef(function Select(
+ {
+ label,
+ hint,
+ error,
+ id,
+ className,
+ 'aria-describedby': describedBy,
+ 'aria-invalid': invalid,
+ ...props
+ },
+ ref,
+) {
+ const field = useField(id, describedBy, { hint, error });
+ return (
+
+
+
+
+
+
+ );
+});
diff --git a/frontend/src/components/ui/Sheet.tsx b/frontend/src/components/ui/Sheet.tsx
new file mode 100644
index 000000000..fbbad05c7
--- /dev/null
+++ b/frontend/src/components/ui/Sheet.tsx
@@ -0,0 +1,5 @@
+import { ModalFrame, type DialogProps } from './Dialog';
+
+export function Sheet(props: DialogProps) {
+ return ;
+}
diff --git a/frontend/src/components/ui/Spinner.tsx b/frontend/src/components/ui/Spinner.tsx
new file mode 100644
index 000000000..e339498a1
--- /dev/null
+++ b/frontend/src/components/ui/Spinner.tsx
@@ -0,0 +1,10 @@
+import { LoaderCircle } from 'lucide-react';
+
+export function Spinner({ label = 'Loading', size = 18 }: { label?: string; size?: number }) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx
new file mode 100644
index 000000000..0a911500b
--- /dev/null
+++ b/frontend/src/components/ui/Tabs.tsx
@@ -0,0 +1,28 @@
+import { type ReactNode } from 'react';
+import * as TabsPrimitive from '@radix-ui/react-tabs';
+
+export interface TabsProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ items: Array<{ value: string; label: ReactNode; content: ReactNode }>;
+ 'aria-label'?: string;
+}
+
+export function Tabs({ value, onValueChange, items, 'aria-label': label = 'View' }: TabsProps) {
+ return (
+
+
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+ {items.map((item) => (
+
+ {item.content}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
new file mode 100644
index 000000000..ca97e483c
--- /dev/null
+++ b/frontend/src/components/ui/index.ts
@@ -0,0 +1,14 @@
+import '../../styles/tokens.css';
+import './ui.css';
+
+export { Button, type ButtonProps } from './Button';
+export { Input, type InputProps } from './Input';
+export { Select, type SelectProps } from './Select';
+export { Badge, type BadgeProps } from './Badge';
+export { Card } from './Card';
+export { EmptyState, type EmptyStateProps } from './EmptyState';
+export { Dialog, type DialogProps } from './Dialog';
+export { Sheet } from './Sheet';
+export { Tabs, type TabsProps } from './Tabs';
+export { Spinner } from './Spinner';
+export { cx } from './utils';
diff --git a/frontend/src/components/ui/ui.css b/frontend/src/components/ui/ui.css
new file mode 100644
index 000000000..847ff63c0
--- /dev/null
+++ b/frontend/src/components/ui/ui.css
@@ -0,0 +1,344 @@
+.ui-button,
+.ui-input,
+.ui-tabs-trigger {
+ font: inherit;
+}
+.ui-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ min-height: 36px;
+ padding: 7px 12px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font-size: 12px;
+ font-weight: 550;
+ line-height: 20px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ background 0.12s,
+ border-color 0.12s;
+}
+.ui-button svg {
+ flex-shrink: 0;
+}
+.ui-button[data-variant='primary'] {
+ background: var(--color-primary);
+ color: var(--color-on-primary);
+}
+.ui-button[data-variant='primary']:hover:not(:disabled) {
+ background: var(--color-primary-hover);
+}
+.ui-button[data-variant='secondary'] {
+ border-color: var(--color-border);
+}
+.ui-button[data-variant='secondary']:hover:not(:disabled),
+.ui-button[data-variant='ghost']:hover:not(:disabled) {
+ background: var(--color-surface-hover);
+ border-color: var(--color-border);
+}
+.ui-button[data-variant='ghost'] {
+ background: transparent;
+}
+.ui-button[data-variant='danger'] {
+ color: var(--color-danger);
+ background: var(--color-danger-soft);
+}
+.ui-button[data-variant='danger']:hover:not(:disabled) {
+ border-color: var(--color-danger);
+}
+.ui-button[data-size='sm'] {
+ min-height: 30px;
+ padding: 4px 9px;
+ font-size: 11px;
+}
+.ui-button[data-size='icon'] {
+ width: 34px;
+ height: 34px;
+ min-height: 34px;
+ padding: 7px;
+}
+.ui-button:disabled,
+.ui-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.ui-button:focus-visible,
+.ui-input:focus-visible,
+.ui-tabs-trigger:focus-visible,
+.ui-tabs-content:focus-visible {
+ outline: 2px solid var(--color-focus);
+ outline-offset: 3px;
+}
+.ui-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+.ui-field-label {
+ font-size: 12px;
+ color: var(--color-text);
+ font-weight: 550;
+ line-height: 1.4;
+}
+.ui-input {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ min-height: 36px;
+ padding: 8px 10px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font-size: 12px;
+ line-height: 18px;
+}
+.ui-input::placeholder {
+ color: var(--color-muted);
+ opacity: 0.85;
+}
+.ui-input:hover:not(:disabled) {
+ border-color: var(--color-border-strong);
+}
+.ui-input[aria-invalid='true'] {
+ border-color: var(--color-danger);
+}
+.ui-field-message {
+ margin: 0;
+ color: var(--color-muted);
+ font-size: 11px;
+ line-height: 1.5;
+}
+.ui-field[data-invalid='true'] .ui-field-message {
+ color: var(--color-danger);
+}
+.ui-select-frame {
+ position: relative;
+}
+.ui-select {
+ appearance: none;
+ padding-right: 32px;
+ cursor: pointer;
+}
+.ui-select-chevron {
+ position: absolute;
+ right: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--color-muted);
+ pointer-events: none;
+}
+.ui-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ border-radius: 4px;
+ padding: 3px 7px;
+ font-size: 10px;
+ line-height: 15px;
+ font-weight: 550;
+ white-space: nowrap;
+ background: var(--color-surface-hover);
+ color: var(--color-muted);
+}
+.ui-badge[data-tone='success'] {
+ color: var(--color-success);
+ background: var(--color-success-soft);
+}
+.ui-badge[data-tone='warning'] {
+ color: var(--color-warning);
+ background: var(--color-warning-soft);
+}
+.ui-badge[data-tone='danger'] {
+ color: var(--color-danger);
+ background: var(--color-danger-soft);
+}
+.ui-card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+.ui-empty-state {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ gap: 12px;
+ padding: 48px 24px;
+ text-align: center;
+}
+.ui-empty-state-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ color: var(--color-muted);
+ background: var(--color-bg);
+}
+.ui-empty-state h3 {
+ margin: 4px 0 0;
+ font-size: 15px;
+ font-weight: 550;
+ color: var(--color-text);
+}
+.ui-empty-state p {
+ max-width: 400px;
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.7;
+ color: var(--color-muted);
+}
+.ui-empty-state-action {
+ margin-top: 5px;
+}
+.ui-dialog-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ background: var(--color-overlay);
+ animation: ui-fade-in 0.14s ease-out;
+}
+.ui-dialog,
+.ui-sheet {
+ box-sizing: border-box;
+ position: fixed;
+ z-index: 101;
+ color: var(--color-text);
+ background: var(--color-surface);
+ box-shadow: var(--shadow-panel);
+ font-family: var(--font-sans);
+ overflow-y: auto;
+}
+.ui-dialog {
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: min(520px, calc(100vw - 32px));
+ max-height: calc(100dvh - 48px);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ animation: ui-fade-in 0.14s ease-out;
+}
+.ui-sheet {
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: min(560px, 100vw);
+ border-left: 1px solid var(--color-border);
+ animation: ui-slide-in 0.18s ease-out;
+}
+.ui-dialog-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 22px 22px 18px;
+ border-bottom: 1px solid var(--color-border);
+}
+.ui-dialog-title {
+ margin: 3px 0 0;
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: -0.3px;
+ line-height: 1.4;
+}
+.ui-dialog-description {
+ margin: 7px 0 0;
+ color: var(--color-muted);
+ font-size: 12px;
+ line-height: 1.65;
+}
+.ui-dialog-body {
+ padding: 22px;
+}
+.ui-dialog-footer {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ margin-top: 24px;
+}
+.ui-tabs-list {
+ display: flex;
+ gap: 20px;
+ overflow-x: auto;
+ border-bottom: 1px solid var(--color-border);
+}
+.ui-tabs-trigger {
+ background: transparent;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ padding: 11px 2px;
+ color: var(--color-muted);
+ font-size: 12px;
+ white-space: nowrap;
+ cursor: pointer;
+}
+.ui-tabs-trigger[data-state='active'] {
+ color: var(--color-primary);
+ border-bottom-color: var(--color-primary);
+ font-weight: 600;
+}
+.ui-tabs-content {
+ padding-top: 18px;
+}
+.ui-spinner {
+ display: inline-flex;
+ vertical-align: middle;
+ color: var(--color-primary);
+}
+.ui-spinner > svg {
+ animation: ui-spin 0.9s linear infinite;
+}
+.ui-sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+@keyframes ui-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@keyframes ui-fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@keyframes ui-slide-in {
+ from {
+ transform: translateX(24px);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .ui-button,
+ .ui-dialog,
+ .ui-sheet,
+ .ui-dialog-overlay,
+ .ui-spinner > svg {
+ animation: none;
+ transition: none;
+ }
+}
diff --git a/frontend/src/components/ui/utils.ts b/frontend/src/components/ui/utils.ts
new file mode 100644
index 000000000..edea87aa4
--- /dev/null
+++ b/frontend/src/components/ui/utils.ts
@@ -0,0 +1,3 @@
+export function cx(...classes: Array) {
+ return classes.filter(Boolean).join(' ');
+}
diff --git a/frontend/src/features/dashboards/DashboardTile.tsx b/frontend/src/features/dashboards/DashboardTile.tsx
new file mode 100644
index 000000000..a478e98a7
--- /dev/null
+++ b/frontend/src/features/dashboards/DashboardTile.tsx
@@ -0,0 +1,55 @@
+import { useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import { Trash2, ArrowUpRight } from 'lucide-react';
+import { Button, Card, Badge } from '../../components/ui';
+import { QueryState } from '../../components/explorer/QueryState';
+import { LogHistogram } from '../../components/explorer/LogHistogram';
+import { useApp } from '../../app/AppProvider';
+import { useAsync } from '../../hooks/useAsync';
+import { buildLogQuery, timeBounds } from '../../lib/query';
+import type { Dashboard } from './storage';
+export function DashboardTile({
+ dashboard,
+ onDelete,
+}: {
+ dashboard: Dashboard;
+ onDelete: () => void;
+}) {
+ const { client } = useApp();
+ const results = useAsync(
+ useCallback(
+ (signal) =>
+ client.query(
+ { sql: buildLogQuery(dashboard.dataset, '', [], 100), ...timeBounds('1h') },
+ signal,
+ ),
+ [client, dashboard.dataset],
+ ),
+ );
+ return (
+
+
+
+
{dashboard.title}
+
{dashboard.description || dashboard.dataset}
+
+
+
+
+
+
+ {!results.loading && !results.error && }
+
+
Last hour · up to 100 events
+
+ Explore
+
+
+
+ );
+}
diff --git a/frontend/src/features/dashboards/DashboardsPage.tsx b/frontend/src/features/dashboards/DashboardsPage.tsx
new file mode 100644
index 000000000..3d9cdc816
--- /dev/null
+++ b/frontend/src/features/dashboards/DashboardsPage.tsx
@@ -0,0 +1,169 @@
+import { createId } from '../../lib/ids';
+import { useCallback, useState, type FormEvent } from 'react';
+import { Plus, Search } from 'lucide-react';
+import { Button, Dialog, Input, Select, EmptyState } from '../../components/ui';
+import { PageHeader } from '../../components/explorer/PageHeader';
+import { QueryState } from '../../components/explorer/QueryState';
+import { useApp } from '../../app/AppProvider';
+import { useAsync } from '../../hooks/useAsync';
+import { DashboardTile } from './DashboardTile';
+import { loadDashboards, titleKey, type Dashboard } from './storage';
+export function DashboardsPage() {
+ const { client, mode } = useApp();
+ const key = `parseable-dashboards-v1-${mode}`;
+ const [dashboards, setDashboards] = useState(() => loadDashboards(key));
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ const [deleting, setDeleting] = useState();
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [dataset, setDataset] = useState('');
+ const [error, setError] = useState('');
+ const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client]));
+ const name = title.trim();
+ // Titles label each tile's delete button, so they must be unique.
+ const duplicate = dashboards.some((d) => titleKey(d.title) === titleKey(name));
+ function persist(next: Dashboard[]) {
+ try {
+ localStorage.setItem(key, JSON.stringify(next));
+ setDashboards(next);
+ setError('');
+ return true;
+ } catch {
+ setError('Could not save to browser storage. Free up space and try again.');
+ return false;
+ }
+ }
+ function create(e: FormEvent) {
+ e.preventDefault();
+ const selected = dataset || datasets.data?.[0]?.name;
+ if (!name || duplicate || !selected) return;
+ if (persist([...dashboards, { id: createId(), title: name, description, dataset: selected }])) {
+ setOpen(false);
+ setTitle('');
+ setDescription('');
+ }
+ }
+ return (
+
+
setOpen(true)}>
+
+ Create dashboard
+
+ }
+ />
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
{dashboards.length} dashboards
+
+
+ Saved in this browser only. Each tile shows up to 100 returned events from the last hour.
+
+ {error && (
+
+ {error}
+
+ )}
+ {!dashboards.length ? (
+ setOpen(true)}>
+
+ Create your first dashboard
+
+ }
+ />
+ ) : (
+
+ {dashboards
+ .filter((d) =>
+ `${d.title} ${d.description}`.toLowerCase().includes(search.toLowerCase()),
+ )
+ .map((d) => (
+ setDeleting(d)} />
+ ))}
+
+ )}
+ {!!dashboards.length &&
+ !dashboards.some((d) =>
+ `${d.title} ${d.description}`.toLowerCase().includes(search.toLowerCase()),
+ ) && }
+
+
+
+ {
+ if (!v) setDeleting(undefined);
+ }}
+ title="Delete dashboard?"
+ description={`Remove ${deleting?.title || 'this dashboard'} from this browser?`}
+ >
+ {
+ if (persist(dashboards.filter((d) => d.id !== deleting?.id))) setDeleting(undefined);
+ }}
+ >
+ Delete dashboard
+
+
+
+ );
+}
diff --git a/frontend/src/features/dashboards/storage.test.ts b/frontend/src/features/dashboards/storage.test.ts
new file mode 100644
index 000000000..adcd1bb84
--- /dev/null
+++ b/frontend/src/features/dashboards/storage.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { uniqueTitles, type Dashboard } from './storage';
+
+const dashboard = (id: string, title: string): Dashboard => ({
+ id,
+ title,
+ description: '',
+ dataset: 'logs',
+});
+describe('uniqueTitles', () => {
+ it('leaves distinct titles untouched', () => {
+ const list = [dashboard('a', 'API errors'), dashboard('b', 'Latency')];
+ expect(uniqueTitles(list)).toEqual(list);
+ });
+ it('renames later duplicates, ignoring case and whitespace', () => {
+ const titles = uniqueTitles([
+ dashboard('a', 'API errors'),
+ dashboard('b', ' api ERRORS '),
+ dashboard('c', 'API errors'),
+ ]).map((d) => d.title);
+ expect(titles).toEqual(['API errors', 'api ERRORS (2)', 'API errors (3)']);
+ });
+ it('skips suffixes that are already taken', () => {
+ const titles = uniqueTitles([
+ dashboard('a', 'API errors'),
+ dashboard('b', 'API errors'),
+ dashboard('c', 'API errors (2)'),
+ ]).map((d) => d.title);
+ expect(titles).toEqual(['API errors', 'API errors (3)', 'API errors (2)']);
+ });
+});
diff --git a/frontend/src/features/dashboards/storage.ts b/frontend/src/features/dashboards/storage.ts
new file mode 100644
index 000000000..2043ea9d5
--- /dev/null
+++ b/frontend/src/features/dashboards/storage.ts
@@ -0,0 +1,43 @@
+export type Dashboard = { id: string; title: string; description: string; dataset: string };
+/** Normalises a title the way accessible names are compared: trimmed, collapsed, case-insensitive. */
+export function titleKey(title: string) {
+ return title.trim().replace(/\s+/g, ' ').toLowerCase();
+}
+/** Renames later duplicates to "Title (2)", "Title (3)", … so every tile gets a distinct label. */
+export function uniqueTitles(dashboards: Dashboard[]): Dashboard[] {
+ const taken = new Set(dashboards.map((d) => titleKey(d.title)));
+ const seen = new Set();
+ return dashboards.map((d) => {
+ const key = titleKey(d.title);
+ if (!seen.has(key)) {
+ seen.add(key);
+ return d;
+ }
+ const base = d.title.trim().replace(/\s+/g, ' ');
+ let n = 2;
+ while (taken.has(titleKey(`${base} (${n})`))) n++;
+ const title = `${base} (${n})`;
+ taken.add(titleKey(title));
+ seen.add(titleKey(title));
+ return { ...d, title };
+ });
+}
+export function loadDashboards(key: string): Dashboard[] {
+ try {
+ const value: unknown = JSON.parse(localStorage.getItem(key) || '[]');
+ return Array.isArray(value)
+ ? uniqueTitles(
+ value.filter(
+ (v): v is Dashboard =>
+ v &&
+ typeof v.id === 'string' &&
+ typeof v.title === 'string' &&
+ typeof v.description === 'string' &&
+ typeof v.dataset === 'string',
+ ),
+ )
+ : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/frontend/src/features/datasets/DatasetsPage.tsx b/frontend/src/features/datasets/DatasetsPage.tsx
new file mode 100644
index 000000000..03d59400b
--- /dev/null
+++ b/frontend/src/features/datasets/DatasetsPage.tsx
@@ -0,0 +1,132 @@
+import { useCallback, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Database, ArrowUpRight, Search, RefreshCw } from 'lucide-react';
+import { Badge, Button, Card, Input, Sheet, EmptyState } from '../../components/ui';
+import { PageHeader } from '../../components/explorer/PageHeader';
+import { QueryState } from '../../components/explorer/QueryState';
+import { useApp } from '../../app/AppProvider';
+import { useAsync } from '../../hooks/useAsync';
+function DatasetSchema({ dataset }: { dataset: string }) {
+ const { client } = useApp();
+ const schema = useAsync(
+ useCallback((signal) => client.schema(dataset, signal), [client, dataset]),
+ );
+ return (
+ <>
+
+
+ {schema.data?.map((field) => (
+
+ {field}
+ field
+
+ ))}
+
+
+ Explore dataset
+
+ >
+ );
+}
+export function DatasetsPage() {
+ const { client } = useApp();
+ const data = useAsync(useCallback((signal) => client.listDatasets(signal), [client]));
+ const [search, setSearch] = useState('');
+ const [selected, setSelected] = useState();
+ const datasets =
+ data.data?.filter((d) => d.name.toLowerCase().includes(search.toLowerCase())) || [];
+ return (
+
+
+
+ Refresh
+
+ }
+ />
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
{datasets.length} datasets
+
+
+
+
+
+ Datasets
+
+
+ Name
+ Type
+ Actions
+
+
+
+ {datasets.map((d) => (
+
+
+
+
+ {d.name}
+
+
+
+ {d.type}
+
+
+
+
setSelected(d.name)}
+ aria-label={`View schema for ${d.name}`}
+ >
+ View schema
+
+
+ Explore
+
+
+
+
+ ))}
+
+
+
+
+ {!data.loading && !data.error && !datasets.length && (
+
+ )}
+ {
+ if (!v) setSelected(undefined);
+ }}
+ title={selected || 'Dataset'}
+ description="Fields available in this dataset."
+ >
+ {selected && }
+
+
+ );
+}
diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx
new file mode 100644
index 000000000..f4be1823d
--- /dev/null
+++ b/frontend/src/features/library/LibraryPage.tsx
@@ -0,0 +1,126 @@
+import { useState } from 'react';
+import { Plus } from 'lucide-react';
+import {
+ Badge,
+ Button,
+ Card,
+ Dialog,
+ EmptyState,
+ Input,
+ Select,
+ Sheet,
+ Tabs,
+} from '../../components/ui';
+import { PageHeader } from '../../components/explorer/PageHeader';
+export function LibraryPage() {
+ const [dialog, setDialog] = useState(false);
+ const [sheet, setSheet] = useState(false);
+ const [tab, setTab] = useState('overview');
+ return (
+
+
+
+
+ Actions
+ A consistent hierarchy for each next step.
+
+
+
+ Primary action
+
+
Secondary
+
Quiet action
+
Destructive
+
Disabled
+
+
+
+ Status
+ Status is communicated with text as well as color.
+
+ Neutral
+ Healthy
+ Warning
+ Error
+
+
+
+ Form controls
+
+
+
+
+ Logs
+ Metrics
+ Traces
+
+
+
+
+ Overlays
+ Focus stays inside the overlay and returns when it closes.
+
+ setDialog(true)}>
+ Open dialog
+
+ setSheet(true)}>
+ Open sheet
+
+
+
+
+
+ setDialog(false)} data-dialog-confirm>
+ Done
+
+
+
+
+ Compose fields, actions, and related data here.
+
+
+
+ Tabs
+ Use the arrow keys to switch tabs.,
+ },
+ {
+ value: 'details',
+ label: 'Details',
+ content: Each panel has a clear purpose.
,
+ },
+ ]}
+ />
+
+
+ Empty state
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/logs/FieldSidebar.tsx b/frontend/src/features/logs/FieldSidebar.tsx
new file mode 100644
index 000000000..94c0ed7f2
--- /dev/null
+++ b/frontend/src/features/logs/FieldSidebar.tsx
@@ -0,0 +1,48 @@
+import { useState } from 'react';
+import { Hash } from 'lucide-react';
+import { Input, Button } from '../../components/ui';
+export function FieldSidebar({
+ fields,
+ selected,
+ onToggle,
+}: {
+ fields: string[];
+ selected: string[];
+ onToggle: (field: string) => void;
+}) {
+ const [search, setSearch] = useState('');
+ return (
+
+ );
+}
diff --git a/frontend/src/features/logs/FilterBar.tsx b/frontend/src/features/logs/FilterBar.tsx
new file mode 100644
index 000000000..e22ff9ac6
--- /dev/null
+++ b/frontend/src/features/logs/FilterBar.tsx
@@ -0,0 +1,131 @@
+import { createId } from '../../lib/ids';
+import { useState, type FormEvent } from 'react';
+import { Plus, Search, X } from 'lucide-react';
+import { Button, Dialog, Input, Select } from '../../components/ui';
+export type LogFilter = { id: string; field: string; operator: '=' | '!='; value: string };
+export function FilterBar({
+ search,
+ onSearch,
+ filters,
+ fields,
+ onAdd,
+ onRemove,
+ onUpdate,
+ onClear,
+}: {
+ search: string;
+ onSearch: (v: string) => void;
+ filters: LogFilter[];
+ fields: string[];
+ onAdd: (v: LogFilter) => void;
+ onRemove: (id: string) => void;
+ onUpdate?: (filter: LogFilter) => void;
+ onClear?: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [editing, setEditing] = useState();
+ const [field, setField] = useState('');
+ const [operator, setOperator] = useState<'=' | '!='>('=');
+ const [value, setValue] = useState('');
+ // The field list can be empty (no schema and no returned rows), so a filter
+ // is only submitted with a field that is actually on offer.
+ const selected = fields.includes(field) ? field : fields[0] || '';
+ function add(e: FormEvent) {
+ e.preventDefault();
+ if (!selected) return;
+ const filter = { id: editing || createId(), field: selected, operator, value };
+ if (editing && onUpdate) onUpdate(filter);
+ else onAdd(filter);
+ setOpen(false);
+ setValue('');
+ }
+ return (
+ <>
+
+
+
+ onSearch(e.target.value)}
+ />
+
+
{
+ setEditing(undefined);
+ setValue('');
+ setOpen(true);
+ }}
+ disabled={!fields.length}
+ >
+
+ Add filter
+
+
+ {filters.length > 0 && (
+
+ {filters.map((f) => (
+
+ {
+ setEditing(f.id);
+ setField(f.field);
+ setOperator(f.operator);
+ setValue(f.value);
+ setOpen(true);
+ }}
+ >
+ {f.field} {f.operator}{' '}
+ {f.value}
+
+ onRemove(f.id)}
+ >
+
+
+
+ ))}
+ {onClear && (
+
+ Clear all
+
+ )}
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/features/logs/LogsPage.tsx b/frontend/src/features/logs/LogsPage.tsx
new file mode 100644
index 000000000..65cc7d178
--- /dev/null
+++ b/frontend/src/features/logs/LogsPage.tsx
@@ -0,0 +1,267 @@
+import { createPortal } from 'react-dom';
+import { createId } from '../../lib/ids';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Link, useNavigate, useParams } from 'react-router-dom';
+import { PanelLeft, RefreshCw, Terminal, WrapText, Download } from 'lucide-react';
+import { Button, EmptyState, Select } from '../../components/ui';
+import { QueryState } from '../../components/explorer/QueryState';
+import { TimeRangePicker } from '../../components/explorer/TimeRangePicker';
+import { ColumnPicker } from '../../components/explorer/ColumnPicker';
+import { DataTable, exportRecords } from '../../components/explorer/DataTable';
+import { LogHistogram } from '../../components/explorer/LogHistogram';
+import { RecordSheet } from '../../components/explorer/RecordSheet';
+import { FieldSidebar } from './FieldSidebar';
+import { FilterBar, type LogFilter } from './FilterBar';
+import { useApp } from '../../app/AppProvider';
+import { ApiError } from '../../lib/client';
+import { useAsync } from '../../hooks/useAsync';
+import { buildLogQuery, timeBounds } from '../../lib/query';
+import type { LogRecord, TimeRange } from '../../lib/types';
+export function LogsPage() {
+ const { client } = useApp();
+ const { currentDataset } = useParams();
+ const navigate = useNavigate();
+ const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client]));
+ const dataset = currentDataset || datasets.data?.[0]?.name || '';
+ const [range, setRange] = useState('1h');
+ const [search, setSearch] = useState('');
+ // Tagged with its dataset so a timer left over from the previous dataset is ignored.
+ const [debounced, setDebounced] = useState({ dataset, search: '' });
+ const [filters, setFilters] = useState([]);
+ const [columns, setColumns] = useState();
+ const [showFields, setShowFields] = useState(true);
+ const [wrap, setWrap] = useState(false);
+ const [showHistogram, setShowHistogram] = useState(true);
+ const [row, setRow] = useState();
+ // Fields seen in the latest returned rows, kept while the next results load
+ // or if they fail, so the field list does not empty out. Reset with the dataset.
+ const [rowFields, setRowFields] = useState<{ rows?: LogRecord[]; fields: string[] }>({
+ fields: [],
+ });
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced({ dataset, search }), 250);
+ return () => clearTimeout(timer);
+ }, [dataset, search]);
+ // Reset during render, not in an effect, so the query never runs with the
+ // previous dataset's filters against the new one.
+ const [stateDataset, setStateDataset] = useState(dataset);
+ if (stateDataset !== dataset) {
+ setStateDataset(dataset);
+ setFilters([]);
+ setColumns(undefined);
+ setSearch('');
+ setDebounced({ dataset, search: '' });
+ setRow(undefined);
+ setRowFields({ fields: [] });
+ }
+ const schema = useAsync(
+ useCallback(
+ (signal) => (dataset ? client.schema(dataset, signal) : Promise.resolve([])),
+ [client, dataset],
+ ),
+ );
+ const debouncedSearch = debounced.dataset === dataset ? debounced.search : '';
+ const sql = useMemo(
+ () => (dataset ? buildLogQuery(dataset, debouncedSearch, filters, 100) : ''),
+ [dataset, debouncedSearch, filters],
+ );
+ const results = useAsync(
+ useCallback(
+ (signal) => (sql ? client.query({ sql, ...timeBounds(range) }, signal) : Promise.resolve([])),
+ [client, sql, range],
+ ),
+ );
+ if (results.data && rowFields.rows !== results.data)
+ setRowFields({ rows: results.data, fields: [...new Set(results.data.flatMap(Object.keys))] });
+ const fields = schema.data?.length ? schema.data : rowFields.fields;
+ const visibleColumns = columns ?? ['p_timestamp'].filter((f) => fields.includes(f));
+ // An identical filter changes nothing, so it is not added twice.
+ const addFilter = (filter: LogFilter) =>
+ setFilters((items) =>
+ items.some(
+ (f) =>
+ f.field === filter.field && f.operator === filter.operator && f.value === filter.value,
+ )
+ ? items
+ : [...items, filter],
+ );
+ return (
+
+
Logs
+ {(() => {
+ const selector = (
+
navigate(`/logs/explore/${encodeURIComponent(e.target.value)}`)}
+ >
+ {!datasets.data?.length && Select a dataset }
+ {datasets.data?.map((d) => (
+ {d.name}
+ ))}
+
+ );
+ const slot = document.getElementById('workspace-context');
+ return slot ? (
+ createPortal(selector, slot)
+ ) : (
+
{selector}
+ );
+ })()}
+
+ {!datasets.loading && !datasets.error && !dataset && (
+
+ )}
+ {!!dataset && !datasets.error && (
+
+ {showFields && (
+
+ setColumns(
+ visibleColumns.includes(field)
+ ? visibleColumns.length > 1
+ ? visibleColumns.filter((f) => f !== field)
+ : visibleColumns
+ : [...visibleColumns, field],
+ )
+ }
+ />
+ )}
+
+
+
setShowFields(!showFields)}
+ >
+
+
+
+
+
+
+
+
+
+
+ setFilters(filters.filter((f) => f.id !== id))}
+ onUpdate={(filter) =>
+ setFilters(filters.map((f) => (f.id === filter.id ? filter : f)))
+ }
+ onClear={() => {
+ setFilters([]);
+ setSearch('');
+ }}
+ />
+
+
+
+
+ <>
+ {schema.error && (
+
+ {schema.error instanceof ApiError && schema.error.status === 403
+ ? 'Permission denied for field schema.'
+ : 'Field schema unavailable.'}{' '}
+ Showing fields from returned events.{' '}
+
+ Retry schema
+
+
+ )}
+ >
+
+ {!results.loading && !results.error && (
+ <>
+ {showHistogram && (
+
+ )}
+
+
{results.data?.length || 0} records
+
+ setShowHistogram(!showHistogram)}
+ >
+ {showHistogram ? 'Hide' : 'Show'} histogram
+
+
+ setWrap(!wrap)}
+ >
+
+
+ exportRecords(results.data || [], 'json')}
+ >
+
+ Export JSON
+
+ exportRecords(results.data || [], 'csv')}
+ >
+ Export CSV
+
+
+
+
+ >
+ )}
+
+
+ )}
+
setRow(undefined)}
+ onFilter={(field, value, operator = '=') =>
+ addFilter({ id: createId(), field, operator, value })
+ }
+ />
+
+ );
+}
diff --git a/frontend/src/features/sql/SqlEditor.tsx b/frontend/src/features/sql/SqlEditor.tsx
new file mode 100644
index 000000000..7ca517937
--- /dev/null
+++ b/frontend/src/features/sql/SqlEditor.tsx
@@ -0,0 +1,84 @@
+import { useEffect, useRef } from 'react';
+import { EditorView, keymap } from '@codemirror/view';
+import { EditorState, Prec } from '@codemirror/state';
+import { sql } from '@codemirror/lang-sql';
+import { basicSetup } from 'codemirror';
+import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
+import { tags } from '@lezer/highlight';
+export function SqlEditor({
+ value,
+ onChange,
+ onRun,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ onRun: () => void;
+}) {
+ const element = useRef(null);
+ const view = useRef(null);
+ const callbacks = useRef({ onChange, onRun });
+ callbacks.current = { onChange, onRun };
+ useEffect(() => {
+ const editor = new EditorView({
+ parent: element.current!,
+ state: EditorState.create({
+ doc: value,
+ extensions: [
+ basicSetup,
+ sql(),
+ Prec.highest(
+ keymap.of([
+ {
+ key: 'Mod-Enter',
+ run: () => {
+ callbacks.current.onRun();
+ return true;
+ },
+ },
+ ]),
+ ),
+ syntaxHighlighting(
+ HighlightStyle.define([
+ { tag: tags.keyword, color: 'var(--color-primary)', fontWeight: '600' },
+ { tag: [tags.string, tags.name], color: 'var(--color-success)' },
+ { tag: tags.number, color: 'var(--color-warning)' },
+ { tag: tags.comment, color: 'var(--color-muted)' },
+ ]),
+ ),
+ EditorView.contentAttributes.of({ 'aria-label': 'SQL query' }),
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) callbacks.current.onChange(update.state.doc.toString());
+ }),
+ EditorView.theme({
+ '&': {
+ backgroundColor: 'var(--color-surface)',
+ color: 'var(--color-text)',
+ fontSize: '13px',
+ minHeight: '220px',
+ },
+ '.cm-content': { fontFamily: 'var(--font-mono)', padding: '18px 0' },
+ '.cm-gutters': {
+ backgroundColor: 'var(--color-bg)',
+ color: 'var(--color-muted)',
+ borderRight: '1px solid var(--color-border)',
+ },
+ '.cm-activeLine': { backgroundColor: 'var(--color-primary-soft)' },
+ '.cm-activeLineGutter': { backgroundColor: 'var(--color-primary-soft)' },
+ '.cm-cursor': { borderLeftColor: 'var(--color-text)' },
+ }),
+ ],
+ }),
+ });
+ view.current = editor;
+ return () => {
+ view.current = null;
+ editor.destroy();
+ };
+ }, []);
+ useEffect(() => {
+ const editor = view.current;
+ if (editor && editor.state.doc.toString() !== value)
+ editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
+ }, [value]);
+ return
;
+}
diff --git a/frontend/src/features/sql/SqlPage.tsx b/frontend/src/features/sql/SqlPage.tsx
new file mode 100644
index 000000000..8cd875e2a
--- /dev/null
+++ b/frontend/src/features/sql/SqlPage.tsx
@@ -0,0 +1,168 @@
+import { useCallback, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { Play, Download, WrapText, Database } from 'lucide-react';
+import { Button, Card, EmptyState, Input } from '../../components/ui';
+import { PageHeader } from '../../components/explorer/PageHeader';
+import { ColumnPicker } from '../../components/explorer/ColumnPicker';
+import { DataTable, exportRecords } from '../../components/explorer/DataTable';
+import { QueryState } from '../../components/explorer/QueryState';
+import { RecordSheet } from '../../components/explorer/RecordSheet';
+import { TimeRangePicker } from '../../components/explorer/TimeRangePicker';
+import { SqlEditor } from './SqlEditor';
+import { useApp } from '../../app/AppProvider';
+import { useAsync } from '../../hooks/useAsync';
+import { timeBounds, quoteIdentifier } from '../../lib/query';
+import type { LogRecord, TimeRange } from '../../lib/types';
+export function SqlPage() {
+ const { client, mode } = useApp();
+ const [params] = useSearchParams();
+ const [query, setQuery] = useState(
+ params.get('query') ||
+ (mode === 'demo'
+ ? 'SELECT * FROM "application_logs"\nORDER BY "p_timestamp" DESC\nLIMIT 100'
+ : ''),
+ );
+ const [range, setRange] = useState('1h');
+ const [request, setRequest] = useState<{ sql: string; startTime: string; endTime: string }>();
+ const [row, setRow] = useState();
+ const [wrap, setWrap] = useState(false);
+ const [columns, setColumns] = useState();
+ const [datasetSearch, setDatasetSearch] = useState('');
+ const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client]));
+ const results = useAsync(
+ useCallback(
+ (signal) => (request ? client.query(request, signal) : Promise.resolve([])),
+ [request, client],
+ ),
+ );
+ const run = () => {
+ if (query.trim()) {
+ setColumns(undefined);
+ setRequest({ sql: query, ...timeBounds(range) });
+ }
+ };
+ return (
+
+
Query 1
+
}
+ />
+
+
+
+
+
+
Query
+ ⌘ / Ctrl + Enter to run
+
+
+
+
+ {mode === 'demo'
+ ? 'Demo supports SELECT *, exact filters, message search, ordering, and limits.'
+ : 'Queries run against your connected Parseable server.'}
+
+
+
+ Run query
+
+
+
+
+
+
Results
+
+
+ setWrap(!wrap)}
+ >
+
+
+ exportRecords(results.data || [], 'json')}
+ >
+
+ Export JSON
+
+ exportRecords(results.data || [], 'csv')}
+ >
+ Export CSV
+
+
+
+ {!request ? (
+
+ ) : (
+ <>
+
+ {!results.loading && !results.error && (
+
+ )}
+ >
+ )}
+
+
+
+
setRow(undefined)} />
+
+ );
+}
diff --git a/frontend/src/hooks/useAsync.ts b/frontend/src/hooks/useAsync.ts
new file mode 100644
index 000000000..917ebd75a
--- /dev/null
+++ b/frontend/src/hooks/useAsync.ts
@@ -0,0 +1,36 @@
+import { useEffect, useState } from 'react';
+// Callers memoize the loader: replacing it cancels the previous request.
+export function useAsync(loader: (signal: AbortSignal) => Promise) {
+ const [revision, setRevision] = useState(0);
+ // Tagged with the request it answers, so a result never outlives its loader:
+ // until the new request's effect runs, the stale result reads as loading.
+ const [state, setState] = useState<{
+ data?: T;
+ error?: Error;
+ loader?: typeof loader;
+ revision?: number;
+ }>({});
+ useEffect(() => {
+ const abort = new AbortController();
+ loader(abort.signal)
+ .then((data) => {
+ if (!abort.signal.aborted) setState({ data, loader, revision });
+ })
+ .catch((error) => {
+ if (!abort.signal.aborted)
+ setState({
+ error: error instanceof Error ? error : new Error(String(error)),
+ loader,
+ revision,
+ });
+ });
+ return () => abort.abort();
+ }, [loader, revision]);
+ const current = state.loader === loader && state.revision === revision;
+ return {
+ data: current ? state.data : undefined,
+ error: current ? state.error : undefined,
+ loading: !current,
+ reload: () => setRevision((v) => v + 1),
+ };
+}
diff --git a/frontend/src/lib/auth.test.ts b/frontend/src/lib/auth.test.ts
new file mode 100644
index 000000000..c1235ed65
--- /dev/null
+++ b/frontend/src/lib/auth.test.ts
@@ -0,0 +1,43 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cookieIdentity, safeReturnPath, startSso } from './auth';
+afterEach(() => vi.unstubAllGlobals());
+
+describe('safe authentication navigation', () => {
+ it.each([
+ 'https://evil.test/logs',
+ '//evil.test/logs',
+ '/\\evil.test',
+ '/api/v1/o/logout',
+ '/login?next=/logs',
+ '/oidc-not-configured',
+ '/\n/evil.test',
+ ])('rejects unsafe return path %s', (path) => {
+ expect(safeReturnPath(path, 'https://logs.example.test')).toBe('/');
+ });
+ it('preserves a local application path, filters and fragment', () => {
+ expect(
+ safeReturnPath('/logs/explore/team%20logs?range=1h#event', 'https://logs.example.test'),
+ ).toBe('/logs/explore/team%20logs?range=1h#event');
+ });
+ it('starts OAuth as a document navigation and remembers the intended local route', () => {
+ const assign = vi.fn();
+ const setItem = vi.fn();
+ vi.stubGlobal('window', { location: { origin: 'https://logs.example.test', assign } });
+ vi.stubGlobal('sessionStorage', { setItem });
+ vi.stubGlobal('document', { cookie: 'session=expired' });
+ startSso('/logs?range=1h');
+ expect(assign).toHaveBeenCalledWith(
+ '/api/v1/o/login?redirect=https%3A%2F%2Flogs.example.test%2Flogs%3Frange%3D1h',
+ );
+ expect(setItem).toHaveBeenCalledWith('parseable-return-path', '/logs?range=1h');
+ expect(setItem).toHaveBeenCalledWith('parseable-mode', 'live');
+ });
+ it('handles cookie hints without requiring both display cookies', () => {
+ expect(cookieIdentity('user_id=team%2Freader')).toEqual({
+ id: 'team/reader',
+ username: 'team/reader',
+ source: 'cookie',
+ });
+ expect(cookieIdentity('session=anything')).toBeUndefined();
+ });
+});
diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts
new file mode 100644
index 000000000..15b9c6dde
--- /dev/null
+++ b/frontend/src/lib/auth.ts
@@ -0,0 +1,72 @@
+import type { SessionIdentity } from './types';
+import { appPath } from './config';
+
+const RETURN_PATH_KEY = 'parseable-return-path';
+
+/** Accept only local application paths, never authentication endpoints or external origins. */
+export function safeReturnPath(
+ value: string | null | undefined,
+ origin = window.location.origin,
+): string {
+ if (!value || !value.startsWith('/') || value.startsWith('//') || /[\\\u0000-\u001f]/.test(value))
+ return '/';
+ try {
+ const url = new URL(value, origin);
+ if (
+ url.origin !== origin ||
+ /^\/(?:api(?:\/|$)|login(?:\/|$)|oidc-not-configured(?:\/|$))/.test(url.pathname)
+ )
+ return '/';
+ return `${url.pathname}${url.search}${url.hash}`;
+ } catch {
+ return '/';
+ }
+}
+
+export function authEndpoint(action: 'login' | 'logout', path: string): string {
+ const destination = action === 'logout' ? '/login' : safeReturnPath(path);
+ const redirect = new URL(appPath(destination), window.location.origin).href;
+ return `/api/v1/o/${action}?${new URLSearchParams({ redirect })}`;
+}
+
+export function rememberReturnPath(path: string) {
+ sessionStorage.setItem(RETURN_PATH_KEY, safeReturnPath(path));
+}
+
+export function intendedReturnPath(search: string): string {
+ const next = new URLSearchParams(search).get('next');
+ return safeReturnPath(next ?? sessionStorage.getItem(RETURN_PATH_KEY));
+}
+
+export function startSso(path: string) {
+ rememberReturnPath(path);
+ sessionStorage.setItem('parseable-mode', 'live');
+ clearSessionCookies();
+ window.location.assign(authEndpoint('login', path));
+}
+
+/** Display-only hints; cookies never establish authentication or permissions. */
+export function cookieIdentity(cookie = document.cookie): SessionIdentity | undefined {
+ const values = new Map(
+ cookie.split(';').map((part) => {
+ const split = part.indexOf('=');
+ const key = part.slice(0, split).trim();
+ const value = part.slice(split + 1).trim();
+ try {
+ return [key, decodeURIComponent(value)] as const;
+ } catch {
+ return [key, value] as const;
+ }
+ }),
+ );
+ const id = values.get('user_id') || values.get('username');
+ if (!id) return undefined;
+ return { id, username: values.get('username') || id, source: 'cookie' };
+}
+
+/** The server currently leaves expired session cookies in the browser. */
+export function clearSessionCookies() {
+ for (const name of ['session', 'username', 'user_id']) {
+ document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
+ }
+}
diff --git a/frontend/src/lib/classicUi.test.ts b/frontend/src/lib/classicUi.test.ts
new file mode 100644
index 000000000..48c80e516
--- /dev/null
+++ b/frontend/src/lib/classicUi.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { classicUiPath } from './classicUi';
+import { appPath } from './config';
+
+describe('side-by-side classic UI', () => {
+ it.each([
+ ['/', '/'],
+ ['/logs', '/'],
+ ['/logs/explore/team%20logs', '/logs/explore/team%20logs'],
+ ['/sql-editor', '/sql-editor'],
+ ['/datasets', '/datasets'],
+ ['/dashboards', '/dashboards'],
+ ['/components', '/'],
+ ])('maps %s to the classic page %s', (route, classic) => {
+ expect(classicUiPath(route)).toBe(classic);
+ });
+ it('prefixes document navigations with the mount path', () => {
+ expect(appPath('/login', '/next')).toBe('/next/login');
+ expect(appPath('/login', '')).toBe('/login');
+ });
+});
diff --git a/frontend/src/lib/classicUi.ts b/frontend/src/lib/classicUi.ts
new file mode 100644
index 000000000..8c12c98d1
--- /dev/null
+++ b/frontend/src/lib/classicUi.ts
@@ -0,0 +1,20 @@
+import { basePath } from './config';
+
+/** The classic (Prism) UI owns the root while this frontend is mounted under a prefix. */
+export const classicUiAvailable = basePath !== '';
+
+/** Classic UI pages for features this frontend does not provide yet. */
+export const classicOnlyPages = [
+ { href: '/alerts', label: 'Alerts', id: 'alerts' },
+ { href: '/team?tab=user', label: 'Users', id: 'users' },
+ { href: '/team?tab=roles', label: 'Roles', id: 'roles' },
+] as const;
+
+/** The classic UI page equivalent to a route in this frontend, for comparison. */
+export function classicUiPath(pathname: string): string {
+ const [, section, view, dataset] = pathname.split('/');
+ if (section === 'logs' && view && dataset) return `/logs/explore/${dataset}`;
+ if (section === 'sql-editor' || section === 'datasets' || section === 'dashboards')
+ return `/${section}`;
+ return '/';
+}
diff --git a/frontend/src/lib/client.test.ts b/frontend/src/lib/client.test.ts
new file mode 100644
index 000000000..5668134ed
--- /dev/null
+++ b/frontend/src/lib/client.test.ts
@@ -0,0 +1,207 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ApiError, createClient } from './client';
+
+afterEach(() => vi.unstubAllGlobals());
+const json = (data: unknown, status = 200) =>
+ new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
+
+describe('live HTTP contract', () => {
+ it('uses backend endpoints, cookie credentials, abort signal and Arrow field names', async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(json([{ name: 'api_logs' }]))
+ .mockResolvedValueOnce(
+ json({ fields: [{ name: 'p_timestamp' }, { name: 'message' }], metadata: {} }),
+ );
+ vi.stubGlobal('fetch', fetch);
+ const signal = new AbortController().signal;
+ const client = createClient({ mode: 'live' });
+ expect(await client.listDatasets(signal)).toEqual([{ name: 'api_logs', type: 'logs' }]);
+ expect(await client.schema('logs / staging', signal)).toEqual(['p_timestamp', 'message']);
+ expect(fetch).toHaveBeenNthCalledWith(1, '/api/v1/logstream', {
+ signal,
+ credentials: 'include',
+ });
+ expect(fetch).toHaveBeenNthCalledWith(2, '/api/v1/logstream/logs%20%2F%20staging/schema', {
+ signal,
+ credentials: 'include',
+ });
+ });
+ it('translates query request into the Rust camelCase contract', async () => {
+ const fetch = vi.fn().mockResolvedValue(json([{ message: 'ok', optional: null }]));
+ vi.stubGlobal('fetch', fetch);
+ const result = await createClient({ mode: 'live' }).query({
+ sql: 'SELECT * FROM logs',
+ startTime: 'start',
+ endTime: 'end',
+ });
+ expect(result).toEqual([{ message: 'ok', optional: null }]);
+ const [url, init] = fetch.mock.calls[0];
+ expect(url).toBe('/api/v1/query');
+ expect(init.credentials).toBe('include');
+ expect(JSON.parse(init.body)).toEqual({
+ query: 'SELECT * FROM logs',
+ startTime: 'start',
+ endTime: 'end',
+ sendNull: true,
+ });
+ });
+ it('preserves HTTP status and human readable error bodies', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(new Response('Permission denied', { status: 403 })),
+ );
+ await expect(createClient({ mode: 'live' }).listDatasets()).rejects.toMatchObject({
+ name: 'ApiError',
+ status: 403,
+ message: 'Permission denied',
+ });
+ });
+ it('rejects malformed success responses', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ records: [] })));
+ await expect(
+ createClient({ mode: 'live' }).query({ sql: 'select', startTime: '', endTime: '' }),
+ ).rejects.toBeInstanceOf(ApiError);
+ });
+ it('exchanges Basic credentials, then verifies cookie auth without retaining a password', async () => {
+ vi.stubGlobal('window', { location: { origin: 'http://localhost:5173' } });
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('app'))
+ .mockResolvedValueOnce(json([]));
+ vi.stubGlobal('fetch', fetch);
+ await createClient({ mode: 'live' }).login('admin', 'secret');
+ expect(fetch.mock.calls[0][0]).toBe(
+ '/api/v1/o/login?redirect=http%3A%2F%2Flocalhost%3A5173%2F',
+ );
+ expect(fetch.mock.calls[0][1].headers.Authorization).toBe(`Basic ${btoa('admin:secret')}`);
+ expect(fetch.mock.calls[1]).toEqual(['/api/v1/logstream', { credentials: 'include' }]);
+ });
+ it('forwards network and abort failures', async () => {
+ const error = new DOMException('Aborted', 'AbortError');
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error));
+ await expect(createClient({ mode: 'live' }).listDatasets()).rejects.toBe(error);
+ });
+});
+
+describe('demo contract', () => {
+ it('requires no network and honors cancellation', async () => {
+ const fetch = vi.fn();
+ vi.stubGlobal('fetch', fetch);
+ const client = createClient({ mode: 'demo' });
+ expect(await client.listDatasets()).toHaveLength(3);
+ expect(await client.schema('application_logs')).toContain('message');
+ const controller = new AbortController();
+ controller.abort();
+ await expect(client.listDatasets(controller.signal)).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
+
+describe('session and navigation contracts', () => {
+ it('prefers the authorized user API identity over cookie display hints', async () => {
+ vi.stubGlobal('document', { cookie: 'username=Old%20Name; user_id=team%2Freader' });
+ const fetch = vi.fn().mockResolvedValue(
+ json({
+ id: 'team/reader',
+ username: 'Current Name',
+ method: 'oauth',
+ email: 'reader@example.test',
+ }),
+ );
+ vi.stubGlobal('fetch', fetch);
+ const identity = await createClient({ mode: 'live' }).identity();
+ expect(identity).toEqual({
+ id: 'team/reader',
+ username: 'Current Name',
+ method: 'oauth',
+ email: 'reader@example.test',
+ source: 'server',
+ });
+ expect(fetch).toHaveBeenCalledWith('/api/v1/users/team%2Freader', {
+ signal: undefined,
+ credentials: 'include',
+ });
+ });
+ it.each([403, 404, 503])(
+ 'falls back to display cookies when the identity API returns %s',
+ async (status) => {
+ vi.stubGlobal('document', { cookie: 'username=Reader; user_id=reader' });
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Unavailable', { status })));
+ expect(await createClient({ mode: 'live' }).identity()).toEqual({
+ id: 'reader',
+ username: 'Reader',
+ source: 'cookie',
+ });
+ },
+ );
+ it('does not trust a stale identity cookie after the server returns 401', async () => {
+ vi.stubGlobal('document', { cookie: 'username=Reader; user_id=reader' });
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Expired', { status: 401 })));
+ const onUnauthorized = vi.fn();
+ await expect(createClient({ mode: 'live', onUnauthorized }).identity()).rejects.toMatchObject({
+ status: 401,
+ });
+ expect(onUnauthorized).toHaveBeenCalledOnce();
+ });
+ it('notifies session expiry for query requests, but preserves permission errors', async () => {
+ const onUnauthorized = vi.fn();
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValueOnce(new Response('Expired', { status: 401 }))
+ .mockResolvedValueOnce(new Response('Forbidden', { status: 403 })),
+ );
+ const client = createClient({ mode: 'live', onUnauthorized });
+ await expect(
+ client.query({ sql: 'SELECT 1', startTime: '', endTime: '' }),
+ ).rejects.toMatchObject({ status: 401 });
+ await expect(client.schema('logs')).rejects.toMatchObject({ status: 403 });
+ expect(onUnauthorized).toHaveBeenCalledOnce();
+ });
+ it('accepts a cookie-authenticated login with restricted stream-list permissions', async () => {
+ vi.stubGlobal('window', { location: { origin: 'https://logs.example.test' } });
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('app'))
+ .mockResolvedValueOnce(new Response('Forbidden', { status: 403 }));
+ vi.stubGlobal('fetch', fetch);
+ await createClient({ mode: 'live' }).login(
+ 'reader',
+ 'secret',
+ '/sql-editor?dataset=logs#query',
+ );
+ const url = new URL(fetch.mock.calls[0][0], 'https://logs.example.test');
+ expect(url.searchParams.get('redirect')).toBe(
+ 'https://logs.example.test/sql-editor?dataset=logs#query',
+ );
+ });
+ it('navigates the browser through logout so provider redirects can complete', async () => {
+ const assign = vi.fn();
+ const fetch = vi.fn();
+ vi.stubGlobal('window', { location: { origin: 'https://logs.example.test', assign } });
+ vi.stubGlobal('fetch', fetch);
+ await createClient({ mode: 'live' }).logout();
+ expect(assign).toHaveBeenCalledWith(
+ '/api/v1/o/logout?redirect=https%3A%2F%2Flogs.example.test%2Flogin',
+ );
+ expect(fetch).not.toHaveBeenCalled();
+ });
+ it('rejects production demo clients unless the build explicitly enables sample data', async () => {
+ vi.stubEnv('DEV', false);
+ vi.stubEnv('VITE_ENABLE_DEMO', 'false');
+ vi.resetModules();
+ const { createClient: productionClient } = await import('./client');
+ const fetch = vi.fn().mockResolvedValue(json([{ name: 'real_logs' }]));
+ vi.stubGlobal('fetch', fetch);
+ expect(await productionClient({ mode: 'demo' }).listDatasets()).toEqual([
+ { name: 'real_logs', type: 'logs' },
+ ]);
+ expect(fetch).toHaveBeenCalledOnce();
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+});
diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts
new file mode 100644
index 000000000..254796481
--- /dev/null
+++ b/frontend/src/lib/client.ts
@@ -0,0 +1,155 @@
+import { createDemoClient } from './demo';
+import { authEndpoint, cookieIdentity } from './auth';
+import { demoEnabled } from './config';
+import type { Dataset, LogRecord, ParseableClient } from './types';
+
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ public readonly detail?: unknown,
+ ) {
+ super(message);
+ this.name = 'ApiError';
+ }
+}
+
+function object(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+async function checked(response: Response): Promise {
+ if (!response.ok) {
+ const body = await response.text();
+ let detail: unknown = body;
+ try {
+ detail = JSON.parse(body);
+ } catch {
+ /* Backend errors can be plain text. */
+ }
+ const message =
+ object(detail) && typeof detail.message === 'string'
+ ? detail.message
+ : object(detail) && typeof detail.error === 'string'
+ ? detail.error
+ : body || response.statusText;
+ throw new ApiError(message || `Request failed (${response.status})`, response.status, detail);
+ }
+ return response;
+}
+
+function malformed(endpoint: string): never {
+ throw new ApiError(`Unexpected response from ${endpoint}`, 502);
+}
+
+async function readJson(
+ endpoint: string,
+ init: RequestInit,
+ onUnauthorized?: () => void,
+): Promise {
+ const received = await fetch(endpoint, { ...init, credentials: 'include' });
+ if (received.status === 401) onUnauthorized?.();
+ const response = await checked(received);
+ try {
+ return await response.json();
+ } catch {
+ return malformed(endpoint);
+ }
+}
+
+export function createClient({
+ mode,
+ onUnauthorized,
+}: {
+ mode: 'demo' | 'live';
+ onUnauthorized?: () => void;
+}): ParseableClient {
+ if (mode === 'demo' && demoEnabled) return createDemoClient();
+ const base = '/api/v1';
+ return {
+ async listDatasets(signal) {
+ const endpoint = `${base}/logstream`;
+ const data = await readJson(endpoint, { signal }, onUnauthorized);
+ if (
+ !Array.isArray(data) ||
+ !data.every((item) => object(item) && typeof item.name === 'string')
+ )
+ return malformed(endpoint);
+ // This endpoint returns names only. Do not infer telemetry type from a name.
+ return data.map((item) => ({ name: item.name, type: 'logs' }) satisfies Dataset);
+ },
+ async schema(dataset, signal) {
+ const endpoint = `${base}/logstream/${encodeURIComponent(dataset)}/schema`;
+ const data = await readJson(endpoint, { signal }, onUnauthorized);
+ if (
+ !object(data) ||
+ !Array.isArray(data.fields) ||
+ !data.fields.every((field) => object(field) && typeof field.name === 'string')
+ )
+ return malformed(endpoint);
+ return data.fields.map((field) => field.name as string);
+ },
+ async query({ sql, startTime, endTime }, signal) {
+ const endpoint = `${base}/query`;
+ const data = await readJson(
+ endpoint,
+ {
+ method: 'POST',
+ signal,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: sql, startTime, endTime, sendNull: true }),
+ },
+ onUnauthorized,
+ );
+ if (!Array.isArray(data) || !data.every(object)) return malformed(endpoint);
+ return data as LogRecord[];
+ },
+ async identity(signal) {
+ const fallback = cookieIdentity();
+ if (!fallback) return undefined;
+ const endpoint = `${base}/users/${encodeURIComponent(fallback.id)}`;
+ try {
+ const data = await readJson(endpoint, { signal }, onUnauthorized);
+ if (!object(data) || typeof data.id !== 'string' || typeof data.username !== 'string')
+ return malformed(endpoint);
+ return {
+ id: data.id,
+ username: data.username,
+ method: typeof data.method === 'string' ? data.method : undefined,
+ email: typeof data.email === 'string' ? data.email : undefined,
+ source: 'server',
+ };
+ } catch (error) {
+ if (signal?.aborted || (error instanceof ApiError && error.status === 401)) throw error;
+ // Identity hints are display-only; a missing/unavailable profile must not
+ // make the workspace unusable or grant access to protected requests.
+ return fallback;
+ }
+ },
+ async login(username, password, returnPath = '/') {
+ if (!username || username.includes(':'))
+ throw new ApiError('Enter a username without a colon', 400);
+ const bytes = new TextEncoder().encode(`${username}:${password}`);
+ const credentials = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''));
+ await checked(
+ await fetch(authEndpoint('login', returnPath), {
+ credentials: 'include',
+ headers: { Authorization: `Basic ${credentials}` },
+ redirect: 'follow',
+ }),
+ );
+ // Verify the exchanged cookie independently; never persist the password or
+ // resend Basic auth with data requests.
+ try {
+ await readJson(`${base}/logstream`, {});
+ } catch (error) {
+ // A restricted account may sign in successfully without stream-list access.
+ if (!(error instanceof ApiError && error.status === 403)) throw error;
+ }
+ },
+ async logout() {
+ // OIDC logout can cross origins; a fetch cannot complete that browser flow.
+ window.location.assign(authEndpoint('logout', '/login'));
+ },
+ };
+}
diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts
new file mode 100644
index 000000000..848b0c97d
--- /dev/null
+++ b/frontend/src/lib/config.ts
@@ -0,0 +1,10 @@
+/** Sample workspaces are an explicit development/build capability. */
+export const demoEnabled = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEMO === 'true';
+
+/** Router base without a trailing slash; `/next` while served beside the classic UI. */
+export const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
+
+/** Document path for an application route, for navigations outside the router. */
+export function appPath(path: string, base = basePath): string {
+ return `${base}${path}`;
+}
diff --git a/frontend/src/lib/demo.ts b/frontend/src/lib/demo.ts
new file mode 100644
index 000000000..623373ff1
--- /dev/null
+++ b/frontend/src/lib/demo.ts
@@ -0,0 +1,239 @@
+import type { Dataset, LogRecord, ParseableClient, QueryRequest } from './types';
+
+export const demoDatasets: Dataset[] = [
+ { name: 'application_logs', type: 'logs' },
+ { name: 'api_logs', type: 'logs' },
+ { name: 'infrastructure_logs', type: 'logs' },
+];
+
+/** Fixed sequences anchored to now keep the initial relative time range useful. */
+export function createDemoRecords(now: number = Date.now()): LogRecord[] {
+ const services = ['api-gateway', 'checkout', 'inventory', 'auth', 'worker'];
+ const messages = [
+ 'Request completed successfully',
+ 'Order created and queued for fulfillment',
+ 'Inventory cache refreshed',
+ 'Session token validated',
+ 'Background job completed',
+ ];
+ return Array.from({ length: 100 }, (_, index) => {
+ const level = index % 13 === 0 ? 'ERROR' : index % 7 === 0 ? 'WARN' : 'INFO';
+ return {
+ p_timestamp: new Date(now - index * 8_000).toISOString(),
+ level,
+ service: services[index % services.length],
+ message:
+ level === 'ERROR'
+ ? 'Upstream request timed out after 3000ms'
+ : level === 'WARN'
+ ? 'Retrying request after a transient connection failure'
+ : messages[index % messages.length],
+ host: `prod-node-${String((index % 4) + 1).padStart(2, '0')}`,
+ environment: 'production',
+ region: index % 3 === 0 ? 'eu-west-1' : 'us-east-1',
+ method: index % 3 === 0 ? 'POST' : 'GET',
+ path: ['/api/orders', '/api/products', '/health', '/api/session'][index % 4],
+ status_code: level === 'ERROR' ? 504 : level === 'WARN' ? 429 : 200,
+ duration_ms: level === 'ERROR' ? 3000 : 12 + ((index * 37) % 240),
+ trace_id: `8e74f620af734b9086bb${index.toString(16).padStart(12, '0')}`,
+ };
+ });
+}
+
+export const demoLogs = createDemoRecords();
+type Token = { kind: 'identifier' | 'string' | 'word' | 'number' | 'symbol'; value: string };
+const unsupported = () =>
+ new Error(
+ 'Demo SQL supports SELECT * FROM a sample dataset, AND filters (= or !=), message ILIKE search, ORDER BY and LIMIT. Connect to a server for other SQL.',
+ );
+
+function tokenize(sql: string): Token[] {
+ const tokens: Token[] = [];
+ const expression =
+ /\s+|"(?:[^"]|"")*"|'(?:[^']|'')*'|!=|<>|[=*,();]|[A-Za-z_][A-Za-z_0-9]*|\d+/gy;
+ let offset = 0;
+ while (offset < sql.length) {
+ expression.lastIndex = offset;
+ const match = expression.exec(sql);
+ if (!match) throw unsupported();
+ const value = match[0];
+ offset = expression.lastIndex;
+ if (/^\s+$/.test(value)) continue;
+ if (value[0] === '"')
+ tokens.push({ kind: 'identifier', value: value.slice(1, -1).replaceAll('""', '"') });
+ else if (value[0] === "'")
+ tokens.push({ kind: 'string', value: value.slice(1, -1).replaceAll("''", "'") });
+ else if (/^\d+$/.test(value)) tokens.push({ kind: 'number', value });
+ else if (/^[A-Za-z_]/.test(value)) tokens.push({ kind: 'word', value });
+ else tokens.push({ kind: 'symbol', value });
+ }
+ return tokens;
+}
+
+function likeRegex(pattern: string, escape: string): RegExp {
+ let expression = '';
+ for (let index = 0; index < pattern.length; index++) {
+ let character = pattern[index];
+ if (character === escape) {
+ character = pattern[++index];
+ if (character === undefined) throw unsupported();
+ } else if (character === '%') {
+ expression += '[\\s\\S]*';
+ continue;
+ } else if (character === '_') {
+ expression += '[\\s\\S]';
+ continue;
+ }
+ expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+ return new RegExp(`^${expression}$`, 'i');
+}
+
+export function executeDemoQuery(request: QueryRequest, records: LogRecord[]): LogRecord[] {
+ const tokens = tokenize(request.sql);
+ let position = 0;
+ const peek = (value: string) =>
+ ['word', 'symbol'].includes(tokens[position]?.kind) &&
+ tokens[position].value.toUpperCase() === value;
+ const expect = (value: string) => {
+ if (!peek(value)) throw unsupported();
+ position++;
+ };
+ const identifier = () => {
+ const token = tokens[position++];
+ if (!token || !['word', 'identifier'].includes(token.kind)) throw unsupported();
+ return token.kind === 'word' ? token.value.toLowerCase() : token.value;
+ };
+ const literal = () => {
+ const token = tokens[position++];
+ if (token?.kind !== 'string') throw unsupported();
+ return token.value;
+ };
+ const field = () => {
+ const name = identifier();
+ if (records.length && !Object.hasOwn(records[0], name))
+ throw new Error(`Unknown demo field: ${name}`);
+ return name;
+ };
+ expect('SELECT');
+ expect('*');
+ expect('FROM');
+ const dataset = identifier();
+ if (!demoDatasets.some((item) => item.name === dataset))
+ throw new Error(`Unknown demo dataset: ${dataset}`);
+ const predicates: ((row: LogRecord) => boolean)[] = [];
+ if (peek('WHERE')) {
+ position++;
+ let firstCondition = true;
+ do {
+ if (!firstCondition) expect('AND');
+ firstCondition = false;
+ if (peek('AND')) throw unsupported();
+ if (peek('CAST')) {
+ position++;
+ expect('(');
+ const name = field();
+ expect('AS');
+ expect('VARCHAR');
+ expect(')');
+ expect('ILIKE');
+ const pattern = literal();
+ expect('ESCAPE');
+ const escape = literal();
+ if (escape.length !== 1) throw unsupported();
+ const matcher = likeRegex(pattern, escape);
+ predicates.push((row) => row[name] != null && matcher.test(String(row[name])));
+ } else {
+ const name = field();
+ const operation = tokens[position++]?.value;
+ if (!['=', '!=', '<>'].includes(operation)) throw unsupported();
+ const token = tokens[position++];
+ if (!token || !['string', 'number'].includes(token.kind)) throw unsupported();
+ const value = token.value;
+ predicates.push(
+ (row) =>
+ row[name] != null &&
+ (operation === '=' ? String(row[name]) === value : String(row[name]) !== value),
+ );
+ }
+ } while (peek('AND'));
+ }
+ let orderField: string | undefined;
+ let descending = false;
+ if (peek('ORDER')) {
+ position++;
+ expect('BY');
+ orderField = field();
+ if (peek('DESC')) {
+ descending = true;
+ position++;
+ } else if (peek('ASC')) position++;
+ }
+ let limit = 100;
+ if (peek('LIMIT')) {
+ position++;
+ const token = tokens[position++];
+ if (token?.kind !== 'number') throw unsupported();
+ limit = Number(token.value);
+ if (!Number.isSafeInteger(limit) || limit > 10_000)
+ throw new Error('Demo limit must be between 0 and 10000');
+ }
+ if (peek(';')) position++;
+ if (position !== tokens.length) throw unsupported();
+ const start = Date.parse(request.startTime),
+ end = Date.parse(request.endTime);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end)
+ throw new Error('Invalid query time range');
+ const result = records.filter((row) => {
+ const timestamp = Date.parse(String(row.p_timestamp));
+ return timestamp >= start && timestamp < end && predicates.every((predicate) => predicate(row));
+ });
+ if (orderField) {
+ const key = orderField;
+ result.sort((a, b) => {
+ const av = a[key],
+ bv = b[key];
+ const comparison =
+ typeof av === 'number' && typeof bv === 'number'
+ ? av - bv
+ : String(av).localeCompare(String(bv));
+ return descending ? -comparison : comparison;
+ });
+ }
+ return result.slice(0, limit).map((row) => ({ ...row }));
+}
+
+export function createDemoClient(): ParseableClient {
+ const records = createDemoRecords(Date.now() - 1_000);
+ async function ready(signal?: AbortSignal) {
+ signal?.throwIfAborted();
+ await Promise.resolve();
+ signal?.throwIfAborted();
+ }
+ return {
+ async listDatasets(signal) {
+ await ready(signal);
+ return demoDatasets.map((dataset) => ({ ...dataset }));
+ },
+ async schema(dataset, signal) {
+ await ready(signal);
+ if (!demoDatasets.some((item) => item.name === dataset))
+ throw new Error(`Unknown demo dataset: ${dataset}`);
+ return Object.keys(records[0]);
+ },
+ async query(request, signal) {
+ await ready(signal);
+ return executeDemoQuery(request, records);
+ },
+ async identity(signal) {
+ await ready(signal);
+ return { id: 'demo', username: 'Demo user', source: 'demo' };
+ },
+ async login() {
+ await ready();
+ },
+ async logout() {
+ await ready();
+ },
+ };
+}
diff --git a/frontend/src/lib/ids.ts b/frontend/src/lib/ids.ts
new file mode 100644
index 000000000..8bc177511
--- /dev/null
+++ b/frontend/src/lib/ids.ts
@@ -0,0 +1,5 @@
+/** Local model IDs also work on HTTP previews accessed through a tailnet. */
+export function createId(): string {
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+}
diff --git a/frontend/src/lib/query.test.ts b/frontend/src/lib/query.test.ts
new file mode 100644
index 000000000..26a61970d
--- /dev/null
+++ b/frontend/src/lib/query.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import { buildLogQuery, quoteIdentifier, timeBounds } from './query';
+import { createDemoRecords, executeDemoQuery } from './demo';
+
+const now = Date.parse('2026-10-06T12:00:00Z');
+const bounds = timeBounds('15m', now + 1);
+describe('query construction', () => {
+ it('escapes SQL identifiers, values and literal search wildcards', () => {
+ const sql = buildLogQuery('a"; DROP TABLE x;--', "50%_! O'Reilly", [
+ { id: '1', field: 'user"name', operator: '=', value: "x' OR 1=1--" },
+ ]);
+ expect(sql).toContain('FROM "a""; DROP TABLE x;--"');
+ expect(sql).toContain('"user""name" = \'x\'\' OR 1=1--\'');
+ expect(sql).toContain("ILIKE '%50!%!_!! O''Reilly%' ESCAPE '!'");
+ });
+ it('validates and normalizes absolute ranges independently of refresh time', () => {
+ const range = { startTime: '2026-10-06T11:00:00Z', endTime: '2026-10-06T12:00:00Z' };
+ expect(timeBounds(range, now + 3600000)).toEqual({
+ startTime: '2026-10-06T11:00:00.000Z',
+ endTime: '2026-10-06T12:00:00.000Z',
+ });
+ expect(() => timeBounds({ ...range, startTime: range.endTime })).toThrow('Invalid time range');
+ expect(() => timeBounds({ ...range, endTime: 'not a date' })).toThrow('Invalid time range');
+ });
+ it('validates row limits and computes exact UTC ranges', () => {
+ expect(() => buildLogQuery('logs', '', [], -1)).toThrow('Limit');
+ expect(() => buildLogQuery('logs', '', [], 1.5)).toThrow('Limit');
+ expect(() => quoteIdentifier('')).toThrow();
+ expect(timeBounds('1h', now)).toEqual({
+ startTime: '2026-10-06T11:00:00.000Z',
+ endTime: '2026-10-06T12:00:00.000Z',
+ });
+ });
+});
+
+describe('demo query semantics', () => {
+ it('applies filters, literal search, descending order, limit and time bounds', () => {
+ const records = createDemoRecords(now);
+ const sql = buildLogQuery(
+ 'application_logs',
+ 'UPSTREAM',
+ [{ id: '1', field: 'level', operator: '=', value: 'ERROR' }],
+ 3,
+ );
+ const result = executeDemoQuery({ sql, ...bounds }, records);
+ expect(result).toHaveLength(3);
+ expect(result.every((row) => row.level === 'ERROR')).toBe(true);
+ expect(result[0].p_timestamp).toBe(new Date(now).toISOString());
+ expect(
+ executeDemoQuery(
+ {
+ sql,
+ startTime: new Date(now + 1).toISOString(),
+ endTime: new Date(now + 2).toISOString(),
+ },
+ records,
+ ),
+ ).toEqual([]);
+ });
+ it('handles wildcards and quotes as literal search characters', () => {
+ const records = [{ ...createDemoRecords(now)[0], message: "Paid 50%_! O'Reilly" }];
+ const sql = buildLogQuery('application_logs', "50%_! O'Reilly", []);
+ expect(executeDemoQuery({ sql, ...bounds }, records)).toHaveLength(1);
+ expect(
+ executeDemoQuery({ sql, ...bounds }, [{ ...records[0], message: 'Paid 50 other' }]),
+ ).toHaveLength(0);
+ });
+ it('rejects unsupported statements rather than silently approximating them', () => {
+ for (const sql of [
+ 'DELETE FROM application_logs',
+ 'SELECT count(*) FROM application_logs',
+ "SELECT * FROM application_logs WHERE level = 'ERROR' OR 1=1",
+ 'SELECT * FROM application_logs; SELECT * FROM api_logs',
+ 'SELECT * FROM application_logs LIMIT 1 trailing',
+ "SELECT * FROM application_logs WHERE AND level = 'ERROR'",
+ ]) {
+ expect(() => executeDemoQuery({ sql, ...bounds }, createDemoRecords(now))).toThrow(
+ 'Demo SQL supports',
+ );
+ }
+ });
+});
diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts
new file mode 100644
index 000000000..35595cc32
--- /dev/null
+++ b/frontend/src/lib/query.ts
@@ -0,0 +1,56 @@
+import type { LogFilter, TimeRange } from './types';
+
+const durations: Record, number> = {
+ '15m': 15 * 60_000,
+ '1h': 60 * 60_000,
+ '6h': 6 * 60 * 60_000,
+ '24h': 24 * 60 * 60_000,
+ '7d': 7 * 24 * 60 * 60_000,
+};
+
+export function timeBounds(range: TimeRange, now: Date | number = Date.now()) {
+ if (typeof range === 'object' && range !== null) {
+ const start = Date.parse(range.startTime);
+ const end = Date.parse(range.endTime);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end)
+ throw new Error('Invalid time range');
+ return { startTime: new Date(start).toISOString(), endTime: new Date(end).toISOString() };
+ }
+ const end = Number(now);
+ if (!Number.isFinite(end) || !(range in durations)) throw new Error('Invalid time range');
+ return {
+ startTime: new Date(end - durations[range]).toISOString(),
+ endTime: new Date(end).toISOString(),
+ };
+}
+
+export function quoteIdentifier(value: string): string {
+ if (!value || value.includes('\0'))
+ throw new Error('A field or dataset name must be nonempty and contain no null characters');
+ return `"${value.replaceAll('"', '""')}"`;
+}
+
+export function quoteLiteral(value: string): string {
+ if (value.includes('\0')) throw new Error('Filter values cannot contain null characters');
+ return `'${value.replaceAll("'", "''")}'`;
+}
+
+/** Search is a literal case-insensitive substring of the message field. */
+export function buildLogQuery(
+ dataset: string,
+ search: string,
+ filters: LogFilter[],
+ limit = 100,
+): string {
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10_000)
+ throw new Error('Limit must be between 1 and 10000');
+ const conditions = filters.map(({ field, operator, value }) => {
+ if (operator !== '=' && operator !== '!=') throw new Error('Unsupported filter operator');
+ return `${quoteIdentifier(field)} ${operator} ${quoteLiteral(value)}`;
+ });
+ if (search.trim()) {
+ const pattern = `%${search.trim().replaceAll('!', '!!').replaceAll('%', '!%').replaceAll('_', '!_')}%`;
+ conditions.push(`CAST("message" AS VARCHAR) ILIKE ${quoteLiteral(pattern)} ESCAPE '!'`);
+ }
+ return `SELECT * FROM ${quoteIdentifier(dataset)}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY "p_timestamp" DESC LIMIT ${limit}`;
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 000000000..b6ff0ab6f
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,20 @@
+export type Dataset = { name: string; type: 'logs' | 'metrics' | 'traces' };
+export type LogRecord = Record;
+export type TimeRange = '15m' | '1h' | '6h' | '24h' | '7d' | { startTime: string; endTime: string };
+export type QueryRequest = { sql: string; startTime: string; endTime: string };
+export type LogFilter = { id: string; field: string; operator: '=' | '!='; value: string };
+export type SessionIdentity = {
+ id: string;
+ username: string;
+ method?: string;
+ email?: string;
+ source: 'server' | 'cookie' | 'demo';
+};
+export interface ParseableClient {
+ identity(signal?: AbortSignal): Promise;
+ listDatasets(signal?: AbortSignal): Promise;
+ schema(dataset: string, signal?: AbortSignal): Promise;
+ query(request: QueryRequest, signal?: AbortSignal): Promise;
+ login(username: string, password: string, returnPath?: string): Promise;
+ logout(): Promise;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 000000000..af8187b1b
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import { AppProvider } from './app/AppProvider';
+import { App } from './app/App';
+import './styles/tokens.css';
+import './styles/global.css';
+import { basePath } from './lib/config';
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/frontend/src/styles/auth-shell.css b/frontend/src/styles/auth-shell.css
new file mode 100644
index 000000000..8970d4c79
--- /dev/null
+++ b/frontend/src/styles/auth-shell.css
@@ -0,0 +1,431 @@
+/* Measured Prism v3.2.4 shell: full-width 48px header and 192px navigation. */
+body .app-shell {
+ grid-template-columns: 192px minmax(0, 1fr);
+ padding-top: 48px;
+}
+body .app-shell.sidebar-collapsed {
+ grid-template-columns: 56px minmax(0, 1fr);
+}
+body .app-sidebar {
+ padding: 12px 8px 8px;
+ top: 48px;
+ height: calc(100dvh - 48px);
+ background: var(--color-bg);
+}
+.app-sidebar nav {
+ gap: 4px;
+}
+.app-sidebar .nav-item {
+ min-height: 28px;
+ padding: 4px 8px;
+ gap: 9px;
+ font-size: 13px;
+ border-radius: 5px;
+}
+.app-sidebar .nav-item.active {
+ font-weight: 500;
+ background: var(--color-surface-hover);
+}
+.app-sidebar .sidebar-group-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 18px 8px 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--color-muted);
+ font-size: 10px;
+}
+.sidebar-group-label span {
+ flex: 1;
+ height: 1px;
+ background: var(--color-border);
+}
+.sidebar-search {
+ position: relative;
+ margin-bottom: 10px;
+}
+.sidebar-search svg {
+ position: absolute;
+ left: 9px;
+ top: 7px;
+ color: var(--color-muted);
+}
+.sidebar-search input {
+ width: 100%;
+ min-width: 0;
+ height: 28px;
+ padding: 4px 8px 4px 30px;
+ color: var(--color-text);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ font-size: 12px;
+}
+.sidebar-no-results {
+ font-size: 12px;
+ padding: 12px 8px;
+ color: var(--color-muted);
+}
+.app-shell .topbar {
+ position: fixed;
+ inset: 0 0 auto;
+ z-index: 20;
+ height: 48px;
+ min-height: 48px;
+ padding: 0 16px;
+ justify-content: flex-start;
+ gap: 16px;
+ background: var(--color-bg);
+}
+.app-shell .topbar > .inline {
+ margin-left: auto;
+}
+.shell-brand {
+ width: 23px;
+ height: 23px;
+ display: inline-flex;
+}
+.shell-brand svg {
+ width: 100%;
+ height: 100%;
+}
+#workspace-context {
+ min-width: 0;
+}
+#workspace-context select {
+ width: 200px;
+ max-width: 100%;
+ height: 32px;
+}
+.shell-identity {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.shell-username {
+ max-width: 170px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+}
+.shell-identity .avatar {
+ width: 28px;
+ height: 28px;
+ font-size: 12px;
+}
+body .sidebar-collapsed .app-sidebar {
+ padding-inline: 8px;
+}
+.sidebar-collapsed .app-sidebar .brand {
+ padding-inline: 9px;
+}
+.sidebar-collapsed .app-sidebar .workspace-label {
+ text-align: center;
+ padding-inline: 0;
+}
+.sidebar-collapsed .app-sidebar .nav-item {
+ padding-inline: 11px;
+}
+.auth-page {
+ min-height: 100dvh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ background: var(--color-bg);
+}
+.auth-card {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ width: min(1152px, 100%);
+ min-height: 645px;
+ border-radius: 28px;
+ overflow: hidden;
+ background: var(--color-surface);
+ box-shadow: 0 24px 52px #18182724;
+}
+.auth-intro {
+ display: flex;
+ flex-direction: column;
+ padding: 38px 48px 46px;
+ background-color: var(--color-primary-soft);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28'%3E%3Cpath d='M11 14h6m-3-3v6' stroke='%238a83de' stroke-opacity='.16'/%3E%3C/svg%3E");
+}
+.auth-brand {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 32px;
+ font-weight: 750;
+ letter-spacing: -1px;
+ margin-bottom: 33px;
+ width: fit-content;
+}
+.auth-brand svg {
+ width: 25px;
+ height: 25px;
+}
+.auth-intro h1 {
+ font-size: 36px;
+ line-height: 1.22;
+ font-weight: 600;
+ letter-spacing: -0.8px;
+}
+.auth-intro h1 span {
+ color: var(--color-primary);
+}
+.auth-tagline {
+ margin-top: 22px;
+ font-size: 16px;
+ color: var(--color-muted);
+}
+.auth-benefits {
+ list-style: none;
+ padding: 0;
+ margin: 40px 0 0;
+ display: grid;
+ gap: 16px;
+}
+.auth-benefits li {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 16px;
+ line-height: 1.35;
+}
+.auth-benefits svg {
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ padding: 10px;
+ color: #008b91;
+ background: var(--color-surface);
+ border-radius: 12px;
+ box-shadow: 0 2px 2px #18183f18;
+}
+.auth-benefits li:nth-child(2) svg {
+ color: #8542e5;
+}
+.auth-benefits li:nth-child(3) svg {
+ color: #078937;
+}
+.auth-benefits li:nth-child(4) svg {
+ color: #a86500;
+}
+.auth-benefits li:nth-child(5) svg {
+ color: #6554c5;
+}
+.auth-footer {
+ color: var(--color-muted);
+ font-size: 12px;
+ margin-top: auto;
+ padding-top: 40px;
+}
+.auth-content {
+ padding: 48px 64px;
+ display: flex;
+ flex-direction: column;
+}
+.auth-content h2 {
+ font-size: 20px;
+ line-height: 1.4;
+ font-weight: 600;
+ text-align: center;
+}
+.auth-content > .login-form {
+ margin-top: 62px;
+}
+.auth-description {
+ margin-top: 14px;
+ color: var(--color-muted);
+ font-size: 12px;
+}
+.auth-description + .login-form {
+ margin-top: 24px;
+}
+.login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+.login-form .ui-input {
+ height: 40px;
+}
+.login-form > .ui-button {
+ min-height: 40px;
+}
+.password-field {
+ position: relative;
+}
+.password-field input {
+ padding-right: 44px;
+}
+.password-field .password-visibility {
+ position: absolute;
+ right: 4px;
+ bottom: 3px;
+ width: 34px;
+ height: 34px;
+}
+.login-form .forgot-password {
+ align-self: flex-start;
+ padding: 0;
+ min-height: 20px;
+ height: auto;
+ font-size: 14px;
+ color: var(--color-primary);
+}
+.login-divider {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ font-size: 11px;
+ color: var(--color-muted);
+ margin: 16px 0;
+}
+.login-divider::before,
+.login-divider::after {
+ content: '';
+ height: 1px;
+ flex: 1;
+ background: var(--color-border);
+}
+.auth-demo,
+.auth-help {
+ margin-top: auto;
+ padding-top: 40px;
+ text-align: center;
+ font-size: 13px;
+ color: var(--color-muted);
+}
+.auth-demo.ui-button {
+ padding-block: 12px;
+ margin-top: 40px;
+}
+.auth-native-link {
+ justify-content: center;
+ margin-top: 14px;
+}
+.connection-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 20px;
+}
+@media (max-width: 1100px) {
+ .auth-content,
+ .auth-intro {
+ padding: 40px 32px;
+ }
+ .auth-benefits li {
+ font-size: 14px;
+ }
+ .shell-username {
+ max-width: 110px;
+ }
+}
+@media (max-width: 800px) {
+ body .app-shell {
+ grid-template-columns: 56px minmax(0, 1fr);
+ }
+ body .app-sidebar {
+ padding-inline: 8px;
+ }
+ .app-sidebar .nav-item > span,
+ .sidebar-search,
+ .sidebar-group-label {
+ display: none !important;
+ }
+ .app-sidebar .nav-item {
+ padding-inline: 11px;
+ }
+ .app-shell .topbar {
+ padding: 0 16px;
+ }
+ .shell-username {
+ display: none;
+ }
+ .sidebar-footer button {
+ font-size: 0;
+ gap: 0;
+ padding-inline: 11px;
+ }
+ .auth-content,
+ .auth-intro {
+ padding: 32px 24px;
+ }
+ .auth-intro h1 {
+ font-size: 30px;
+ }
+ .auth-benefits {
+ gap: 20px;
+ }
+}
+@media (max-width: 640px) {
+ .auth-page {
+ padding: 24px 16px;
+ }
+ .auth-card {
+ grid-template-columns: 1fr;
+ min-height: auto;
+ border-radius: 20px;
+ }
+ .auth-intro {
+ padding: 24px;
+ }
+ .auth-brand {
+ font-size: 26px;
+ margin-bottom: 20px;
+ }
+ .auth-intro h1 {
+ font-size: 27px;
+ }
+ .auth-intro h1 br {
+ display: none;
+ }
+ .auth-intro h1 span::before {
+ content: ' ';
+ }
+ .auth-tagline {
+ font-size: 14px;
+ margin-top: 10px;
+ }
+ .auth-benefits,
+ .auth-footer {
+ display: none;
+ }
+ .auth-content {
+ padding: 28px 24px;
+ }
+ .auth-content > .login-form {
+ margin-top: 28px;
+ }
+ .auth-help {
+ padding-top: 28px;
+ }
+ .app-shell .topbar {
+ padding-inline: 10px;
+ gap: 8px;
+ }
+ #workspace-context select {
+ width: 110px;
+ }
+ .app-shell .topbar > .inline {
+ gap: 3px;
+ }
+ .app-shell .topbar > .inline > .ui-badge {
+ max-width: 60px;
+ font-size: 9px;
+ padding-inline: 5px;
+ }
+ .shell-brand {
+ display: none;
+ }
+ .topbar .breadcrumbs > span,
+ .topbar .breadcrumbs > svg {
+ display: none;
+ }
+}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
new file mode 100644
index 000000000..862c82213
--- /dev/null
+++ b/frontend/src/styles/global.css
@@ -0,0 +1,1358 @@
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ background: var(--color-bg);
+ color: var(--color-text);
+ font: 13px/1.5 var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+button,
+a,
+input,
+select,
+textarea {
+ -webkit-tap-highlight-color: transparent;
+}
+button svg {
+ flex-shrink: 0;
+}
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+h1 {
+ font-size: 25px;
+ letter-spacing: -0.7px;
+ line-height: 1.3;
+ font-weight: 650;
+}
+h2 {
+ font-size: 14px;
+ font-weight: 650;
+}
+code,
+.mono {
+ font-family: var(--font-mono);
+ font-size: 12px;
+}
+:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 3px;
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.skip-link {
+ position: fixed;
+ top: -100px;
+ left: 12px;
+ z-index: 100;
+ padding: 10px 18px;
+ color: white;
+ background: #3a3a8c;
+}
+.skip-link:focus {
+ top: 10px;
+}
+.app-shell {
+ display: grid;
+ grid-template-columns: 224px minmax(0, 1fr);
+ min-height: 100dvh;
+}
+.app-shell.sidebar-collapsed {
+ grid-template-columns: 68px minmax(0, 1fr);
+}
+.app-sidebar {
+ background: var(--color-surface);
+ border-right: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ padding: 22px 12px 12px;
+ position: sticky;
+ top: 0;
+ height: 100dvh;
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 9px 28px;
+ font-size: 24px;
+ font-weight: 750;
+ letter-spacing: -0.8px;
+}
+.brand-mark {
+ color: white;
+ background: #3a3a8c;
+ height: 29px;
+ width: 29px;
+ border-radius: 8px;
+ font-size: 26px;
+ line-height: 26px;
+ text-align: center;
+ font-weight: 600;
+ position: relative;
+ flex-shrink: 0;
+}
+.brand-mark span {
+ font-size: 17px;
+ position: absolute;
+ top: -7px;
+ right: 3px;
+ color: #c0c0ff;
+}
+.brand-dot {
+ color: var(--color-primary);
+}
+.workspace-label {
+ color: var(--color-muted);
+ font-size: 9px;
+ font-weight: 650;
+ letter-spacing: 1px;
+ padding: 0 10px 14px;
+ white-space: nowrap;
+}
+.app-sidebar nav {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 11px 12px;
+ min-height: 42px;
+ border-radius: 6px;
+ color: var(--color-muted);
+ font-size: 13px;
+ font-weight: 500;
+}
+.nav-item:hover {
+ background: var(--color-surface-hover);
+ color: var(--color-text);
+}
+.nav-item.active {
+ color: var(--color-primary);
+ background: var(--color-primary-soft);
+}
+.sidebar-footer {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 7px;
+ margin-top: auto;
+ padding-top: 30px;
+}
+.sidebar-footer button {
+ justify-content: flex-start;
+ font-size: 12px;
+}
+.workspace-status {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 10px 3px;
+ font-size: 10px;
+}
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #81818f;
+}
+.status-dot.demo {
+ background: #bc7b20;
+}
+.workspace-status .ui-badge {
+ margin-left: auto;
+ font-size: 8px;
+}
+.app-body {
+ min-width: 0;
+}
+.topbar {
+ height: 62px;
+ padding: 0 28px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--color-surface);
+ border-bottom: 1px solid var(--color-border);
+}
+.breadcrumbs {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ font-size: 12px;
+ color: var(--color-muted);
+}
+.breadcrumbs strong {
+ color: var(--color-text);
+ font-weight: 500;
+}
+.avatar {
+ width: 27px;
+ height: 27px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background: var(--color-primary-soft);
+ color: var(--color-primary);
+ font-size: 11px;
+ font-weight: 700;
+ margin-left: 8px;
+}
+.inline {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+.wrap {
+ flex-wrap: wrap;
+}
+.stack {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.muted {
+ color: var(--color-muted);
+ font-size: 12px;
+}
+.demo-banner {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ min-height: 35px;
+ padding: 2px 28px;
+ background: var(--color-primary-soft);
+ color: var(--color-primary);
+ font-size: 11px;
+}
+.demo-banner button {
+ font-size: 11px;
+}
+.page {
+ padding: 30px 28px;
+ max-width: 1800px;
+ margin: 0 auto;
+}
+.page-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 26px;
+}
+.page-heading p {
+ margin-top: 7px;
+ font-size: 12px;
+ color: var(--color-muted);
+}
+.dataset-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 17px;
+}
+.dataset-toolbar select {
+ min-width: 180px;
+}
+.text-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--color-primary);
+ font-weight: 500;
+}
+.text-link:hover {
+ text-decoration: underline;
+}
+.explorer-layout {
+ display: grid;
+ grid-template-columns: 196px minmax(0, 1fr);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ min-height: 590px;
+ overflow: hidden;
+}
+.explorer-layout.fields-hidden {
+ grid-template-columns: minmax(0, 1fr);
+}
+.field-sidebar {
+ border-right: 1px solid var(--color-border);
+ padding: 16px 12px;
+ min-width: 0;
+}
+.field-sidebar > .panel-heading {
+ padding: 0 2px 15px;
+}
+.field-sidebar input {
+ width: 100%;
+ font-size: 11px;
+}
+.eyebrow {
+ color: var(--color-muted);
+ font-size: 9px;
+ letter-spacing: 0.9px;
+ font-weight: 600;
+ margin: 24px 6px 8px;
+}
+.field-node {
+ width: 100%;
+ justify-content: flex-start !important;
+ padding: 7px 5px !important;
+ font-size: 11px !important;
+ min-height: 33px !important;
+ height: auto !important;
+ color: var(--color-muted) !important;
+}
+.field-node > span:not(.field-check) {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.field-node[aria-pressed='true'] {
+ color: var(--color-primary) !important;
+}
+.field-check {
+ margin-left: auto;
+}
+.count-label {
+ background: var(--color-surface-hover);
+ padding: 1px 5px;
+ border-radius: 4px;
+ font-size: 10px;
+ color: var(--color-muted);
+ margin-left: 4px;
+}
+.explorer-content {
+ min-width: 0;
+}
+.explorer-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 12px;
+ border-bottom: 1px solid var(--color-border);
+}
+.filter-bar {
+ display: flex;
+ gap: 9px;
+ flex: 1;
+ min-width: 200px;
+}
+.search-field {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ position: relative;
+}
+.search-field > svg {
+ position: absolute;
+ z-index: 1;
+ left: 11px;
+ color: var(--color-muted);
+ pointer-events: none;
+}
+.search-field input {
+ padding-left: 34px;
+ width: 100%;
+}
+.search-field .ui-field {
+ width: 100%;
+}
+.filter-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ width: 100%;
+}
+.filter-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 11px;
+ padding: 0 4px 0 9px;
+ border: 1px solid var(--color-border);
+ border-radius: 5px;
+ background: var(--color-primary-soft);
+}
+.filter-chip button {
+ width: 24px;
+ min-height: 24px;
+ height: 24px;
+}
+.panel-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 18px;
+}
+.histogram {
+ padding: 0 18px;
+}
+.histogram .panel-heading {
+ padding: 18px 0;
+}
+.histogram h2 {
+ font-size: 12px;
+}
+.histogram-plot {
+ height: 102px;
+ display: flex;
+ gap: 4px;
+ align-items: flex-end;
+ position: relative;
+}
+.chart-grid {
+ position: absolute;
+ inset: 0;
+ background: repeating-linear-gradient(
+ to top,
+ var(--color-border) 0,
+ var(--color-border) 1px,
+ transparent 1px,
+ transparent 33%
+ );
+ opacity: 0.6;
+}
+.histogram-column {
+ position: relative;
+ flex: 1;
+ height: 100%;
+ display: flex;
+ align-items: flex-end;
+}
+.histogram-bar {
+ background: #7775c9;
+ width: 100%;
+ border-radius: 2px 2px 0 0;
+ opacity: 0.85;
+}
+.histogram-column:hover .histogram-bar {
+ background: #3a3a8c;
+ opacity: 1;
+}
+.chart-axis {
+ display: flex;
+ justify-content: space-between;
+ font-size: 9px;
+ color: var(--color-muted);
+ margin: 9px 0 21px;
+}
+.result-heading {
+ border-top: 1px solid var(--color-border);
+ border-bottom: 1px solid var(--color-border);
+ padding: 12px 18px;
+}
+.results-table {
+ min-width: 0;
+}
+.table-scroll {
+ overflow: auto;
+ width: 100%;
+}
+table {
+ border-collapse: collapse;
+ text-align: left;
+ width: 100%;
+ white-space: nowrap;
+}
+th {
+ background: var(--color-bg);
+ color: var(--color-muted);
+ font-size: 10px;
+ font-weight: 550;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--color-border);
+}
+td {
+ padding: 9px 12px;
+ border-bottom: 1px solid var(--color-border);
+ max-width: 480px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 11px;
+}
+td.mono {
+ font-size: 10px;
+}
+td:first-child:has(button) {
+ padding: 2px 3px;
+ width: 28px;
+}
+tr:hover td {
+ background: var(--color-surface-hover);
+}
+.message-cell {
+ min-width: 260px;
+}
+.table-footer {
+ padding: 12px 16px;
+ color: var(--color-muted);
+ font-size: 10px;
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+.loading-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ min-height: 160px;
+ color: var(--color-muted);
+}
+.field-values {
+ margin: 0;
+}
+.field-values > div {
+ border-bottom: 1px solid var(--color-border);
+ padding: 13px 0;
+}
+.field-values dt {
+ font-size: 11px;
+ color: var(--color-muted);
+ margin-bottom: 5px;
+}
+.field-values dd {
+ margin: 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+}
+.field-values code {
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ font-size: 12px;
+}
+.error-text {
+ color: var(--color-danger, #b42318);
+ font-size: 12px;
+}
+.editor-card {
+ overflow: hidden;
+ padding: 0 !important;
+}
+.sql-editor {
+ border-top: 1px solid var(--color-border);
+ border-bottom: 1px solid var(--color-border);
+}
+.cm-editor.cm-focused {
+ outline: 2px solid var(--color-primary);
+ outline-offset: -2px;
+}
+.editor-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ gap: 16px;
+}
+.query-results {
+ margin-top: 22px;
+ overflow: hidden;
+ padding: 0 !important;
+}
+.list-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 18px;
+ margin-bottom: 20px;
+}
+.list-toolbar .search-field {
+ max-width: 340px;
+}
+.dataset-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
+ gap: 18px;
+}
+.dataset-card {
+ padding: 23px !important;
+}
+.dataset-card > .inline {
+ justify-content: space-between;
+}
+.dataset-card h2 {
+ margin: 20px 0 6px;
+ overflow-wrap: anywhere;
+}
+.dataset-card-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-top: 1px solid var(--color-border);
+ padding-top: 18px;
+ margin-top: 23px;
+}
+.icon-tile {
+ display: grid;
+ place-items: center;
+ width: 37px;
+ height: 37px;
+ background: var(--color-primary-soft);
+ color: var(--color-primary);
+ border-radius: 8px;
+ flex-shrink: 0;
+}
+.schema-list {
+ margin: 8px 0 25px;
+}
+.schema-list > div {
+ padding: 12px 0;
+ display: flex;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--color-border);
+}
+.notice {
+ margin: 20px 0;
+ padding: 13px 16px;
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ color: var(--color-muted);
+ background: var(--color-surface);
+ font-size: 12px;
+}
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
+ gap: 20px;
+}
+.dashboard-tile {
+ padding: 0 !important;
+ overflow: hidden;
+}
+.dashboard-footer {
+ border-top: 1px solid var(--color-border);
+ padding: 13px 18px;
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+.library-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px;
+}
+.library-grid > .ui-card {
+ padding: 24px;
+}
+.library-grid h2 {
+ margin-bottom: 12px;
+}
+.library-grid p {
+ margin-bottom: 20px;
+}
+.welcome-card {
+ background: linear-gradient(
+ 110deg,
+ var(--color-surface) 20%,
+ var(--color-primary-soft)
+ ) !important;
+ display: flex;
+ justify-content: space-between;
+ padding: 32px !important;
+ gap: 30px;
+ overflow: hidden;
+}
+.welcome-card h2 {
+ font-size: 29px;
+ font-weight: 550;
+ letter-spacing: -0.8px;
+ margin: 16px 0 8px;
+}
+.welcome-card p {
+ max-width: 400px;
+ color: var(--color-muted);
+ font-size: 13px;
+}
+.primary-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 20px;
+ margin-top: 25px;
+ background: #3a3a8c;
+ color: white;
+ border-radius: 5px;
+ padding: 10px 14px;
+ font-size: 12px;
+}
+.welcome-art {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 260px;
+ padding-right: 20px;
+ transform: skewY(-12deg);
+}
+.welcome-art span {
+ width: 8px;
+ background: #7775c9;
+ opacity: 0.35;
+ border-radius: 3px;
+}
+.overview-stats {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+ margin: 22px 0 32px;
+}
+.overview-stats > .ui-card {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ padding: 22px;
+ gap: 12px;
+}
+.overview-stats svg {
+ color: var(--color-primary);
+ grid-row: 1/3;
+ grid-column: 2;
+}
+.overview-stats strong {
+ font-size: 23px;
+ font-weight: 550;
+}
+.section-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+.overview-dataset {
+ padding: 19px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--color-surface);
+ min-width: 0;
+}
+.overview-dataset:hover {
+ border-color: var(--color-primary);
+}
+.overview-dataset strong {
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+.overview-dataset p {
+ font-size: 10px;
+ color: var(--color-muted);
+ margin-top: 4px;
+}
+.overview-dataset > svg {
+ margin-left: auto;
+ flex-shrink: 0;
+ color: var(--color-muted);
+}
+@media (min-width: 1600px) {
+ .page {
+ padding: 36px 42px;
+ }
+}
+@media (max-width: 1100px) {
+ .app-shell {
+ grid-template-columns: 190px minmax(0, 1fr);
+ }
+ .explorer-layout {
+ grid-template-columns: 160px minmax(0, 1fr);
+ }
+ .page {
+ padding: 24px 20px;
+ }
+ .page-heading {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+ .welcome-art {
+ min-width: 180px;
+ padding: 0;
+ gap: 4px;
+ }
+ .brand {
+ font-size: 22px;
+ }
+}
+@media (max-width: 760px) {
+ .app-shell,
+ .app-shell.sidebar-collapsed {
+ grid-template-columns: 60px minmax(0, 1fr);
+ }
+ .app-sidebar {
+ padding: 18px 6px 8px;
+ }
+ .brand {
+ padding: 0 8px 25px;
+ }
+ .brand > span,
+ .workspace-label,
+ .nav-item > span,
+ .workspace-status {
+ display: none;
+ }
+ .nav-item {
+ padding: 12px 14px;
+ }
+ .sidebar-footer button {
+ font-size: 0;
+ gap: 0;
+ padding: 10px 14px;
+ }
+ .sidebar-footer button svg {
+ width: 18px;
+ }
+ .sidebar-footer [data-sidebar='trigger'] {
+ display: none;
+ }
+ .topbar {
+ padding: 0 14px;
+ height: 54px;
+ }
+ .breadcrumbs {
+ gap: 6px;
+ font-size: 10px;
+ }
+ .breadcrumbs > span,
+ .breadcrumbs > svg,
+ .avatar {
+ display: none;
+ }
+ .page {
+ padding: 22px 14px;
+ }
+ h1 {
+ font-size: 22px;
+ }
+ .page-heading {
+ gap: 15px;
+ margin-bottom: 22px;
+ }
+ .page-heading > .inline {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+ .demo-banner {
+ padding: 8px 14px;
+ font-size: 9px;
+ flex-wrap: wrap;
+ gap: 2px;
+ }
+ .demo-banner button {
+ padding: 0;
+ min-height: 24px;
+ }
+ .dataset-toolbar {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 10px;
+ }
+ .dataset-toolbar .inline {
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ .dataset-toolbar select {
+ min-width: 100px;
+ max-width: 210px;
+ }
+ .explorer-layout {
+ display: flex;
+ flex-direction: column;
+ }
+ .field-sidebar {
+ border-right: 0;
+ border-bottom: 1px solid var(--color-border);
+ max-height: 190px;
+ overflow-y: auto;
+ }
+ .field-sidebar > .panel-heading {
+ padding-bottom: 8px;
+ }
+ .field-sidebar .eyebrow {
+ margin-top: 12px;
+ }
+ .filter-bar {
+ flex-wrap: wrap;
+ min-width: 0;
+ }
+ .filter-bar .search-field {
+ flex-basis: 100%;
+ }
+ .explorer-content {
+ width: 100%;
+ }
+ .histogram-plot {
+ gap: 2px;
+ }
+ .histogram {
+ padding: 0 12px;
+ }
+ .chart-axis {
+ font-size: 8px;
+ }
+ .chart-axis span:nth-child(2) {
+ display: none;
+ }
+ .panel-heading {
+ padding: 12px;
+ }
+ .panel-heading .muted {
+ font-size: 10px;
+ }
+ .table-footer,
+ .editor-footer {
+ flex-wrap: wrap;
+ }
+ .library-grid,
+ .overview-stats {
+ grid-template-columns: 1fr;
+ }
+ .welcome-card {
+ padding: 24px !important;
+ }
+ .welcome-card h2 {
+ font-size: 25px;
+ }
+ .welcome-art {
+ display: none;
+ }
+ .section-heading {
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+ .dashboard-footer {
+ flex-wrap: wrap;
+ }
+ .time-range {
+ gap: 5px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+/* Compact explorer surfaces mapped to the recovered Prism component boundaries. */
+.page {
+ padding: 20px;
+}
+.page-heading {
+ margin-bottom: 18px;
+}
+.page-heading h1 {
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.35px;
+}
+.logs-page .page-heading {
+ margin-bottom: 12px;
+}
+.explorer-layout {
+ border-radius: 6px;
+ grid-template-columns: 208px minmax(0, 1fr);
+}
+.dataset-toolbar {
+ padding-bottom: 12px;
+}
+.field-sidebar {
+ padding: 12px;
+}
+.explorer-toolbar {
+ padding: 10px 12px;
+}
+.filter-chip .filter-chip-edit {
+ width: auto;
+ border: 0;
+ background: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 3px 0;
+ font: inherit;
+ text-align: left;
+}
+.results-actions {
+ justify-content: flex-end;
+ gap: 5px;
+}
+.result-heading {
+ flex-wrap: wrap;
+ padding: 8px 12px;
+}
+.result-heading h2 {
+ font-size: 12px;
+}
+.results-actions .ui-button {
+ font-size: 11px;
+}
+.column-sort {
+ border: 0;
+ background: none;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+}
+.column-sort svg {
+ opacity: 0.6;
+}
+.rows-wrapped td {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ min-width: 130px;
+}
+.rows-wrapped .message-cell {
+ min-width: 220px;
+}
+.column-options {
+ display: grid;
+ gap: 10px;
+ max-height: 320px;
+ overflow: auto;
+ margin: 16px 0;
+}
+.column-options label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ overflow-wrap: anywhere;
+}
+.column-options input {
+ accent-color: var(--color-primary);
+}
+.dialog-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+}
+.event-json {
+ padding: 14px;
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: 5px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font: 12px/1.6 var(--font-mono);
+}
+.field-actions {
+ flex-shrink: 0;
+ gap: 0;
+}
+.field-actions .ui-button {
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+}
+.field-values dd > code {
+ min-width: 0;
+}
+.sql-layout {
+ display: grid;
+ grid-template-columns: 210px minmax(0, 1fr);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--color-surface);
+}
+.sql-datasets {
+ border-right: 1px solid var(--color-border);
+ padding: 12px;
+ min-width: 0;
+}
+.sql-datasets .panel-heading {
+ padding: 0 0 14px;
+}
+.sql-datasets .ui-input {
+ width: 100%;
+}
+.sql-dataset {
+ width: 100%;
+ overflow: hidden;
+ justify-content: flex-start !important;
+ font-size: 11px !important;
+ text-align: left;
+ margin-top: 6px;
+}
+.sql-main {
+ min-width: 0;
+}
+.sql-main .ui-card {
+ border: 0;
+ border-radius: 0;
+}
+.sql-main .query-results {
+ margin-top: 0;
+ border-top: 1px solid var(--color-border);
+}
+.sql-main .panel-heading {
+ padding: 10px 14px;
+ flex-wrap: wrap;
+}
+.sql-main .editor-footer {
+ padding: 10px 14px;
+}
+.dataset-table {
+ padding: 0 !important;
+ overflow: hidden;
+}
+.dataset-table td {
+ padding: 12px 16px;
+}
+.dataset-table th {
+ padding: 10px 16px;
+}
+.dataset-name {
+ display: inline-flex;
+ gap: 9px;
+ align-items: center;
+ font-weight: 500;
+}
+.dataset-name svg {
+ color: var(--color-muted);
+ flex-shrink: 0;
+}
+.dataset-name:hover {
+ color: var(--color-primary);
+}
+@media (max-width: 760px) {
+ .page {
+ padding: 16px 12px;
+ }
+ .sql-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .sql-datasets {
+ border-right: 0;
+ border-bottom: 1px solid var(--color-border);
+ max-height: 180px;
+ overflow: auto;
+ }
+ .time-range {
+ max-width: 100%;
+ }
+ .time-range select {
+ min-width: 0;
+ }
+ .results-actions {
+ justify-content: flex-start;
+ }
+ .dataset-table td {
+ max-width: 220px;
+ }
+}
+
+/* Authenticated v3.2.4 reference: full-height fields and a 48px explorer toolbar. */
+.logs-page.page {
+ padding: 0;
+ max-width: none;
+}
+.logs-page .explorer-layout {
+ border: 0;
+ border-radius: 0;
+ grid-template-columns: 240px minmax(0, 1fr);
+ min-height: calc(100dvh - 48px);
+}
+.logs-page .explorer-layout.fields-hidden {
+ grid-template-columns: minmax(0, 1fr);
+}
+.logs-page .field-sidebar {
+ padding: 12px 16px;
+}
+.field-sidebar .field-group-heading {
+ margin: 12px -16px 6px;
+ padding: 6px 16px;
+ background: var(--color-bg);
+}
+.field-group-heading h2 {
+ font-size: 12px;
+ font-weight: 400;
+}
+.field-node {
+ min-height: 28px !important;
+ font-family: var(--font-mono);
+}
+.logs-page .explorer-toolbar {
+ height: 48px;
+ padding: 7px 16px;
+ flex-wrap: nowrap;
+}
+.explorer-time-controls {
+ margin-left: auto;
+}
+.explorer-filters {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ flex-wrap: wrap;
+ padding: 12px 16px 4px;
+}
+.sql-handoff {
+ height: 28px;
+ width: 28px;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--color-border);
+ border-radius: 5px;
+}
+.logs-page .histogram {
+ padding: 0 16px;
+}
+.logs-page .histogram .panel-heading {
+ padding: 12px 0 8px;
+}
+.logs-page .histogram-plot {
+ height: 82px;
+}
+.logs-page .chart-axis {
+ margin: 7px 0 12px;
+}
+.histogram-bar {
+ background: #3d80f5;
+ border-radius: 2px 2px 0 0;
+ opacity: 1;
+}
+.histogram-column:hover .histogram-bar {
+ background: #2668d9;
+}
+.logs-page .result-heading {
+ min-height: 48px;
+ border-top: 0;
+ padding: 7px 16px;
+}
+.logs-page .result-heading h2 {
+ font-weight: 400;
+ color: var(--color-muted);
+}
+.logs-page th {
+ height: 36px;
+ background: var(--color-surface);
+ font-size: 12px;
+ font-weight: 400;
+}
+.logs-page td {
+ height: 36px;
+ padding-block: 6px;
+}
+.logs-page td.mono {
+ font-size: 12px;
+}
+.logs-page td.log-data-cell {
+ max-width: 600px;
+ width: 100%;
+}
+.log-data-pair {
+ margin-right: 5px;
+}
+.log-data-key {
+ color: var(--color-primary);
+}
+.sql-page.page {
+ padding: 0;
+ max-width: none;
+}
+.sql-query-tab {
+ height: 40px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--color-border);
+ font-size: 12px;
+ background: var(--color-surface);
+}
+.sql-page > .page-heading {
+ height: 52px;
+ padding: 8px 16px;
+ margin: 0;
+ border-bottom: 1px solid var(--color-border);
+ background: var(--color-surface);
+}
+.sql-page > .page-heading h1 {
+ font-size: 14px;
+}
+.sql-page .sql-layout {
+ grid-template-columns: 272px minmax(0, 1fr);
+ min-height: calc(100dvh - 140px);
+ border: 0;
+ border-radius: 0;
+}
+.sql-page .sql-datasets {
+ padding: 10px 16px;
+}
+.sql-page .sql-main {
+ padding: 9px;
+ background: var(--color-surface-hover);
+}
+.sql-page .editor-card {
+ border: 1px solid var(--color-border);
+ border-radius: 7px 7px 0 0;
+}
+.sql-page .editor-card > .panel-heading {
+ display: none;
+}
+.sql-page .query-results {
+ min-height: 380px;
+ border: 1px solid var(--color-border);
+ border-radius: 0 0 7px 7px;
+}
+.sql-page .editor-footer {
+ flex-direction: row-reverse;
+ justify-content: flex-end;
+}
+@media (max-width: 760px) {
+ .logs-page .explorer-layout {
+ display: flex;
+ }
+ .logs-page .explorer-toolbar {
+ padding-inline: 8px;
+ }
+ .explorer-filters {
+ padding-inline: 10px;
+ }
+ .logs-page .field-sidebar {
+ max-height: 180px;
+ }
+ .sql-page .sql-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .sql-page > .page-heading {
+ height: auto;
+ min-height: 52px;
+ }
+}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
new file mode 100644
index 000000000..4e82c5dfb
--- /dev/null
+++ b/frontend/src/styles/tokens.css
@@ -0,0 +1,61 @@
+:root {
+ color-scheme: light;
+ --font-sans:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
+ --color-bg: #f7f8fa;
+ --color-surface: #ffffff;
+ --color-surface-hover: #f1f2f6;
+ --color-surface-raised: #ffffff;
+ --color-border: #e3e5ec;
+ --color-border-strong: #c7cbd7;
+ --color-text: #252735;
+ --color-muted: #656a7d;
+ --color-primary: #3a3a8c;
+ --color-primary-hover: #2e2e72;
+ --color-primary-soft: #ededfa;
+ --color-on-primary: #ffffff;
+ --color-success: #147b54;
+ --color-success-soft: #eaf7ef;
+ --color-warning: #946010;
+ --color-warning-soft: #fff5de;
+ --color-danger: #c03840;
+ --color-danger-soft: #fff0f1;
+ --color-focus: #6464c6;
+ --color-overlay: rgb(19 22 37 / 42%);
+ --radius-sm: 5px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --shadow-panel: 0 12px 48px rgb(20 25 50 / 15%);
+ --bg: var(--color-bg);
+ --surface: var(--color-surface);
+ --text: var(--color-text);
+ --muted: var(--color-muted);
+ --border: var(--color-border);
+ --primary: var(--color-primary);
+}
+
+[data-theme='dark'] {
+ color-scheme: dark;
+ --color-bg: #15161e;
+ --color-surface: #1d1f2a;
+ --color-surface-hover: #272a38;
+ --color-surface-raised: #232633;
+ --color-border: #343747;
+ --color-border-strong: #53586e;
+ --color-text: #edeef5;
+ --color-muted: #a6abc0;
+ --color-primary: #a7a7ef;
+ --color-primary-hover: #bdbdfa;
+ --color-primary-soft: #30304d;
+ --color-on-primary: #17172b;
+ --color-success: #79d8ab;
+ --color-success-soft: #193c30;
+ --color-warning: #f1c576;
+ --color-warning-soft: #40351f;
+ --color-danger: #ff969d;
+ --color-danger-soft: #48282e;
+ --color-focus: #a7a7ef;
+ --color-overlay: rgb(0 0 0 / 60%);
+ --shadow-panel: 0 12px 48px rgb(0 0 0 / 35%);
+}
diff --git a/frontend/story-tests/components.spec.ts b/frontend/story-tests/components.spec.ts
new file mode 100644
index 000000000..0e03fdd8e
--- /dev/null
+++ b/frontend/story-tests/components.spec.ts
@@ -0,0 +1,116 @@
+import { test, expect, type Page } from '@playwright/test';
+import AxeBuilder from '@axe-core/playwright';
+
+async function story(page: Page, name: string) {
+ await page.goto(`/iframe.html?id=prism-component-library--${name}&viewMode=story`);
+ await expect(page.locator('#storybook-root')).not.toBeEmpty();
+}
+
+test('button variants render with names and disabled semantics', async ({ page }) => {
+ await story(page, 'buttons');
+ await expect(page.getByRole('button')).toHaveCount(7);
+ await expect(page.getByRole('button', { name: 'Create dataset', exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Unavailable', exact: true })).toBeDisabled();
+ await expect(page.getByRole('button', { name: 'Refresh results', exact: true })).toBeVisible();
+});
+
+test('input labels, help and errors associate with their controls', async ({ page }) => {
+ await story(page, 'inputs');
+ const input = page.getByRole('textbox', { name: 'Dataset name', exact: true });
+ await input.fill('production_events');
+ await expect(input).toHaveValue('production_events');
+ await expect(input).toHaveAccessibleDescription('Use a name your team can recognize.');
+ const invalid = page.getByRole('textbox', { name: 'Query name', exact: true });
+ await expect(invalid).toHaveAttribute('aria-invalid', 'true');
+ await expect(invalid).toHaveAccessibleDescription('Enter a name to save this query.');
+ await expect(page.getByRole('alert')).toHaveText('Enter a name to save this query.');
+ await expect(page.getByRole('textbox', { name: 'Managed value', exact: true })).toHaveAttribute(
+ 'readonly',
+ '',
+ );
+ await page.getByRole('combobox', { name: 'Time range' }).selectOption('1h');
+ await expect(page.getByRole('combobox', { name: 'Time range' })).toHaveValue('1h');
+});
+
+test('status badges and empty/loading stories render meaningful text', async ({ page }) => {
+ await story(page, 'badges');
+ for (const label of ['Logs', 'Healthy', 'Warning', 'Error']) {
+ await expect(page.getByText(label, { exact: true })).toBeVisible();
+ }
+ await story(page, 'empty');
+ await expect(page.getByRole('heading', { name: 'No events match your filters' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Clear filters' })).toBeVisible();
+ await story(page, 'loading');
+ await expect(page.getByRole('status')).toHaveText('Loading events');
+});
+
+test('modal contains keyboard focus and Escape restores its opener', async ({ page }) => {
+ await story(page, 'modal');
+ const opener = page.getByRole('button', { name: 'Save view', exact: true });
+ await opener.focus();
+ await page.keyboard.press('Enter');
+ const dialog = page.getByRole('dialog', { name: 'Save view', exact: true });
+ await expect(dialog).toBeVisible();
+ await expect(dialog).toHaveAccessibleDescription(
+ 'Keep these filters and columns for your next investigation.',
+ );
+ await dialog.getByRole('textbox', { name: 'View name' }).fill('Production errors');
+ for (let index = 0; index < 8; index++) {
+ await page.keyboard.press(index < 4 ? 'Tab' : 'Shift+Tab');
+ expect(await dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true);
+ }
+ await page.keyboard.press('Escape');
+ await expect(dialog).toHaveCount(0);
+ await expect(opener).toBeFocused();
+});
+
+test('detail sheet exposes its title and record, then restores focus', async ({ page }) => {
+ await story(page, 'detail-sheet');
+ const opener = page.getByRole('button', { name: 'Inspect record' });
+ await opener.click();
+ const sheet = page.getByRole('dialog', { name: 'Record details' });
+ await expect(sheet).toBeVisible();
+ await expect(sheet).toContainText('Request completed');
+ await sheet.getByRole('button', { name: 'Close dialog' }).click();
+ await expect(sheet).toHaveCount(0);
+ await expect(opener).toBeFocused();
+});
+
+test('tabs select panels with arrow keys', async ({ page }) => {
+ await story(page, 'tab-views');
+ await expect(page.getByRole('tablist', { name: 'Result format' })).toBeVisible();
+ const table = page.getByRole('tab', { name: 'Table', exact: true });
+ await table.focus();
+ await expect(table).toHaveAttribute('aria-selected', 'true');
+ await page.keyboard.press('ArrowRight');
+ await expect(page.getByRole('tab', { name: 'JSON', exact: true })).toBeFocused();
+ await expect(page.getByRole('tab', { name: 'JSON', exact: true })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ await expect(page.getByRole('tabpanel')).toHaveText('Raw event fields appear here.');
+ await page.keyboard.press('ArrowLeft');
+ await expect(page.getByRole('tabpanel')).toHaveText('Events appear in a table.');
+});
+
+for (const name of [
+ 'buttons',
+ 'inputs',
+ 'badges',
+ 'modal',
+ 'detail-sheet',
+ 'empty',
+ 'tab-views',
+ 'loading',
+]) {
+ test(`WCAG accessibility smoke: ${name}`, async ({ page }) => {
+ await story(page, name);
+ if (name === 'modal')
+ await page.getByRole('button', { name: 'Save view', exact: true }).click();
+ if (name === 'detail-sheet') await page.getByRole('button', { name: 'Inspect record' }).click();
+ const result = await new AxeBuilder({ page })
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
+ .analyze();
+ expect(result.violations).toEqual([]);
+ });
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 000000000..17e2ebaf9
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "types": ["vite/client", "node"]
+ },
+ "include": [
+ "src",
+ "vite.config.ts",
+ "playwright.config.ts",
+ ".storybook",
+ "e2e",
+ "playwright.live.config.ts",
+ "e2e-live"
+ ]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 000000000..7955d23d6
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig, loadEnv } from 'vite';
+import react from '@vitejs/plugin-react';
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+ return {
+ plugins: [react()],
+ server: {
+ port: 5173,
+ strictPort: true,
+ proxy: {
+ '/api': {
+ target: env.PARSEABLE_PROXY_TARGET || 'http://127.0.0.1:8000',
+ changeOrigin: false,
+ },
+ },
+ },
+ };
+});
diff --git a/src/handlers/http/modal/mod.rs b/src/handlers/http/modal/mod.rs
index 2cc1ef70e..492fe99ea 100644
--- a/src/handlers/http/modal/mod.rs
+++ b/src/handlers/http/modal/mod.rs
@@ -63,6 +63,7 @@ pub static OIDC_CLIENT: OnceCell>>> = OnceCell
pub const DEFAULT_VERSION: &str = "v4";
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
+include!(concat!(env!("OUT_DIR"), "/next_generated.rs"));
#[async_trait]
pub trait ParseableServer {
diff --git a/src/handlers/http/modal/query_server.rs b/src/handlers/http/modal/query_server.rs
index 2b4d05acc..05fff2e21 100644
--- a/src/handlers/http/modal/query_server.rs
+++ b/src/handlers/http/modal/query_server.rs
@@ -89,7 +89,7 @@ impl ParseableServer for QueryServer {
.service(Server::get_traces_webscope())
.service(Server::get_dataset_stats_webscope()),
)
- .service(Server::get_generated());
+ .configure(Server::configure_ui);
}
async fn load_metadata(&self) -> anyhow::Result> {
diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs
index 71b3ba813..12e67c670 100644
--- a/src/handlers/http/modal/server.rs
+++ b/src/handlers/http/modal/server.rs
@@ -73,8 +73,8 @@ use crate::{
// use super::generate;
use super::ParseableServer;
-use super::generate;
use super::load_on_init;
+use super::{generate, generate_next};
pub struct Server;
@@ -123,7 +123,7 @@ impl ParseableServer for Server {
.service(Self::get_ingest_otel_factory().wrap(from_fn(
resource_check::check_resource_utilization_middleware,
)))
- .service(Self::get_generated());
+ .configure(Self::configure_ui);
}
async fn load_metadata(&self) -> anyhow::Result > {
@@ -831,7 +831,67 @@ impl Server {
}
// GET "/" ==> Serve the static frontend directory
- pub fn get_generated() -> ResourceFiles {
- ResourceFiles::new("/", generate()).resolve_not_found_to_root()
+ // GET "/next" ==> Serve the second frontend, when one was embedded at build time
+ pub fn configure_ui(config: &mut web::ServiceConfig) {
+ Self::mount_ui(config, generate(), generate_next());
+ }
+
+ /// Registers `/next` ahead of the catch-all `/` so each UI owns its own routes.
+ fn mount_ui(config: &mut web::ServiceConfig, primary: UiFiles, next: UiFiles) {
+ if !next.is_empty() {
+ config.service(ResourceFiles::new("/next", next).resolve_not_found_to_root());
+ }
+ config.service(ResourceFiles::new("/", primary).resolve_not_found_to_root());
+ }
+}
+
+type UiFiles = std::collections::HashMap<&'static str, static_files::Resource>;
+
+#[cfg(test)]
+mod tests {
+ use actix_web::{App, body::to_bytes, test};
+
+ use super::*;
+
+ fn ui(index: &'static [u8]) -> UiFiles {
+ UiFiles::from([(
+ "index.html",
+ static_files::Resource {
+ data: index,
+ modified: 0,
+ mime_type: "text/html",
+ },
+ )])
+ }
+
+ async fn body_at(path: &str, next: UiFiles) -> Bytes {
+ let app = test::init_service(
+ App::new().configure(|config| Server::mount_ui(config, ui(b"classic"), next)),
+ )
+ .await;
+ let response =
+ test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await;
+ assert!(response.status().is_success(), "GET {path} failed");
+ to_bytes(response.into_body()).await.unwrap()
+ }
+
+ #[actix_web::test]
+ async fn next_routes_take_precedence_over_the_root_fallback() {
+ for path in [
+ "/next",
+ "/next/",
+ "/next/logs?x=1",
+ "/next/oidc-not-configured",
+ ] {
+ assert_eq!(body_at(path, ui(b"next")).await, "next", "GET {path}");
+ }
+ for path in ["/", "/logs", "/nextfoo", "/oidc-not-configured"] {
+ assert_eq!(body_at(path, ui(b"next")).await, "classic", "GET {path}");
+ }
+ }
+
+ #[actix_web::test]
+ async fn without_a_next_build_the_classic_ui_serves_next_paths() {
+ assert_eq!(body_at("/next/logs", UiFiles::new()).await, "classic");
}
}
diff --git a/src/handlers/http/oidc.rs b/src/handlers/http/oidc.rs
index 94753d7cb..f95d807c9 100644
--- a/src/handlers/http/oidc.rs
+++ b/src/handlers/http/oidc.rs
@@ -426,7 +426,13 @@ pub fn redirect_to_client(
}
fn redirect_no_oauth_setup(mut url: Url) -> HttpResponse {
- url.set_path("oidc-not-configured");
+ // The side-by-side frontend under `/next` has its own explanation page.
+ let next_ui = url.path() == "/next" || url.path().starts_with("/next/");
+ url.set_path(if next_ui {
+ "next/oidc-not-configured"
+ } else {
+ "oidc-not-configured"
+ });
let mut response = HttpResponse::MovedPermanently();
response.insert_header((actix_web::http::header::LOCATION, url.as_str()));
response.insert_header((actix_web::http::header::CACHE_CONTROL, "no-store"));
@@ -721,3 +727,46 @@ fn is_valid_redirect_url(base_url: &str, redirect_url: &str) -> bool {
.map(|url| url.origin() == redirect_origin)
.unwrap_or(false)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn location(from: &str) -> String {
+ let response = redirect_no_oauth_setup(Url::parse(from).unwrap());
+ response
+ .headers()
+ .get(actix_web::http::header::LOCATION)
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_owned()
+ }
+
+ #[test]
+ fn no_oauth_redirect_keeps_next_ui_requests_on_next() {
+ assert_eq!(
+ location("http://host/next"),
+ "http://host/next/oidc-not-configured"
+ );
+ assert_eq!(
+ location("http://host/next/logs?x=1"),
+ "http://host/next/oidc-not-configured?x=1"
+ );
+ }
+
+ #[test]
+ fn no_oauth_redirect_sends_everything_else_to_the_classic_page() {
+ for from in [
+ "http://host/",
+ "http://host/nextfoo",
+ "http://host/logs/next",
+ ] {
+ assert_eq!(
+ location(from),
+ "http://host/oidc-not-configured",
+ "redirect from {from}"
+ );
+ }
+ }
+}