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,
|
AnalyticsModelTrendPoint,
|
||||||
AnalyticsHistogramBin,
|
AnalyticsHistogramBin,
|
||||||
AnalyticsBoxPlotPoint,
|
AnalyticsBoxPlotPoint,
|
||||||
|
DashboardSummaryResponse,
|
||||||
UserScript,
|
UserScript,
|
||||||
CreateScriptData,
|
CreateScriptData,
|
||||||
UpdateScriptData,
|
UpdateScriptData,
|
||||||
|
|
@ -165,6 +166,14 @@ export const api = {
|
||||||
fetchJson(`${API_BASE}/admin/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(context) }),
|
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: {
|
analytics: {
|
||||||
getUsage: (userId?: number, backendId?: number, days: number = 30): Promise<UsageStats[]> => {
|
getUsage: (userId?: number, backendId?: number, days: number = 30): Promise<UsageStats[]> => {
|
||||||
const params = new URLSearchParams();
|
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
|
<TimeSeriesChart
|
||||||
data={responseTimeRows()}
|
data={responseTimeRows()}
|
||||||
series={responseTimeSeries()}
|
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
|
<TimeSeriesChart
|
||||||
data={modelTrendRows()}
|
data={modelTrendRows()}
|
||||||
series={modelTrendSeries()}
|
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.">
|
<Panel title="Response Length Distribution" description="Histogram of completion token lengths across the selected window.">
|
||||||
<MetaCluster
|
<MetaCluster
|
||||||
items={[
|
items={[
|
||||||
{ key: 'Bins', value: String((histogram() ?? []).length) },
|
|
||||||
{ key: 'Metric', value: 'completion_tokens' },
|
{ 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.">
|
<Panel title="Daily Response Length Spread" description="Completion token box plot by day using min / q1 / median / q3 / max summary.">
|
||||||
<MetaCluster
|
<MetaCluster
|
||||||
items={[
|
items={[
|
||||||
{ key: 'Days', value: String((boxPlot() ?? []).length) },
|
|
||||||
{ key: 'Outliers', value: 'Hidden in this view' },
|
{ 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 { api } from '../api/client';
|
||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { useAuth } from '../auth';
|
import {
|
||||||
import { Alert, Button, DataGrid, EmptyState, PageHeader, Panel, StatusBadge, SummaryStrip, TextField } from '../ui';
|
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 = () => {
|
export const Dashboard: Component = () => {
|
||||||
const auth = useAuth();
|
const [days, setDays] = createSignal('30');
|
||||||
const [data, { refetch }] = createResource(async () => ({
|
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<Set<string>>(new Set());
|
||||||
users: await api.users.getAll(),
|
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<Set<string>>(new Set());
|
||||||
backends: await api.backends.getAll(),
|
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
|
||||||
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 createToken = async () => {
|
const windowDays = createMemo(() => Number(days()));
|
||||||
try {
|
const [summary, { refetch }] = createResource(windowDays, (value) => api.dashboard.getSummary(value));
|
||||||
const response = await api.auth.createToken(tokenName().trim() || `${auth.session()?.principal?.displayName ?? 'admin'} token`);
|
const [backends] = createResource(() => api.backends.getAll());
|
||||||
setLastIssuedToken(response.token);
|
|
||||||
setTokenName('');
|
|
||||||
setTokenError(null);
|
|
||||||
await refetchTokens();
|
|
||||||
} catch (error) {
|
|
||||||
setTokenError(error instanceof Error ? error.message : 'Failed to create admin token.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteToken = async (tokenId: number) => {
|
const backendNameById = createMemo(() => {
|
||||||
try {
|
const entries = new Map<number, string>();
|
||||||
await api.auth.deleteToken(tokenId);
|
for (const backend of backends() ?? []) {
|
||||||
await refetchTokens();
|
entries.set(backend.id, backend.name);
|
||||||
} catch (error) {
|
|
||||||
setTokenError(error instanceof Error ? error.message : 'Failed to revoke admin token.');
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
|
|
@ -42,83 +175,136 @@ export const Dashboard: Component = () => {
|
||||||
<div class="ui-app-page">
|
<div class="ui-app-page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description="Compact operational overview of registered identities, active backends, and recent traffic."
|
description="Operations cockpit for router health, traffic shape, and the configuration context behind current behavior."
|
||||||
actions={<Button onClick={() => void refetch()}>Refresh</Button>}
|
actions={<button class="ui-button" type="button" onClick={() => void refetch()}><RefreshCcw />Refresh</button>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SummaryStrip
|
<CommandBar class="analytics__filters">
|
||||||
items={[
|
<CommandBarGroup>
|
||||||
{ label: 'Total Users', value: data()?.users.length ?? 0, hint: 'Provisioned API identities' },
|
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
|
||||||
{ label: 'Active Backends', value: data()?.backends.filter((backend) => backend.is_active).length ?? 0, hint: 'Routable upstream targets' },
|
</CommandBarGroup>
|
||||||
{ label: 'Recent Requests', value: data()?.recentRequests.rows.length ?? 0, hint: 'Latest traffic snapshot in this overview' },
|
</CommandBar>
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Panel title="Recent Requests" description="Latest request activity across the router with status and model context.">
|
<SummaryStrip items={summaryItems()} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<Panel title="Admin API Tokens" description="Issue service tokens for automation without exposing the browser session.">
|
<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-stack ui-stack--tight">
|
<div class="ui-section-grid">
|
||||||
<Show when={tokenError()}>
|
<Panel
|
||||||
{(message) => <Alert tone="danger">{message()}</Alert>}
|
title="Traffic Volume"
|
||||||
</Show>
|
description="Daily request and token totals for the selected window."
|
||||||
<Show when={lastIssuedToken()}>
|
actions={
|
||||||
{(token) => <Alert tone="success">Copy this token now: {token()}</Alert>}
|
<ChartLegend
|
||||||
</Show>
|
items={[
|
||||||
<div class="ui-form">
|
{ key: 'requests', label: 'Requests', color: '#2357d8' },
|
||||||
<TextField
|
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
|
||||||
label="Token Name"
|
]}
|
||||||
value={tokenName()}
|
mutedKeys={hiddenTrafficSeries()}
|
||||||
placeholder="e.g. CI deploy automation"
|
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
|
||||||
onInput={(event) => setTokenName(event.currentTarget.value)}
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</Panel>
|
||||||
</div>
|
|
||||||
<DataGrid
|
<Panel
|
||||||
rows={tokens() ?? []}
|
title="Reliability Snapshot"
|
||||||
columns={[
|
description="Success rate and absolute error count across all visible traffic."
|
||||||
{ id: 'name', header: 'Name', cell: (token) => <span>{token.name}</span> },
|
actions={<ChartLegend items={[{ key: 'line', label: 'Success Rate', color: '#2357d8' }, { key: 'bar', label: 'Errors', color: '#b42318' }]} />}
|
||||||
{ 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> },
|
<ComboChart
|
||||||
{ id: 'expires_at', header: 'Expires', cell: (token) => <span>{new Date(token.expires_at).toLocaleString()}</span> },
|
data={reliabilityRows()}
|
||||||
{ id: 'last_used_at', header: 'Last Used', cell: (token) => <span>{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '-'}</span> },
|
lineLabel="Success Rate"
|
||||||
]}
|
barLabel="Errors"
|
||||||
getRowKey={(token) => token.id}
|
lineColor="#2357d8"
|
||||||
loading={tokens.loading}
|
barColor="#b42318"
|
||||||
emptyMessage="No admin tokens issued yet."
|
showLegend={false}
|
||||||
rowActions={(token) => (
|
/>
|
||||||
<Button variant="danger" onClick={() => void deleteToken(token.id)}>
|
</Panel>
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,63 @@ export type AnalyticsBoxPlotPoint = {
|
||||||
count: number;
|
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 ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
|
||||||
|
|
||||||
export type UserScript = {
|
export type UserScript = {
|
||||||
|
|
|
||||||
|
|
@ -631,6 +631,67 @@
|
||||||
grid-template-columns: minmax(320px, 1fr) 1.5fr;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.page-header {
|
.page-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,12 @@
|
||||||
- response length 계열 endpoint는 `completion_tokens` 가 있는 요청만 집계한다.
|
- response length 계열 endpoint는 `completion_tokens` 가 있는 요청만 집계한다.
|
||||||
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
|
- 자세한 내용은 [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) 참고
|
- 관리자 인증과 세션/토큰 정책은 [docs/admin-auth.md](./admin-auth.md) 참고
|
||||||
- OpenID Connect 설정은 [docs/oidc.md](./oidc.md) 참고
|
- OpenID Connect 설정은 [docs/oidc.md](./oidc.md) 참고
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kyush-llm-router",
|
"name": "kyush-llm-router",
|
||||||
"version": "1.0.0",
|
"version": "1.0",
|
||||||
"description": "LLM routing server with multi-user API key management",
|
"description": "LLM routing server with multi-user API key management",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --parallel dev",
|
"dev": "pnpm --parallel dev",
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,17 @@ import {
|
||||||
} from '../../../shared/types';
|
} from '../../../shared/types';
|
||||||
import { getUtcTimestamp } from '../utils/time';
|
import { getUtcTimestamp } from '../utils/time';
|
||||||
import { ModelCatalogService } from '../services/ModelCatalogService';
|
import { ModelCatalogService } from '../services/ModelCatalogService';
|
||||||
|
import { AnalyticsService } from '../services/AnalyticsService';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
|
|
||||||
router.use('/scripts', scriptRoutes);
|
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 ============
|
// ============ User Management ============
|
||||||
|
|
||||||
router.get('/users', (req: Request, res: Response) => {
|
router.get('/users', (req: Request, res: Response) => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { getAnalyticsDb } from '../config/analytics-db';
|
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 { 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 { 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 AnalyticsLogInput = RequestLogInsert;
|
||||||
type RequestLogFilter = {
|
type RequestLogFilter = {
|
||||||
|
|
@ -85,6 +89,14 @@ function calculateQuantile(sortedValues: number[], ratio: number): number {
|
||||||
return sortedValues[lowerIndex] * (1 - weight) + sortedValues[upperIndex] * weight;
|
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 {
|
export class AnalyticsService {
|
||||||
static logRequest(logData: AnalyticsLogInput): void {
|
static logRequest(logData: AnalyticsLogInput): void {
|
||||||
try {
|
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(Array.isArray(boxPlot.body)).toBe(true);
|
||||||
expect(boxPlot.body.some((row: any) => row.date === '2026-03-10' && row.median === 120)).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;
|
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 {
|
export interface OpenAIChatMessage {
|
||||||
role: 'system' | 'user' | 'assistant';
|
role: 'system' | 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue