feat(Analytics): d3 based visualization
This commit is contained in:
parent
e12ac76851
commit
b6152cd6d0
19 changed files with 2088 additions and 79 deletions
|
|
@ -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 정책
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
1
client/src/d3.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module 'd3';
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
811
client/src/ui/patterns/Charts.tsx
Normal file
811
client/src/ui/patterns/Charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
30
docs/analytics.md
Normal 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` 화면에서 담당한다.
|
||||
|
|
@ -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) 참고
|
||||
|
|
|
|||
|
|
@ -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 상태를 표시한다
|
||||
|
|
|
|||
|
|
@ -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
319
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue