refactor(dashboard)

This commit is contained in:
Kyush 2026-03-26 19:42:03 +09:00
commit 1ac8a6e446
11 changed files with 631 additions and 119 deletions

View file

@ -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();

View file

@ -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' },
]}
/>

View file

@ -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>
);

View file

@ -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 = {

View file

@ -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;

View file

@ -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) 참고

View file

@ -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",

View file

@ -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) => {

View file

@ -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'],
},
};
}
}

View file

@ -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}`);
});
});
});

View file

@ -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;