refactor(dashboard)
This commit is contained in:
parent
b6152cd6d0
commit
1ac8a6e446
11 changed files with 631 additions and 119 deletions
|
|
@ -13,6 +13,7 @@ import type {
|
|||
AnalyticsModelTrendPoint,
|
||||
AnalyticsHistogramBin,
|
||||
AnalyticsBoxPlotPoint,
|
||||
DashboardSummaryResponse,
|
||||
UserScript,
|
||||
CreateScriptData,
|
||||
UpdateScriptData,
|
||||
|
|
@ -165,6 +166,14 @@ export const api = {
|
|||
fetchJson(`${API_BASE}/admin/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(context) }),
|
||||
},
|
||||
|
||||
dashboard: {
|
||||
getSummary: (days: number = 30): Promise<DashboardSummaryResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('days', String(days));
|
||||
return fetchJson<DashboardSummaryResponse>(`${API_BASE}/admin/dashboard/summary?${params}`);
|
||||
},
|
||||
},
|
||||
|
||||
analytics: {
|
||||
getUsage: (userId?: number, backendId?: number, days: number = 30): Promise<UsageStats[]> => {
|
||||
const params = new URLSearchParams();
|
||||
|
|
|
|||
|
|
@ -246,12 +246,6 @@ export const Analytics: Component = () => {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<MetaCluster
|
||||
items={[
|
||||
{ key: 'Series', value: String(responseTimeSeries().length) },
|
||||
{ key: 'Window', value: `Last ${days()} days` },
|
||||
]}
|
||||
/>
|
||||
<TimeSeriesChart
|
||||
data={responseTimeRows()}
|
||||
series={responseTimeSeries()}
|
||||
|
|
@ -275,12 +269,6 @@ export const Analytics: Component = () => {
|
|||
/>
|
||||
}
|
||||
>
|
||||
<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()}
|
||||
|
|
@ -298,7 +286,6 @@ export const Analytics: Component = () => {
|
|||
<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' },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -308,7 +295,6 @@ export const Analytics: Component = () => {
|
|||
<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' },
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,173 @@
|
|||
import { createResource, createSignal, Show, type Component } from 'solid-js';
|
||||
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
|
||||
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
|
||||
import { api } from '../api/client';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { useAuth } from '../auth';
|
||||
import { Alert, Button, DataGrid, EmptyState, PageHeader, Panel, StatusBadge, SummaryStrip, TextField } from '../ui';
|
||||
import {
|
||||
ChartLegend,
|
||||
ComboChart,
|
||||
CommandBar,
|
||||
CommandBarGroup,
|
||||
EmptyState,
|
||||
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'];
|
||||
const formatInteger = new Intl.NumberFormat('en-US');
|
||||
|
||||
type DashboardChartRow = { date: string } & Record<string, string | number | null>;
|
||||
|
||||
export const Dashboard: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [data, { refetch }] = createResource(async () => ({
|
||||
users: await api.users.getAll(),
|
||||
backends: await api.backends.getAll(),
|
||||
recentRequests: await api.analytics.getRequests({ limit: 10 }),
|
||||
}));
|
||||
const [tokens, { refetch: refetchTokens }] = createResource(() => api.auth.getTokens());
|
||||
const [tokenName, setTokenName] = createSignal('');
|
||||
const [lastIssuedToken, setLastIssuedToken] = createSignal<string | null>(null);
|
||||
const [tokenError, setTokenError] = createSignal<string | null>(null);
|
||||
const [days, setDays] = createSignal('30');
|
||||
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<Set<string>>(new Set());
|
||||
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<Set<string>>(new Set());
|
||||
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const createToken = async () => {
|
||||
try {
|
||||
const response = await api.auth.createToken(tokenName().trim() || `${auth.session()?.principal?.displayName ?? 'admin'} token`);
|
||||
setLastIssuedToken(response.token);
|
||||
setTokenName('');
|
||||
setTokenError(null);
|
||||
await refetchTokens();
|
||||
} catch (error) {
|
||||
setTokenError(error instanceof Error ? error.message : 'Failed to create admin token.');
|
||||
}
|
||||
};
|
||||
const windowDays = createMemo(() => Number(days()));
|
||||
const [summary, { refetch }] = createResource(windowDays, (value) => api.dashboard.getSummary(value));
|
||||
const [backends] = createResource(() => api.backends.getAll());
|
||||
|
||||
const deleteToken = async (tokenId: number) => {
|
||||
try {
|
||||
await api.auth.deleteToken(tokenId);
|
||||
await refetchTokens();
|
||||
} catch (error) {
|
||||
setTokenError(error instanceof Error ? error.message : 'Failed to revoke admin token.');
|
||||
const backendNameById = createMemo(() => {
|
||||
const entries = new Map<number, string>();
|
||||
for (const backend of backends() ?? []) {
|
||||
entries.set(backend.id, backend.name);
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
|
||||
const trafficRows = createMemo(() =>
|
||||
(summary()?.series.daily_totals ?? []).map((row) => ({
|
||||
date: row.date,
|
||||
requests: row.total_requests,
|
||||
tokens: row.total_tokens,
|
||||
}))
|
||||
);
|
||||
|
||||
const reliabilityRows = createMemo(() => {
|
||||
const grouped = new Map<string, { requests: number; errors: number }>();
|
||||
for (const row of summary()?.series.backend_quality ?? []) {
|
||||
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 latencyRows = createMemo(() => {
|
||||
const grouped = new Map<string, DashboardChartRow>();
|
||||
for (const row of summary()?.series.backend_quality ?? []) {
|
||||
const entry: DashboardChartRow = 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 latencySeries = createMemo(() => {
|
||||
const ids = Array.from(new Set((summary()?.series.backend_quality ?? []).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 modelRows = createMemo(() => {
|
||||
const grouped = new Map<string, DashboardChartRow>();
|
||||
for (const row of summary()?.series.model_trends ?? []) {
|
||||
const entry: DashboardChartRow = 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 modelSeries = createMemo(() => {
|
||||
const models = Array.from(new Set((summary()?.series.model_trends ?? []).map((row) => row.model)));
|
||||
return models.map((model, index) => ({
|
||||
key: `model_${model}`,
|
||||
label: model,
|
||||
color: palette[index % palette.length],
|
||||
}));
|
||||
});
|
||||
|
||||
const summaryItems = createMemo(() => {
|
||||
const payload = summary();
|
||||
const latestTraffic = payload?.series.daily_totals[payload.series.daily_totals.length - 1];
|
||||
|
||||
return [
|
||||
{ label: 'Active Users', value: payload?.overview.active_users ?? 0, hint: `${payload?.overview.total_users ?? 0} total identities` },
|
||||
{ label: 'Active Backends', value: payload?.overview.active_backends ?? 0, hint: `${payload?.overview.total_backends ?? 0} configured upstreams` },
|
||||
{ label: 'Live Scripts', value: payload?.overview.active_scripts ?? 0, hint: `${payload?.overview.total_scripts ?? 0} total middleware rules` },
|
||||
{ label: 'Latest Volume', value: latestTraffic ? formatInteger.format(latestTraffic.total_requests) : '0', hint: latestTraffic ? `${latestTraffic.date} request count` : 'No traffic in window' },
|
||||
];
|
||||
});
|
||||
|
||||
const cacheStateItems = createMemo(() => {
|
||||
const counts = summary()?.health.cache_state_counts;
|
||||
if (!counts) return [];
|
||||
return [
|
||||
{ key: 'Ready', value: String(counts.ready) },
|
||||
{ key: 'Uninitialized', value: String(counts.uninitialized) },
|
||||
{ key: 'Error', value: String(counts.error) },
|
||||
{ key: 'Inactive', value: String(counts.inactive) },
|
||||
];
|
||||
});
|
||||
|
||||
const scriptItems = createMemo(() => {
|
||||
const payload = summary();
|
||||
if (!payload) return [];
|
||||
|
||||
return [
|
||||
{ key: 'Per User', value: `${payload.scripts.active_by_type['per-user']} active / ${payload.scripts.total_by_type['per-user']} total` },
|
||||
{ key: 'Per Backend', value: `${payload.scripts.active_by_type['per-backend']} active / ${payload.scripts.total_by_type['per-backend']} total` },
|
||||
{ key: 'Scoped', value: `${payload.scripts.active_by_type['per-user-backend']} active / ${payload.scripts.total_by_type['per-user-backend']} total` },
|
||||
];
|
||||
});
|
||||
|
||||
const accessItems = createMemo(() => {
|
||||
const payload = summary();
|
||||
if (!payload) return [];
|
||||
|
||||
return [
|
||||
{ key: 'Assignments', value: formatInteger.format(payload.access.permission_assignments) },
|
||||
{ key: 'No Backend Access', value: String(payload.access.users_without_permissions) },
|
||||
{ key: 'User Detail Logs', value: String(payload.logging.users_with_detail_logging) },
|
||||
{ key: 'Backend Detail Logs', value: String(payload.logging.backends_with_detail_logging) },
|
||||
];
|
||||
});
|
||||
|
||||
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 (
|
||||
|
|
@ -42,83 +175,136 @@ export const Dashboard: Component = () => {
|
|||
<div class="ui-app-page">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Compact operational overview of registered identities, active backends, and recent traffic."
|
||||
actions={<Button onClick={() => void refetch()}>Refresh</Button>}
|
||||
description="Operations cockpit for router health, traffic shape, and the configuration context behind current behavior."
|
||||
actions={<button class="ui-button" type="button" onClick={() => void refetch()}><RefreshCcw />Refresh</button>}
|
||||
/>
|
||||
|
||||
<SummaryStrip
|
||||
items={[
|
||||
{ label: 'Total Users', value: data()?.users.length ?? 0, hint: 'Provisioned API identities' },
|
||||
{ label: 'Active Backends', value: data()?.backends.filter((backend) => backend.is_active).length ?? 0, hint: 'Routable upstream targets' },
|
||||
{ label: 'Recent Requests', value: data()?.recentRequests.rows.length ?? 0, hint: 'Latest traffic snapshot in this overview' },
|
||||
]}
|
||||
/>
|
||||
<CommandBar class="analytics__filters">
|
||||
<CommandBarGroup>
|
||||
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
|
||||
</CommandBarGroup>
|
||||
</CommandBar>
|
||||
|
||||
<Panel title="Recent Requests" description="Latest request activity across the router with status and model context.">
|
||||
<DataGrid
|
||||
rows={data()?.recentRequests.rows ?? []}
|
||||
columns={[
|
||||
{ id: 'user_id', header: 'User', mono: true, cell: (request) => <span>{request.user_id}</span> },
|
||||
{ id: 'backend_id', header: 'Backend', mono: true, cell: (request) => <span>{request.backend_id}</span> },
|
||||
{ id: 'model', header: 'Model', truncate: true, cell: (request) => <span title={request.request_model ?? '-'}>{request.request_model || '-'}</span> },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: (request) => <StatusBadge tone={request.status_code >= 400 ? 'danger' : 'success'}>{String(request.status_code)}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'detail_logged',
|
||||
header: 'Detail',
|
||||
cell: (request) => <StatusBadge tone={request.detail_logged ? 'warning' : 'neutral'}>{request.detail_logged ? 'Verbose' : 'Meta'}</StatusBadge>,
|
||||
},
|
||||
{ id: 'time', header: 'Time', cell: (request) => <span>{new Date(request.created_at).toLocaleString()}</span> },
|
||||
]}
|
||||
getRowKey={(request) => request.id}
|
||||
loading={data.loading}
|
||||
emptyMessage="No recent requests yet."
|
||||
/>
|
||||
{!data.loading && (data()?.recentRequests.rows.length ?? 0) === 0 && (
|
||||
<EmptyState title="No requests yet" description="Traffic will appear here once authenticated users send requests through the router." />
|
||||
)}
|
||||
</Panel>
|
||||
<SummaryStrip items={summaryItems()} />
|
||||
|
||||
<Panel title="Admin API Tokens" description="Issue service tokens for automation without exposing the browser session.">
|
||||
<div class="ui-stack ui-stack--tight">
|
||||
<Show when={tokenError()}>
|
||||
{(message) => <Alert tone="danger">{message()}</Alert>}
|
||||
</Show>
|
||||
<Show when={lastIssuedToken()}>
|
||||
{(token) => <Alert tone="success">Copy this token now: {token()}</Alert>}
|
||||
</Show>
|
||||
<div class="ui-form">
|
||||
<TextField
|
||||
label="Token Name"
|
||||
value={tokenName()}
|
||||
placeholder="e.g. CI deploy automation"
|
||||
onInput={(event) => setTokenName(event.currentTarget.value)}
|
||||
<Show when={!summary.error} fallback={<Panel title="Dashboard unavailable" description={summary.error instanceof Error ? summary.error.message : 'Failed to load dashboard summary.'}><EmptyState title="Failed to load summary" description="Refresh the page or verify the admin API is available." /></Panel>}>
|
||||
<div class="ui-section-grid">
|
||||
<Panel
|
||||
title="Traffic Volume"
|
||||
description="Daily request and token totals for the selected window."
|
||||
actions={
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ key: 'requests', label: 'Requests', color: '#2357d8' },
|
||||
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
|
||||
]}
|
||||
mutedKeys={hiddenTrafficSeries()}
|
||||
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TimeSeriesChart
|
||||
data={trafficRows()}
|
||||
series={[
|
||||
{ key: 'requests', label: 'Requests', color: '#2357d8' },
|
||||
{ key: 'tokens', label: 'Tokens', color: '#1f7a45', axis: 'right' },
|
||||
]}
|
||||
showLegend={false}
|
||||
hiddenKeys={hiddenTrafficSeries()}
|
||||
onToggleLegend={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
|
||||
yLeftLabel="Requests"
|
||||
yRightLabel="Tokens"
|
||||
formatLeftValue={(value) => formatInteger.format(Math.round(value))}
|
||||
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
|
||||
tooltipTitle="Traffic volume"
|
||||
/>
|
||||
<Button onClick={() => void createToken()}>Create Admin Token</Button>
|
||||
</div>
|
||||
<DataGrid
|
||||
rows={tokens() ?? []}
|
||||
columns={[
|
||||
{ id: 'name', header: 'Name', cell: (token) => <span>{token.name}</span> },
|
||||
{ id: 'provider', header: 'Provider', cell: (token) => <StatusBadge tone="neutral">{token.provider}</StatusBadge> },
|
||||
{ id: 'prefix', header: 'Prefix', mono: true, cell: (token) => <span>{token.token_prefix}</span> },
|
||||
{ id: 'expires_at', header: 'Expires', cell: (token) => <span>{new Date(token.expires_at).toLocaleString()}</span> },
|
||||
{ id: 'last_used_at', header: 'Last Used', cell: (token) => <span>{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '-'}</span> },
|
||||
]}
|
||||
getRowKey={(token) => token.id}
|
||||
loading={tokens.loading}
|
||||
emptyMessage="No admin tokens issued yet."
|
||||
rowActions={(token) => (
|
||||
<Button variant="danger" onClick={() => void deleteToken(token.id)}>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Reliability Snapshot"
|
||||
description="Success rate and absolute error count across all visible traffic."
|
||||
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>
|
||||
|
||||
<div class="ui-section-grid">
|
||||
<Panel
|
||||
title="Backend Latency"
|
||||
description="Average response time by backend with per-series toggles."
|
||||
actions={<ChartLegend items={latencySeries()} mutedKeys={hiddenLatencySeries()} onToggle={(key) => toggleHiddenKey(setHiddenLatencySeries, key)} />}
|
||||
>
|
||||
<TimeSeriesChart
|
||||
data={latencyRows()}
|
||||
series={latencySeries()}
|
||||
showLegend={false}
|
||||
hiddenKeys={hiddenLatencySeries()}
|
||||
onToggleLegend={(key) => toggleHiddenKey(setHiddenLatencySeries, key)}
|
||||
yLeftLabel="Milliseconds"
|
||||
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
|
||||
tooltipTitle="Backend latency"
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Model Activity"
|
||||
description="Top models by request volume across the current window."
|
||||
actions={<ChartLegend items={modelSeries()} mutedKeys={hiddenModelSeries()} onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)} />}
|
||||
>
|
||||
<TimeSeriesChart
|
||||
data={modelRows()}
|
||||
series={modelSeries()}
|
||||
showLegend={false}
|
||||
hiddenKeys={hiddenModelSeries()}
|
||||
onToggleLegend={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
|
||||
yLeftLabel="Requests"
|
||||
formatLeftValue={(value) => `${Math.round(value)}`}
|
||||
tooltipTitle="Model activity"
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div class="ui-section-grid dashboard__context-grid">
|
||||
<Panel title="Backend Health" description="Cache readiness, liveness, and sync drift indicators for current backends.">
|
||||
<MetaCluster items={cacheStateItems()} />
|
||||
<Show
|
||||
when={(summary()?.health.stale_backends.length ?? 0) > 0}
|
||||
fallback={<EmptyState title="No stale backend syncs" description="All active backends synced within the freshness window." />}
|
||||
>
|
||||
<div class="dashboard__status-list">
|
||||
{summary()?.health.stale_backends.map((backend) => (
|
||||
<div class="dashboard__status-item">
|
||||
<div>
|
||||
<strong>{backend.name}</strong>
|
||||
<p>Last sync: {backend.last_synced_at ? new Date(backend.last_synced_at).toLocaleString() : 'Never'}</p>
|
||||
</div>
|
||||
<span>{backend.state}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Script Runtime" description="Active middleware footprint and target distribution.">
|
||||
<MetaCluster items={scriptItems()} />
|
||||
<div class="dashboard__note">
|
||||
Active scripts shape request and response behavior before traffic reaches the upstream backend.
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Access Context" description="Identity and logging posture behind current routing activity.">
|
||||
<MetaCluster items={accessItems()} />
|
||||
</Panel>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -167,6 +167,63 @@ export type AnalyticsBoxPlotPoint = {
|
|||
count: number;
|
||||
};
|
||||
|
||||
export type DashboardHealthStatus = {
|
||||
status: 'ok';
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type DashboardOverviewSummary = {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_backends: number;
|
||||
active_backends: number;
|
||||
total_permissions: number;
|
||||
total_scripts: number;
|
||||
active_scripts: number;
|
||||
};
|
||||
|
||||
export type DashboardHealthSummary = {
|
||||
cache_state_counts: Record<Backend['model_cache_state'] extends infer T ? Extract<T, string> : never, number>;
|
||||
stale_backends: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
state: NonNullable<Backend['model_cache_state']>;
|
||||
last_synced_at?: string;
|
||||
}>;
|
||||
public_health: DashboardHealthStatus;
|
||||
admin_health: DashboardHealthStatus;
|
||||
};
|
||||
|
||||
export type DashboardLoggingSummary = {
|
||||
users_with_detail_logging: number;
|
||||
backends_with_detail_logging: number;
|
||||
};
|
||||
|
||||
export type DashboardScriptSummary = {
|
||||
active_by_type: Record<ScriptType, number>;
|
||||
total_by_type: Record<ScriptType, number>;
|
||||
};
|
||||
|
||||
export type DashboardAccessSummary = {
|
||||
permission_assignments: number;
|
||||
users_without_permissions: number;
|
||||
};
|
||||
|
||||
export type DashboardSummaryResponse = {
|
||||
window_days: number;
|
||||
generated_at: string;
|
||||
overview: DashboardOverviewSummary;
|
||||
health: DashboardHealthSummary;
|
||||
logging: DashboardLoggingSummary;
|
||||
scripts: DashboardScriptSummary;
|
||||
access: DashboardAccessSummary;
|
||||
series: {
|
||||
daily_totals: AnalyticsDailyTotalsPoint[];
|
||||
backend_quality: AnalyticsBackendQualityPoint[];
|
||||
model_trends: AnalyticsModelTrendPoint[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
|
||||
|
||||
export type UserScript = {
|
||||
|
|
|
|||
|
|
@ -631,6 +631,67 @@
|
|||
grid-template-columns: minmax(320px, 1fr) 1.5fr;
|
||||
}
|
||||
|
||||
.dashboard__context-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.dashboard__status-list,
|
||||
.dashboard__shortcut-grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.dashboard__status-item {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-bg-panel);
|
||||
}
|
||||
|
||||
.dashboard__status-item p,
|
||||
.dashboard__shortcut-card p,
|
||||
.dashboard__note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.dashboard__shortcut-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.dashboard__shortcut-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-bg-panel);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard__shortcut-card:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.dashboard__shortcut-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.page-header {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -130,6 +130,12 @@
|
|||
- response length 계열 endpoint는 `completion_tokens` 가 있는 요청만 집계한다.
|
||||
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
|
||||
|
||||
### Dashboard Summary
|
||||
|
||||
| Method | Path | Query Params | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| GET | `/admin/dashboard/summary` | days | Dashboard 홈용 운영 요약, backend/script/access context, 최소 시계열 집계 반환 |
|
||||
|
||||
참고:
|
||||
- 관리자 인증과 세션/토큰 정책은 [docs/admin-auth.md](./admin-auth.md) 참고
|
||||
- OpenID Connect 설정은 [docs/oidc.md](./oidc.md) 참고
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kyush-llm-router",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0",
|
||||
"description": "LLM routing server with multi-user API key management",
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel dev",
|
||||
|
|
|
|||
|
|
@ -15,11 +15,17 @@ import {
|
|||
} from '../../../shared/types';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
import { ModelCatalogService } from '../services/ModelCatalogService';
|
||||
import { AnalyticsService } from '../services/AnalyticsService';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.use('/scripts', scriptRoutes);
|
||||
|
||||
router.get('/dashboard/summary', (req: Request, res: Response) => {
|
||||
const days = req.query.days ? Number(req.query.days) : 30;
|
||||
res.json(AnalyticsService.getDashboardSummary(days));
|
||||
});
|
||||
|
||||
// ============ User Management ============
|
||||
|
||||
router.get('/users', (req: Request, res: Response) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { getAnalyticsDb } from '../config/analytics-db';
|
||||
import { RequestLogPage } from '../../../shared/types';
|
||||
import { DashboardSummaryResponse, RequestLogPage, ScriptType } from '../../../shared/types';
|
||||
import { RequestLogInsert, RequestLogQuery, RequestLogService } from './RequestLogService';
|
||||
import { getLocalDateKey } from '../utils/time';
|
||||
import { getLocalDateKey, getUtcTimestamp } from '../utils/time';
|
||||
import { getRequestLogsDb, listRequestLogMonths } from '../config/request-logs-db';
|
||||
import { UserModel } from '../models/User';
|
||||
import { PermissionModel } from '../models/Permission';
|
||||
import { ScriptModel } from '../models/Script';
|
||||
import { ModelCatalogService } from './ModelCatalogService';
|
||||
|
||||
type AnalyticsLogInput = RequestLogInsert;
|
||||
type RequestLogFilter = {
|
||||
|
|
@ -85,6 +89,14 @@ function calculateQuantile(sortedValues: number[], ratio: number): number {
|
|||
return sortedValues[lowerIndex] * (1 - weight) + sortedValues[upperIndex] * weight;
|
||||
}
|
||||
|
||||
function createScriptTypeCounts(): Record<ScriptType, number> {
|
||||
return {
|
||||
'per-user-backend': 0,
|
||||
'per-backend': 0,
|
||||
'per-user': 0,
|
||||
};
|
||||
}
|
||||
|
||||
export class AnalyticsService {
|
||||
static logRequest(logData: AnalyticsLogInput): void {
|
||||
try {
|
||||
|
|
@ -371,4 +383,97 @@ export class AnalyticsService {
|
|||
};
|
||||
});
|
||||
}
|
||||
|
||||
static getDashboardSummary(days: number = 30): DashboardSummaryResponse {
|
||||
const normalizedDays = Math.max(1, days);
|
||||
const users = UserModel.findAll();
|
||||
const backends = ModelCatalogService.getBackendsWithSummary();
|
||||
const permissions = PermissionModel.findAll();
|
||||
const scripts = ScriptModel.findAll();
|
||||
const cacheOverview = ModelCatalogService.getCacheOverview();
|
||||
const now = getUtcTimestamp();
|
||||
const staleThresholdMs = 24 * 60 * 60 * 1000;
|
||||
const permissionsByUserId = new Set(permissions.map((permission) => permission.user_id));
|
||||
const totalByType = createScriptTypeCounts();
|
||||
const activeByType = createScriptTypeCounts();
|
||||
|
||||
for (const script of scripts) {
|
||||
totalByType[script.script_type] += 1;
|
||||
if (script.is_active) {
|
||||
activeByType[script.script_type] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const cacheStateCounts = cacheOverview.backends.reduce(
|
||||
(acc, backend) => {
|
||||
acc[backend.state] += 1;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
ready: 0,
|
||||
uninitialized: 0,
|
||||
error: 0,
|
||||
inactive: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const staleBackends = backends
|
||||
.filter((backend) => {
|
||||
if (!backend.is_active || !backend.last_model_sync_at) {
|
||||
return false;
|
||||
}
|
||||
const lastSyncedAt = Date.parse(backend.last_model_sync_at);
|
||||
return Number.isFinite(lastSyncedAt) && Date.now() - lastSyncedAt > staleThresholdMs;
|
||||
})
|
||||
.map((backend) => ({
|
||||
id: backend.id,
|
||||
name: backend.name,
|
||||
state: backend.model_cache_state ?? 'uninitialized',
|
||||
last_synced_at: backend.last_model_sync_at,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
window_days: normalizedDays,
|
||||
generated_at: now,
|
||||
overview: {
|
||||
total_users: users.length,
|
||||
active_users: users.filter((user) => user.is_active).length,
|
||||
total_backends: backends.length,
|
||||
active_backends: backends.filter((backend) => backend.is_active).length,
|
||||
total_permissions: permissions.length,
|
||||
total_scripts: scripts.length,
|
||||
active_scripts: scripts.filter((script) => script.is_active).length,
|
||||
},
|
||||
health: {
|
||||
cache_state_counts: cacheStateCounts,
|
||||
stale_backends: staleBackends,
|
||||
public_health: {
|
||||
status: 'ok',
|
||||
timestamp: now,
|
||||
},
|
||||
admin_health: {
|
||||
status: 'ok',
|
||||
timestamp: now,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
users_with_detail_logging: users.filter((user) => user.detail_logging).length,
|
||||
backends_with_detail_logging: backends.filter((backend) => backend.detail_logging).length,
|
||||
},
|
||||
scripts: {
|
||||
active_by_type: activeByType,
|
||||
total_by_type: totalByType,
|
||||
},
|
||||
access: {
|
||||
permission_assignments: permissions.length,
|
||||
users_without_permissions: users.filter((user) => !permissionsByUserId.has(user.id)).length,
|
||||
},
|
||||
series: {
|
||||
daily_totals: this.getDailyTotals(undefined, normalizedDays),
|
||||
backend_quality: this.getBackendQuality(undefined, normalizedDays) as DashboardSummaryResponse['series']['backend_quality'],
|
||||
model_trends: this.getModelTrends(undefined, normalizedDays, 6) as DashboardSummaryResponse['series']['model_trends'],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,5 +223,44 @@ describe('Auth & Proxy API', () => {
|
|||
expect(Array.isArray(boxPlot.body)).toBe(true);
|
||||
expect(boxPlot.body.some((row: any) => row.date === '2026-03-10' && row.median === 120)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose dashboard summary data for the ops cockpit', async () => {
|
||||
const response = await admin.get('/admin/dashboard/summary?days=30');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.window_days).toBe(30);
|
||||
expect(response.body.overview.total_users).toBeGreaterThanOrEqual(1);
|
||||
expect(response.body.overview.total_backends).toBeGreaterThanOrEqual(1);
|
||||
expect(response.body.overview.total_permissions).toBeGreaterThanOrEqual(1);
|
||||
expect(response.body.overview.total_scripts).toBeGreaterThanOrEqual(0);
|
||||
expect(response.body.health.public_health.status).toBe('ok');
|
||||
expect(response.body.health.admin_health.status).toBe('ok');
|
||||
expect(Array.isArray(response.body.series.daily_totals)).toBe(true);
|
||||
expect(Array.isArray(response.body.series.backend_quality)).toBe(true);
|
||||
expect(Array.isArray(response.body.series.model_trends)).toBe(true);
|
||||
expect(typeof response.body.logging.users_with_detail_logging).toBe('number');
|
||||
expect(typeof response.body.access.users_without_permissions).toBe('number');
|
||||
});
|
||||
|
||||
it('should keep dashboard summary stable for empty datasets', async () => {
|
||||
const emptyUser = await admin.post('/admin/users').send({ name: 'Dashboard Empty User' });
|
||||
const emptyBackend = await admin.post('/admin/backends').send({
|
||||
name: 'Dashboard Empty Backend',
|
||||
base_url: 'http://localhost:8999/v1',
|
||||
});
|
||||
|
||||
const response = await admin.get('/admin/dashboard/summary?days=7');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.window_days).toBe(7);
|
||||
expect(response.body.overview.total_users).toBeGreaterThanOrEqual(2);
|
||||
expect(response.body.overview.total_backends).toBeGreaterThanOrEqual(2);
|
||||
expect(Array.isArray(response.body.health.stale_backends)).toBe(true);
|
||||
expect(response.body.health.cache_state_counts.uninitialized).toBeGreaterThanOrEqual(1);
|
||||
expect(response.body.access.users_without_permissions).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await admin.delete(`/admin/users/${emptyUser.body.id}`);
|
||||
await admin.delete(`/admin/backends/${emptyBackend.body.id}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -218,6 +218,63 @@ export interface AnalyticsBoxPlotPoint {
|
|||
count: number;
|
||||
}
|
||||
|
||||
export interface DashboardHealthStatus {
|
||||
status: 'ok';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DashboardOverviewSummary {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_backends: number;
|
||||
active_backends: number;
|
||||
total_permissions: number;
|
||||
total_scripts: number;
|
||||
active_scripts: number;
|
||||
}
|
||||
|
||||
export interface DashboardHealthSummary {
|
||||
cache_state_counts: Record<ModelCacheState, number>;
|
||||
stale_backends: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
state: ModelCacheState;
|
||||
last_synced_at?: string;
|
||||
}>;
|
||||
public_health: DashboardHealthStatus;
|
||||
admin_health: DashboardHealthStatus;
|
||||
}
|
||||
|
||||
export interface DashboardLoggingSummary {
|
||||
users_with_detail_logging: number;
|
||||
backends_with_detail_logging: number;
|
||||
}
|
||||
|
||||
export interface DashboardScriptSummary {
|
||||
active_by_type: Record<ScriptType, number>;
|
||||
total_by_type: Record<ScriptType, number>;
|
||||
}
|
||||
|
||||
export interface DashboardAccessSummary {
|
||||
permission_assignments: number;
|
||||
users_without_permissions: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummaryResponse {
|
||||
window_days: number;
|
||||
generated_at: string;
|
||||
overview: DashboardOverviewSummary;
|
||||
health: DashboardHealthSummary;
|
||||
logging: DashboardLoggingSummary;
|
||||
scripts: DashboardScriptSummary;
|
||||
access: DashboardAccessSummary;
|
||||
series: {
|
||||
daily_totals: AnalyticsDailyTotalsPoint[];
|
||||
backend_quality: AnalyticsBackendQualityPoint[];
|
||||
model_trends: AnalyticsModelTrendPoint[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenAIChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue