feat(Analytics): d3 based visualization

This commit is contained in:
Kyush 2026-03-26 19:01:33 +09:00
commit b6152cd6d0
19 changed files with 2088 additions and 79 deletions

View file

@ -83,6 +83,7 @@ pnpm run bench # 벤치마크 실행
클라이언트 중심
- [docs/client.md](docs/client.md) — 클라이언트 구조, `/dashboard` 라우팅, 관리자 UI 동작
- [docs/analytics.md](docs/analytics.md) — Analytics 대시보드 구성, 시각화 패널, 집계 규칙
- [docs/frontend-design.md](docs/frontend-design.md) — 프론트엔드 디자인 가이드
- [docs/admin-auth.md](docs/admin-auth.md) — 관리자 인증, 세션, CSRF, 관리자 토큰
- [docs/oidc.md](docs/oidc.md) — OpenID Connect 설정과 allowlist 정책

View file

@ -17,6 +17,7 @@
"dependencies": {
"@kobalte/core": "^0.13.11",
"@solidjs/router": "^0.15.4",
"d3": "^7.9.0",
"lucide-solid": "^1.1.0",
"solid-js": "^1.9.11",
"solid-monaco": "^0.3.0"

View file

@ -8,6 +8,11 @@ import type {
RequestLogPage,
UsageStats,
BackendMetrics,
AnalyticsDailyTotalsPoint,
AnalyticsBackendQualityPoint,
AnalyticsModelTrendPoint,
AnalyticsHistogramBin,
AnalyticsBoxPlotPoint,
UserScript,
CreateScriptData,
UpdateScriptData,
@ -187,5 +192,37 @@ export const api = {
params.append('days', String(days));
return fetchJson<BackendMetrics[]>(`${API_BASE}/admin/analytics/metrics?${params}`);
},
getDailyTotals: (backendId?: number, days: number = 30): Promise<AnalyticsDailyTotalsPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsDailyTotalsPoint[]>(`${API_BASE}/admin/analytics/daily-totals?${params}`);
},
getBackendQuality: (backendId?: number, days: number = 30): Promise<AnalyticsBackendQualityPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsBackendQualityPoint[]>(`${API_BASE}/admin/analytics/backend-quality?${params}`);
},
getModelTrends: (params: { backendId?: number; days?: number; limit?: number } = {}): Promise<AnalyticsModelTrendPoint[]> => {
const search = new URLSearchParams();
if (params.backendId) search.set('backendId', String(params.backendId));
search.set('days', String(params.days ?? 30));
search.set('limit', String(params.limit ?? 8));
return fetchJson<AnalyticsModelTrendPoint[]>(`${API_BASE}/admin/analytics/model-trends?${search}`);
},
getResponseLengthHistogram: (params: { backendId?: number; days?: number; bins?: number } = {}): Promise<AnalyticsHistogramBin[]> => {
const search = new URLSearchParams();
if (params.backendId) search.set('backendId', String(params.backendId));
search.set('days', String(params.days ?? 30));
search.set('bins', String(params.bins ?? 20));
return fetchJson<AnalyticsHistogramBin[]>(`${API_BASE}/admin/analytics/response-length-histogram?${search}`);
},
getResponseLengthBoxPlot: (backendId?: number, days: number = 30): Promise<AnalyticsBoxPlotPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsBoxPlotPoint[]>(`${API_BASE}/admin/analytics/response-length-box-plot?${params}`);
},
},
};

1
client/src/d3.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module 'd3';

View file

@ -1,96 +1,320 @@
import { createResource, type Component } from 'solid-js';
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import { DataGrid, EmptyState, MetaCluster, PageHeader, Panel, StatusBadge } from '../ui';
import {
BoxPlotChart,
ChartLegend,
ComboChart,
CommandBar,
CommandBarGroup,
HistogramChart,
MetaCluster,
PageHeader,
Panel,
Select,
SummaryStrip,
TimeSeriesChart,
} from '../ui';
const dayOptions = [
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: '90', label: 'Last 90 days' },
];
const palette = ['#2357d8', '#1f7a45', '#c05621', '#8b5cf6', '#0f766e', '#b42318', '#7c3aed', '#0b7285'];
type AnalyticsChartRow = { date: string } & Record<string, string | number | null>;
const formatInteger = new Intl.NumberFormat('en-US');
export const Analytics: Component = () => {
const [requests] = createResource(() => api.analytics.getRequests({ limit: 50 }));
const [usage] = createResource(() => api.analytics.getUsage(undefined, undefined, 7));
const [metrics] = createResource(() => api.analytics.getMetrics(undefined, 7));
const [days, setDays] = createSignal('30');
const [backendFilter, setBackendFilter] = createSignal('all');
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(new Set());
const [hiddenResponseSeries, setHiddenResponseSeries] = createSignal<Set<string>>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
const requestRows = () => requests()?.rows ?? [];
const usageRows = () => usage() ?? [];
const metricRows = () => metrics() ?? [];
const filters = createMemo(() => ({
days: Number(days()),
backendId: backendFilter() === 'all' ? undefined : Number(backendFilter()),
}));
const [backends] = createResource(() => api.backends.getAll());
const [dailyTotals] = createResource(filters, (params) => api.analytics.getDailyTotals(params.backendId, params.days));
const [backendQuality] = createResource(filters, (params) => api.analytics.getBackendQuality(params.backendId, params.days));
const [modelTrends] = createResource(filters, (params) => api.analytics.getModelTrends({ backendId: params.backendId, days: params.days, limit: 8 }));
const [histogram] = createResource(filters, (params) => api.analytics.getResponseLengthHistogram({ backendId: params.backendId, days: params.days, bins: 20 }));
const [boxPlot] = createResource(filters, (params) => api.analytics.getResponseLengthBoxPlot(params.backendId, params.days));
const backendOptions = createMemo(() => [
{ value: 'all', label: 'All Backends' },
...((backends() ?? []).map((backend) => ({
value: String(backend.id),
label: backend.name,
}))),
]);
const backendNameById = createMemo(() => {
const entries = new Map<number, string>();
for (const backend of backends() ?? []) {
entries.set(backend.id, backend.name);
}
return entries;
});
const dailyVolumeRows = createMemo(() =>
(dailyTotals() ?? []).map((row) => ({
date: row.date,
requests: row.total_requests,
tokens: row.total_tokens,
}))
);
const responseTimeRows = createMemo(() => {
const grouped = new Map<string, AnalyticsChartRow>();
for (const row of backendQuality() ?? []) {
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
entry[`backend_${row.backend_id}`] = row.avg_response_time_ms;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
const responseTimeSeries = createMemo(() => {
const ids = Array.from(new Set((backendQuality() ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
return ids.map((backendId, index) => ({
key: `backend_${backendId}`,
label: backendNameById().get(backendId) ?? `Backend ${backendId}`,
color: palette[index % palette.length],
}));
});
const reliabilityRows = createMemo(() => {
const grouped = new Map<string, { requests: number; errors: number }>();
for (const row of backendQuality() ?? []) {
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
entry.requests += row.total_requests;
entry.errors += row.error_count;
grouped.set(row.date, entry);
}
return Array.from(grouped.entries())
.sort((left, right) => left[0].localeCompare(right[0]))
.map(([date, value]) => ({
date,
lineValue: value.requests === 0 ? 0 : ((value.requests - value.errors) / value.requests) * 100,
barValue: value.errors,
}));
});
const modelTrendRows = createMemo(() => {
const grouped = new Map<string, AnalyticsChartRow>();
for (const row of modelTrends() ?? []) {
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
entry[`model_${row.model}`] = row.request_count;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
const modelTrendSeries = createMemo(() => {
const models = Array.from(new Set((modelTrends() ?? []).map((row) => row.model)));
return models.map((model, index) => ({
key: `model_${model}`,
label: model,
color: palette[index % palette.length],
}));
});
const summaryItems = createMemo(() => {
const totals = (dailyTotals() ?? []).reduce(
(acc, row) => {
acc.requests += row.total_requests;
acc.tokens += row.total_tokens;
return acc;
},
{ requests: 0, tokens: 0 }
);
const qualityRows = backendQuality() ?? [];
const avgLatency =
qualityRows.length === 0 ? 0 : qualityRows.reduce((sum, row) => sum + row.avg_response_time_ms, 0) / qualityRows.length;
const errorCount = qualityRows.reduce((sum, row) => sum + row.error_count, 0);
return [
{ label: 'Requests', value: formatInteger.format(totals.requests), hint: `Last ${days()} days` },
{ label: 'Tokens', value: formatInteger.format(totals.tokens), hint: 'Aggregated daily total tokens' },
{ label: 'Avg Response', value: `${avgLatency.toFixed(1)}ms`, hint: 'Across visible backend series' },
{ label: 'Errors', value: formatInteger.format(errorCount), hint: 'Absolute backend error count' },
];
});
const toggleHiddenKey = (
setter: (value: Set<string> | ((current: Set<string>) => Set<string>)) => void,
key: string,
) => {
setter((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
return (
<Layout>
<div class="ui-app-page">
<PageHeader
title="Analytics"
description="Panel-based operational analytics for request logs, usage totals, and backend performance."
description="Operational analytics with D3-driven time series, reliability, and response-length distributions."
/>
<CommandBar class="analytics__filters">
<CommandBarGroup>
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
<Select label="Backend" value={backendFilter()} options={backendOptions()} onChange={setBackendFilter} />
</CommandBarGroup>
</CommandBar>
<SummaryStrip items={summaryItems()} />
<div class="ui-section-grid">
<Panel title="Recent Requests" description="Latest loaded request outcomes and token volume from the analytics log feed.">
<DataGrid
rows={requestRows()}
columns={[
{ id: 'user', header: 'User', mono: true, cell: (row) => <span>{row.user_id}</span> },
{ id: 'tokens', header: 'Tokens', mono: true, cell: (row) => <span>{row.total_tokens || 0}</span> },
{
id: 'status',
header: 'Status',
cell: (row) => <StatusBadge tone={row.status_code >= 400 ? 'danger' : 'success'}>{String(row.status_code)}</StatusBadge>,
},
{
id: 'detail',
header: 'Detail',
cell: (row) => <StatusBadge tone={row.detail_logged ? 'warning' : 'neutral'}>{row.detail_logged ? 'Verbose' : 'Meta'}</StatusBadge>,
},
<Panel
title="Daily Volume"
description="Daily request and token totals on shared time axis."
actions={
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenDailySeries()}
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
}
>
<TimeSeriesChart
data={dailyVolumeRows()}
series={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45', axis: 'right' },
]}
getRowKey={(row) => row.id}
loading={requests.loading}
emptyMessage="No request analytics available."
showLegend={false}
hiddenKeys={hiddenDailySeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
yLeftLabel="Requests"
yRightLabel="Tokens"
formatLeftValue={(value) => new Intl.NumberFormat('en-US').format(Math.round(value))}
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
tooltipTitle="Daily request and token totals"
/>
</Panel>
<Panel title="Usage Stats" description="Daily request and token totals aggregated for the last 7 days.">
<DataGrid
rows={usageRows()}
columns={[
{ id: 'date', header: 'Date', cell: (row) => <span>{row.date}</span> },
{ id: 'requests', header: 'Requests', mono: true, cell: (row) => <span>{row.total_requests}</span> },
{ id: 'tokens', header: 'Tokens', mono: true, cell: (row) => <span>{row.total_tokens}</span> },
]}
getRowKey={(row) => `${row.user_id}-${row.backend_id}-${row.date}`}
loading={usage.loading}
emptyMessage="No usage data available."
<Panel
title="Backend Reliability"
description="Success rate and absolute error count per day."
actions={
<ChartLegend
items={[
{ key: 'line', label: 'Success Rate', color: '#2357d8' },
{ key: 'bar', label: 'Errors', color: '#b42318' },
]}
/>
}
>
<ComboChart
data={reliabilityRows()}
lineLabel="Success Rate"
barLabel="Errors"
lineColor="#2357d8"
barColor="#b42318"
showLegend={false}
/>
</Panel>
</div>
<Panel title="Backend Metrics" description="Per-backend performance windows and success rates for the last 7 days.">
<MetaCluster
items={[
{ key: 'Window', value: 'Last 7 days' },
{ key: 'Grouping', value: 'Per backend / per date' },
]}
/>
<DataGrid
rows={metricRows()}
columns={[
{ id: 'date', header: 'Date', cell: (row) => <span>{row.date}</span> },
{ id: 'backend', header: 'Backend', mono: true, cell: (row) => <span>{row.backend_id}</span> },
{ id: 'requests', header: 'Requests', mono: true, cell: (row) => <span>{row.total_requests}</span> },
{ id: 'latency', header: 'Avg Response', mono: true, cell: (row) => <span>{row.avg_response_time_ms?.toFixed(1) || 0}ms</span> },
{
id: 'success_rate',
header: 'Success Rate',
cell: (row) => (
<StatusBadge tone={row.success_rate >= 0.95 ? 'success' : row.success_rate >= 0.8 ? 'warning' : 'danger'}>
{`${(row.success_rate * 100).toFixed(1)}%`}
</StatusBadge>
),
},
]}
getRowKey={(row) => `${row.backend_id}-${row.date}`}
loading={metrics.loading}
emptyMessage="No backend metrics available."
/>
{!metrics.loading && metricRows().length === 0 && (
<EmptyState title="No metrics yet" description="Backend performance data will appear after requests have been logged." />
)}
</Panel>
<div class="ui-section-grid">
<Panel
title="Backend Response Time"
description="Average response time by backend with toggleable backend series."
actions={
<ChartLegend
items={responseTimeSeries()}
mutedKeys={hiddenResponseSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenResponseSeries, key)}
/>
}
>
<MetaCluster
items={[
{ key: 'Series', value: String(responseTimeSeries().length) },
{ key: 'Window', value: `Last ${days()} days` },
]}
/>
<TimeSeriesChart
data={responseTimeRows()}
series={responseTimeSeries()}
showLegend={false}
hiddenKeys={hiddenResponseSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenResponseSeries, key)}
yLeftLabel="Milliseconds"
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
tooltipTitle="Average backend response time"
/>
</Panel>
<Panel
title="Model Request Trends"
description="Top routed/response models by request volume over time."
actions={
<ChartLegend
items={modelTrendSeries()}
mutedKeys={hiddenModelSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
/>
}
>
<MetaCluster
items={[
{ key: 'Models', value: String(modelTrendSeries().length) },
{ key: 'Selection', value: backendFilter() === 'all' ? 'All backends' : backendOptions().find((option) => option.value === backendFilter())?.label ?? 'Selected backend' },
]}
/>
<TimeSeriesChart
data={modelTrendRows()}
series={modelTrendSeries()}
showLegend={false}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
yLeftLabel="Requests"
formatLeftValue={(value) => `${Math.round(value)}`}
tooltipTitle="Model request trend"
/>
</Panel>
</div>
<div class="ui-section-grid analytics__grid--spread-wide">
<Panel title="Response Length Distribution" description="Histogram of completion token lengths across the selected window.">
<MetaCluster
items={[
{ key: 'Bins', value: String((histogram() ?? []).length) },
{ key: 'Metric', value: 'completion_tokens' },
]}
/>
<HistogramChart data={histogram() ?? []} />
</Panel>
<Panel title="Daily Response Length Spread" description="Completion token box plot by day using min / q1 / median / q3 / max summary.">
<MetaCluster
items={[
{ key: 'Days', value: String((boxPlot() ?? []).length) },
{ key: 'Outliers', value: 'Hidden in this view' },
]}
/>
<BoxPlotChart data={boxPlot() ?? []} />
</Panel>
</div>
</div>
</Layout>
);

View file

@ -129,6 +129,44 @@ export type BackendMetrics = {
success_rate: number;
};
export type AnalyticsDailyTotalsPoint = {
date: string;
total_requests: number;
total_tokens: number;
};
export type AnalyticsBackendQualityPoint = {
date: string;
backend_id: number;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
};
export type AnalyticsModelTrendPoint = {
date: string;
model: string;
request_count: number;
};
export type AnalyticsHistogramBin = {
bin_start: number;
bin_end: number;
count: number;
};
export type AnalyticsBoxPlotPoint = {
date: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
count: number;
};
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
export type UserScript = {

View file

@ -15,6 +15,7 @@ export * from './primitives/Tooltip';
export * from './patterns/CommandBar';
export * from './patterns/ConfirmDialog';
export * from './patterns/ConversationTimeline';
export * from './patterns/Charts';
export * from './patterns/DataGrid';
export * from './patterns/EmptyState';
export * from './patterns/FieldRow';

View file

@ -20,10 +20,10 @@ const navItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/users', label: 'Users', icon: Users },
{ path: '/backends', label: 'Backends', icon: Server },
{ path: '/analytics', label: 'Analytics', icon: ChartColumn },
{ path: '/models', label: 'Models', icon: Network },
{ path: '/detail-logs', label: 'Detail Logs', icon: Logs },
{ path: '/scripts', label: 'Scripts', icon: FileCode },
{ path: '/detail-logs', label: 'Detail Logs', icon: Logs },
{ path: '/analytics', label: 'Analytics', icon: ChartColumn },
];
interface AppShellProps {

View file

@ -0,0 +1,811 @@
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import * as d3 from 'd3';
import { Button } from '../primitives/Button';
import { cn } from '../lib/cn';
const parseDate = d3.utcParse('%Y-%m-%d');
const formatDate = d3.utcFormat('%b %d');
interface ChartTheme {
bg: string;
text: string;
textMuted: string;
border: string;
accent: string;
success: string;
warning: string;
danger: string;
}
interface ChartDimensions {
width: number;
height: number;
marginTop: number;
marginRight: number;
marginBottom: number;
marginLeft: number;
}
function readChartTheme(): ChartTheme {
const styles = getComputedStyle(document.documentElement);
return {
bg: styles.getPropertyValue('--color-bg-elevated').trim(),
text: styles.getPropertyValue('--color-text').trim(),
textMuted: styles.getPropertyValue('--color-text-muted').trim(),
border: styles.getPropertyValue('--color-border').trim(),
accent: styles.getPropertyValue('--color-accent').trim(),
success: styles.getPropertyValue('--color-success').trim(),
warning: styles.getPropertyValue('--color-warning').trim(),
danger: styles.getPropertyValue('--color-danger').trim(),
};
}
function createChartEnvironment() {
const [width, setWidth] = createSignal(0);
const [themeVersion, setThemeVersion] = createSignal(0);
let rootRef: HTMLDivElement | undefined;
onMount(() => {
const observer = new ResizeObserver((entries) => {
const nextWidth = entries[0]?.contentRect.width ?? 0;
setWidth(nextWidth);
});
const mutationObserver = new MutationObserver(() => {
setThemeVersion((current) => current + 1);
});
if (rootRef) {
observer.observe(rootRef);
}
mutationObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
onCleanup(() => {
observer.disconnect();
mutationObserver.disconnect();
});
});
return {
rootRef: (element: HTMLDivElement) => {
rootRef = element;
},
width,
themeVersion,
};
}
function buildChartDimensions(width: number, height: number, marginRight: number = 56): ChartDimensions {
return {
width,
height,
marginTop: 20,
marginRight,
marginBottom: 30,
marginLeft: 48,
};
}
function getInnerWidth(dimensions: ChartDimensions): number {
return Math.max(0, dimensions.width - dimensions.marginLeft - dimensions.marginRight);
}
function getInnerHeight(dimensions: ChartDimensions): number {
return Math.max(0, dimensions.height - dimensions.marginTop - dimensions.marginBottom);
}
function formatCompactNumber(value: number): string {
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
}
function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
function getDateTicks(values: Date[], width: number): Date[] {
if (values.length <= 7) {
return values;
}
const scale = d3.scaleUtc()
.domain(d3.extent(values) as [Date, Date])
.range([0, width]);
return scale.ticks(Math.max(2, Math.floor(width / 120)));
}
interface TimeSeriesChartSeries {
key: string;
label: string;
color: string;
axis?: 'left' | 'right';
}
interface ChartLegendItem {
key: string;
label: string;
color: string;
}
interface TimeSeriesDatum {
date: string;
[key: string]: string | number | null;
}
interface TimeSeriesChartProps {
data: TimeSeriesDatum[];
series: TimeSeriesChartSeries[];
height?: number;
class?: string;
showLegend?: boolean;
hiddenKeys?: Set<string>;
onToggleLegend?: (key: string) => void;
yLeftLabel?: string;
yRightLabel?: string;
formatLeftValue?: (value: number) => string;
formatRightValue?: (value: number) => string;
tooltipTitle?: string;
}
interface ChartLegendProps {
items: ChartLegendItem[];
mutedKeys?: Set<string>;
onToggle?: (key: string) => void;
}
export function ChartLegend(props: ChartLegendProps) {
return (
<div class="ui-chart__legend">
<For each={props.items}>
{(item) => (
props.onToggle ? (
<button
type="button"
class={cn('ui-chart__legend-button', props.mutedKeys?.has(item.key) && 'ui-chart__legend-button--muted')}
onClick={() => props.onToggle?.(item.key)}
>
<span class="ui-chart__legend-swatch" style={{ background: item.color }} />
<span>{item.label}</span>
</button>
) : (
<div class="ui-chart__legend-static">
<span class="ui-chart__legend-swatch" style={{ background: item.color }} />
<span>{item.label}</span>
</div>
)
)}
</For>
</div>
);
}
type ParsedTimeSeriesDatum = TimeSeriesDatum & { parsedDate: Date };
type ParsedComboDatum = { date: string; lineValue: number; barValue: number; parsedDate: Date };
type ParsedBoxPlotDatum = { date: string; min: number; q1: number; median: number; q3: number; max: number; parsedDate: Date };
export function TimeSeriesChart(props: TimeSeriesChartProps) {
const env = createChartEnvironment();
const [internalHiddenSeries, setInternalHiddenSeries] = createSignal<Set<string>>(new Set());
const [hoverIndex, setHoverIndex] = createSignal<number | null>(null);
const theme = createMemo(() => {
env.themeVersion();
return readChartTheme();
});
const hiddenSeries = createMemo(() => props.hiddenKeys ?? internalHiddenSeries());
const visibleSeries = createMemo(() => props.series.filter((series) => !hiddenSeries().has(series.key)));
const chartHeight = () => props.height ?? 240;
const dimensions = createMemo(() => buildChartDimensions(env.width(), chartHeight(), props.series.some((series) => series.axis === 'right') ? 56 : 20));
const points = createMemo(() =>
props.data
.map((row) => ({
...row,
parsedDate: parseDate(row.date),
}))
.filter((row): row is ParsedTimeSeriesDatum => row.parsedDate instanceof Date)
.sort((left, right) => left.parsedDate.getTime() - right.parsedDate.getTime())
);
const xScale = createMemo(() => {
const values = points().map((point) => point.parsedDate);
const domain = d3.extent(values) as [Date, Date];
return d3.scaleUtc().domain(domain).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]);
});
const leftSeries = createMemo(() => visibleSeries().filter((series) => series.axis !== 'right'));
const rightSeries = createMemo(() => visibleSeries().filter((series) => series.axis === 'right'));
const leftScale = createMemo(() => {
const maxValue =
d3.max(points(), (point: ParsedTimeSeriesDatum) =>
d3.max(leftSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
) ?? 0;
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
});
const rightScale = createMemo(() => {
const maxValue =
d3.max(points(), (point: ParsedTimeSeriesDatum) =>
d3.max(rightSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
) ?? 0;
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
});
const leftTicks = createMemo(() => leftScale().ticks(4));
const rightTicks = createMemo(() => rightSeries().length > 0 ? rightScale().ticks(4) : []);
const xTicks = createMemo(() => xScale().ticks(Math.max(2, Math.floor(getInnerWidth(dimensions()) / 120))));
const dateTicks = createMemo(() => getDateTicks(points().map((point) => point.parsedDate), getInnerWidth(dimensions())));
const linePath = (series: TimeSeriesChartSeries) =>
d3.line()
.defined((point: ParsedTimeSeriesDatum) => typeof point[series.key] === 'number')
.x((point: ParsedTimeSeriesDatum) => xScale()(point.parsedDate))
.y((point: ParsedTimeSeriesDatum) => (series.axis === 'right' ? rightScale() : leftScale())(Number(point[series.key] ?? 0)))(points()) ?? '';
const hoveredPoint = createMemo(() => {
const index = hoverIndex();
return index === null ? null : points()[index] ?? null;
});
const tooltipRows = createMemo(() =>
hoveredPoint()
? visibleSeries()
.map((series) => ({
...series,
value: Number(hoveredPoint()?.[series.key] ?? 0),
}))
.filter((series) => Number.isFinite(series.value))
: []
);
const toggleSeries = (key: string) => {
if (props.onToggleLegend) {
props.onToggleLegend(key);
return;
}
setInternalHiddenSeries((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const handlePointerMove = (event: PointerEvent) => {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const hoveredDate = xScale().invert(offsetX);
const nearestIndex = d3.leastIndex(points(), (point: ParsedTimeSeriesDatum) => Math.abs(point.parsedDate.getTime() - hoveredDate.getTime()));
setHoverIndex(nearestIndex ?? null);
};
return (
<div class={cn('ui-chart', props.class)}>
<Show when={props.showLegend !== false && props.series.length > 1}>
<ChartLegend items={props.series} mutedKeys={hiddenSeries()} onToggle={toggleSeries} />
</Show>
<div class="ui-chart__frame" ref={env.rootRef}>
<Show when={env.width() > 0 && points().length > 0} fallback={<div class="ui-chart__empty">No chart data available.</div>}>
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label={props.tooltipTitle ?? 'Time series chart'}>
<g>
<For each={leftTicks()}>
{(tick) => (
<g>
<line
x1={dimensions().marginLeft}
x2={dimensions().marginLeft + getInnerWidth(dimensions())}
y1={leftScale()(tick)}
y2={leftScale()(tick)}
stroke={theme().border}
stroke-dasharray="3 4"
/>
<text x={dimensions().marginLeft - 8} y={leftScale()(tick)} fill={theme().textMuted} text-anchor="end" dominant-baseline="middle" class="ui-chart__tick">
{(props.formatLeftValue ?? formatCompactNumber)(tick)}
</text>
</g>
)}
</For>
<For each={dateTicks()}>
{(tick) => (
<text
x={xScale()(tick)}
y={dimensions().marginTop + getInnerHeight(dimensions()) + 20}
fill={theme().textMuted}
text-anchor="middle"
class="ui-chart__tick"
>
{formatDate(tick)}
</text>
)}
</For>
<For each={visibleSeries()}>
{(series) => (
<path
d={linePath(series)}
fill="none"
stroke={series.color}
stroke-width="2.25"
stroke-linejoin="round"
stroke-linecap="round"
/>
)}
</For>
<Show when={rightSeries().length > 0}>
<For each={rightTicks()}>
{(tick) => (
<text
x={dimensions().width - dimensions().marginRight + 8}
y={rightScale()(tick)}
fill={theme().textMuted}
text-anchor="start"
dominant-baseline="middle"
class="ui-chart__tick"
>
{(props.formatRightValue ?? formatCompactNumber)(tick)}
</text>
)}
</For>
</Show>
<Show when={hoveredPoint()}>
{(point) => (
<>
<line
x1={xScale()(point().parsedDate)}
x2={xScale()(point().parsedDate)}
y1={dimensions().marginTop}
y2={dimensions().marginTop + getInnerHeight(dimensions())}
stroke={theme().textMuted}
stroke-dasharray="4 4"
/>
<For each={tooltipRows()}>
{(series) => (
<circle
cx={xScale()(point().parsedDate)}
cy={(series.axis === 'right' ? rightScale() : leftScale())(series.value)}
r="4"
fill={series.color}
stroke={theme().bg}
stroke-width="2"
/>
)}
</For>
</>
)}
</Show>
<rect
x={dimensions().marginLeft}
y={dimensions().marginTop}
width={getInnerWidth(dimensions())}
height={getInnerHeight(dimensions())}
fill="transparent"
onPointerMove={handlePointerMove}
onPointerLeave={() => setHoverIndex(null)}
/>
</g>
</svg>
<Show when={hoveredPoint()}>
{(point) => (
<div class="ui-chart__tooltip">
<div class="ui-chart__tooltip-title">{formatDate(point().parsedDate)}</div>
<For each={tooltipRows()}>
{(series) => (
<div class="ui-chart__tooltip-row">
<span class="ui-chart__legend-swatch" style={{ background: series.color }} />
<span>{series.label}</span>
<strong>
{(series.axis === 'right'
? props.formatRightValue ?? formatCompactNumber
: props.formatLeftValue ?? formatCompactNumber)(series.value)}
</strong>
</div>
)}
</For>
</div>
)}
</Show>
</Show>
</div>
<Show when={props.yLeftLabel || props.yRightLabel}>
<div class="ui-chart__axis-labels">
<Show when={props.yLeftLabel}>
<span>{props.yLeftLabel}</span>
</Show>
<Show when={props.yRightLabel}>
<span>{props.yRightLabel}</span>
</Show>
</div>
</Show>
</div>
);
}
interface ComboChartProps {
data: Array<{ date: string; lineValue: number; barValue: number }>;
lineLabel: string;
barLabel: string;
lineColor?: string;
barColor?: string;
height?: number;
showLegend?: boolean;
}
export function ComboChart(props: ComboChartProps) {
const env = createChartEnvironment();
const [hoverIndex, setHoverIndex] = createSignal<number | null>(null);
const theme = createMemo(() => {
env.themeVersion();
return readChartTheme();
});
const dimensions = createMemo(() => buildChartDimensions(env.width(), props.height ?? 240, 60));
const points = createMemo(() =>
props.data
.map((row) => ({ ...row, parsedDate: parseDate(row.date) }))
.filter((row): row is ParsedComboDatum => row.parsedDate instanceof Date)
.sort((left, right) => left.parsedDate.getTime() - right.parsedDate.getTime())
);
const xScale = createMemo(() => {
const values = points().map((point) => point.parsedDate);
const domain = d3.extent(values) as [Date, Date];
return d3.scaleUtc().domain(domain).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]);
});
const barScale = createMemo(() => {
const maxValue = d3.max(points(), (point: ParsedComboDatum) => point.barValue) ?? 0;
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
});
const lineScale = createMemo(() => d3.scaleLinear().domain([0, 100]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]));
const dateTicks = createMemo(() => getDateTicks(points().map((point) => point.parsedDate), getInnerWidth(dimensions())));
const barTicks = createMemo(() => barScale().ticks(4));
const linePath = createMemo(
() =>
d3.line()
.x((point: ParsedComboDatum) => xScale()(point.parsedDate))
.y((point: ParsedComboDatum) => lineScale()(point.lineValue))(points()) ?? ''
);
const barWidth = createMemo(() => {
const maxSlotWidth = getInnerWidth(dimensions()) / Math.max(1, points().length);
return Math.max(8, Math.min(48, maxSlotWidth * 0.6));
});
const hoveredPoint = createMemo(() => {
const index = hoverIndex();
return index === null ? null : points()[index] ?? null;
});
const handlePointerMove = (event: PointerEvent) => {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const hoveredDate = xScale().invert(offsetX);
const nearestIndex = d3.leastIndex(points(), (point: ParsedComboDatum) => Math.abs(point.parsedDate.getTime() - hoveredDate.getTime()));
setHoverIndex(nearestIndex ?? null);
};
return (
<div class="ui-chart">
<Show when={props.showLegend !== false}>
<ChartLegend
items={[
{ key: 'line', label: props.lineLabel, color: props.lineColor ?? theme().accent },
{ key: 'bar', label: props.barLabel, color: props.barColor ?? theme().danger },
]}
/>
</Show>
<div class="ui-chart__frame" ref={env.rootRef}>
<Show when={env.width() > 0 && points().length > 0} fallback={<div class="ui-chart__empty">No chart data available.</div>}>
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label="Combo chart">
<For each={barTicks()}>
{(tick) => (
<>
<line
x1={dimensions().marginLeft}
x2={dimensions().marginLeft + getInnerWidth(dimensions())}
y1={barScale()(tick)}
y2={barScale()(tick)}
stroke={theme().border}
stroke-dasharray="3 4"
/>
<text x={dimensions().marginLeft - 8} y={barScale()(tick)} fill={theme().textMuted} text-anchor="end" dominant-baseline="middle" class="ui-chart__tick">
{formatCompactNumber(tick)}
</text>
</>
)}
</For>
<For each={points()}>
{(point) => (
<rect
x={Math.max(dimensions().marginLeft, Math.min(xScale()(point.parsedDate) - barWidth() / 2, dimensions().marginLeft + getInnerWidth(dimensions()) - barWidth()))}
y={barScale()(point.barValue)}
width={barWidth()}
height={dimensions().marginTop + getInnerHeight(dimensions()) - barScale()(point.barValue)}
fill={props.barColor ?? theme().danger}
opacity="0.7"
rx="2"
/>
)}
</For>
<path d={linePath()} fill="none" stroke={props.lineColor ?? theme().accent} stroke-width="2.25" />
<For each={dateTicks()}>
{(tick) => (
<text
x={xScale()(tick)}
y={dimensions().marginTop + getInnerHeight(dimensions()) + 20}
fill={theme().textMuted}
text-anchor="middle"
class="ui-chart__tick"
>
{formatDate(tick)}
</text>
)}
</For>
<For each={[0, 25, 50, 75, 100]}>
{(tick) => (
<text
x={dimensions().width - dimensions().marginRight + 8}
y={lineScale()(tick)}
fill={theme().textMuted}
text-anchor="start"
dominant-baseline="middle"
class="ui-chart__tick"
>
{formatPercent(tick)}
</text>
)}
</For>
<Show when={hoveredPoint()}>
{(point) => (
<>
<line
x1={xScale()(point().parsedDate)}
x2={xScale()(point().parsedDate)}
y1={dimensions().marginTop}
y2={dimensions().marginTop + getInnerHeight(dimensions())}
stroke={theme().textMuted}
stroke-dasharray="4 4"
/>
<circle
cx={xScale()(point().parsedDate)}
cy={lineScale()(point().lineValue)}
r="4"
fill={props.lineColor ?? theme().accent}
stroke={theme().bg}
stroke-width="2"
/>
</>
)}
</Show>
<rect
x={dimensions().marginLeft}
y={dimensions().marginTop}
width={getInnerWidth(dimensions())}
height={getInnerHeight(dimensions())}
fill="transparent"
onPointerMove={handlePointerMove}
onPointerLeave={() => setHoverIndex(null)}
/>
</svg>
<Show when={hoveredPoint()}>
{(point) => (
<div class="ui-chart__tooltip">
<div class="ui-chart__tooltip-title">{formatDate(point().parsedDate)}</div>
<div class="ui-chart__tooltip-row">
<span class="ui-chart__legend-swatch" style={{ background: props.lineColor ?? theme().accent }} />
<span>{props.lineLabel}</span>
<strong>{formatPercent(point().lineValue)}</strong>
</div>
<div class="ui-chart__tooltip-row">
<span class="ui-chart__legend-swatch" style={{ background: props.barColor ?? theme().danger }} />
<span>{props.barLabel}</span>
<strong>{formatCompactNumber(point().barValue)}</strong>
</div>
</div>
)}
</Show>
</Show>
</div>
</div>
);
}
interface HistogramChartProps {
data: Array<{ bin_start: number; bin_end: number; count: number }>;
height?: number;
}
export function HistogramChart(props: HistogramChartProps) {
const env = createChartEnvironment();
const theme = createMemo(() => {
env.themeVersion();
return readChartTheme();
});
const dimensions = createMemo(() => buildChartDimensions(env.width(), props.height ?? 200, 20));
const xScale = createMemo(() => {
const min = d3.min(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.bin_start) ?? 0;
const max = d3.max(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.bin_end) ?? 1;
return d3.scaleLinear().domain([min, max]).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]);
});
const yScale = createMemo(() => {
const max = d3.max(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.count) ?? 0;
return d3.scaleLinear().domain([0, max === 0 ? 1 : max * 1.1]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
});
const xTicks = createMemo(() => xScale().ticks(Math.max(2, Math.floor(getInnerWidth(dimensions()) / 100))));
return (
<div class="ui-chart">
<div class="ui-chart__frame" ref={env.rootRef}>
<Show when={env.width() > 0 && props.data.length > 0} fallback={<div class="ui-chart__empty">No histogram data available.</div>}>
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label="Histogram">
<For each={yScale().ticks(4)}>
{(tick) => (
<>
<line
x1={dimensions().marginLeft}
x2={dimensions().marginLeft + getInnerWidth(dimensions())}
y1={yScale()(tick)}
y2={yScale()(tick)}
stroke={theme().border}
stroke-dasharray="3 4"
/>
<text x={dimensions().marginLeft - 8} y={yScale()(tick)} fill={theme().textMuted} text-anchor="end" dominant-baseline="middle" class="ui-chart__tick">
{formatCompactNumber(tick)}
</text>
</>
)}
</For>
<For each={props.data}>
{(bin) => (
<rect
x={xScale()(bin.bin_start) + 1}
y={yScale()(bin.count)}
width={Math.max(2, xScale()(bin.bin_end) - xScale()(bin.bin_start) - 2)}
height={dimensions().marginTop + getInnerHeight(dimensions()) - yScale()(bin.count)}
fill={theme().warning}
opacity="0.8"
rx="2"
/>
)}
</For>
<For each={xTicks()}>
{(tick) => (
<text
x={xScale()(tick)}
y={dimensions().marginTop + getInnerHeight(dimensions()) + 20}
fill={theme().textMuted}
text-anchor="middle"
class="ui-chart__tick"
>
{formatCompactNumber(tick)}
</text>
)}
</For>
</svg>
</Show>
</div>
</div>
);
}
interface BoxPlotChartProps {
data: Array<{ date: string; min: number; q1: number; median: number; q3: number; max: number }>;
height?: number;
}
export function BoxPlotChart(props: BoxPlotChartProps) {
const env = createChartEnvironment();
const theme = createMemo(() => {
env.themeVersion();
return readChartTheme();
});
const dimensions = createMemo(() => buildChartDimensions(env.width(), props.height ?? 200, 20));
const points = createMemo(() =>
props.data
.map((row) => ({ ...row, parsedDate: parseDate(row.date) }))
.filter((row): row is ParsedBoxPlotDatum => row.parsedDate instanceof Date)
.sort((left, right) => left.parsedDate.getTime() - right.parsedDate.getTime())
);
const xScale = createMemo(() => {
const domain = points().map((point) => point.date);
return d3.scaleBand().domain(domain).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]).padding(0.35);
});
const yScale = createMemo(() => {
const max = d3.max(points(), (point: ParsedBoxPlotDatum) => point.max) ?? 0;
return d3.scaleLinear().domain([0, max === 0 ? 1 : max * 1.1]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
});
return (
<div class="ui-chart">
<div class="ui-chart__frame" ref={env.rootRef}>
<Show when={env.width() > 0 && points().length > 0} fallback={<div class="ui-chart__empty">No box plot data available.</div>}>
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label="Box plot chart">
<For each={yScale().ticks(4)}>
{(tick) => (
<>
<line
x1={dimensions().marginLeft}
x2={dimensions().marginLeft + getInnerWidth(dimensions())}
y1={yScale()(tick)}
y2={yScale()(tick)}
stroke={theme().border}
stroke-dasharray="3 4"
/>
<text x={dimensions().marginLeft - 8} y={yScale()(tick)} fill={theme().textMuted} text-anchor="end" dominant-baseline="middle" class="ui-chart__tick">
{formatCompactNumber(tick)}
</text>
</>
)}
</For>
<For each={points()}>
{(point) => {
const center = () => (xScale()(point.date) ?? 0) + xScale().bandwidth() / 2;
return (
<>
<line x1={center()} x2={center()} y1={yScale()(point.min)} y2={yScale()(point.max)} stroke={theme().textMuted} />
<rect
x={xScale()(point.date)}
y={yScale()(point.q3)}
width={xScale().bandwidth()}
height={Math.max(2, yScale()(point.q1) - yScale()(point.q3))}
fill={theme().accent}
opacity="0.25"
stroke={theme().accent}
/>
<line x1={xScale()(point.date)} x2={(xScale()(point.date) ?? 0) + xScale().bandwidth()} y1={yScale()(point.median)} y2={yScale()(point.median)} stroke={theme().accent} stroke-width="2" />
<text
x={center()}
y={dimensions().marginTop + getInnerHeight(dimensions()) + 20}
fill={theme().textMuted}
text-anchor="middle"
class="ui-chart__tick"
>
{formatDate(point.parsedDate)}
</text>
</>
);
}}
</For>
</svg>
</Show>
</div>
</div>
);
}
interface ChartPlaceholderProps {
message: string;
actionLabel?: string;
onAction?: () => void;
}
export function ChartPlaceholder(props: ChartPlaceholderProps) {
return (
<div class="ui-chart__empty">
<span>{props.message}</span>
<Show when={props.actionLabel && props.onAction}>
<Button onClick={() => props.onAction?.()}>{props.actionLabel}</Button>
</Show>
</div>
);
}

View file

@ -20,6 +20,8 @@
.ui-panel__body {
min-width: 0;
padding: var(--space-5);
display: grid;
gap: var(--space-4);
}
.ui-panel__header-copy {
@ -502,6 +504,133 @@
border-top: 1px solid var(--color-border);
}
.ui-chart {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.ui-chart__frame {
position: relative;
min-width: 0;
min-height: 200px;
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: linear-gradient(180deg, var(--color-bg-panel) 0%, var(--color-bg-elevated) 100%);
overflow: hidden;
}
.ui-chart__svg {
display: block;
width: 100%;
height: auto;
}
.ui-chart__legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.ui-chart__legend-button,
.ui-chart__legend-static {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 28px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg-elevated);
color: var(--color-text);
font-size: var(--text-2);
}
.ui-chart__legend-button {
cursor: pointer;
}
.ui-chart__legend-button--muted {
opacity: 0.45;
}
.ui-chart__legend-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
flex: 0 0 auto;
}
.ui-chart__tick {
font-size: var(--text-1);
}
.ui-chart__tooltip {
position: absolute;
top: var(--space-4);
right: var(--space-4);
min-width: 180px;
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--color-bg-elevated) 92%, transparent);
box-shadow: var(--shadow-panel);
backdrop-filter: blur(8px);
}
.ui-chart__tooltip-title {
font-size: var(--text-2);
color: var(--color-text-muted);
}
.ui-chart__tooltip-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
font-size: var(--text-2);
}
.ui-chart__axis-labels {
display: flex;
justify-content: space-between;
gap: var(--space-4);
color: var(--color-text-muted);
font-size: var(--text-1);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ui-chart__empty {
min-height: 200px;
display: grid;
place-items: center;
gap: var(--space-3);
padding: var(--space-6);
color: var(--color-text-muted);
text-align: center;
}
.analytics__filters {
align-items: end;
}
.analytics__filters .ui-select,
.analytics__filters .ui-field {
min-width: 180px;
}
.analytics__grid--wide {
grid-template-columns: 1.5fr minmax(340px, 1fr);
}
.analytics__grid--spread-wide {
grid-template-columns: minmax(320px, 1fr) 1.5fr;
}
@media (max-width: 960px) {
.page-header {
display: grid;
@ -523,6 +652,14 @@
.ui-data-grid__table {
min-width: 520px;
}
.analytics__grid--wide {
grid-template-columns: 1fr;
}
.analytics__grid--spread-wide {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {

30
docs/analytics.md Normal file
View file

@ -0,0 +1,30 @@
# Analytics
현재 관리자 Analytics 화면은 표 중심 조회가 아니라 운영 지표를 빠르게 훑을 수 있는 D3 기반 시계열 대시보드로 구성된다.
## UI
- 경로: `/dashboard/analytics`
- 공통 필터: 기간(`7`, `30`, `90`일), backend 선택
- 상단 요약: requests, tokens, 평균 응답 시간, errors
- 주요 패널
- Daily Volume: 일별 requests + tokens
- Backend Reliability: success rate + error count
- Backend Response Time: backend별 평균 응답 시간
- Model Request Trends: 상위 모델 요청 추이
- Response Length Distribution: `completion_tokens` histogram
- Daily Response Length Spread: `completion_tokens` box plot
## API
- `GET /admin/analytics/daily-totals`
- `GET /admin/analytics/backend-quality`
- `GET /admin/analytics/model-trends`
- `GET /admin/analytics/response-length-histogram`
- `GET /admin/analytics/response-length-box-plot`
## 집계 규칙
- 모델 추이의 모델 키는 `response_model -> routed_model -> request_model -> unknown` 순서로 결정한다.
- response length 계열 시각화는 `completion_tokens` 값이 있는 요청만 집계한다.
- 상세 요청 단위의 latency/body 확인은 계속 `DetailLogs` 화면에서 담당한다.

View file

@ -118,9 +118,18 @@
| GET | `/admin/analytics/usage` | userId, backendId, days | 사용량 통계 |
| GET | `/admin/analytics/requests` | month, date, limit, offset, q, userId, backendId, endpoint, detailLogged | 월별 상세 요청 로그 조회 |
| GET | `/admin/analytics/metrics` | backendId, days | 백엔드 성능 메트릭 |
| GET | `/admin/analytics/daily-totals` | backendId, days | 일별 전체 request/token 합계 |
| GET | `/admin/analytics/backend-quality` | backendId, days | 일별 backend response time / error / success rate 시계열 |
| GET | `/admin/analytics/model-trends` | backendId, days, limit | 모델별 일별 요청 추이 |
| GET | `/admin/analytics/response-length-histogram` | backendId, days, bins | `completion_tokens` 분포 histogram |
| GET | `/admin/analytics/response-length-box-plot` | backendId, days | `completion_tokens` 일별 box plot 요약 |
상세 로그는 `users.detail_logging=1` 또는 `backends.detail_logging=1`일 때만 request/response header/body가 저장된다.
- `model-trends``response_model -> routed_model -> request_model -> unknown` 순서로 모델 키를 결정한다.
- response length 계열 endpoint는 `completion_tokens` 가 있는 요청만 집계한다.
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
참고:
- 관리자 인증과 세션/토큰 정책은 [docs/admin-auth.md](./admin-auth.md) 참고
- OpenID Connect 설정은 [docs/oidc.md](./oidc.md) 참고

View file

@ -15,10 +15,9 @@ client/src/
index.ts # TypeScript 타입 정의
routes/
Dashboard.tsx # 운영 요약, 최근 요청, 관리자 토큰 관리
Users.tsx # 사용자 CRUD
Users.tsx # 사용자 CRUD / 권한 매핑 관리
Backends.tsx # 백엔드 CRUD
Models.tsx # 모델 캐시/리라이트 규칙 관리
Permissions.tsx # 권한 매핑 관리
Analytics.tsx # 분석 화면
DetailLogs.tsx # 상세 요청 로그 탐색
Scripts.tsx # 스크립트 관리 및 테스트
@ -40,10 +39,9 @@ client/src/
| `/dashboard/users` | Users | 사용자 관리 |
| `/dashboard/backends` | Backends | 백엔드 관리 |
| `/dashboard/models` | Models | 모델 캐시/리라이트 관리 |
| `/dashboard/permissions` | Permissions | 권한 관리 |
| `/dashboard/analytics` | Analytics | 분석 대시보드 |
| `/dashboard/detail-logs` | DetailLogs | 상세 요청 로그 탐색 |
| `/dashboard/scripts` | Scripts | 스크립트 관리 |
| `/dashboard/detail-logs` | DetailLogs | 상세 요청 로그 탐색 |
| `/dashboard/analytics` | Analytics | 분석 대시보드 |
모든 관리자 라우트는 로그인 게이트 아래에서 렌더링된다.
@ -66,6 +64,14 @@ SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `
- 세션 기반 쓰기 요청에는 `X-CSRF-Token`이 자동 포함된다
- 401 응답이 오면 UI는 로그인 상태로 되돌아간다
## Analytics 메모
- `Analytics` 화면은 D3 기반 시계열 대시보드로 동작한다.
- 공통 필터는 기간(`7`, `30`, `90`일)과 backend 선택이다.
- 상단 summary strip 뒤에 일별 volume, reliability, response time, model trends, response length 분포 패널이 배치된다.
- 상세 raw request 확인은 계속 `DetailLogs` 화면이 담당한다.
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
## Model Management UI
- `Backends` 화면은 백엔드별 모델 캐시 상태, 모델 수, 마지막 sync 상태를 표시한다

View file

@ -66,6 +66,15 @@ server/src/
참고:
- 세부 라우팅 규칙과 캐시 트리거는 [docs/model-routing.md](./model-routing.md) 참고
## Analytics 메모
- `/admin/analytics` 는 기존 usage/requests/metrics 외에 chart 전용 집계 endpoint를 함께 제공한다.
- 추가 endpoint: `daily-totals`, `backend-quality`, `model-trends`, `response-length-histogram`, `response-length-box-plot`
- `AnalyticsService``analytics.db` 의 일별 집계와 `request_logs_YYYY-MM.db` 의 범위 조회를 함께 사용해 시계열/분포 데이터를 만든다.
- 모델 추이 키는 `response_model -> routed_model -> request_model -> unknown` 순서로 결정한다.
- response length 계열 집계는 `completion_tokens` 가 있는 요청만 포함한다.
- 자세한 화면/API 설명은 [docs/analytics.md](./analytics.md) 참고.
## Deployment Notes
- 권장 런타임은 단일 OCI 이미지다

319
pnpm-lock.yaml generated
View file

@ -16,6 +16,9 @@ importers:
'@solidjs/router':
specifier: ^0.15.4
version: 0.15.4(solid-js@1.9.11)
d3:
specifier: ^7.9.0
version: 7.9.0
lucide-solid:
specifier: ^1.1.0
version: 1.1.0(solid-js@1.9.11)
@ -1065,6 +1068,10 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
@ -1100,6 +1107,133 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-axis@3.0.0:
resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
engines: {node: '>=12'}
d3-brush@3.0.0:
resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
engines: {node: '>=12'}
d3-chord@3.0.1:
resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-contour@4.0.2:
resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
engines: {node: '>=12'}
d3-delaunay@6.0.4:
resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-dsv@3.0.1:
resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
engines: {node: '>=12'}
hasBin: true
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-fetch@3.0.1:
resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
engines: {node: '>=12'}
d3-force@3.0.0:
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-geo@3.1.1:
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
engines: {node: '>=12'}
d3-hierarchy@3.1.2:
resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-polygon@3.0.1:
resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
engines: {node: '>=12'}
d3-quadtree@3.0.1:
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
engines: {node: '>=12'}
d3-random@3.0.1:
resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
engines: {node: '>=12'}
d3-scale-chromatic@3.1.0:
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
d3@7.9.0:
resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
engines: {node: '>=12'}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@ -1137,6 +1271,9 @@ packages:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
delaunator@5.1.0:
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -1359,6 +1496,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@ -1376,6 +1517,10 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@ -1700,6 +1845,9 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -1713,6 +1861,9 @@ packages:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@ -3074,6 +3225,8 @@ snapshots:
commander@14.0.3: {}
commander@7.2.0: {}
component-emitter@1.3.1: {}
content-disposition@1.0.1: {}
@ -3097,6 +3250,158 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-axis@3.0.0: {}
d3-brush@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
d3-chord@3.0.1:
dependencies:
d3-path: 3.1.0
d3-color@3.1.0: {}
d3-contour@4.0.2:
dependencies:
d3-array: 3.2.4
d3-delaunay@6.0.4:
dependencies:
delaunator: 5.1.0
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-dsv@3.0.1:
dependencies:
commander: 7.2.0
iconv-lite: 0.6.3
rw: 1.3.3
d3-ease@3.0.1: {}
d3-fetch@3.0.1:
dependencies:
d3-dsv: 3.0.1
d3-force@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-format@3.1.2: {}
d3-geo@3.1.1:
dependencies:
d3-array: 3.2.4
d3-hierarchy@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-polygon@3.0.1: {}
d3-quadtree@3.0.1: {}
d3-random@3.0.1: {}
d3-scale-chromatic@3.1.0:
dependencies:
d3-color: 3.1.0
d3-interpolate: 3.0.1
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
d3@7.9.0:
dependencies:
d3-array: 3.2.4
d3-axis: 3.0.0
d3-brush: 3.0.0
d3-chord: 3.0.1
d3-color: 3.1.0
d3-contour: 4.0.2
d3-delaunay: 6.0.4
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-dsv: 3.0.1
d3-ease: 3.0.1
d3-fetch: 3.0.1
d3-force: 3.0.0
d3-format: 3.1.2
d3-geo: 3.1.1
d3-hierarchy: 3.1.2
d3-interpolate: 3.0.1
d3-path: 3.1.0
d3-polygon: 3.0.1
d3-quadtree: 3.0.1
d3-random: 3.0.1
d3-scale: 4.0.2
d3-scale-chromatic: 3.1.0
d3-selection: 3.0.0
d3-shape: 3.2.0
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-timer: 3.0.1
d3-transition: 3.0.1(d3-selection@3.0.0)
d3-zoom: 3.0.0
data-uri-to-buffer@4.0.1: {}
debug@4.4.3:
@ -3120,6 +3425,10 @@ snapshots:
define-lazy-prop@3.0.0: {}
delaunator@5.1.0:
dependencies:
robust-predicates: 3.0.3
delayed-stream@1.0.0: {}
depd@2.0.0: {}
@ -3370,6 +3679,10 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@ -3382,6 +3695,8 @@ snapshots:
ini@1.3.8: {}
internmap@2.0.3: {}
ipaddr.js@1.9.1: {}
is-docker@3.0.0: {}
@ -3662,6 +3977,8 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
robust-predicates@3.0.3: {}
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
@ -3705,6 +4022,8 @@ snapshots:
run-applescript@7.1.0: {}
rw@1.3.3: {}
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}

View file

@ -38,4 +38,51 @@ router.get('/metrics', (req: Request, res: Response) => {
res.json(result);
});
router.get('/daily-totals', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getDailyTotals(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
);
res.json(result);
});
router.get('/backend-quality', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getBackendQuality(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
);
res.json(result);
});
router.get('/model-trends', (req: Request, res: Response) => {
const { backendId, days, limit } = req.query;
const result = AnalyticsService.getModelTrends(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
limit ? Number(limit) : 8
);
res.json(result);
});
router.get('/response-length-histogram', (req: Request, res: Response) => {
const { backendId, days, bins } = req.query;
const result = AnalyticsService.getResponseLengthHistogram(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
bins ? Number(bins) : 20
);
res.json(result);
});
router.get('/response-length-box-plot', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getResponseLengthBoxPlot(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
);
res.json(result);
});
export default router;

View file

@ -2,8 +2,88 @@ import { getAnalyticsDb } from '../config/analytics-db';
import { RequestLogPage } from '../../../shared/types';
import { RequestLogInsert, RequestLogQuery, RequestLogService } from './RequestLogService';
import { getLocalDateKey } from '../utils/time';
import { getRequestLogsDb, listRequestLogMonths } from '../config/request-logs-db';
type AnalyticsLogInput = RequestLogInsert;
type RequestLogFilter = {
backendId?: number;
startDate: string;
endDate: string;
};
type DailyTotalsRow = {
date: string;
total_requests: number;
total_tokens: number;
};
type RequestLogRangeRow = {
local_date: string;
backend_id: number;
request_model: string | null;
routed_model: string | null;
response_model: string | null;
completion_tokens: number | null;
};
function getDateRange(days: number): { startDate: string; endDate: string } {
const normalizedDays = Math.max(1, days);
const endDate = getLocalDateKey();
const startDate = getLocalDateKey(new Date(Date.now() - (normalizedDays - 1) * 24 * 60 * 60 * 1000));
return { startDate, endDate };
}
function buildRequestLogRangeWhere(filter: RequestLogFilter): { whereClause: string; params: unknown[] } {
const clauses = ['local_date >= ?', 'local_date <= ?'];
const params: unknown[] = [filter.startDate, filter.endDate];
if (filter.backendId) {
clauses.push('backend_id = ?');
params.push(filter.backendId);
}
return {
whereClause: `WHERE ${clauses.join(' AND ')}`,
params,
};
}
function getRequestLogMonthsForRange(startDate: string, endDate: string): string[] {
const startMonth = startDate.slice(0, 7);
const endMonth = endDate.slice(0, 7);
return listRequestLogMonths().filter((month) => month >= startMonth && month <= endMonth);
}
function groupByDate(rows: DailyTotalsRow[]): DailyTotalsRow[] {
const grouped = new Map<string, DailyTotalsRow>();
for (const row of rows) {
const existing = grouped.get(row.date);
if (existing) {
existing.total_requests += row.total_requests;
existing.total_tokens += row.total_tokens;
} else {
grouped.set(row.date, { ...row });
}
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
}
function calculateQuantile(sortedValues: number[], ratio: number): number {
if (sortedValues.length === 0) return 0;
if (sortedValues.length === 1) return sortedValues[0];
const index = (sortedValues.length - 1) * ratio;
const lowerIndex = Math.floor(index);
const upperIndex = Math.ceil(index);
if (lowerIndex === upperIndex) {
return sortedValues[lowerIndex];
}
const weight = index - lowerIndex;
return sortedValues[lowerIndex] * (1 - weight) + sortedValues[upperIndex] * weight;
}
export class AnalyticsService {
static logRequest(logData: AnalyticsLogInput): void {
@ -115,8 +195,7 @@ export class AnalyticsService {
static getBackendMetrics(backendId?: number, days: number = 30): unknown[] {
const db = getAnalyticsDb();
const endDate = getLocalDateKey();
const startDate = getLocalDateKey(new Date(Date.now() - days * 24 * 60 * 60 * 1000));
const { startDate, endDate } = getDateRange(days);
let query = `
SELECT * FROM backend_metrics
@ -133,4 +212,163 @@ export class AnalyticsService {
return db.prepare(query).all(...params);
}
static getDailyTotals(backendId?: number, days: number = 30): DailyTotalsRow[] {
const db = getAnalyticsDb();
const { startDate, endDate } = getDateRange(days);
if (backendId) {
return db.prepare(`
SELECT date, SUM(total_requests) as total_requests, SUM(total_tokens) as total_tokens
FROM usage_stats
WHERE date >= ? AND date <= ? AND backend_id = ?
GROUP BY date
ORDER BY date ASC
`).all(startDate, endDate, backendId) as DailyTotalsRow[];
}
return db.prepare(`
SELECT date, SUM(total_requests) as total_requests, SUM(total_tokens) as total_tokens
FROM usage_stats
WHERE date >= ? AND date <= ?
GROUP BY date
ORDER BY date ASC
`).all(startDate, endDate) as DailyTotalsRow[];
}
static getBackendQuality(backendId?: number, days: number = 30): unknown[] {
const db = getAnalyticsDb();
const { startDate, endDate } = getDateRange(days);
let query = `
SELECT backend_id, date, total_requests, total_tokens, avg_response_time_ms, error_count, success_rate
FROM backend_metrics
WHERE date >= ? AND date <= ?
`;
const params: unknown[] = [startDate, endDate];
if (backendId) {
query += ' AND backend_id = ?';
params.push(backendId);
}
query += ' ORDER BY date ASC, backend_id ASC';
return db.prepare(query).all(...params);
}
private static collectRequestLogRangeRows(filter: RequestLogFilter): RequestLogRangeRow[] {
const { whereClause, params } = buildRequestLogRangeWhere(filter);
const rows: RequestLogRangeRow[] = [];
for (const month of getRequestLogMonthsForRange(filter.startDate, filter.endDate)) {
const db = getRequestLogsDb(month);
const monthRows = db.prepare(`
SELECT local_date, backend_id, request_model, routed_model, response_model, completion_tokens
FROM request_logs
${whereClause}
`).all(...params) as RequestLogRangeRow[];
rows.push(...monthRows);
}
return rows;
}
static getModelTrends(backendId?: number, days: number = 30, limit: number = 8): unknown[] {
const { startDate, endDate } = getDateRange(days);
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
const countsByModel = new Map<string, number>();
const countsByDateAndModel = new Map<string, number>();
for (const row of rows) {
const model = row.response_model || row.routed_model || row.request_model || 'unknown';
countsByModel.set(model, (countsByModel.get(model) ?? 0) + 1);
const key = `${row.local_date}::${model}`;
countsByDateAndModel.set(key, (countsByDateAndModel.get(key) ?? 0) + 1);
}
const topModels = Array.from(countsByModel.entries())
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.slice(0, Math.max(1, limit))
.map(([model]) => model);
const result: Array<{ date: string; model: string; request_count: number }> = [];
const seenDates = new Set(rows.map((row) => row.local_date));
for (const date of Array.from(seenDates).sort((left, right) => left.localeCompare(right))) {
for (const model of topModels) {
result.push({
date,
model,
request_count: countsByDateAndModel.get(`${date}::${model}`) ?? 0,
});
}
}
return result;
}
static getResponseLengthHistogram(backendId?: number, days: number = 30, bins: number = 20): unknown[] {
const { startDate, endDate } = getDateRange(days);
const values = this.collectRequestLogRangeRows({ backendId, startDate, endDate })
.map((row) => row.completion_tokens)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0);
if (values.length === 0) {
return [];
}
const safeBinCount = Math.max(1, bins);
const min = Math.min(...values);
const max = Math.max(...values);
if (min === max) {
return [{ bin_start: min, bin_end: max, count: values.length }];
}
const width = (max - min) / safeBinCount;
const histogram = Array.from({ length: safeBinCount }, (_, index) => ({
bin_start: min + width * index,
bin_end: index === safeBinCount - 1 ? max : min + width * (index + 1),
count: 0,
}));
for (const value of values) {
const index = Math.min(safeBinCount - 1, Math.floor((value - min) / width));
histogram[index].count += 1;
}
return histogram;
}
static getResponseLengthBoxPlot(backendId?: number, days: number = 30): unknown[] {
const { startDate, endDate } = getDateRange(days);
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
const valuesByDate = new Map<string, number[]>();
for (const row of rows) {
if (typeof row.completion_tokens !== 'number' || !Number.isFinite(row.completion_tokens) || row.completion_tokens < 0) {
continue;
}
const values = valuesByDate.get(row.local_date) ?? [];
values.push(row.completion_tokens);
valuesByDate.set(row.local_date, values);
}
return Array.from(valuesByDate.entries())
.sort((left, right) => left[0].localeCompare(right[0]))
.map(([date, values]) => {
const sortedValues = [...values].sort((left, right) => left - right);
return {
date,
min: sortedValues[0],
q1: calculateQuantile(sortedValues, 0.25),
median: calculateQuantile(sortedValues, 0.5),
q3: calculateQuantile(sortedValues, 0.75),
max: sortedValues[sortedValues.length - 1],
count: sortedValues.length,
};
});
}
}

View file

@ -3,6 +3,7 @@ import request from 'supertest';
import { createTestApp } from '../utils/testApp';
import { initDb } from '../../src/config/database';
import { RequestLogService } from '../../src/services/RequestLogService';
import { AnalyticsService } from '../../src/services/AnalyticsService';
import { createAdminClient } from '../utils/adminClient';
describe('Auth & Proxy API', () => {
@ -161,5 +162,66 @@ describe('Auth & Proxy API', () => {
expect(firstPage.body.rows[0].user_id).toBe(9992);
expect(secondPage.body.rows[0].user_id).toBe(9991);
});
it('should expose chart-friendly analytics endpoints', async () => {
AnalyticsService.logRequest({
user_id: 7001,
backend_id: backendId,
endpoint: '/v1/chat/completions',
request_model: 'gpt-4o-mini',
routed_model: 'gpt-4o-mini',
response_model: 'gpt-4o-mini',
completion_tokens: 120,
total_tokens: 240,
response_time_ms: 380,
status_code: 200,
local_date: '2026-03-10',
created_at: '2026-03-10T03:00:00.000Z',
});
AnalyticsService.logRequest({
user_id: 7002,
backend_id: backendId,
endpoint: '/v1/chat/completions',
request_model: 'gpt-4.1-mini',
routed_model: 'gpt-4.1-mini',
response_model: 'gpt-4.1-mini',
completion_tokens: 60,
total_tokens: 190,
response_time_ms: 510,
status_code: 500,
error_message: 'synthetic-error',
local_date: '2026-03-11',
created_at: '2026-03-11T03:00:00.000Z',
});
const [dailyTotals, backendQuality, modelTrends, histogram, boxPlot] = await Promise.all([
admin.get(`/admin/analytics/daily-totals?backendId=${backendId}&days=30`),
admin.get(`/admin/analytics/backend-quality?backendId=${backendId}&days=30`),
admin.get(`/admin/analytics/model-trends?backendId=${backendId}&days=30&limit=8`),
admin.get(`/admin/analytics/response-length-histogram?backendId=${backendId}&days=30&bins=6`),
admin.get(`/admin/analytics/response-length-box-plot?backendId=${backendId}&days=30`),
]);
expect(dailyTotals.status).toBe(200);
expect(Array.isArray(dailyTotals.body)).toBe(true);
expect(dailyTotals.body.some((row: any) => row.total_requests >= 1 && typeof row.total_tokens === 'number')).toBe(true);
expect(backendQuality.status).toBe(200);
expect(Array.isArray(backendQuality.body)).toBe(true);
expect(backendQuality.body.some((row: any) => row.backend_id === backendId && typeof row.error_count === 'number')).toBe(true);
expect(modelTrends.status).toBe(200);
expect(Array.isArray(modelTrends.body)).toBe(true);
expect(modelTrends.body.some((row: any) => row.model === 'gpt-4o-mini')).toBe(true);
expect(histogram.status).toBe(200);
expect(Array.isArray(histogram.body)).toBe(true);
expect(histogram.body.every((row: any) => typeof row.count === 'number')).toBe(true);
expect(boxPlot.status).toBe(200);
expect(Array.isArray(boxPlot.body)).toBe(true);
expect(boxPlot.body.some((row: any) => row.date === '2026-03-10' && row.median === 120)).toBe(true);
});
});
});

View file

@ -180,6 +180,44 @@ export interface BackendMetrics {
success_rate: number;
}
export interface AnalyticsDailyTotalsPoint {
date: string;
total_requests: number;
total_tokens: number;
}
export interface AnalyticsBackendQualityPoint {
date: string;
backend_id: number;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
}
export interface AnalyticsModelTrendPoint {
date: string;
model: string;
request_count: number;
}
export interface AnalyticsHistogramBin {
bin_start: number;
bin_end: number;
count: number;
}
export interface AnalyticsBoxPlotPoint {
date: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
count: number;
}
export interface OpenAIChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;