feat: migrate Express to Hono with OpenAPI/Swagger and add root ESLint

Server changes:
- Migrate from Express 5 to Hono with @hono/node-server adapter
- Convert server package to ESM (NodeNext) with explicit .js import extensions
- Split entry points: index.ts exports createApp(), main.ts starts the server
- Add @hono/zod-openapi for /v1/* routes with zod-validated request/response
- Add @hono/swagger-ui at /admin/docs (admin-auth gated) and OpenAPI 3.1 spec
  at /admin/openapi.json
- Rewrite SSE streaming using hono/streaming with onAbort cancellation
- Replace AuthenticatedRequest/AdminRequest with Hono Variables generics
- Switch cookie handling to hono/cookie helpers
- Drop express, cors, supertest and their type packages

Test infrastructure:
- New tests/utils/httpClient.ts: supertest-compatible chainable API on top of
  Hono's app.request(), with cookie-jar agent for session-based admin auth
- Rewrite testApp/mockBackend/adminClient on Hono + @hono/node-server
- Update vitest config with extensionAlias for .js -> .ts NodeNext resolution

Root ESLint:
- Add flat config (eslint.config.mjs) using youtube-music as the base reference
- typescript-eslint recommendedTypeChecked + prettier + @stylistic + import +
  solid plugins, with separate overrides for client (Solid/JSX) and server (Node)
- Add server/tsconfig.eslint.json so the parser can resolve tests/benchmarks
- Wire lint and lint:fix scripts at the workspace root

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JellyBrick 2026-04-08 01:07:57 +09:00
commit 66261474d2
No known key found for this signature in database
GPG key ID: B0F85809E2E3759D
105 changed files with 9804 additions and 3340 deletions

View file

@ -1,21 +1,46 @@
import { Router, Route } from '@solidjs/router';
import { lazy, Show, Suspense } from 'solid-js';
import { AuthProvider, useAuth } from './auth';
import { LoginGate } from './components/LoginGate';
import { Panel } from './ui';
const Dashboard = lazy(() => import('./routes/Dashboard').then((module) => ({ default: module.Dashboard })));
const Users = lazy(() => import('./routes/Users').then((module) => ({ default: module.Users })));
const Backends = lazy(() => import('./routes/Backends').then((module) => ({ default: module.Backends })));
const Analytics = lazy(() => import('./routes/Analytics').then((module) => ({ default: module.Analytics })));
const DetailLogs = lazy(() => import('./routes/DetailLogs').then((module) => ({ default: module.DetailLogs })));
const Models = lazy(() => import('./routes/Models').then((module) => ({ default: module.Models })));
const Scripts = lazy(() => import('./routes/Scripts').then((module) => ({ default: module.Scripts })));
const Dashboard = lazy(() =>
import('./routes/Dashboard').then((module) => ({
default: module.Dashboard,
})),
);
const Users = lazy(() =>
import('./routes/Users').then((module) => ({ default: module.Users })),
);
const Backends = lazy(() =>
import('./routes/Backends').then((module) => ({ default: module.Backends })),
);
const Analytics = lazy(() =>
import('./routes/Analytics').then((module) => ({
default: module.Analytics,
})),
);
const DetailLogs = lazy(() =>
import('./routes/DetailLogs').then((module) => ({
default: module.DetailLogs,
})),
);
const Models = lazy(() =>
import('./routes/Models').then((module) => ({ default: module.Models })),
);
const Scripts = lazy(() =>
import('./routes/Scripts').then((module) => ({ default: module.Scripts })),
);
function RouteLoadingFallback() {
return (
<div class="auth-screen">
<Panel class="auth-screen__panel" title="Loading Admin Page" description="Preparing the selected dashboard view." />
<Panel
class="auth-screen__panel"
description="Preparing the selected dashboard view."
title="Loading Admin Page"
/>
</div>
);
}
@ -25,23 +50,27 @@ function AuthenticatedApp() {
return (
<Show
when={!auth.loading()}
fallback={
<div class="auth-screen">
<Panel class="auth-screen__panel" title="Loading Admin Session" description="Restoring the current administrator session." />
<Panel
class="auth-screen__panel"
description="Restoring the current administrator session."
title="Loading Admin Session"
/>
</div>
}
when={!auth.loading()}
>
<Show when={auth.session()?.authenticated} fallback={<LoginGate />}>
<Show fallback={<LoginGate />} when={auth.session()?.authenticated}>
<Suspense fallback={<RouteLoadingFallback />}>
<Router base="/dashboard">
<Route path="/" component={Dashboard} />
<Route path="/users" component={Users} />
<Route path="/backends" component={Backends} />
<Route path="/analytics" component={Analytics} />
<Route path="/models" component={Models} />
<Route path="/detail-logs" component={DetailLogs} />
<Route path="/scripts" component={Scripts} />
<Route component={Dashboard} path="/" />
<Route component={Users} path="/users" />
<Route component={Backends} path="/backends" />
<Route component={Analytics} path="/analytics" />
<Route component={Models} path="/models" />
<Route component={DetailLogs} path="/detail-logs" />
<Route component={Scripts} path="/scripts" />
</Router>
</Suspense>
</Show>

View file

@ -43,14 +43,20 @@ export function setUnauthorizedHandler(handler: (() => void) | null) {
unauthorizedHandler = handler;
}
async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const isUnsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes((options.method ?? 'GET').toUpperCase());
async function fetchJson<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
const isUnsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes(
(options.method ?? 'GET').toUpperCase(),
);
const nextHeaders: Record<string, string> = {
...(options.headers as Record<string, string> | undefined),
};
if (isUnsafeMethod) {
nextHeaders['Content-Type'] = nextHeaders['Content-Type'] ?? 'application/json';
nextHeaders['Content-Type'] =
nextHeaders['Content-Type'] ?? 'application/json';
}
if (isUnsafeMethod && url.startsWith('/admin') && csrfToken) {
@ -69,13 +75,18 @@ async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T>
return {} as T;
}
const payload = await response.json().catch(() => ({ error: 'Request failed' }));
const payload = await response
.json()
.catch(() => ({ error: 'Request failed' }));
if (!response.ok) {
if (response.status === 401 && !url.endsWith('/admin/auth/session')) {
unauthorizedHandler?.();
}
throw new ApiError(response.status, payload.error || `HTTP ${response.status}`);
throw new ApiError(
response.status,
payload.error || `HTTP ${response.status}`,
);
}
return payload;
@ -83,106 +94,236 @@ async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T>
export const api = {
auth: {
getSession: (): Promise<AdminSessionResponse> => fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/session`),
login: (username: string, password: string): Promise<AdminSessionResponse> =>
fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/login`, { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: (): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/logout`, { method: 'POST' }),
getSession: (): Promise<AdminSessionResponse> =>
fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/session`),
login: (
username: string,
password: string,
): Promise<AdminSessionResponse> =>
fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/login`, {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
logout: (): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/auth/logout`, { method: 'POST' }),
beginOidc: (next: string = window.location.pathname) => {
const search = new URLSearchParams({ next });
window.location.href = `${API_BASE}/admin/auth/oidc/start?${search.toString()}`;
},
getTokens: (): Promise<AdminApiTokenSummary[]> => fetchJson<AdminApiTokenSummary[]>(`${API_BASE}/admin/auth/tokens`),
createToken: (name: string, expiresInDays?: number): Promise<{ token: string; record: AdminApiTokenSummary }> =>
fetchJson(`${API_BASE}/admin/auth/tokens`, { method: 'POST', body: JSON.stringify({ name, expiresInDays }) }),
deleteToken: (id: number): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/tokens/${id}`, { method: 'DELETE' }),
getTokens: (): Promise<AdminApiTokenSummary[]> =>
fetchJson<AdminApiTokenSummary[]>(`${API_BASE}/admin/auth/tokens`),
createToken: (
name: string,
expiresInDays?: number,
): Promise<{ token: string; record: AdminApiTokenSummary }> =>
fetchJson(`${API_BASE}/admin/auth/tokens`, {
method: 'POST',
body: JSON.stringify({ name, expiresInDays }),
}),
deleteToken: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/auth/tokens/${id}`, {
method: 'DELETE',
}),
},
users: {
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
create: (data: { name: string; email?: string; api_key?: string; detail_logging?: boolean }): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
getById: (id: number): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}`),
create: (data: {
name: string;
email?: string;
api_key?: string;
detail_logging?: boolean;
}): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users`, {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: number, data: Partial<User>): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
fetchJson<User>(`${API_BASE}/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/users/${id}`, { method: 'DELETE' }),
regenerateApiKey: (id: number): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}/regenerate-api-key`, { method: 'POST' }),
fetchJson<User>(`${API_BASE}/admin/users/${id}/regenerate-api-key`, {
method: 'POST',
}),
},
backends: {
getAll: (): Promise<Backend[]> => fetchJson<Backend[]>(`${API_BASE}/admin/backends`),
getById: (id: number): Promise<Backend> => fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`),
getModels: (id: number): Promise<BackendModelsResponse> => fetchJson<BackendModelsResponse>(`${API_BASE}/admin/backends/${id}/models`),
getAll: (): Promise<Backend[]> =>
fetchJson<Backend[]>(`${API_BASE}/admin/backends`),
getById: (id: number): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`),
getModels: (id: number): Promise<BackendModelsResponse> =>
fetchJson<BackendModelsResponse>(
`${API_BASE}/admin/backends/${id}/models`,
),
refreshModels: (id: number): Promise<BackendModelsResponse> =>
fetchJson<BackendModelsResponse>(`${API_BASE}/admin/backends/${id}/models/refresh`, { method: 'POST' }),
create: (data: { name: string; base_url: string; api_key?: string; detail_logging?: boolean }): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends`, { method: 'POST', body: JSON.stringify(data) }),
fetchJson<BackendModelsResponse>(
`${API_BASE}/admin/backends/${id}/models/refresh`,
{ method: 'POST' },
),
create: (data: {
name: string;
base_url: string;
api_key?: string;
detail_logging?: boolean;
}): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends`, {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: number, data: Partial<Backend>): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/backends/${id}`, { method: 'DELETE' }),
},
permissions: {
getAll: (): Promise<Permission[]> => fetchJson<Permission[]>(`${API_BASE}/admin/permissions`),
getAll: (): Promise<Permission[]> =>
fetchJson<Permission[]>(`${API_BASE}/admin/permissions`),
getByUser: (userId: number): Promise<Permission[]> =>
fetchJson<Permission[]>(`${API_BASE}/admin/permissions/user/${userId}`),
getByBackend: (backendId: number): Promise<Permission[]> =>
fetchJson<Permission[]>(`${API_BASE}/admin/permissions/backend/${backendId}`),
create: (data: { user_id: number; backend_id: number }): Promise<Permission> =>
fetchJson<Permission>(`${API_BASE}/admin/permissions`, { method: 'POST', body: JSON.stringify(data) }),
fetchJson<Permission[]>(
`${API_BASE}/admin/permissions/backend/${backendId}`,
),
create: (data: {
user_id: number;
backend_id: number;
}): Promise<Permission> =>
fetchJson<Permission>(`${API_BASE}/admin/permissions`, {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (userId: number, backendId: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/permissions?user_id=${userId}&backend_id=${backendId}`, { method: 'DELETE' }),
fetchJson<void>(
`${API_BASE}/admin/permissions?user_id=${userId}&backend_id=${backendId}`,
{ method: 'DELETE' },
),
},
modelRewrites: {
getAll: (): Promise<ModelRewriteRule[]> => fetchJson<ModelRewriteRule[]>(`${API_BASE}/admin/model-rewrites`),
create: (data: { source_model: string; target_model: string; is_active?: boolean; force?: boolean; note?: string }): Promise<ModelRewriteRule> =>
fetchJson<ModelRewriteRule>(`${API_BASE}/admin/model-rewrites`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<ModelRewriteRule>): Promise<ModelRewriteRule> =>
fetchJson<ModelRewriteRule>(`${API_BASE}/admin/model-rewrites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
getAll: (): Promise<ModelRewriteRule[]> =>
fetchJson<ModelRewriteRule[]>(`${API_BASE}/admin/model-rewrites`),
create: (data: {
source_model: string;
target_model: string;
is_active?: boolean;
force?: boolean;
note?: string;
}): Promise<ModelRewriteRule> =>
fetchJson<ModelRewriteRule>(`${API_BASE}/admin/model-rewrites`, {
method: 'POST',
body: JSON.stringify(data),
}),
update: (
id: number,
data: Partial<ModelRewriteRule>,
): Promise<ModelRewriteRule> =>
fetchJson<ModelRewriteRule>(`${API_BASE}/admin/model-rewrites/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/model-rewrites/${id}`, { method: 'DELETE' }),
fetchJson<void>(`${API_BASE}/admin/model-rewrites/${id}`, {
method: 'DELETE',
}),
},
modelCache: {
getOverview: (): Promise<ModelCacheOverview> => fetchJson<ModelCacheOverview>(`${API_BASE}/admin/models/cache`),
getOverview: (): Promise<ModelCacheOverview> =>
fetchJson<ModelCacheOverview>(`${API_BASE}/admin/models/cache`),
},
scripts: {
getAll: (): Promise<UserScript[]> => fetchJson<UserScript[]>(`${API_BASE}/admin/scripts`),
getById: (id: number): Promise<UserScript> => fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`),
getAll: (): Promise<UserScript[]> =>
fetchJson<UserScript[]>(`${API_BASE}/admin/scripts`),
getById: (id: number): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`),
create: (data: CreateScriptData): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts`, { method: 'POST', body: JSON.stringify(data) }),
fetchJson<UserScript>(`${API_BASE}/admin/scripts`, {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: number, data: UpdateScriptData): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/scripts/${id}`, { method: 'DELETE' }),
activate: (id: number): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/activate`, { method: 'POST' }),
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/activate`, {
method: 'POST',
}),
deactivate: (id: number): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/deactivate`, { method: 'POST' }),
test: (id: number, context: { user?: User; backend?: Backend; request: { method: string; path: string; headers: Record<string, string>; body: unknown; isStream: boolean } }): Promise<{ success: boolean; error?: string; executionTime?: number }> =>
fetchJson(`${API_BASE}/admin/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(context) }),
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/deactivate`, {
method: 'POST',
}),
test: (
id: number,
context: {
user?: User;
backend?: Backend;
request: {
method: string;
path: string;
headers: Record<string, string>;
body: unknown;
isStream: boolean;
};
},
): Promise<{ success: boolean; error?: string; executionTime?: number }> =>
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}`);
return fetchJson<DashboardSummaryResponse>(
`${API_BASE}/admin/dashboard/summary?${params}`,
);
},
},
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();
if (userId) params.append('userId', String(userId));
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<UsageStats[]>(`${API_BASE}/admin/analytics/usage?${params}`);
return fetchJson<UsageStats[]>(
`${API_BASE}/admin/analytics/usage?${params}`,
);
},
getRequests: (params: { limit?: number; offset?: number; month?: string; date?: string; q?: string; userId?: number; backendId?: number; endpoint?: string; detailLogged?: boolean } = {}): Promise<RequestLogPage> => {
getRequests: (
params: {
limit?: number;
offset?: number;
month?: string;
date?: string;
q?: string;
userId?: number;
backendId?: number;
endpoint?: string;
detailLogged?: boolean;
} = {},
): Promise<RequestLogPage> => {
const search = new URLSearchParams();
search.set('limit', String(params.limit ?? 100));
search.set('offset', String(params.offset ?? 0));
@ -192,46 +333,77 @@ export const api = {
if (params.userId) search.set('userId', String(params.userId));
if (params.backendId) search.set('backendId', String(params.backendId));
if (params.endpoint) search.set('endpoint', params.endpoint);
if (params.detailLogged !== undefined) search.set('detailLogged', params.detailLogged ? '1' : '0');
return fetchJson<RequestLogPage>(`${API_BASE}/admin/analytics/requests?${search}`);
if (params.detailLogged !== undefined)
search.set('detailLogged', params.detailLogged ? '1' : '0');
return fetchJson<RequestLogPage>(
`${API_BASE}/admin/analytics/requests?${search}`,
);
},
getMetrics: (backendId?: number, days: number = 30): Promise<BackendMetrics[]> => {
getMetrics: (
backendId?: number,
days: number = 30,
): Promise<BackendMetrics[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<BackendMetrics[]>(`${API_BASE}/admin/analytics/metrics?${params}`);
return fetchJson<BackendMetrics[]>(
`${API_BASE}/admin/analytics/metrics?${params}`,
);
},
getDailyTotals: (backendId?: number, days: number = 30): Promise<AnalyticsDailyTotalsPoint[]> => {
getDailyTotals: (
backendId?: number,
days: number = 30,
): Promise<AnalyticsDailyTotalsPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsDailyTotalsPoint[]>(`${API_BASE}/admin/analytics/daily-totals?${params}`);
return fetchJson<AnalyticsDailyTotalsPoint[]>(
`${API_BASE}/admin/analytics/daily-totals?${params}`,
);
},
getBackendQuality: (backendId?: number, days: number = 30): Promise<AnalyticsBackendQualityPoint[]> => {
getBackendQuality: (
backendId?: number,
days: number = 30,
): Promise<AnalyticsBackendQualityPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsBackendQualityPoint[]>(`${API_BASE}/admin/analytics/backend-quality?${params}`);
return fetchJson<AnalyticsBackendQualityPoint[]>(
`${API_BASE}/admin/analytics/backend-quality?${params}`,
);
},
getModelTrends: (params: { backendId?: number; days?: number; limit?: number } = {}): Promise<AnalyticsModelTrendPoint[]> => {
getModelTrends: (
params: { backendId?: number; days?: number; limit?: number } = {},
): Promise<AnalyticsModelTrendPoint[]> => {
const search = new URLSearchParams();
if (params.backendId) search.set('backendId', String(params.backendId));
search.set('days', String(params.days ?? 30));
search.set('limit', String(params.limit ?? 8));
return fetchJson<AnalyticsModelTrendPoint[]>(`${API_BASE}/admin/analytics/model-trends?${search}`);
return fetchJson<AnalyticsModelTrendPoint[]>(
`${API_BASE}/admin/analytics/model-trends?${search}`,
);
},
getResponseLengthHistogram: (params: { backendId?: number; days?: number; bins?: number } = {}): Promise<AnalyticsHistogramBin[]> => {
getResponseLengthHistogram: (
params: { backendId?: number; days?: number; bins?: number } = {},
): Promise<AnalyticsHistogramBin[]> => {
const search = new URLSearchParams();
if (params.backendId) search.set('backendId', String(params.backendId));
search.set('days', String(params.days ?? 30));
search.set('bins', String(params.bins ?? 20));
return fetchJson<AnalyticsHistogramBin[]>(`${API_BASE}/admin/analytics/response-length-histogram?${search}`);
return fetchJson<AnalyticsHistogramBin[]>(
`${API_BASE}/admin/analytics/response-length-histogram?${search}`,
);
},
getResponseLengthBoxPlot: (backendId?: number, days: number = 30): Promise<AnalyticsBoxPlotPoint[]> => {
getResponseLengthBoxPlot: (
backendId?: number,
days: number = 30,
): Promise<AnalyticsBoxPlotPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsBoxPlotPoint[]>(`${API_BASE}/admin/analytics/response-length-box-plot?${params}`);
return fetchJson<AnalyticsBoxPlotPoint[]>(
`${API_BASE}/admin/analytics/response-length-box-plot?${params}`,
);
},
},
};

View file

@ -1,7 +1,17 @@
import { createContext, createSignal, onMount, useContext, type Accessor, type JSX, type ParentComponent } from 'solid-js';
import type { AdminSessionResponse } from './types';
import {
createContext,
createSignal,
onMount,
useContext,
type Accessor,
type JSX,
type ParentComponent,
} from 'solid-js';
import { api, setAdminCsrfToken, setUnauthorizedHandler } from './api/client';
import type { AdminSessionResponse } from './types';
interface AuthContextValue {
session: Accessor<AdminSessionResponse | null>;
loading: Accessor<boolean>;
@ -12,7 +22,9 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue>();
function unauthenticatedState(previous: AdminSessionResponse | null): AdminSessionResponse {
function unauthenticatedState(
previous: AdminSessionResponse | null,
): AdminSessionResponse {
return {
authenticated: false,
authMode: previous?.authMode ?? 'both',
@ -21,7 +33,9 @@ function unauthenticatedState(previous: AdminSessionResponse | null): AdminSessi
};
}
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (props) => {
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (
props,
) => {
const [session, setSession] = createSignal<AdminSessionResponse | null>(null);
const [loading, setLoading] = createSignal(true);
@ -53,13 +67,20 @@ export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (props)
});
void refreshSession().catch(() => {
setSession({ authenticated: false, authMode: 'both', csrfToken: null, principal: null });
setSession({
authenticated: false,
authMode: 'both',
csrfToken: null,
principal: null,
});
setLoading(false);
});
});
return (
<AuthContext.Provider value={{ session, loading, refreshSession, login, logout }}>
<AuthContext.Provider
value={{ session, loading, refreshSession, login, logout }}
>
{props.children}
</AuthContext.Provider>
);

View file

@ -1,4 +1,5 @@
import { For, createSignal, type Component } from 'solid-js';
import { Button, Checkbox, FormDialog, TextField } from '../ui';
type FieldType = 'text' | 'email' | 'checkbox';
@ -42,7 +43,9 @@ export const EditModal: Component<EditModalProps> = (props) => {
await props.onSubmit(data);
props.onClose();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Update failed.');
setErrorMessage(
error instanceof Error ? error.message : 'Update failed.',
);
} finally {
setSubmitting(false);
}
@ -50,38 +53,54 @@ export const EditModal: Component<EditModalProps> = (props) => {
return (
<FormDialog
open={props.isOpen}
onOpenChange={(open) => {
if (!open) props.onClose();
}}
title={props.title}
class="ui-dialog__content--compact"
footer={
<>
<Button onClick={props.onClose} disabled={submitting()}>
<Button disabled={submitting()} onClick={props.onClose}>
Cancel
</Button>
<Button type="submit" variant="primary" form="legacy-edit-form" disabled={submitting()}>
<Button
disabled={submitting()}
form="legacy-edit-form"
type="submit"
variant="primary"
>
Update
</Button>
</>
}
class="ui-dialog__content--compact"
onOpenChange={(open) => {
if (!open) props.onClose();
}}
open={props.isOpen}
title={props.title}
>
<form id="legacy-edit-form" class="ui-form" onSubmit={(event) => void handleSubmit(event)}>
<form
class="ui-form"
id="legacy-edit-form"
onSubmit={(event) => void handleSubmit(event)}
>
<For each={props.fields}>
{(field) =>
field.type === 'checkbox' ? (
<Checkbox
label={field.label}
checked={Boolean(formData()[field.name])}
onChange={(checked) => setFormData({ ...formData(), [field.name]: checked })}
label={field.label}
onChange={(checked) =>
setFormData({ ...formData(), [field.name]: checked })
}
/>
) : (
<TextField
label={field.label}
value={String(formData()[field.name] ?? '')}
onInput={(event) =>
setFormData({
...formData(),
[field.name]: event.currentTarget.value,
})
}
placeholder={field.placeholder}
onInput={(event) => setFormData({ ...formData(), [field.name]: event.currentTarget.value })}
value={String(formData()[field.name] ?? '')}
/>
)
}

View file

@ -1,8 +1,11 @@
import type { JSX, ParentComponent } from 'solid-js';
import { AppShell } from '../ui';
import type { JSX, ParentComponent } from 'solid-js';
interface LayoutProps {
children: JSX.Element;
}
export const Layout: ParentComponent<LayoutProps> = (props) => <AppShell>{props.children}</AppShell>;
export const Layout: ParentComponent<LayoutProps> = (props) => (
<AppShell>{props.children}</AppShell>
);

View file

@ -1,4 +1,5 @@
import { createSignal, Show, type Component } from 'solid-js';
import { Alert, Button, Panel, TextField } from '../ui';
import { useAuth } from '../auth';
import { api, ApiError } from '../api/client';
@ -18,7 +19,9 @@ export const LoginGate: Component = () => {
await auth.login(username().trim(), password());
setPassword('');
} catch (error) {
setErrorMessage(error instanceof ApiError ? error.message : 'Admin login failed.');
setErrorMessage(
error instanceof ApiError ? error.message : 'Admin login failed.',
);
} finally {
setSubmitting(false);
}
@ -30,17 +33,33 @@ export const LoginGate: Component = () => {
return (
<div class="auth-screen">
<Panel class="auth-screen__panel" title="Admin Authentication" description="Sign in through the internal admin gateway before accessing router operations.">
<Panel
class="auth-screen__panel"
description="Sign in through the internal admin gateway before accessing router operations."
title="Admin Authentication"
>
<div class="ui-stack">
<Show when={errorMessage()}>
{(message) => <Alert tone="danger">{message()}</Alert>}
</Show>
<Show when={envEnabled()}>
<form class="ui-form" onSubmit={(event) => void handleSubmit(event)}>
<TextField label="Username" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} />
<TextField type="password" label="Password" value={password()} onInput={(event) => setPassword(event.currentTarget.value)} />
<Button type="submit" variant="primary" disabled={submitting()}>
<form
class="ui-form"
onSubmit={(event) => void handleSubmit(event)}
>
<TextField
label="Username"
onInput={(event) => setUsername(event.currentTarget.value)}
value={username()}
/>
<TextField
label="Password"
onInput={(event) => setPassword(event.currentTarget.value)}
type="password"
value={password()}
/>
<Button disabled={submitting()} type="submit" variant="primary">
{submitting() ? 'Signing In...' : 'Sign In'}
</Button>
</form>
@ -48,8 +67,14 @@ export const LoginGate: Component = () => {
<Show when={oidcEnabled()}>
<div class="ui-stack ui-stack--tight">
<p class="ui-subtitle">Single sign-on is available through the configured OpenID provider.</p>
<Button onClick={() => api.auth.beginOidc()} disabled={submitting()}>
<p class="ui-subtitle">
Single sign-on is available through the configured OpenID
provider.
</p>
<Button
disabled={submitting()}
onClick={() => api.auth.beginOidc()}
>
Continue With OpenID
</Button>
</div>

View file

@ -2,7 +2,9 @@ import { Dynamic } from 'solid-js/web';
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
const THEME_STORAGE_KEY = 'kyush-theme';
const MonacoEditor = lazy(() => import('solid-monaco').then((module) => ({ default: module.MonacoEditor })));
const MonacoEditor = lazy(() =>
import('solid-monaco').then((module) => ({ default: module.MonacoEditor })),
);
interface ScriptEditorProps {
value: string;
@ -58,7 +60,9 @@ export async function onResponse(ctx) {
}
`;
const [editorTheme, setEditorTheme] = createSignal<'vs' | 'vs-dark'>('vs-dark');
const [editorTheme, setEditorTheme] = createSignal<'vs' | 'vs-dark'>(
'vs-dark',
);
onMount(() => {
const root = document.documentElement;
@ -67,13 +71,21 @@ export async function onResponse(ctx) {
const syncTheme = () => {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const explicitTheme = root.dataset.theme;
const preferredTheme = storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : explicitTheme;
const isDark = preferredTheme ? preferredTheme === 'dark' : mediaQuery.matches;
const preferredTheme =
storedTheme === 'light' || storedTheme === 'dark'
? storedTheme
: explicitTheme;
const isDark = preferredTheme
? preferredTheme === 'dark'
: mediaQuery.matches;
setEditorTheme(isDark ? 'vs-dark' : 'vs');
};
const observer = new MutationObserver(syncTheme);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
observer.observe(root, {
attributes: true,
attributeFilter: ['data-theme'],
});
mediaQuery.addEventListener('change', syncTheme);
syncTheme();
@ -88,7 +100,7 @@ export async function onResponse(ctx) {
<div class="script-editor">
<Suspense
fallback={
<div class="script-editor__loading" role="status" aria-live="polite">
<div aria-live="polite" class="script-editor__loading" role="status">
<div class="script-editor__skeleton script-editor__skeleton--toolbar" />
<div class="script-editor__skeleton script-editor__skeleton--line" />
<div class="script-editor__skeleton script-editor__skeleton--line script-editor__skeleton--short" />
@ -101,10 +113,7 @@ export async function onResponse(ctx) {
<Dynamic
component={MonacoEditor}
language="typescript"
value={props.value || defaultCode}
path={props.path}
onChange={(value: string) => props.onChange(value)}
theme={editorTheme()}
options={{
minimap: { enabled: false },
fontSize: 14,
@ -114,6 +123,9 @@ export async function onResponse(ctx) {
scrollBeyondLastLine: false,
padding: { top: 16, bottom: 16 },
}}
path={props.path}
theme={editorTheme()}
value={props.value || defaultCode}
/>
</Suspense>
</div>

View file

@ -4,7 +4,7 @@ declare global {
interface Window {
Snakeground?: new (
canvas: HTMLCanvasElement,
opts?: Record<string, unknown>
opts?: Record<string, unknown>,
) => {
stop?: () => void;
setPageHeight?: (height: number) => void;
@ -16,16 +16,21 @@ const SCRIPT_ID = 'snakeground-script';
const SCRIPT_SRC = '/snakeground.js';
const SEED_STORAGE_KEY = 'snakeground-seed';
export default function SnakegroundBg(props: { opts?: Record<string, unknown> }) {
export default function SnakegroundBg(props: {
opts?: Record<string, unknown>;
}) {
let canvasRef: HTMLCanvasElement | undefined;
let wrapRef: HTMLDivElement | undefined;
let snakeground: { stop?: () => void; setPageHeight?: (height: number) => void } | undefined;
let snakeground:
| { stop?: () => void; setPageHeight?: (height: number) => void }
| undefined;
const onScroll = () => {
if (!wrapRef) return;
const scrollY = window.scrollY;
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const maxScroll =
document.documentElement.scrollHeight - window.innerHeight;
const ratio = maxScroll > 0 ? scrollY / maxScroll : 0;
const offset = 5 - ratio * 30;
@ -55,7 +60,9 @@ export default function SnakegroundBg(props: { opts?: Record<string, unknown> })
};
onMount(() => {
const existingScript = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
const existingScript = document.getElementById(
SCRIPT_ID,
) as HTMLScriptElement | null;
if (window.Snakeground) {
mountSnakeground();
@ -80,8 +87,8 @@ export default function SnakegroundBg(props: { opts?: Record<string, unknown> })
});
return (
<div ref={wrapRef} class="pub-bg-canvas-wrap">
<canvas ref={canvasRef} class="pub-bg-canvas" />
<div class="pub-bg-canvas-wrap" ref={wrapRef}>
<canvas class="pub-bg-canvas" ref={canvasRef} />
</div>
);
}

View file

@ -1,4 +1,5 @@
import { render } from 'solid-js/web';
import App from './App';
import './ui/styles.css';

View file

@ -1,4 +1,11 @@
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
import {
Show,
createMemo,
createResource,
createSignal,
type Component,
} from 'solid-js';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import {
@ -22,16 +29,34 @@ const dayOptions = [
{ value: '90', label: 'Last 90 days' },
];
const palette = ['#2357d8', '#1f7a45', '#c05621', '#8b5cf6', '#0f766e', '#b42318', '#7c3aed', '#0b7285'];
type AnalyticsChartRow = { date: string } & Record<string, string | number | null>;
const palette = [
'#2357d8',
'#1f7a45',
'#c05621',
'#8b5cf6',
'#0f766e',
'#b42318',
'#7c3aed',
'#0b7285',
];
type AnalyticsChartRow = { date: string } & Record<
string,
string | number | null
>;
const formatInteger = new Intl.NumberFormat('en-US');
export const Analytics: Component = () => {
const [days, setDays] = createSignal('30');
const [backendFilter, setBackendFilter] = createSignal('all');
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(new Set());
const [hiddenResponseSeries, setHiddenResponseSeries] = createSignal<Set<string>>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(
new Set(),
);
const [hiddenResponseSeries, setHiddenResponseSeries] = createSignal<
Set<string>
>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(
new Set(),
);
const filters = createMemo(() => ({
days: Number(days()),
@ -39,18 +64,36 @@ export const Analytics: Component = () => {
}));
const [backends] = createResource(() => api.backends.getAll());
const [dailyTotals] = createResource(filters, (params) => api.analytics.getDailyTotals(params.backendId, params.days));
const [backendQuality] = createResource(filters, (params) => api.analytics.getBackendQuality(params.backendId, params.days));
const [modelTrends] = createResource(filters, (params) => api.analytics.getModelTrends({ backendId: params.backendId, days: params.days, limit: 8 }));
const [histogram] = createResource(filters, (params) => api.analytics.getResponseLengthHistogram({ backendId: params.backendId, days: params.days, bins: 20 }));
const [boxPlot] = createResource(filters, (params) => api.analytics.getResponseLengthBoxPlot(params.backendId, params.days));
const [dailyTotals] = createResource(filters, (params) =>
api.analytics.getDailyTotals(params.backendId, params.days),
);
const [backendQuality] = createResource(filters, (params) =>
api.analytics.getBackendQuality(params.backendId, params.days),
);
const [modelTrends] = createResource(filters, (params) =>
api.analytics.getModelTrends({
backendId: params.backendId,
days: params.days,
limit: 8,
}),
);
const [histogram] = createResource(filters, (params) =>
api.analytics.getResponseLengthHistogram({
backendId: params.backendId,
days: params.days,
bins: 20,
}),
);
const [boxPlot] = createResource(filters, (params) =>
api.analytics.getResponseLengthBoxPlot(params.backendId, params.days),
);
const backendOptions = createMemo(() => [
{ value: 'all', label: 'All Backends' },
...((backends() ?? []).map((backend) => ({
...(backends() ?? []).map((backend) => ({
value: String(backend.id),
label: backend.name,
}))),
})),
]);
const backendNameById = createMemo(() => {
@ -66,21 +109,27 @@ export const Analytics: Component = () => {
date: row.date,
requests: row.total_requests,
tokens: row.total_tokens,
}))
})),
);
const responseTimeRows = createMemo(() => {
const grouped = new Map<string, AnalyticsChartRow>();
for (const row of backendQuality() ?? []) {
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
const entry: AnalyticsChartRow = grouped.get(row.date) ?? {
date: row.date,
};
entry[`backend_${row.backend_id}`] = row.avg_response_time_ms;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
});
const responseTimeSeries = createMemo(() => {
const ids = Array.from(new Set((backendQuality() ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
const ids = Array.from(
new Set((backendQuality() ?? []).map((row) => row.backend_id)),
).sort((left, right) => left - right);
return ids.map((backendId, index) => ({
key: `backend_${backendId}`,
label: backendNameById().get(backendId) ?? `Backend ${backendId}`,
@ -101,7 +150,10 @@ export const Analytics: Component = () => {
.sort((left, right) => left[0].localeCompare(right[0]))
.map(([date, value]) => ({
date,
lineValue: value.requests === 0 ? 0 : ((value.requests - value.errors) / value.requests) * 100,
lineValue:
value.requests === 0
? 0
: ((value.requests - value.errors) / value.requests) * 100,
barValue: value.errors,
}));
});
@ -109,15 +161,21 @@ export const Analytics: Component = () => {
const modelTrendRows = createMemo(() => {
const grouped = new Map<string, AnalyticsChartRow>();
for (const row of modelTrends() ?? []) {
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
const entry: AnalyticsChartRow = grouped.get(row.date) ?? {
date: row.date,
};
entry[`model_${row.model}`] = row.request_count;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
});
const modelTrendSeries = createMemo(() => {
const models = Array.from(new Set((modelTrends() ?? []).map((row) => row.model)));
const models = Array.from(
new Set((modelTrends() ?? []).map((row) => row.model)),
);
return models.map((model, index) => ({
key: `model_${model}`,
label: model,
@ -132,23 +190,47 @@ export const Analytics: Component = () => {
acc.tokens += row.total_tokens;
return acc;
},
{ requests: 0, tokens: 0 }
{ requests: 0, tokens: 0 },
);
const qualityRows = backendQuality() ?? [];
const avgLatency =
qualityRows.length === 0 ? 0 : qualityRows.reduce((sum, row) => sum + row.avg_response_time_ms, 0) / qualityRows.length;
const errorCount = qualityRows.reduce((sum, row) => sum + row.error_count, 0);
qualityRows.length === 0
? 0
: qualityRows.reduce((sum, row) => sum + row.avg_response_time_ms, 0) /
qualityRows.length;
const errorCount = qualityRows.reduce(
(sum, row) => sum + row.error_count,
0,
);
return [
{ label: 'Requests', value: formatInteger.format(totals.requests), hint: `Last ${days()} days` },
{ label: 'Tokens', value: formatInteger.format(totals.tokens), hint: 'Aggregated daily total tokens' },
{ label: 'Avg Response', value: `${avgLatency.toFixed(1)}ms`, hint: 'Across visible backend series' },
{ label: 'Errors', value: formatInteger.format(errorCount), hint: 'Absolute backend error count' },
{
label: 'Requests',
value: formatInteger.format(totals.requests),
hint: `Last ${days()} days`,
},
{
label: 'Tokens',
value: formatInteger.format(totals.tokens),
hint: 'Aggregated daily total tokens',
},
{
label: 'Avg Response',
value: `${avgLatency.toFixed(1)}ms`,
hint: 'Across visible backend series',
},
{
label: 'Errors',
value: formatInteger.format(errorCount),
hint: 'Absolute backend error count',
},
];
});
const toggleHiddenKey = (
setter: (value: Set<string> | ((current: Set<string>) => Set<string>)) => void,
setter: (
value: Set<string> | ((current: Set<string>) => Set<string>),
) => void,
key: string,
) => {
setter((current) => {
@ -166,14 +248,24 @@ export const Analytics: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Analytics"
description="Operational analytics with D3-driven time series, reliability, and response-length distributions."
title="Analytics"
/>
<CommandBar class="analytics__filters">
<CommandBarGroup>
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
<Select label="Backend" value={backendFilter()} options={backendOptions()} onChange={setBackendFilter} />
<Select
label="Window"
onChange={setDays}
options={dayOptions}
value={days()}
/>
<Select
label="Backend"
onChange={setBackendFilter}
options={backendOptions()}
value={backendFilter()}
/>
</CommandBarGroup>
</CommandBar>
@ -181,8 +273,6 @@ export const Analytics: Component = () => {
<div class="ui-section-grid">
<Panel
title="Daily Volume"
description="Daily request and token totals on shared time axis."
actions={
<ChartLegend
items={[
@ -193,27 +283,41 @@ export const Analytics: Component = () => {
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
}
description="Daily request and token totals on shared time axis."
title="Daily Volume"
>
<TimeSeriesChart
data={dailyVolumeRows()}
formatLeftValue={(value) =>
new Intl.NumberFormat('en-US').format(Math.round(value))
}
formatRightValue={(value) =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value)
}
hiddenKeys={hiddenDailySeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenDailySeries, key)
}
series={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45', axis: 'right' },
{
key: 'tokens',
label: 'Tokens',
color: '#1f7a45',
axis: 'right',
},
]}
showLegend={false}
hiddenKeys={hiddenDailySeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
tooltipTitle="Daily request and token totals"
yLeftLabel="Requests"
yRightLabel="Tokens"
formatLeftValue={(value) => new Intl.NumberFormat('en-US').format(Math.round(value))}
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
tooltipTitle="Daily request and token totals"
/>
</Panel>
<Panel
title="Backend Reliability"
description="Success rate and absolute error count per day."
actions={
<ChartLegend
items={[
@ -222,13 +326,15 @@ export const Analytics: Component = () => {
]}
/>
}
description="Success rate and absolute error count per day."
title="Backend Reliability"
>
<ComboChart
data={reliabilityRows()}
lineLabel="Success Rate"
barLabel="Errors"
lineColor="#2357d8"
barColor="#b42318"
barLabel="Errors"
data={reliabilityRows()}
lineColor="#2357d8"
lineLabel="Success Rate"
showLegend={false}
/>
</Panel>
@ -236,31 +342,33 @@ export const Analytics: Component = () => {
<div class="ui-section-grid">
<Panel
title="Backend Response Time"
description="Average response time by backend with toggleable backend series."
actions={
<ChartLegend
items={responseTimeSeries()}
mutedKeys={hiddenResponseSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenResponseSeries, key)}
onToggle={(key) =>
toggleHiddenKey(setHiddenResponseSeries, key)
}
/>
}
description="Average response time by backend with toggleable backend series."
title="Backend Response Time"
>
<TimeSeriesChart
data={responseTimeRows()}
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
hiddenKeys={hiddenResponseSeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenResponseSeries, key)
}
series={responseTimeSeries()}
showLegend={false}
hiddenKeys={hiddenResponseSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenResponseSeries, key)}
yLeftLabel="Milliseconds"
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
tooltipTitle="Average backend response time"
yLeftLabel="Milliseconds"
/>
</Panel>
<Panel
title="Model Request Trends"
description="Top routed/response models by request volume over time."
actions={
<ChartLegend
items={modelTrendSeries()}
@ -268,35 +376,41 @@ export const Analytics: Component = () => {
onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
/>
}
description="Top routed/response models by request volume over time."
title="Model Request Trends"
>
<TimeSeriesChart
data={modelTrendRows()}
formatLeftValue={(value) => `${Math.round(value)}`}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenModelSeries, key)
}
series={modelTrendSeries()}
showLegend={false}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
yLeftLabel="Requests"
formatLeftValue={(value) => `${Math.round(value)}`}
tooltipTitle="Model request trend"
yLeftLabel="Requests"
/>
</Panel>
</div>
<div class="ui-section-grid analytics__grid--spread-wide">
<Panel title="Response Length Distribution" description="Histogram of completion token lengths across the selected window.">
<Panel
description="Histogram of completion token lengths across the selected window."
title="Response Length Distribution"
>
<MetaCluster
items={[
{ key: 'Metric', value: 'completion_tokens' },
]}
items={[{ key: 'Metric', value: 'completion_tokens' }]}
/>
<HistogramChart data={histogram() ?? []} />
</Panel>
<Panel title="Daily Response Length Spread" description="Completion token box plot by day using min / q1 / median / q3 / max summary.">
<Panel
description="Completion token box plot by day using min / q1 / median / q3 / max summary."
title="Daily Response Length Spread"
>
<MetaCluster
items={[
{ key: 'Outliers', value: 'Hidden in this view' },
]}
items={[{ key: 'Outliers', value: 'Hidden in this view' }]}
/>
<BoxPlotChart data={boxPlot() ?? []} />
</Panel>

View file

@ -1,11 +1,18 @@
import { For, createResource, createSignal, Show, type Component } from 'solid-js';
import {
For,
createResource,
createSignal,
Show,
type Component,
} from 'solid-js';
import Pencil from 'lucide-solid/icons/pencil';
import Plus from 'lucide-solid/icons/plus';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import Trash2 from 'lucide-solid/icons/trash-2';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import type { Backend, BackendModelsResponse } from '../types';
import {
Alert,
Button,
@ -21,6 +28,8 @@ import {
TextField,
} from '../ui';
import type { Backend, BackendModelsResponse } from '../types';
interface BackendFormState {
name: string;
base_url: string;
@ -41,15 +50,27 @@ export const Backends: Component = () => {
const [backends, { refetch }] = createResource(() => api.backends.getAll());
const [dialogOpen, setDialogOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [editingBackend, setEditingBackend] = createSignal<Backend | null>(null);
const [pendingDeleteBackend, setPendingDeleteBackend] = createSignal<Backend | null>(null);
const [editingBackend, setEditingBackend] = createSignal<Backend | null>(
null,
);
const [pendingDeleteBackend, setPendingDeleteBackend] =
createSignal<Backend | null>(null);
const [form, setForm] = createSignal<BackendFormState>(emptyForm());
const [submitting, setSubmitting] = createSignal(false);
const [notice, setNotice] = createSignal<{ tone: 'success' | 'danger'; message: string } | null>(null);
const [expandedBackendId, setExpandedBackendId] = createSignal<number | null>(null);
const [backendModels, setBackendModels] = createSignal<Record<number, BackendModelsResponse>>({});
const [notice, setNotice] = createSignal<{
tone: 'success' | 'danger';
message: string;
} | null>(null);
const [expandedBackendId, setExpandedBackendId] = createSignal<number | null>(
null,
);
const [backendModels, setBackendModels] = createSignal<
Record<number, BackendModelsResponse>
>({});
const modelStateTone = (backend: Backend): 'success' | 'warning' | 'danger' | 'neutral' => {
const modelStateTone = (
backend: Backend,
): 'success' | 'warning' | 'danger' | 'neutral' => {
switch (backend.model_cache_state) {
case 'ready':
return 'success';
@ -127,7 +148,11 @@ export const Backends: Component = () => {
setForm(emptyForm());
await refetch();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Backend save failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Backend save failed.',
});
} finally {
setSubmitting(false);
}
@ -150,7 +175,11 @@ export const Backends: Component = () => {
setPendingDeleteBackend(null);
await refetch();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Backend deletion failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Backend deletion failed.',
});
} finally {
setSubmitting(false);
}
@ -167,7 +196,13 @@ export const Backends: Component = () => {
const detail = await api.backends.getModels(backend.id);
setBackendModels((current) => ({ ...current, [backend.id]: detail }));
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Failed to load backend models.' });
setNotice({
tone: 'danger',
message:
error instanceof Error
? error.message
: 'Failed to load backend models.',
});
}
};
@ -178,10 +213,17 @@ export const Backends: Component = () => {
try {
const detail = await api.backends.refreshModels(backend.id);
setBackendModels((current) => ({ ...current, [backend.id]: detail }));
setNotice({ tone: 'success', message: `${backend.name} model cache refreshed.` });
setNotice({
tone: 'success',
message: `${backend.name} model cache refreshed.`,
});
await refetch();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Model refresh failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Model refresh failed.',
});
} finally {
setSubmitting(false);
}
@ -191,60 +233,112 @@ export const Backends: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Backends"
actions={
<IconButton
icon={<Plus />}
label="Add Backend"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Register upstream LLM targets, connection URLs, and activation state for routing."
actions={<IconButton variant="primary" icon={<Plus />} label="Add Backend" onClick={openCreateDialog} />}
title="Backends"
/>
<Show when={notice()}>
{(currentNotice) => <Alert tone={currentNotice().tone}>{currentNotice().message}</Alert>}
{(currentNotice) => (
<Alert tone={currentNotice().tone}>{currentNotice().message}</Alert>
)}
</Show>
<Panel title="Backend catalog" description="Operational list with overflow-safe URL presentation and compact actions.">
<Panel
description="Operational list with overflow-safe URL presentation and compact actions."
title="Backend catalog"
>
<Show
fallback={
<EmptyState
description="Reading upstream routing targets from the admin API."
title="Loading backends"
/>
}
when={!backends.loading || (backends()?.length ?? 0) > 0}
fallback={<EmptyState title="Loading backends" description="Reading upstream routing targets from the admin API." />}
>
<Show
when={(backends()?.length ?? 0) > 0}
fallback={
<EmptyState
title="No backends yet"
action={
<IconButton
icon={<Plus />}
label="Add Backend"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Add a backend before granting permissions or routing requests."
action={<IconButton variant="primary" icon={<Plus />} label="Add Backend" onClick={openCreateDialog} />}
title="No backends yet"
/>
}
when={(backends()?.length ?? 0) > 0}
>
<DataGrid
rows={backends() ?? []}
columns={[
{ id: 'id', header: 'ID', mono: true, cell: (backend) => <span>{backend.id}</span> },
{ id: 'name', header: 'Name', cell: (backend) => <span>{backend.name}</span> },
{
id: 'id',
header: 'ID',
mono: true,
cell: (backend) => <span>{backend.id}</span>,
},
{
id: 'name',
header: 'Name',
cell: (backend) => <span>{backend.name}</span>,
},
{
id: 'base_url',
header: 'Base URL',
class: 'ui-text-mono',
cell: (backend) => <span title={backend.base_url}>{backend.base_url}</span>,
cell: (backend) => (
<span title={backend.base_url}>{backend.base_url}</span>
),
},
{
id: 'detail_logging',
header: 'Detail Log',
cell: (backend) => <StatusBadge tone={backend.detail_logging ? 'warning' : 'neutral'}>{backend.detail_logging ? 'On' : 'Off'}</StatusBadge>,
cell: (backend) => (
<StatusBadge
tone={backend.detail_logging ? 'warning' : 'neutral'}
>
{backend.detail_logging ? 'On' : 'Off'}
</StatusBadge>
),
},
{
id: 'model_cache',
header: 'Model Cache',
cell: (backend) => <StatusBadge tone={modelStateTone(backend)}>{modelStateLabel(backend)}</StatusBadge>,
cell: (backend) => (
<StatusBadge tone={modelStateTone(backend)}>
{modelStateLabel(backend)}
</StatusBadge>
),
},
{
id: 'model_count',
header: 'Models',
cell: (backend) => <span>{backend.cached_model_count ?? 0}</span>,
cell: (backend) => (
<span>{backend.cached_model_count ?? 0}</span>
),
},
{
id: 'status',
header: 'Status',
cell: (backend) => <StatusBadge tone={backend.is_active ? 'success' : 'warning'}>{backend.is_active ? 'Active' : 'Inactive'}</StatusBadge>,
cell: (backend) => (
<StatusBadge
tone={backend.is_active ? 'success' : 'warning'}
>
{backend.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
),
},
]}
getRowKey={(backend) => backend.id}
@ -252,38 +346,79 @@ export const Backends: Component = () => {
rowActions={(backend) => (
<div class="ui-row-actions">
<IconButton
disabled={!backend.is_active || submitting()}
icon={<RefreshCw />}
label="Refresh Models"
disabled={!backend.is_active || submitting()}
onClick={() => void refreshModels(backend)}
/>
<IconButton icon={<Pencil />} label="Edit" onClick={() => openEditDialog(backend)} />
<Button onClick={() => void toggleDetails(backend)}>{expandedBackendId() === backend.id ? 'Hide Models' : 'View Models'}</Button>
<IconButton variant="danger" icon={<Trash2 />} label="Delete" onClick={() => requestDelete(backend)} />
<IconButton
icon={<Pencil />}
label="Edit"
onClick={() => openEditDialog(backend)}
/>
<Button onClick={() => void toggleDetails(backend)}>
{expandedBackendId() === backend.id
? 'Hide Models'
: 'View Models'}
</Button>
<IconButton
icon={<Trash2 />}
label="Delete"
onClick={() => requestDelete(backend)}
variant="danger"
/>
</div>
)}
rows={backends() ?? []}
/>
<Show when={expandedBackendId()}>
{(backendId) => {
const detail = () => backendModels()[backendId()];
return (
<Panel
description={
detail()?.cache.state === 'inactive'
? 'Inactive backends skip model fetches and only keep the last DB snapshot.'
: 'Live cache state and last persisted model snapshot.'
}
title={`Backend ${backendId()} Models`}
description={detail()?.cache.state === 'inactive' ? 'Inactive backends skip model fetches and only keep the last DB snapshot.' : 'Live cache state and last persisted model snapshot.'}
>
<div class="ui-stack ui-stack--tight">
<Show when={detail()} fallback={<EmptyState title="Loading models" description="Reading cached model information for this backend." />}>
<Alert tone={detail()!.cache.last_error ? 'danger' : 'success'}>
{detail()!.cache.last_error
? `Last error: ${detail()!.cache.last_error}`
: `State: ${detail()!.cache.state}, models: ${detail()!.cache.model_count}, last sync: ${detail()!.cache.last_synced_at ?? 'never'}`}
<Show
fallback={
<EmptyState
description="Reading cached model information for this backend."
title="Loading models"
/>
}
when={detail()}
>
<Alert
tone={
detail().cache.last_error ? 'danger' : 'success'
}
>
{detail().cache.last_error
? `Last error: ${detail().cache.last_error}`
: `State: ${detail().cache.state}, models: ${detail().cache.model_count}, last sync: ${detail().cache.last_synced_at ?? 'never'}`}
</Alert>
<Show
when={detail()!.models.length > 0}
fallback={<EmptyState title="No cached models" description="This backend has not published any models yet or the last refresh failed." />}
fallback={
<EmptyState
description="This backend has not published any models yet or the last refresh failed."
title="No cached models"
/>
}
when={detail().models.length > 0}
>
<div class="ui-chip-row">
<For each={detail()!.models}>{(modelId) => <StatusBadge tone="neutral">{modelId}</StatusBadge>}</For>
<For each={detail().models}>
{(modelId) => (
<StatusBadge tone="neutral">
{modelId}
</StatusBadge>
)}
</For>
</div>
</Show>
</Show>
@ -297,59 +432,96 @@ export const Backends: Component = () => {
</Panel>
<FormDialog
open={dialogOpen()}
onOpenChange={setDialogOpen}
title={editingBackend() ? 'Edit Backend' : 'Add Backend'}
description="Compact backend form with URL and optional credential fields."
footer={
<>
<Button onClick={() => setDialogOpen(false)} disabled={submitting()}>Cancel</Button>
<Button type="submit" form="backend-form" variant="primary" disabled={submitting()}>
<Button
disabled={submitting()}
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button
disabled={submitting()}
form="backend-form"
type="submit"
variant="primary"
>
{editingBackend() ? 'Save Changes' : 'Create Backend'}
</Button>
</>
}
onOpenChange={setDialogOpen}
open={dialogOpen()}
title={editingBackend() ? 'Edit Backend' : 'Add Backend'}
>
<form id="backend-form" class="ui-form" onSubmit={(event) => void saveBackend(event)}>
<TextField label="Name" value={form().name} onInput={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))} />
<form
class="ui-form"
id="backend-form"
onSubmit={(event) => void saveBackend(event)}
>
<TextField
label="Name"
onInput={(event) =>
setForm((current) => ({
...current,
name: event.currentTarget.value,
}))
}
value={form().name}
/>
<TextField
label="Base URL"
value={form().base_url}
onInput={(event) =>
setForm((current) => ({
...current,
base_url: event.currentTarget.value,
}))
}
placeholder="https://api.openai.com/v1"
onInput={(event) => setForm((current) => ({ ...current, base_url: event.currentTarget.value }))}
value={form().base_url}
/>
<TextField
label="API Key"
value={form().api_key}
onInput={(event) =>
setForm((current) => ({
...current,
api_key: event.currentTarget.value,
}))
}
placeholder="Optional upstream API key"
onInput={(event) => setForm((current) => ({ ...current, api_key: event.currentTarget.value }))}
value={form().api_key}
/>
<Show when={editingBackend()}>
<Checkbox
label="Backend is active"
description="Inactive backends stay configured but are not selected for routing."
checked={form().is_active}
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
description="Inactive backends stay configured but are not selected for routing."
label="Backend is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
/>
</Show>
<Checkbox
label="Enable detailed logging"
description="When enabled, proxied request and response headers/bodies are stored for this backend."
checked={form().detail_logging}
onChange={(checked) => setForm((current) => ({ ...current, detail_logging: checked }))}
description="When enabled, proxied request and response headers/bodies are stored for this backend."
label="Enable detailed logging"
onChange={(checked) =>
setForm((current) => ({ ...current, detail_logging: checked }))
}
/>
</form>
</FormDialog>
<ConfirmDialog
open={confirmOpen()}
onOpenChange={setConfirmOpen}
title="Delete backend"
description="Deleting a backend removes it from routing and any dependent permission mapping."
confirmLabel="Delete Backend"
tone="danger"
busy={submitting()}
confirmLabel="Delete Backend"
description="Deleting a backend removes it from routing and any dependent permission mapping."
onConfirm={() => void deleteBackend()}
onOpenChange={setConfirmOpen}
open={confirmOpen()}
title="Delete backend"
tone="danger"
/>
</div>
</Layout>

View file

@ -1,5 +1,13 @@
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
import {
Show,
createMemo,
createResource,
createSignal,
type Component,
For,
} from 'solid-js';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import {
@ -22,19 +30,37 @@ const dayOptions = [
{ value: '90', label: 'Last 90 days' },
];
const palette = ['#2357d8', '#1f7a45', '#c05621', '#8b5cf6', '#0f766e', '#b42318'];
const palette = [
'#2357d8',
'#1f7a45',
'#c05621',
'#8b5cf6',
'#0f766e',
'#b42318',
];
const formatInteger = new Intl.NumberFormat('en-US');
type DashboardChartRow = { date: string } & Record<string, string | number | null>;
type DashboardChartRow = { date: string } & Record<
string,
string | number | null
>;
export const Dashboard: Component = () => {
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 [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<
Set<string>
>(new Set());
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<
Set<string>
>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(
new Set(),
);
const windowDays = createMemo(() => Number(days()));
const [summary, { refetch }] = createResource(windowDays, (value) => api.dashboard.getSummary(value));
const [summary, { refetch }] = createResource(windowDays, (value) =>
api.dashboard.getSummary(value),
);
const [backends] = createResource(() => api.backends.getAll());
const backendNameById = createMemo(() => {
@ -50,7 +76,7 @@ export const Dashboard: Component = () => {
date: row.date,
requests: row.total_requests,
tokens: row.total_tokens,
}))
})),
);
const reliabilityRows = createMemo(() => {
@ -66,7 +92,10 @@ export const Dashboard: Component = () => {
.sort((left, right) => left[0].localeCompare(right[0]))
.map(([date, value]) => ({
date,
lineValue: value.requests === 0 ? 0 : ((value.requests - value.errors) / value.requests) * 100,
lineValue:
value.requests === 0
? 0
: ((value.requests - value.errors) / value.requests) * 100,
barValue: value.errors,
}));
});
@ -74,15 +103,23 @@ export const Dashboard: Component = () => {
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 };
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));
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);
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}`,
@ -93,15 +130,21 @@ export const Dashboard: Component = () => {
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 };
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));
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)));
const models = Array.from(
new Set((summary()?.series.model_trends ?? []).map((row) => row.model)),
);
return models.map((model, index) => ({
key: `model_${model}`,
label: model,
@ -111,13 +154,34 @@ export const Dashboard: Component = () => {
const summaryItems = createMemo(() => {
const payload = summary();
const latestTraffic = payload?.series.daily_totals[payload.series.daily_totals.length - 1];
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' },
{
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',
},
];
});
@ -137,9 +201,18 @@ export const Dashboard: Component = () => {
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` },
{
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`,
},
];
});
@ -148,15 +221,29 @@ export const Dashboard: Component = () => {
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) },
{
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,
setter: (
value: Set<string> | ((current: Set<string>) => Set<string>),
) => void,
key: string,
) => {
setter((current) => {
@ -174,24 +261,53 @@ export const Dashboard: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Dashboard"
actions={
<button
class="ui-button"
onClick={() => void refetch()}
type="button"
>
<RefreshCcw />
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>}
title="Dashboard"
/>
<CommandBar class="analytics__filters">
<CommandBarGroup>
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
<Select
label="Window"
onChange={setDays}
options={dayOptions}
value={days()}
/>
</CommandBarGroup>
</CommandBar>
<SummaryStrip items={summaryItems()} />
<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>}>
<Show
fallback={
<Panel
description={
summary.error instanceof Error
? summary.error.message
: 'Failed to load dashboard summary.'
}
title="Dashboard unavailable"
>
<EmptyState
description="Refresh the page or verify the admin API is available."
title="Failed to load summary"
/>
</Panel>
}
when={!summary.error}
>
<div class="ui-section-grid">
<Panel
title="Traffic Volume"
description="Daily request and token totals for the selected window."
actions={
<ChartLegend
items={[
@ -199,38 +315,63 @@ export const Dashboard: Component = () => {
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenTrafficSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
onToggle={(key) =>
toggleHiddenKey(setHiddenTrafficSeries, key)
}
/>
}
description="Daily request and token totals for the selected window."
title="Traffic Volume"
>
<TimeSeriesChart
data={trafficRows()}
formatLeftValue={(value) =>
formatInteger.format(Math.round(value))
}
formatRightValue={(value) =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value)
}
hiddenKeys={hiddenTrafficSeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenTrafficSeries, key)
}
series={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45', axis: 'right' },
{
key: 'tokens',
label: 'Tokens',
color: '#1f7a45',
axis: 'right',
},
]}
showLegend={false}
hiddenKeys={hiddenTrafficSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
tooltipTitle="Traffic volume"
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"
/>
</Panel>
<Panel
title="Reliability Snapshot"
actions={
<ChartLegend
items={[
{ key: 'line', label: 'Success Rate', color: '#2357d8' },
{ key: 'bar', label: 'Errors', color: '#b42318' },
]}
/>
}
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' }]} />}
title="Reliability Snapshot"
>
<ComboChart
data={reliabilityRows()}
lineLabel="Success Rate"
barLabel="Errors"
lineColor="#2357d8"
barColor="#b42318"
barLabel="Errors"
data={reliabilityRows()}
lineColor="#2357d8"
lineLabel="Success Rate"
showLegend={false}
/>
</Panel>
@ -238,69 +379,113 @@ export const Dashboard: Component = () => {
<div class="ui-section-grid">
<Panel
title="Backend Latency"
actions={
<ChartLegend
items={latencySeries()}
mutedKeys={hiddenLatencySeries()}
onToggle={(key) =>
toggleHiddenKey(setHiddenLatencySeries, key)
}
/>
}
description="Average response time by backend with per-series toggles."
actions={<ChartLegend items={latencySeries()} mutedKeys={hiddenLatencySeries()} onToggle={(key) => toggleHiddenKey(setHiddenLatencySeries, key)} />}
title="Backend Latency"
>
<TimeSeriesChart
data={latencyRows()}
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
hiddenKeys={hiddenLatencySeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenLatencySeries, key)
}
series={latencySeries()}
showLegend={false}
hiddenKeys={hiddenLatencySeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenLatencySeries, key)}
yLeftLabel="Milliseconds"
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
tooltipTitle="Backend latency"
yLeftLabel="Milliseconds"
/>
</Panel>
<Panel
title="Model Activity"
actions={
<ChartLegend
items={modelSeries()}
mutedKeys={hiddenModelSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
/>
}
description="Top models by request volume across the current window."
actions={<ChartLegend items={modelSeries()} mutedKeys={hiddenModelSeries()} onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)} />}
title="Model Activity"
>
<TimeSeriesChart
data={modelRows()}
formatLeftValue={(value) => `${Math.round(value)}`}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenModelSeries, key)
}
series={modelSeries()}
showLegend={false}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
yLeftLabel="Requests"
formatLeftValue={(value) => `${Math.round(value)}`}
tooltipTitle="Model activity"
yLeftLabel="Requests"
/>
</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.">
<Panel
description="Cache readiness, liveness, and sync drift indicators for current backends."
title="Backend Health"
>
<MetaCluster items={cacheStateItems()} />
<Show
fallback={
<EmptyState
description="All active backends synced within the freshness window."
title="No stale backend syncs"
/>
}
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>
))}
{
<For each={summary()?.health.stale_backends}>
{(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>
)}
</For>
}
</div>
</Show>
</Panel>
<Panel title="Script Runtime" description="Active middleware footprint and target distribution.">
<Panel
description="Active middleware footprint and target distribution."
title="Script Runtime"
>
<MetaCluster items={scriptItems()} />
<div class="dashboard__note">
Active scripts shape request and response behavior before traffic reaches the upstream backend.
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.">
<Panel
description="Identity and logging posture behind current routing activity."
title="Access Context"
>
<MetaCluster items={accessItems()} />
</Panel>
</div>

View file

@ -1,9 +1,34 @@
import { createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
import {
createMemo,
createResource,
createSignal,
Show,
type Component,
} from 'solid-js';
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import {
Button,
CommandBar,
CommandBarGroup,
ConversationTimeline,
DataGrid,
EmptyState,
MetaCluster,
PageHeader,
Panel,
Select,
StatusBadge,
SummaryStrip,
Tabs,
TextField,
hasRenderableConversation,
} from '../ui';
import type { RequestLog } from '../types';
import { Button, CommandBar, CommandBarGroup, ConversationTimeline, DataGrid, EmptyState, MetaCluster, PageHeader, Panel, Select, StatusBadge, SummaryStrip, Tabs, TextField, hasRenderableConversation } from '../ui';
interface FilterState {
month: string;
@ -39,13 +64,12 @@ function extractAssistantPreview(responseBody?: string): string {
const content = parsed.choices?.[0]?.message?.content;
if (typeof content !== 'string') return '-';
const normalized = content
.replace(/\r/g, '')
.replace(/\n+/g, ' ')
.trim();
const normalized = content.replace(/\r/g, '').replace(/\n+/g, ' ').trim();
if (!normalized) return '-';
return normalized.length > 50 ? `${normalized.slice(0, 50)}...` : normalized;
return normalized.length > 50
? `${normalized.slice(0, 50)}...`
: normalized;
} catch {
return '-';
}
@ -85,14 +109,18 @@ export const DetailLogs: Component = () => {
userId: params.userId ? Number(params.userId) : undefined,
backendId: params.backendId ? Number(params.backendId) : undefined,
endpoint: params.endpoint || undefined,
})
}),
);
const requestPage = createMemo(() => logs());
const requestRows = createMemo(() => requestPage()?.rows ?? []);
const totalRows = createMemo(() => requestPage()?.total ?? 0);
const pageCount = createMemo(() => Math.max(1, Math.ceil(totalRows() / pageSize())));
const rangeStart = createMemo(() => (totalRows() === 0 ? 0 : (page() - 1) * pageSize() + 1));
const pageCount = createMemo(() =>
Math.max(1, Math.ceil(totalRows() / pageSize())),
);
const rangeStart = createMemo(() =>
totalRows() === 0 ? 0 : (page() - 1) * pageSize() + 1,
);
const rangeEnd = createMemo(() => Math.min(totalRows(), page() * pageSize()));
const sourceScope = createMemo(() => {
const currentFilters = filters();
@ -117,7 +145,12 @@ export const DetailLogs: Component = () => {
});
const activeFilterCount = createMemo(() => {
const currentFilters = filters();
return [currentFilters.q, currentFilters.userId, currentFilters.backendId, currentFilters.endpoint].filter((value) => value.trim().length > 0).length;
return [
currentFilters.q,
currentFilters.userId,
currentFilters.backendId,
currentFilters.endpoint,
].filter((value) => value.trim().length > 0).length;
});
const activeFilterHint = createMemo(() => {
const currentFilters = filters();
@ -128,7 +161,9 @@ export const DetailLogs: Component = () => {
currentFilters.endpoint.trim() ? 'Endpoint' : null,
].filter((value): value is string => Boolean(value));
return labels.length > 0 ? labels.join(' + ') : 'No search/user/backend/endpoint filters';
return labels.length > 0
? labels.join(' + ')
: 'No search/user/backend/endpoint filters';
});
const pageWindow = createMemo(() => {
if (totalRows() === 0) {
@ -144,19 +179,32 @@ export const DetailLogs: Component = () => {
}
return previews;
});
const selectedLog = createMemo<RequestLog | undefined>(() => requestRows().find((row) => row.id === selectedLogId()));
const selectedLog = createMemo<RequestLog | undefined>(() =>
requestRows().find((row) => row.id === selectedLogId()),
);
const selectedLogHasConversation = createMemo(() =>
selectedLog() ? hasRenderableConversation(selectedLog()!.request_body, selectedLog()!.response_body) : false
selectedLog()
? hasRenderableConversation(
selectedLog()!.request_body,
selectedLog()!.response_body,
)
: false,
);
const userOptions = createMemo(() => [
{ value: '', label: 'All users' },
...((users() ?? []).map((user) => ({ value: String(user.id), label: `${user.id} - ${user.name}` }))),
...(users() ?? []).map((user) => ({
value: String(user.id),
label: `${user.id} - ${user.name}`,
})),
]);
const backendOptions = createMemo(() => [
{ value: '', label: 'All backends' },
...((backends() ?? []).map((backend) => ({ value: String(backend.id), label: `${backend.id} - ${backend.name}` }))),
...(backends() ?? []).map((backend) => ({
value: String(backend.id),
label: `${backend.id} - ${backend.name}`,
})),
]);
const endpointOptions = [
@ -178,16 +226,33 @@ export const DetailLogs: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Detail Logs"
actions={
<Button onClick={() => void refetch()}>
<RefreshCcw />
Refresh
</Button>
}
description="Inspect verbose request logs with monthly filters, text search, and full request/response payload views."
actions={<Button onClick={() => void refetch()}><RefreshCcw />Refresh</Button>}
title="Detail Logs"
/>
<SummaryStrip
items={[
{ label: 'Source Scope', value: sourceScope().value, hint: sourceScope().hint },
{ label: 'Active Filters', value: activeFilterCount(), hint: activeFilterHint() },
{ label: 'Page Window', value: pageWindow(), hint: `Page ${page()} of ${pageCount()} - ${pageSize()} per page` },
{
label: 'Source Scope',
value: sourceScope().value,
hint: sourceScope().hint,
},
{
label: 'Active Filters',
value: activeFilterCount(),
hint: activeFilterHint(),
},
{
label: 'Page Window',
value: pageWindow(),
hint: `Page ${page()} of ${pageCount()} - ${pageSize()} per page`,
},
]}
/>
@ -195,42 +260,97 @@ export const DetailLogs: Component = () => {
<CommandBarGroup>
<TextField
label="Search"
value={filters().q}
placeholder="Search body, headers, models, errors"
onInput={(event) => updateFilter('q', event.currentTarget.value)}
placeholder="Search body, headers, models, errors"
value={filters().q}
/>
<TextField
label="Month"
value={filters().month}
onInput={(event) =>
updateFilter('month', event.currentTarget.value)
}
placeholder="YYYY-MM"
onInput={(event) => updateFilter('month', event.currentTarget.value)}
value={filters().month}
/>
<TextField
label="Date"
value={filters().date}
onInput={(event) =>
updateFilter('date', event.currentTarget.value)
}
placeholder="YYYY-MM-DD"
onInput={(event) => updateFilter('date', event.currentTarget.value)}
value={filters().date}
/>
</CommandBarGroup>
<CommandBarGroup>
<Select label="User" value={filters().userId} options={userOptions()} onChange={(value) => updateFilter('userId', value)} />
<Select label="Backend" value={filters().backendId} options={backendOptions()} onChange={(value) => updateFilter('backendId', value)} />
<Select label="Endpoint" value={filters().endpoint} options={endpointOptions} onChange={(value) => updateFilter('endpoint', value)} />
<Select
label="User"
onChange={(value) => updateFilter('userId', value)}
options={userOptions()}
value={filters().userId}
/>
<Select
label="Backend"
onChange={(value) => updateFilter('backendId', value)}
options={backendOptions()}
value={filters().backendId}
/>
<Select
label="Endpoint"
onChange={(value) => updateFilter('endpoint', value)}
options={endpointOptions}
value={filters().endpoint}
/>
<Button onClick={resetFilters}>Reset</Button>
</CommandBarGroup>
</CommandBar>
<div class="ui-section-grid">
<Panel title="Log Results" description="Monthly request log rows. Select one to inspect full payload snapshots.">
<Panel
description="Monthly request log rows. Select one to inspect full payload snapshots."
title="Log Results"
>
<DataGrid
tableLayout="fixed"
rows={requestRows()}
columns={[
{ id: 'id', header: 'ID', width: '48px', mono: true, cell: (row) => <span>{row.id}</span> },
{ id: 'created_at', header: 'UTC Time', width: '148px', cell: (row) => <span>{new Date(row.created_at).toLocaleString()}</span> },
{ id: 'user_id', header: 'User', width: '40px', mono: true, cell: (row) => <span>{row.user_id}</span> },
{ id: 'backend_id', header: 'Backend', width: '56px', mono: true, cell: (row) => <span>{row.backend_id}</span> },
{ id: 'request_model', header: 'Model', width: '120px', truncate: true, cell: (row) => <span title={row.request_model ?? '-'}>{row.request_model || '-'}</span> },
{
id: 'id',
header: 'ID',
width: '48px',
mono: true,
cell: (row) => <span>{row.id}</span>,
},
{
id: 'created_at',
header: 'UTC Time',
width: '148px',
cell: (row) => (
<span>{new Date(row.created_at).toLocaleString()}</span>
),
},
{
id: 'user_id',
header: 'User',
width: '40px',
mono: true,
cell: (row) => <span>{row.user_id}</span>,
},
{
id: 'backend_id',
header: 'Backend',
width: '56px',
mono: true,
cell: (row) => <span>{row.backend_id}</span>,
},
{
id: 'request_model',
header: 'Model',
width: '120px',
truncate: true,
cell: (row) => (
<span title={row.request_model ?? '-'}>
{row.request_model || '-'}
</span>
),
},
{
id: 'assistant_preview',
header: 'Assistant',
@ -244,18 +364,30 @@ export const DetailLogs: Component = () => {
id: 'status_code',
header: 'Status',
width: '48px',
cell: (row) => <StatusBadge tone={row.status_code >= 400 ? 'danger' : 'success'}>{String(row.status_code)}</StatusBadge>,
cell: (row) => (
<StatusBadge
tone={row.status_code >= 400 ? 'danger' : 'success'}
>
{String(row.status_code)}
</StatusBadge>
),
},
{
id: 'detail_logged',
header: 'Detail',
width: '68px',
cell: (row) => <StatusBadge tone={row.detail_logged ? 'warning' : 'neutral'}>{row.detail_logged ? 'Verbose' : 'Meta'}</StatusBadge>,
cell: (row) => (
<StatusBadge
tone={row.detail_logged ? 'warning' : 'neutral'}
>
{row.detail_logged ? 'Verbose' : 'Meta'}
</StatusBadge>
),
},
]}
emptyMessage="No detailed logs matched the current filters."
getRowKey={(row) => row.id}
loading={logs.loading}
emptyMessage="No detailed logs matched the current filters."
onRowClick={(row) => setSelectedLogId(row.id)}
pagination={{
page: page(),
@ -268,16 +400,29 @@ export const DetailLogs: Component = () => {
},
pageSizeOptions: PAGE_SIZE_OPTIONS,
}}
rows={requestRows()}
tableLayout="fixed"
/>
{!logs.loading && requestRows().length === 0 && (
<EmptyState title="No logs found" description="Try a different month, date, or search term." />
<EmptyState
description="Try a different month, date, or search term."
title="No logs found"
/>
)}
</Panel>
<Panel title="Selected Log" description="Expanded metadata and serialized request/response snapshots for the active row.">
<Panel
description="Expanded metadata and serialized request/response snapshots for the active row."
title="Selected Log"
>
<Show
fallback={
<EmptyState
description="Select a row from the log table to inspect the request and response snapshots."
title="No log selected"
/>
}
when={selectedLog()}
fallback={<EmptyState title="No log selected" description="Select a row from the log table to inspect the request and response snapshots." />}
>
{(log) => (
<div class="ui-stack">
@ -289,19 +434,35 @@ export const DetailLogs: Component = () => {
{ key: 'Backend', value: String(log().backend_id) },
{ key: 'Endpoint', value: log().endpoint },
{ key: 'Status', value: String(log().status_code) },
{ key: 'Latency', value: `${log().response_time_ms ?? 0}ms` },
{ key: 'Verbose', value: log().detail_logged ? 'Yes' : 'No' },
{
key: 'Latency',
value: `${log().response_time_ms ?? 0}ms`,
},
{
key: 'Verbose',
value: log().detail_logged ? 'Yes' : 'No',
},
]}
/>
<Show when={log().error_message}>
<TextField label="Error" value={log().error_message ?? ''} multiline />
<TextField
label="Error"
multiline
value={log().error_message ?? ''}
/>
</Show>
<Tabs.Root defaultValue={selectedLogHasConversation() ? 'conversation' : 'request'}>
<Tabs.Root
defaultValue={
selectedLogHasConversation() ? 'conversation' : 'request'
}
>
<Tabs.List aria-label="Detail log inspector">
<Show when={selectedLogHasConversation()}>
<Tabs.Trigger value="conversation">Conversation</Tabs.Trigger>
<Tabs.Trigger value="conversation">
Conversation
</Tabs.Trigger>
</Show>
<Tabs.Trigger value="request">Request</Tabs.Trigger>
<Tabs.Trigger value="response">Response</Tabs.Trigger>
@ -319,15 +480,31 @@ export const DetailLogs: Component = () => {
<Tabs.Content value="request">
<div class="ui-stack">
<TextField label="Request Headers" value={prettyPrint(log().request_headers)} multiline />
<TextField label="Request Body" value={prettyPrint(log().request_body)} multiline />
<TextField
label="Request Headers"
multiline
value={prettyPrint(log().request_headers)}
/>
<TextField
label="Request Body"
multiline
value={prettyPrint(log().request_body)}
/>
</div>
</Tabs.Content>
<Tabs.Content value="response">
<div class="ui-stack">
<TextField label="Response Headers" value={prettyPrint(log().response_headers)} multiline />
<TextField label="Response Body" value={prettyPrint(log().response_body)} multiline />
<TextField
label="Response Headers"
multiline
value={prettyPrint(log().response_headers)}
/>
<TextField
label="Response Body"
multiline
value={prettyPrint(log().response_body)}
/>
</div>
</Tabs.Content>
@ -335,8 +512,8 @@ export const DetailLogs: Component = () => {
<div class="ui-stack">
<TextField
label="Raw Log JSON"
value={JSON.stringify(log(), null, 2)}
multiline
value={JSON.stringify(log(), null, 2)}
/>
</div>
</Tabs.Content>

View file

@ -1,10 +1,17 @@
import { createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
import {
createMemo,
createResource,
createSignal,
Show,
type Component,
} from 'solid-js';
import Pencil from 'lucide-solid/icons/pencil';
import Plus from 'lucide-solid/icons/plus';
import Trash2 from 'lucide-solid/icons/trash-2';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import type { ModelRewriteRule } from '../types';
import {
Alert,
Button,
@ -21,6 +28,8 @@ import {
TextField,
} from '../ui';
import type { ModelRewriteRule } from '../types';
interface RewriteFormState {
source_model: string;
target_model: string;
@ -38,16 +47,26 @@ const emptyForm = (): RewriteFormState => ({
});
export const Models: Component = () => {
const [overview, { refetch: refetchOverview }] = createResource(() => api.modelCache.getOverview());
const [overview, { refetch: refetchOverview }] = createResource(() =>
api.modelCache.getOverview(),
);
const [backends] = createResource(() => api.backends.getAll());
const [rules, { refetch: refetchRules }] = createResource(() => api.modelRewrites.getAll());
const [rules, { refetch: refetchRules }] = createResource(() =>
api.modelRewrites.getAll(),
);
const [dialogOpen, setDialogOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [editingRule, setEditingRule] = createSignal<ModelRewriteRule | null>(null);
const [pendingDeleteRule, setPendingDeleteRule] = createSignal<ModelRewriteRule | null>(null);
const [editingRule, setEditingRule] = createSignal<ModelRewriteRule | null>(
null,
);
const [pendingDeleteRule, setPendingDeleteRule] =
createSignal<ModelRewriteRule | null>(null);
const [form, setForm] = createSignal<RewriteFormState>(emptyForm());
const [submitting, setSubmitting] = createSignal(false);
const [notice, setNotice] = createSignal<{ tone: 'success' | 'danger'; message: string } | null>(null);
const [notice, setNotice] = createSignal<{
tone: 'success' | 'danger';
message: string;
} | null>(null);
const backendNameById = createMemo(() => {
const names = new Map<number, string>();
for (const backend of backends() ?? []) {
@ -56,12 +75,15 @@ export const Models: Component = () => {
return names;
});
const getBackendName = (backendId: number) => backendNameById().get(backendId) ?? `Backend ${backendId}`;
const getBackendName = (backendId: number) =>
backendNameById().get(backendId) ?? `Backend ${backendId}`;
const modelCatalogRows = createMemo(() =>
(overview()?.models ?? []).map((entry) => ({
...entry,
backend_names: entry.backend_ids.map((backendId) => getBackendName(backendId)).join(', '),
}))
backend_names: entry.backend_ids
.map((backendId) => getBackendName(backendId))
.join(', '),
})),
);
const openCreateDialog = () => {
@ -86,7 +108,10 @@ export const Models: Component = () => {
event.preventDefault();
const current = form();
if (!current.source_model.trim() || !current.target_model.trim()) {
setNotice({ tone: 'danger', message: 'Source and target model are required.' });
setNotice({
tone: 'danger',
message: 'Source and target model are required.',
});
return;
}
@ -116,7 +141,11 @@ export const Models: Component = () => {
setForm(emptyForm());
await Promise.all([refetchRules(), refetchOverview()]);
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Model rule save failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Model rule save failed.',
});
} finally {
setSubmitting(false);
}
@ -129,12 +158,21 @@ export const Models: Component = () => {
setSubmitting(true);
try {
await api.modelRewrites.delete(current.id);
setNotice({ tone: 'success', message: `${current.source_model} removed.` });
setNotice({
tone: 'success',
message: `${current.source_model} removed.`,
});
setConfirmOpen(false);
setPendingDeleteRule(null);
await Promise.all([refetchRules(), refetchOverview()]);
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Model rule deletion failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error
? error.message
: 'Model rule deletion failed.',
});
} finally {
setSubmitting(false);
}
@ -144,68 +182,155 @@ export const Models: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Models"
actions={
<Button
onClick={() =>
void Promise.all([refetchOverview(), refetchRules()])
}
>
Refresh
</Button>
}
description="Inspect cached backend model catalogs and manage global model rewrite rules."
actions={<Button onClick={() => void Promise.all([refetchOverview(), refetchRules()])}>Refresh</Button>}
title="Models"
/>
<SummaryStrip
items={[
{ label: 'Catalog Models', value: overview()?.models.length ?? 0, hint: 'Unique models across active backends' },
{ label: 'Tracked Backends', value: overview()?.backends.length ?? 0, hint: 'Memory cache status by backend' },
{ label: 'Rewrite Rules', value: rules()?.length ?? 0, hint: 'Global source -> target mappings' },
{
label: 'Catalog Models',
value: overview()?.models.length ?? 0,
hint: 'Unique models across active backends',
},
{
label: 'Tracked Backends',
value: overview()?.backends.length ?? 0,
hint: 'Memory cache status by backend',
},
{
label: 'Rewrite Rules',
value: rules()?.length ?? 0,
hint: 'Global source -> target mappings',
},
]}
/>
<Show when={notice()}>
{(currentNotice) => <Alert tone={currentNotice().tone}>{currentNotice().message}</Alert>}
{(currentNotice) => (
<Alert tone={currentNotice().tone}>{currentNotice().message}</Alert>
)}
</Show>
<div class="ui-section-grid">
<Panel title="Backend Cache Status" description="Memory-backed backend cache state used by request routing and `/v1/models`.">
<Panel
description="Memory-backed backend cache state used by request routing and `/v1/models`."
title="Backend Cache Status"
>
<Show
fallback={
<EmptyState
description="Backend model states appear here after the server has seen active backends."
title="No backend cache yet"
/>
}
when={(overview()?.backends.length ?? 0) > 0}
fallback={<EmptyState title="No backend cache yet" description="Backend model states appear here after the server has seen active backends." />}
>
<DataGrid
rows={overview()?.backends ?? []}
columns={[
{
id: 'backend_id',
header: 'Backend',
class: 'models__catalog-column',
cell: (item) => <span title={getBackendName(item.backend_id)}>{getBackendName(item.backend_id)}</span>,
cell: (item) => (
<span title={getBackendName(item.backend_id)}>
{getBackendName(item.backend_id)}
</span>
),
},
{
id: 'state',
header: 'State',
cell: (item) => (
<StatusBadge
tone={
item.state === 'ready'
? 'success'
: item.state === 'error'
? 'danger'
: item.state === 'inactive'
? 'neutral'
: 'warning'
}
>
{item.state}
</StatusBadge>
),
},
{
id: 'model_count',
header: 'Models',
cell: (item) => <span>{item.model_count}</span>,
},
{
id: 'last_synced_at',
header: 'Last Sync',
cell: (item) => (
<span>
{item.last_synced_at
? new Date(item.last_synced_at).toLocaleString()
: '-'}
</span>
),
},
{
id: 'last_error',
header: 'Last Error',
cell: (item) => (
<span title={item.last_error ?? '-'}>
{item.last_error ?? '-'}
</span>
),
},
{ id: 'state', header: 'State', cell: (item) => <StatusBadge tone={item.state === 'ready' ? 'success' : item.state === 'error' ? 'danger' : item.state === 'inactive' ? 'neutral' : 'warning'}>{item.state}</StatusBadge> },
{ id: 'model_count', header: 'Models', cell: (item) => <span>{item.model_count}</span> },
{ id: 'last_synced_at', header: 'Last Sync', cell: (item) => <span>{item.last_synced_at ? new Date(item.last_synced_at).toLocaleString() : '-'}</span> },
{ id: 'last_error', header: 'Last Error', cell: (item) => <span title={item.last_error ?? '-'}>{item.last_error ?? '-'}</span> },
]}
getRowKey={(item) => item.backend_id}
loading={overview.loading}
rows={overview()?.backends ?? []}
/>
</Show>
</Panel>
<Panel title="Model Catalog" description="Unique models and the backend names currently advertising each one.">
<Panel
description="Unique models and the backend names currently advertising each one."
title="Model Catalog"
>
<Show
fallback={
<EmptyState
description="Model catalog entries appear here after backend model snapshots are available."
title="No cached models yet"
/>
}
when={modelCatalogRows().length > 0}
fallback={<EmptyState title="No cached models yet" description="Model catalog entries appear here after backend model snapshots are available." />}
>
<DataGrid
rows={modelCatalogRows()}
columns={[
{
id: 'model_id',
header: 'Model',
class: 'models__catalog-column',
cell: (item) => <span title={item.model_id}>{item.model_id}</span>,
cell: (item) => (
<span title={item.model_id}>{item.model_id}</span>
),
},
{
id: 'backend_names',
header: 'Backends',
class: 'models__catalog-column',
cell: (item) => <span title={item.backend_names}>{item.backend_names}</span>,
cell: (item) => (
<span title={item.backend_names}>
{item.backend_names}
</span>
),
},
{
id: 'backend_count',
@ -217,93 +342,187 @@ export const Models: Component = () => {
]}
getRowKey={(item) => item.model_id}
loading={overview.loading}
rows={modelCatalogRows()}
/>
</Show>
</Panel>
</div>
<Panel
title="Model Rewrite Rules"
actions={
<IconButton
icon={<Plus />}
label="Add Rule"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Force rules always rewrite. Fallback rules rewrite only when the original model has no usable backend."
actions={<IconButton variant="primary" icon={<Plus />} label="Add Rule" onClick={openCreateDialog} />}
title="Model Rewrite Rules"
>
<div class="ui-stack ui-stack--tight">
<Show
fallback={
<EmptyState
description="Requests currently route using the original model name."
title="No rewrite rules"
/>
}
when={(rules()?.length ?? 0) > 0}
fallback={<EmptyState title="No rewrite rules" description="Requests currently route using the original model name." />}
>
<DataGrid
rows={rules() ?? []}
columns={[
{ id: 'source_model', header: 'Source', cell: (rule) => <span>{rule.source_model}</span> },
{ id: 'target_model', header: 'Target', cell: (rule) => <span>{rule.target_model}</span> },
{ id: 'mode', header: 'Mode', cell: (rule) => <StatusBadge tone={rule.force ? 'warning' : 'neutral'}>{rule.force ? 'Force' : 'Fallback'}</StatusBadge> },
{ id: 'is_active', header: 'Status', cell: (rule) => <StatusBadge tone={rule.is_active ? 'success' : 'warning'}>{rule.is_active ? 'Active' : 'Inactive'}</StatusBadge> },
{ id: 'note', header: 'Note', cell: (rule) => <span title={rule.note ?? '-'}>{rule.note ?? '-'}</span> },
{
id: 'source_model',
header: 'Source',
cell: (rule) => <span>{rule.source_model}</span>,
},
{
id: 'target_model',
header: 'Target',
cell: (rule) => <span>{rule.target_model}</span>,
},
{
id: 'mode',
header: 'Mode',
cell: (rule) => (
<StatusBadge tone={rule.force ? 'warning' : 'neutral'}>
{rule.force ? 'Force' : 'Fallback'}
</StatusBadge>
),
},
{
id: 'is_active',
header: 'Status',
cell: (rule) => (
<StatusBadge
tone={rule.is_active ? 'success' : 'warning'}
>
{rule.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
),
},
{
id: 'note',
header: 'Note',
cell: (rule) => (
<span title={rule.note ?? '-'}>{rule.note ?? '-'}</span>
),
},
]}
getRowKey={(rule) => rule.id}
loading={rules.loading}
rowActions={(rule) => (
<div class="ui-row-actions">
<IconButton icon={<Pencil />} label="Edit" onClick={() => openEditDialog(rule)} />
<IconButton
variant="danger"
icon={<Pencil />}
label="Edit"
onClick={() => openEditDialog(rule)}
/>
<IconButton
icon={<Trash2 />}
label="Delete"
onClick={() => {
setPendingDeleteRule(rule);
setConfirmOpen(true);
}}
variant="danger"
/>
</div>
)}
rows={rules() ?? []}
/>
</Show>
</div>
</Panel>
<FormDialog
open={dialogOpen()}
onOpenChange={setDialogOpen}
title={editingRule() ? 'Edit Model Rule' : 'Add Model Rule'}
description="Choose whether the target model should always replace the source, or only act as a fallback when the source is unavailable."
footer={
<>
<Button onClick={() => setDialogOpen(false)} disabled={submitting()}>Cancel</Button>
<Button type="submit" form="model-rule-form" variant="primary" disabled={submitting()}>
<Button
disabled={submitting()}
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button
disabled={submitting()}
form="model-rule-form"
type="submit"
variant="primary"
>
{editingRule() ? 'Save Changes' : 'Create Rule'}
</Button>
</>
}
onOpenChange={setDialogOpen}
open={dialogOpen()}
title={editingRule() ? 'Edit Model Rule' : 'Add Model Rule'}
>
<form id="model-rule-form" class="ui-form" onSubmit={(event) => void saveRule(event)}>
<TextField label="Source Model" value={form().source_model} onInput={(event) => setForm((current) => ({ ...current, source_model: event.currentTarget.value }))} />
<TextField label="Target Model" value={form().target_model} onInput={(event) => setForm((current) => ({ ...current, target_model: event.currentTarget.value }))} />
<TextField label="Note" value={form().note} onInput={(event) => setForm((current) => ({ ...current, note: event.currentTarget.value }))} />
<Checkbox
label="Always force rewrite"
description="When enabled, requests always route to the target model. When disabled, the target model is only used as a fallback."
checked={form().force}
onChange={(checked) => setForm((current) => ({ ...current, force: checked }))}
<form
class="ui-form"
id="model-rule-form"
onSubmit={(event) => void saveRule(event)}
>
<TextField
label="Source Model"
onInput={(event) =>
setForm((current) => ({
...current,
source_model: event.currentTarget.value,
}))
}
value={form().source_model}
/>
<TextField
label="Target Model"
onInput={(event) =>
setForm((current) => ({
...current,
target_model: event.currentTarget.value,
}))
}
value={form().target_model}
/>
<TextField
label="Note"
onInput={(event) =>
setForm((current) => ({
...current,
note: event.currentTarget.value,
}))
}
value={form().note}
/>
<Checkbox
checked={form().force}
description="When enabled, requests always route to the target model. When disabled, the target model is only used as a fallback."
label="Always force rewrite"
onChange={(checked) =>
setForm((current) => ({ ...current, force: checked }))
}
/>
<Checkbox
label="Rule is active"
description="Inactive rules stay stored but do not affect request routing."
checked={form().is_active}
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
description="Inactive rules stay stored but do not affect request routing."
label="Rule is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
/>
</form>
</FormDialog>
<ConfirmDialog
open={confirmOpen()}
onOpenChange={setConfirmOpen}
title="Delete rewrite rule"
description="Removing the rule stops rewriting requests that target this source model."
confirmLabel="Delete Rule"
tone="danger"
busy={submitting()}
confirmLabel="Delete Rule"
description="Removing the rule stops rewriting requests that target this source model."
onConfirm={() => void deleteRule()}
onOpenChange={setConfirmOpen}
open={confirmOpen()}
title="Delete rewrite rule"
tone="danger"
/>
</div>
</Layout>

View file

@ -1,6 +1,13 @@
import { createMemo, createResource, createSignal, lazy, Show, Suspense, type Component } from 'solid-js';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import {
createMemo,
createResource,
createSignal,
lazy,
Show,
Suspense,
type Component,
} from 'solid-js';
import Play from 'lucide-solid/icons/play';
import Plus from 'lucide-solid/icons/plus';
import Power from 'lucide-solid/icons/power';
@ -9,7 +16,10 @@ import RefreshCw from 'lucide-solid/icons/refresh-cw';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import Save from 'lucide-solid/icons/save';
import Trash2 from 'lucide-solid/icons/trash-2';
import type { ScriptType, UserScript } from '../types';
import { Layout } from '../components/Layout';
import { api } from '../api/client';
import {
Alert,
Button,
@ -29,8 +39,14 @@ import {
TextField,
} from '../ui';
import type { ScriptType, UserScript } from '../types';
type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
const ScriptEditor = lazy(() => import('../components/ScriptEditor').then((module) => ({ default: module.ScriptEditor })));
const ScriptEditor = lazy(() =>
import('../components/ScriptEditor').then((module) => ({
default: module.ScriptEditor,
})),
);
interface ScriptFormState {
id?: number;
@ -104,23 +120,55 @@ const scriptTypeLabels: Record<ScriptType, string> = {
};
export const Scripts: Component = () => {
const [scripts, { refetch: refetchScripts }] = createResource(() => api.scripts.getAll());
const [users, { refetch: refetchUsers }] = createResource(() => api.users.getAll());
const [backends, { refetch: refetchBackends }] = createResource(() => api.backends.getAll());
const [scripts, { refetch: refetchScripts }] = createResource(() =>
api.scripts.getAll(),
);
const [users, { refetch: refetchUsers }] = createResource(() =>
api.users.getAll(),
);
const [backends, { refetch: refetchBackends }] = createResource(() =>
api.backends.getAll(),
);
const [form, setForm] = createSignal<ScriptFormState>(emptyForm());
const [selectedScriptId, setSelectedScriptId] = createSignal<number | null>(null);
const [pendingDeleteScript, setPendingDeleteScript] = createSignal<UserScript | null>(null);
const [selectedScriptId, setSelectedScriptId] = createSignal<number | null>(
null,
);
const [pendingDeleteScript, setPendingDeleteScript] =
createSignal<UserScript | null>(null);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false);
const [notice, setNotice] = createSignal<{ tone: NoticeTone; message: string } | null>(null);
const [testResult, setTestResult] = createSignal<{ success: boolean; error?: string; executionTime?: number } | null>(null);
const [notice, setNotice] = createSignal<{
tone: NoticeTone;
message: string;
} | null>(null);
const [testResult, setTestResult] = createSignal<{
success: boolean;
error?: string;
executionTime?: number;
} | null>(null);
const [testing, setTesting] = createSignal(false);
const userOptions = createMemo(() => (users() ?? []).map((user) => ({ value: String(user.id), label: user.name })));
const backendOptions = createMemo(() => (backends() ?? []).map((backend) => ({ value: String(backend.id), label: backend.name })));
const userOptions = createMemo(() =>
(users() ?? []).map((user) => ({
value: String(user.id),
label: user.name,
})),
);
const backendOptions = createMemo(() =>
(backends() ?? []).map((backend) => ({
value: String(backend.id),
label: backend.name,
})),
);
const activeCount = createMemo(() => (scripts() ?? []).filter((script) => script.is_active).length);
const selectedScript = createMemo(() => (scripts() ?? []).find((script) => script.id === selectedScriptId()) ?? null);
const activeCount = createMemo(
() => (scripts() ?? []).filter((script) => script.is_active).length,
);
const selectedScript = createMemo(
() =>
(scripts() ?? []).find((script) => script.id === selectedScriptId()) ??
null,
);
const syncForm = (script?: UserScript | null) => {
if (!script) {
@ -135,17 +183,30 @@ export const Scripts: Component = () => {
id: script.id,
name: script.name,
script_type: script.script_type,
target_user_id: script.target_user_id ? String(script.target_user_id) : '',
target_backend_id: script.target_backend_id ? String(script.target_backend_id) : '',
target_user_id: script.target_user_id
? String(script.target_user_id)
: '',
target_backend_id: script.target_backend_id
? String(script.target_backend_id)
: '',
script_code: script.script_code,
is_active: script.is_active,
});
setTestResult(null);
};
const getTargetLabel = (script: Pick<UserScript, 'script_type' | 'target_user_id' | 'target_backend_id'>) => {
const user = (users() ?? []).find((item) => item.id === script.target_user_id);
const backend = (backends() ?? []).find((item) => item.id === script.target_backend_id);
const getTargetLabel = (
script: Pick<
UserScript,
'script_type' | 'target_user_id' | 'target_backend_id'
>,
) => {
const user = (users() ?? []).find(
(item) => item.id === script.target_user_id,
);
const backend = (backends() ?? []).find(
(item) => item.id === script.target_backend_id,
);
if (script.script_type === 'per-user-backend') {
return {
@ -171,7 +232,10 @@ export const Scripts: Component = () => {
const current = form();
if (!current.name.trim()) return 'Script name is required.';
if (!current.script_code.trim()) return 'Script code is required.';
if (current.script_type === 'per-user-backend' && (!current.target_user_id || !current.target_backend_id)) {
if (
current.script_type === 'per-user-backend' &&
(!current.target_user_id || !current.target_backend_id)
) {
return 'Select both a target user and backend.';
}
if (current.script_type === 'per-user' && !current.target_user_id) {
@ -194,8 +258,12 @@ export const Scripts: Component = () => {
const payload = {
name: current.name.trim(),
script_type: current.script_type,
target_user_id: current.target_user_id ? Number(current.target_user_id) : null,
target_backend_id: current.target_backend_id ? Number(current.target_backend_id) : null,
target_user_id: current.target_user_id
? Number(current.target_user_id)
: null,
target_backend_id: current.target_backend_id
? Number(current.target_backend_id)
: null,
script_code: current.script_code,
is_active: current.is_active,
};
@ -215,7 +283,13 @@ export const Scripts: Component = () => {
await refetchUsers();
await refetchBackends();
} catch (saveError) {
setNotice({ tone: 'danger', message: saveError instanceof Error ? saveError.message : 'Script save failed.' });
setNotice({
tone: 'danger',
message:
saveError instanceof Error
? saveError.message
: 'Script save failed.',
});
} finally {
setSubmitting(false);
}
@ -228,13 +302,20 @@ export const Scripts: Component = () => {
} else {
await api.scripts.activate(script.id);
}
setNotice({ tone: 'success', message: `${script.name} ${script.is_active ? 'deactivated' : 'activated'}.` });
setNotice({
tone: 'success',
message: `${script.name} ${script.is_active ? 'deactivated' : 'activated'}.`,
});
await refetchScripts();
if (selectedScriptId() === script.id) {
syncForm({ ...script, is_active: !script.is_active });
}
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Status update failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Status update failed.',
});
}
};
@ -258,7 +339,11 @@ export const Scripts: Component = () => {
}
await refetchScripts();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Script deletion failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Script deletion failed.',
});
} finally {
setSubmitting(false);
}
@ -267,7 +352,10 @@ export const Scripts: Component = () => {
const runTest = async () => {
const current = selectedScript();
if (!current) {
setNotice({ tone: 'warning', message: 'Save the script before running a test.' });
setNotice({
tone: 'warning',
message: 'Save the script before running a test.',
});
return;
}
@ -281,7 +369,10 @@ export const Scripts: Component = () => {
method: 'POST',
path: '/v1/chat/completions',
headers: { 'Content-Type': 'application/json' },
body: { model: 'test', messages: [{ role: 'user', content: 'test' }] },
body: {
model: 'test',
messages: [{ role: 'user', content: 'test' }],
},
isStream: false,
},
});
@ -300,13 +391,23 @@ export const Scripts: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Scripts"
description="Create and maintain request and response middleware with compact editing, metadata, and test feedback."
title="Scripts"
/>
<Show when={notice()}>
{(currentNotice) => (
<Alert tone={currentNotice().tone === 'danger' ? 'danger' : currentNotice().tone === 'warning' ? 'warning' : currentNotice().tone === 'success' ? 'success' : 'info'}>
<Alert
tone={
currentNotice().tone === 'danger'
? 'danger'
: currentNotice().tone === 'warning'
? 'warning'
: currentNotice().tone === 'success'
? 'success'
: 'info'
}
>
{currentNotice().message}
</Alert>
)}
@ -314,38 +415,53 @@ export const Scripts: Component = () => {
<CommandBar>
<CommandBarGroup>
<StatusBadge tone="info">{scripts.loading ? 'Syncing' : 'Ready'}</StatusBadge>
<StatusBadge tone="info">
{scripts.loading ? 'Syncing' : 'Ready'}
</StatusBadge>
<StatusBadge tone="success">{`${activeCount()} active`}</StatusBadge>
</CommandBarGroup>
</CommandBar>
<div class="ui-split-panel">
<Panel
title="Script registry"
description="Select a script to edit, test, or change activation state."
actions={
<IconButton icon={<RefreshCw />} label="Refresh" onClick={() => void refetchScripts()} />
<IconButton
icon={<RefreshCw />}
label="Refresh"
onClick={() => void refetchScripts()}
/>
}
bodyClass="ui-stack ui-stack--tight"
description="Select a script to edit, test, or change activation state."
title="Script registry"
>
<Show
fallback={
<EmptyState
description="Reading middleware definitions and target mappings."
title="Loading scripts"
/>
}
when={!scripts.loading || (scripts()?.length ?? 0) > 0}
fallback={<EmptyState title="Loading scripts" description="Reading middleware definitions and target mappings." />}
>
<Show
when={(scripts()?.length ?? 0) > 0}
fallback={
<EmptyState
title="No scripts yet"
description="Create your first middleware script to intercept requests or responses."
action={
<IconButton variant="primary" icon={<Plus />} label="Create Script" onClick={() => syncForm(null)} />
<IconButton
icon={<Plus />}
label="Create Script"
onClick={() => syncForm(null)}
variant="primary"
/>
}
description="Create your first middleware script to intercept requests or responses."
title="No scripts yet"
/>
}
when={(scripts()?.length ?? 0) > 0}
>
<DataGrid
rows={scripts() ?? []}
columns={[
{
id: 'name',
@ -355,7 +471,11 @@ export const Scripts: Component = () => {
{
id: 'type',
header: 'Type',
cell: (script) => <StatusBadge tone="info">{scriptTypeLabels[script.script_type]}</StatusBadge>,
cell: (script) => (
<StatusBadge tone="info">
{scriptTypeLabels[script.script_type]}
</StatusBadge>
),
},
{
id: 'target',
@ -364,8 +484,12 @@ export const Scripts: Component = () => {
const target = getTargetLabel(script);
return (
<div class="script-target">
<p class="script-target__primary">{target.primary}</p>
<p class="script-target__secondary">{target.secondary}</p>
<p class="script-target__primary">
{target.primary}
</p>
<p class="script-target__secondary">
{target.secondary}
</p>
</div>
);
},
@ -373,7 +497,13 @@ export const Scripts: Component = () => {
{
id: 'status',
header: 'Status',
cell: (script) => <StatusBadge tone={script.is_active ? 'success' : 'warning'}>{script.is_active ? 'Active' : 'Inactive'}</StatusBadge>,
cell: (script) => (
<StatusBadge
tone={script.is_active ? 'success' : 'warning'}
>
{script.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
),
},
]}
getRowKey={(script) => script.id}
@ -386,80 +516,139 @@ export const Scripts: Component = () => {
label={script.is_active ? 'Disable' : 'Enable'}
onClick={() => void toggleActive(script)}
/>
<IconButton variant="danger" icon={<Trash2 />} label="Delete" onClick={() => requestDelete(script)} />
<IconButton
icon={<Trash2 />}
label="Delete"
onClick={() => requestDelete(script)}
variant="danger"
/>
</div>
)}
rows={scripts() ?? []}
/>
</Show>
</Show>
</Panel>
<Panel
title={form().id ? `Editing ${form().name}` : 'New script draft'}
description="Configure middleware scripts, run validation tests before applying changes."
actions={
<div class="ui-chip-group">
<StatusBadge tone={form().is_active ? 'success' : 'warning'}>{form().is_active ? 'Active' : 'Draft'}</StatusBadge>
<StatusBadge tone={form().is_active ? 'success' : 'warning'}>
{form().is_active ? 'Active' : 'Draft'}
</StatusBadge>
<IconButton
variant="primary"
disabled={submitting()}
icon={<Save />}
label={form().id ? 'Save Script' : 'Create Script'}
onClick={() => void saveScript()}
disabled={submitting()}
variant="primary"
/>
<IconButton
icon={<Plus />}
label="New Script"
onClick={() => syncForm(null)}
/>
<IconButton
icon={<RotateCcw />}
label="Reset"
onClick={() => syncForm(selectedScript())}
/>
<IconButton icon={<Plus />} label="New Script" onClick={() => syncForm(null)} />
<IconButton icon={<RotateCcw />} label="Reset" onClick={() => syncForm(selectedScript())} />
</div>
}
bodyClass="ui-stack"
description="Configure middleware scripts, run validation tests before applying changes."
title={form().id ? `Editing ${form().name}` : 'New script draft'}
>
<div class="ui-form__section">
<TextField label="Script name" value={form().name} onInput={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))} />
<TextField
label="Script name"
onInput={(event) =>
setForm((current) => ({
...current,
name: event.currentTarget.value,
}))
}
value={form().name}
/>
<Select
label="Scope"
value={form().script_type}
onChange={(value) => setForm((current) => ({ ...current, script_type: value as ScriptType, target_user_id: '', target_backend_id: '' }))}
onChange={(value) =>
setForm((current) => ({
...current,
script_type: value as ScriptType,
target_user_id: '',
target_backend_id: '',
}))
}
options={[
{ value: 'per-user-backend', label: scriptTypeLabels['per-user-backend'] },
{
value: 'per-user-backend',
label: scriptTypeLabels['per-user-backend'],
},
{ value: 'per-user', label: scriptTypeLabels['per-user'] },
{ value: 'per-backend', label: scriptTypeLabels['per-backend'] },
{
value: 'per-backend',
label: scriptTypeLabels['per-backend'],
},
]}
value={form().script_type}
/>
<Show when={form().script_type !== 'per-backend'}>
<Select
label="Target user"
value={form().target_user_id}
onChange={(value) => setForm((current) => ({ ...current, target_user_id: value }))}
onChange={(value) =>
setForm((current) => ({
...current,
target_user_id: value,
}))
}
options={userOptions()}
placeholder="Select user"
value={form().target_user_id}
/>
</Show>
<Show when={form().script_type !== 'per-user'}>
<Select
label="Target backend"
value={form().target_backend_id}
onChange={(value) => setForm((current) => ({ ...current, target_backend_id: value }))}
onChange={(value) =>
setForm((current) => ({
...current,
target_backend_id: value,
}))
}
options={backendOptions()}
placeholder="Select backend"
value={form().target_backend_id}
/>
</Show>
<Checkbox
label="Script is active"
description="Inactive scripts remain editable but are skipped during routing."
checked={form().is_active}
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
description="Inactive scripts remain editable but are skipped during routing."
label="Script is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
/>
</div>
<MetaCluster
items={[
{ key: 'Mode', value: form().id ? 'Saved script' : 'Unsaved draft' },
{ key: 'User context', value: form().target_user_id || 'Not assigned' },
{ key: 'Backend context', value: form().target_backend_id || 'Not assigned' },
{
key: 'Mode',
value: form().id ? 'Saved script' : 'Unsaved draft',
},
{
key: 'User context',
value: form().target_user_id || 'Not assigned',
},
{
key: 'Backend context',
value: form().target_backend_id || 'Not assigned',
},
]}
/>
@ -469,27 +658,59 @@ export const Scripts: Component = () => {
<Tabs.Trigger value="test">Test</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor">
<Suspense fallback={<Panel title="Loading Editor" description="Preparing the Monaco runtime for this script." class="script-editor__fallback-panel" />}>
<Suspense
fallback={
<Panel
class="script-editor__fallback-panel"
description="Preparing the Monaco runtime for this script."
title="Loading Editor"
/>
}
>
<ScriptEditor
onChange={(value) =>
setForm((current) => ({ ...current, script_code: value }))
}
path={
form().id
? `inmemory://model/scripts/${form().id}.ts`
: 'inmemory://model/scripts/draft.ts'
}
value={form().script_code}
path={form().id ? `inmemory://model/scripts/${form().id}.ts` : 'inmemory://model/scripts/draft.ts'}
onChange={(value) => setForm((current) => ({ ...current, script_code: value }))}
/>
</Suspense>
</Tabs.Content>
<Tabs.Content value="test">
<div class="ui-stack">
<p class="ui-copy">The test runner uses the first available user/backend as sample context and a mock chat completion request.</p>
<p class="ui-copy">
The test runner uses the first available user/backend as
sample context and a mock chat completion request.
</p>
<div class="ui-row-actions">
<IconButton variant="primary" icon={<Play />} label={testing() ? 'Running...' : 'Run Test'} onClick={() => void runTest()} disabled={testing()} />
<IconButton
disabled={testing()}
icon={<Play />}
label={testing() ? 'Running...' : 'Run Test'}
onClick={() => void runTest()}
variant="primary"
/>
</div>
<Show
fallback={
<EmptyState
description="Save or select a script, then run the built-in test harness to inspect the result."
title="No test run yet"
/>
}
when={testResult()}
fallback={<EmptyState title="No test run yet" description="Save or select a script, then run the built-in test harness to inspect the result." />}
>
{(result) => (
<Alert tone={result().success ? 'success' : 'danger'} title={result().success ? 'Test passed' : 'Test failed'}>
{result().error ?? `Execution time: ${result().executionTime ?? 0}ms`}
<Alert
title={result().success ? 'Test passed' : 'Test failed'}
tone={result().success ? 'success' : 'danger'}
>
{result().error ??
`Execution time: ${result().executionTime ?? 0}ms`}
</Alert>
)}
</Show>
@ -500,20 +721,19 @@ export const Scripts: Component = () => {
</div>
<ConfirmDialog
open={confirmOpen()}
onOpenChange={setConfirmOpen}
title="Delete script"
description="This permanently removes the middleware definition and its current target binding."
confirmLabel="Delete Script"
tone="danger"
busy={submitting()}
confirmLabel="Delete Script"
description="This permanently removes the middleware definition and its current target binding."
details={
<Show when={pendingDeleteScript()}>
{(script) => (
<MetaCluster
items={[
{ key: 'Name', value: script().name },
{ key: 'Type', value: scriptTypeLabels[script().script_type] },
{
key: 'Type',
value: scriptTypeLabels[script().script_type],
},
{ key: 'Target', value: getTargetLabel(script()).primary },
]}
/>
@ -521,6 +741,10 @@ export const Scripts: Component = () => {
</Show>
}
onConfirm={() => void deleteScript()}
onOpenChange={setConfirmOpen}
open={confirmOpen()}
title="Delete script"
tone="danger"
/>
</div>
</Layout>

View file

@ -1,4 +1,11 @@
import { createEffect, createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
import {
createEffect,
createMemo,
createResource,
createSignal,
Show,
type Component,
} from 'solid-js';
import Copy from 'lucide-solid/icons/copy';
import Ellipsis from 'lucide-solid/icons/ellipsis';
import KeyRound from 'lucide-solid/icons/key-round';
@ -6,9 +13,10 @@ import Pencil from 'lucide-solid/icons/pencil';
import Plus from 'lucide-solid/icons/plus';
import ShieldMinus from 'lucide-solid/icons/shield-minus';
import Trash2 from 'lucide-solid/icons/trash-2';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import type { User } from '../types';
import {
Alert,
Button,
@ -30,6 +38,8 @@ import {
TextField,
} from '../ui';
import type { User } from '../types';
type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
interface UserFormState {
@ -51,21 +61,33 @@ const emptyForm = (): UserFormState => ({
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
export const Users: Component = () => {
const [users, { refetch: refetchUsers }] = createResource(() => api.users.getAll());
const [users, { refetch: refetchUsers }] = createResource(() =>
api.users.getAll(),
);
const [backends] = createResource(() => api.backends.getAll());
const [permissions, { refetch: refetchPermissions }] = createResource(() => api.permissions.getAll());
const [permissions, { refetch: refetchPermissions }] = createResource(() =>
api.permissions.getAll(),
);
const [query, setQuery] = createSignal('');
const [dialogOpen, setDialogOpen] = createSignal(false);
const [userDeleteConfirmOpen, setUserDeleteConfirmOpen] = createSignal(false);
const [permissionDialogOpen, setPermissionDialogOpen] = createSignal(false);
const [permissionConfirmOpen, setPermissionConfirmOpen] = createSignal(false);
const [editingUser, setEditingUser] = createSignal<User | null>(null);
const [pendingDeleteUser, setPendingDeleteUser] = createSignal<User | null>(null);
const [pendingDeleteUser, setPendingDeleteUser] = createSignal<User | null>(
null,
);
const [selectedUserId, setSelectedUserId] = createSignal<number | null>(null);
const [pendingDeletePermission, setPendingDeletePermission] = createSignal<{ user_id: number; backend_id: number } | null>(null);
const [pendingDeletePermission, setPendingDeletePermission] = createSignal<{
user_id: number;
backend_id: number;
} | null>(null);
const [permissionBackendId, setPermissionBackendId] = createSignal('');
const [submitting, setSubmitting] = createSignal(false);
const [notice, setNotice] = createSignal<{ tone: NoticeTone; message: string } | null>(null);
const [notice, setNotice] = createSignal<{
tone: NoticeTone;
message: string;
} | null>(null);
const [form, setForm] = createSignal<UserFormState>(emptyForm());
const filteredUsers = createMemo(() => {
@ -73,23 +95,36 @@ export const Users: Component = () => {
const list = users() ?? [];
if (!value) return list;
return list.filter((user) => {
const haystack = [user.name, user.email ?? '', user.api_key].join(' ').toLowerCase();
const haystack = [user.name, user.email ?? '', user.api_key]
.join(' ')
.toLowerCase();
return haystack.includes(value);
});
});
const activeCount = createMemo(() => (users() ?? []).filter((user) => user.is_active).length);
const selectedUser = createMemo(() => (users() ?? []).find((user) => user.id === selectedUserId()) ?? null);
const activeCount = createMemo(
() => (users() ?? []).filter((user) => user.is_active).length,
);
const selectedUser = createMemo(
() => (users() ?? []).find((user) => user.id === selectedUserId()) ?? null,
);
const permissionsForSelectedUser = createMemo(() => {
const currentUserId = selectedUserId();
if (!currentUserId) return [];
return (permissions() ?? []).filter((permission) => permission.user_id === currentUserId);
return (permissions() ?? []).filter(
(permission) => permission.user_id === currentUserId,
);
});
const assignedBackendIds = createMemo(() => new Set(permissionsForSelectedUser().map((permission) => permission.backend_id)));
const assignedBackendIds = createMemo(
() =>
new Set(
permissionsForSelectedUser().map((permission) => permission.backend_id),
),
);
const availableBackendOptions = createMemo(() =>
(backends() ?? [])
.filter((backend) => !assignedBackendIds().has(backend.id))
.map((backend) => ({ value: String(backend.id), label: backend.name }))
.map((backend) => ({ value: String(backend.id), label: backend.name })),
);
const backendNameById = createMemo(() => {
const names = new Map<number, string>();
@ -110,7 +145,10 @@ export const Users: Component = () => {
return;
}
if (currentSelectedUserId === null || !list.some((user) => user.id === currentSelectedUserId)) {
if (
currentSelectedUserId === null ||
!list.some((user) => user.id === currentSelectedUserId)
) {
setSelectedUserId(list[0].id);
}
});
@ -168,7 +206,10 @@ export const Users: Component = () => {
setEditingUser(null);
await refetchUsers();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'User save failed.' });
setNotice({
tone: 'danger',
message: error instanceof Error ? error.message : 'User save failed.',
});
} finally {
setSubmitting(false);
}
@ -177,10 +218,19 @@ export const Users: Component = () => {
const handleRegenerateApiKey = async (user: User) => {
try {
await api.users.regenerateApiKey(user.id);
setNotice({ tone: 'success', message: `API key regenerated for ${user.name}.` });
setNotice({
tone: 'success',
message: `API key regenerated for ${user.name}.`,
});
await refetchUsers();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'API key regeneration failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error
? error.message
: 'API key regeneration failed.',
});
}
};
@ -189,7 +239,11 @@ export const Users: Component = () => {
await navigator.clipboard.writeText(apiKey);
setNotice({ tone: 'success', message: 'API key copied to clipboard.' });
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Clipboard copy failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Clipboard copy failed.',
});
}
};
@ -210,7 +264,11 @@ export const Users: Component = () => {
setPendingDeleteUser(null);
await Promise.all([refetchUsers(), refetchPermissions()]);
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'User deletion failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'User deletion failed.',
});
} finally {
setSubmitting(false);
}
@ -226,24 +284,40 @@ export const Users: Component = () => {
const user = selectedUser();
if (!user) {
setNotice({ tone: 'warning', message: 'Select a user before granting backend access.' });
setNotice({
tone: 'warning',
message: 'Select a user before granting backend access.',
});
return;
}
if (!permissionBackendId()) {
setNotice({ tone: 'danger', message: 'Select a backend to grant access.' });
setNotice({
tone: 'danger',
message: 'Select a backend to grant access.',
});
return;
}
setSubmitting(true);
try {
await api.permissions.create({ user_id: user.id, backend_id: Number(permissionBackendId()) });
setNotice({ tone: 'success', message: `Backend access granted to ${user.name}.` });
await api.permissions.create({
user_id: user.id,
backend_id: Number(permissionBackendId()),
});
setNotice({
tone: 'success',
message: `Backend access granted to ${user.name}.`,
});
setPermissionBackendId('');
setPermissionDialogOpen(false);
await refetchPermissions();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Permission grant failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Permission grant failed.',
});
} finally {
setSubmitting(false);
}
@ -269,7 +343,11 @@ export const Users: Component = () => {
setPendingDeletePermission(null);
await refetchPermissions();
} catch (error) {
setNotice({ tone: 'danger', message: error instanceof Error ? error.message : 'Permission revoke failed.' });
setNotice({
tone: 'danger',
message:
error instanceof Error ? error.message : 'Permission revoke failed.',
});
} finally {
setSubmitting(false);
}
@ -279,14 +357,31 @@ export const Users: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Users"
actions={
<IconButton
icon={<Plus />}
label="Add User"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Manage API identities, lifecycle state, and operational access for the router."
actions={<IconButton variant="primary" icon={<Plus />} label="Add User" onClick={openCreateDialog} />}
title="Users"
/>
<Show when={notice()}>
{(currentNotice) => (
<Alert tone={currentNotice().tone === 'danger' ? 'danger' : currentNotice().tone === 'warning' ? 'warning' : currentNotice().tone === 'success' ? 'success' : 'info'}>
<Alert
tone={
currentNotice().tone === 'danger'
? 'danger'
: currentNotice().tone === 'warning'
? 'warning'
: currentNotice().tone === 'success'
? 'success'
: 'info'
}
>
{currentNotice().message}
</Alert>
)}
@ -294,7 +389,11 @@ export const Users: Component = () => {
<CommandBar>
<CommandBarGroup>
<TextField label="Search users" value={query()} onInput={(event) => setQuery(event.currentTarget.value)} />
<TextField
label="Search users"
onInput={(event) => setQuery(event.currentTarget.value)}
value={query()}
/>
</CommandBarGroup>
<CommandBarGroup>
<StatusBadge tone="success">{`${activeCount()} active`}</StatusBadge>
@ -306,26 +405,37 @@ export const Users: Component = () => {
<div class="ui-section-grid">
<Panel
title="User registry"
description="Dense operational view with API key overflow handling and row-level actions."
bodyClass="ui-stack ui-stack--tight"
description="Dense operational view with API key overflow handling and row-level actions."
title="User registry"
>
<Show
fallback={
<EmptyState
description="Fetching identities and access state from the admin API."
title="Loading users"
/>
}
when={!users.loading || filteredUsers().length > 0}
fallback={<EmptyState title="Loading users" description="Fetching identities and access state from the admin API." />}
>
<Show
when={filteredUsers().length > 0 || users.loading}
fallback={
<EmptyState
title="No users yet"
action={
<IconButton
icon={<Plus />}
label="Add User"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Create the first user to issue an API key and start routing traffic."
action={<IconButton variant="primary" icon={<Plus />} label="Add User" onClick={openCreateDialog} />}
title="No users yet"
/>
}
when={filteredUsers().length > 0 || users.loading}
>
<DataGrid
rows={filteredUsers()}
columns={[
{
id: 'id',
@ -342,7 +452,11 @@ export const Users: Component = () => {
id: 'email',
header: 'Email',
truncate: true,
cell: (user) => <span title={user.email ?? '-'}>{user.email || '-'}</span>,
cell: (user) => (
<span title={user.email ?? '-'}>
{user.email || '-'}
</span>
),
},
{
id: 'api_key',
@ -350,45 +464,76 @@ export const Users: Component = () => {
class: 'ui-text-mono',
cell: (user) => (
<div class="api-key-cell">
<span class="api-key-cell__value" title="Hidden by default">
<span
class="api-key-cell__value"
title="Hidden by default"
>
{maskApiKey(user.api_key)}
</span>
<IconButton icon={<Copy />} label="Copy" onClick={() => void handleCopyApiKey(user.api_key)} />
<IconButton
icon={<Copy />}
label="Copy"
onClick={() => void handleCopyApiKey(user.api_key)}
/>
</div>
),
},
{
id: 'detail_logging',
header: 'Detail Log',
cell: (user) => <StatusBadge tone={user.detail_logging ? 'warning' : 'neutral'}>{user.detail_logging ? 'On' : 'Off'}</StatusBadge>,
cell: (user) => (
<StatusBadge
tone={user.detail_logging ? 'warning' : 'neutral'}
>
{user.detail_logging ? 'On' : 'Off'}
</StatusBadge>
),
},
{
id: 'status',
header: 'Status',
cell: (user) => <StatusBadge tone={user.is_active ? 'success' : 'danger'}>{user.is_active ? 'Active' : 'Inactive'}</StatusBadge>,
cell: (user) => (
<StatusBadge
tone={user.is_active ? 'success' : 'danger'}
>
{user.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
),
},
]}
emptyMessage="No users match the current search."
getRowKey={(user) => user.id}
loading={users.loading}
emptyMessage="No users match the current search."
onRowClick={(user) => setSelectedUserId(user.id)}
rowActions={(user) => (
<div class="ui-row-actions">
<IconButton icon={<KeyRound />} label="Regenerate" onClick={() => void handleRegenerateApiKey(user)} />
<IconButton
icon={<KeyRound />}
label="Regenerate"
onClick={() => void handleRegenerateApiKey(user)}
/>
<DropdownMenu.Root>
<DropdownMenu.Trigger as={Button} class="ui-button--icon" aria-label="More actions">
<span class="ui-button__icon" aria-hidden="true">
<DropdownMenu.Trigger
aria-label="More actions"
as={Button}
class="ui-button--icon"
>
<span aria-hidden="true" class="ui-button__icon">
<Ellipsis />
</span>
<span class="ui-button__label">More</span>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => openEditDialog(user)}>
<DropdownMenu.Item
onSelect={() => openEditDialog(user)}
>
<Pencil />
Edit
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => requestDelete(user)}>
<DropdownMenu.Item
onSelect={() => requestDelete(user)}
>
<Trash2 />
Delete
</DropdownMenu.Item>
@ -397,20 +542,38 @@ export const Users: Component = () => {
</DropdownMenu.Root>
</div>
)}
rows={filteredUsers()}
/>
</Show>
</Show>
</Panel>
<Panel
title={selectedUser() ? `${selectedUser()!.name} Access` : 'User Access'}
description="Grant or revoke backend access for the currently selected user."
actions={<IconButton variant="primary" icon={<Plus />} label="Grant Backend" onClick={openPermissionDialog} disabled={!selectedUser() || availableBackendOptions().length === 0} />}
actions={
<IconButton
disabled={
!selectedUser() || availableBackendOptions().length === 0
}
icon={<Plus />}
label="Grant Backend"
onClick={openPermissionDialog}
variant="primary"
/>
}
bodyClass="ui-stack ui-stack--tight"
description="Grant or revoke backend access for the currently selected user."
title={
selectedUser() ? `${selectedUser()!.name} Access` : 'User Access'
}
>
<Show
fallback={
<EmptyState
description="Select a user from the registry to manage backend access."
title="No user selected"
/>
}
when={selectedUser()}
fallback={<EmptyState title="No user selected" description="Select a user from the registry to manage backend access." />}
>
{(user) => (
<>
@ -418,57 +581,106 @@ export const Users: Component = () => {
items={[
{ key: 'User', value: user().name },
{ key: 'Email', value: user().email ?? '-' },
{ key: 'Assigned backends', value: String(permissionsForSelectedUser().length) },
{
key: 'Assigned backends',
value: String(permissionsForSelectedUser().length),
},
]}
/>
<Show
when={!permissions.loading || permissionsForSelectedUser().length > 0}
fallback={<EmptyState title="Loading access" description="Reading backend assignments for the selected user." />}
fallback={
<EmptyState
description="Reading backend assignments for the selected user."
title="Loading access"
/>
}
when={
!permissions.loading ||
permissionsForSelectedUser().length > 0
}
>
<Show
when={permissionsForSelectedUser().length > 0}
fallback={
<EmptyState
title="No backend access yet"
action={
<IconButton
disabled={availableBackendOptions().length === 0}
icon={<Plus />}
label="Grant Backend"
onClick={openPermissionDialog}
variant="primary"
/>
}
description="Grant this user access to a backend to allow routing."
action={<IconButton variant="primary" icon={<Plus />} label="Grant Backend" onClick={openPermissionDialog} disabled={availableBackendOptions().length === 0} />}
title="No backend access yet"
/>
}
when={permissionsForSelectedUser().length > 0}
>
<DataGrid
rows={permissionsForSelectedUser()}
columns={[
{
id: 'backend',
header: 'Backend',
cell: (permission) => <span title={backendNameById().get(permission.backend_id) ?? `Backend #${permission.backend_id}`}>{backendNameById().get(permission.backend_id) ?? `Backend #${permission.backend_id}`}</span>,
cell: (permission) => (
<span
title={
backendNameById().get(
permission.backend_id,
) ?? `Backend #${permission.backend_id}`
}
>
{backendNameById().get(permission.backend_id) ??
`Backend #${permission.backend_id}`}
</span>
),
},
{
id: 'created_at',
header: 'Granted',
cell: (permission) => <span>{new Date(permission.created_at).toLocaleString()}</span>,
cell: (permission) => (
<span>
{new Date(
permission.created_at,
).toLocaleString()}
</span>
),
},
{
id: 'status',
header: 'Status',
cell: () => <StatusBadge tone="success">Assigned</StatusBadge>,
cell: () => (
<StatusBadge tone="success">Assigned</StatusBadge>
),
},
]}
getRowKey={(permission) => `${permission.user_id}-${permission.backend_id}`}
getRowKey={(permission) =>
`${permission.user_id}-${permission.backend_id}`
}
loading={permissions.loading || backends.loading}
rowActions={(permission) => (
<IconButton
variant="danger"
icon={<ShieldMinus />}
label="Revoke"
onClick={() => requestPermissionDelete(permission.backend_id)}
onClick={() =>
requestPermissionDelete(permission.backend_id)
}
variant="danger"
/>
)}
rows={permissionsForSelectedUser()}
/>
</Show>
</Show>
<Show when={availableBackendOptions().length === 0 && permissionsForSelectedUser().length > 0}>
<Alert tone="info">All available backends are already assigned to this user.</Alert>
<Show
when={
availableBackendOptions().length === 0 &&
permissionsForSelectedUser().length > 0
}
>
<Alert tone="info">
All available backends are already assigned to this user.
</Alert>
</Show>
</>
)}
@ -477,66 +689,97 @@ export const Users: Component = () => {
</div>
<FormDialog
open={dialogOpen()}
onOpenChange={setDialogOpen}
title={editingUser() ? 'Edit User' : 'Add User'}
class="ui-dialog__content--compact"
description="Compact form dialog for user identity and lifecycle status."
footer={
<>
<Button onClick={() => setDialogOpen(false)} disabled={submitting()}>
<Button
disabled={submitting()}
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" variant="primary" form="user-form" disabled={submitting()}>
<Button
disabled={submitting()}
form="user-form"
type="submit"
variant="primary"
>
{editingUser() ? 'Save Changes' : 'Create User'}
</Button>
</>
}
class="ui-dialog__content--compact"
onOpenChange={setDialogOpen}
open={dialogOpen()}
title={editingUser() ? 'Edit User' : 'Add User'}
>
<form id="user-form" class="ui-form" onSubmit={(event) => void handleSubmit(event)}>
<TextField label="Name" value={form().name} onInput={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))} />
<form
class="ui-form"
id="user-form"
onSubmit={(event) => void handleSubmit(event)}
>
<TextField
label="Email"
value={form().email}
placeholder="ops@example.com"
onInput={(event) => setForm((current) => ({ ...current, email: event.currentTarget.value }))}
label="Name"
onInput={(event) =>
setForm((current) => ({
...current,
name: event.currentTarget.value,
}))
}
value={form().name}
/>
<TextField
label="Email"
onInput={(event) =>
setForm((current) => ({
...current,
email: event.currentTarget.value,
}))
}
placeholder="ops@example.com"
value={form().email}
/>
<TextField
label="API Key"
value={form().api_key}
placeholder="Leave blank to auto-generate"
description={
editingUser()
? 'Set a replacement key for migrations or leave blank to keep the current key.'
: 'Optional. Paste a legacy key to preserve it during migration, or leave blank to auto-generate.'
}
onInput={(event) => setForm((current) => ({ ...current, api_key: event.currentTarget.value }))}
label="API Key"
onInput={(event) =>
setForm((current) => ({
...current,
api_key: event.currentTarget.value,
}))
}
placeholder="Leave blank to auto-generate"
value={form().api_key}
/>
<Show when={editingUser()}>
<Checkbox
label="User is active"
description="Inactive users keep their record but cannot route traffic."
checked={form().is_active}
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
description="Inactive users keep their record but cannot route traffic."
label="User is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
/>
</Show>
<Checkbox
label="Enable detailed logging"
description="When enabled, proxied request and response headers/bodies are stored for this user."
checked={form().detail_logging}
onChange={(checked) => setForm((current) => ({ ...current, detail_logging: checked }))}
description="When enabled, proxied request and response headers/bodies are stored for this user."
label="Enable detailed logging"
onChange={(checked) =>
setForm((current) => ({ ...current, detail_logging: checked }))
}
/>
</form>
</FormDialog>
<ConfirmDialog
open={userDeleteConfirmOpen()}
onOpenChange={setUserDeleteConfirmOpen}
title="Delete user"
description="This removes the user record and invalidates the current API key."
confirmLabel="Delete User"
tone="danger"
busy={submitting()}
confirmLabel="Delete User"
description="This removes the user record and invalidates the current API key."
details={
<Show when={pendingDeleteUser()}>
{(user) => (
@ -550,63 +793,105 @@ export const Users: Component = () => {
</Show>
}
onConfirm={() => void handleDelete()}
onOpenChange={setUserDeleteConfirmOpen}
open={userDeleteConfirmOpen()}
title="Delete user"
tone="danger"
/>
<FormDialog
open={permissionDialogOpen()}
onOpenChange={setPermissionDialogOpen}
title={selectedUser() ? `Grant Backend to ${selectedUser()!.name}` : 'Grant Backend'}
class="ui-dialog__content--compact"
description="Assign backend access for the selected user."
footer={
<>
<Button onClick={() => setPermissionDialogOpen(false)} disabled={submitting()}>
<Button
disabled={submitting()}
onClick={() => setPermissionDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" form="user-permission-form" variant="primary" disabled={submitting() || !selectedUser() || availableBackendOptions().length === 0}>
<Button
disabled={
submitting() ||
!selectedUser() ||
availableBackendOptions().length === 0
}
form="user-permission-form"
type="submit"
variant="primary"
>
Grant
</Button>
</>
}
class="ui-dialog__content--compact"
onOpenChange={setPermissionDialogOpen}
open={permissionDialogOpen()}
title={
selectedUser()
? `Grant Backend to ${selectedUser()!.name}`
: 'Grant Backend'
}
>
<form id="user-permission-form" class="ui-form" onSubmit={(event) => void createPermission(event)}>
<form
class="ui-form"
id="user-permission-form"
onSubmit={(event) => void createPermission(event)}
>
<Show
fallback={
<Alert tone="warning">
Select a user before granting backend access.
</Alert>
}
when={selectedUser()}
fallback={<Alert tone="warning">Select a user before granting backend access.</Alert>}
>
<MetaCluster items={[{ key: 'User', value: selectedUser()!.name }]} />
<MetaCluster
items={[{ key: 'User', value: selectedUser()!.name }]}
/>
</Show>
<Select
label="Backend"
value={permissionBackendId()}
onChange={setPermissionBackendId}
options={availableBackendOptions()}
placeholder={availableBackendOptions().length > 0 ? 'Select backend' : 'No unassigned backends'}
placeholder={
availableBackendOptions().length > 0
? 'Select backend'
: 'No unassigned backends'
}
value={permissionBackendId()}
/>
</form>
</FormDialog>
<ConfirmDialog
open={permissionConfirmOpen()}
onOpenChange={setPermissionConfirmOpen}
title="Revoke backend access"
description="This removes the routing relationship between the selected user and backend."
confirmLabel="Revoke"
tone="danger"
busy={submitting()}
confirmLabel="Revoke"
description="This removes the routing relationship between the selected user and backend."
details={
<Show when={pendingDeletePermission()}>
{(current) => (
<MetaCluster
items={[
{ key: 'User', value: selectedUser()?.name ?? String(current().user_id) },
{ key: 'Backend', value: backendNameById().get(current().backend_id) ?? String(current().backend_id) },
{
key: 'User',
value: selectedUser()?.name ?? String(current().user_id),
},
{
key: 'Backend',
value:
backendNameById().get(current().backend_id) ??
String(current().backend_id),
},
]}
/>
)}
</Show>
}
onConfirm={() => void revokePermission()}
onOpenChange={setPermissionConfirmOpen}
open={permissionConfirmOpen()}
title="Revoke backend access"
tone="danger"
/>
</div>
</Layout>

View file

@ -183,7 +183,10 @@ export type DashboardOverviewSummary = {
};
export type DashboardHealthSummary = {
cache_state_counts: Record<Backend['model_cache_state'] extends infer T ? Extract<T, string> : never, number>;
cache_state_counts: Record<
Backend['model_cache_state'] extends infer T ? Extract<T, string> : never,
number
>;
stale_backends: Array<{
id: number;
name: string;

View file

@ -9,11 +9,21 @@ import Moon from 'lucide-solid/icons/moon';
import Server from 'lucide-solid/icons/server';
import Sun from 'lucide-solid/icons/sun';
import Users from 'lucide-solid/icons/users';
import { For, createMemo, createSignal, onCleanup, onMount, type JSX, type ParentComponent } from 'solid-js';
import {
For,
createMemo,
createSignal,
onCleanup,
onMount,
type JSX,
type ParentComponent,
} from 'solid-js';
import SnakegroundBg from '../../components/SnakegroundBg';
import { useAuth } from '../../auth';
import { IconButton } from '../primitives/IconButton';
import { cn } from '../lib/cn';
import type { ThemeMode } from '../tokens';
const navItems = [
@ -53,12 +63,16 @@ export const AppShell: ParentComponent<AppShellProps> = (props) => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = (mode: ThemeMode) => {
const nextTheme = mode === 'system' ? (mediaQuery.matches ? 'dark' : 'light') : mode;
const nextTheme =
mode === 'system' ? (mediaQuery.matches ? 'dark' : 'light') : mode;
root.dataset.theme = nextTheme;
};
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const initialMode: ThemeMode = storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system';
const initialMode: ThemeMode =
storedTheme === 'light' || storedTheme === 'dark'
? storedTheme
: 'system';
const syncSystemTheme = () => {
setSystemPrefersDark(mediaQuery.matches);
@ -98,11 +112,17 @@ export const AppShell: ParentComponent<AppShellProps> = (props) => {
</div>
</div>
<nav class="nav-rail__nav" aria-label="Primary navigation">
<nav aria-label="Primary navigation" class="nav-rail__nav">
<For each={navItems}>
{(item) => (
<A href={item.path} class={cn('nav-rail__link', location.pathname === item.path && 'nav-rail__link--active')}>
<span class="nav-rail__link-mark" aria-hidden="true">
<A
class={cn(
'nav-rail__link',
location.pathname === item.path && 'nav-rail__link--active',
)}
href={item.path}
>
<span aria-hidden="true" class="nav-rail__link-mark">
<item.icon />
</span>
<span>{item.label}</span>
@ -113,8 +133,14 @@ export const AppShell: ParentComponent<AppShellProps> = (props) => {
<div class="nav-rail__footer">
<div class="nav-rail__session">
<p class="nav-rail__session-name">{auth.session()?.principal?.displayName ?? 'Admin'}</p>
<p class="nav-rail__session-meta">{auth.session()?.principal?.email ?? auth.session()?.principal?.subject ?? ''}</p>
<p class="nav-rail__session-name">
{auth.session()?.principal?.displayName ?? 'Admin'}
</p>
<p class="nav-rail__session-meta">
{auth.session()?.principal?.email ??
auth.session()?.principal?.subject ??
''}
</p>
</div>
<IconButton
class="nav-rail__theme-toggle"

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,24 @@
import type { JSX, ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { JSX, ParentProps } from 'solid-js';
export function CommandBar(props: ParentProps<{ class?: string }>) {
return <div class={cn('ui-command-bar', props.class)}>{props.children}</div>;
}
export function CommandBarGroup(props: ParentProps<{ class?: string }>) {
return <div class={cn('ui-command-bar__group', props.class)}>{props.children}</div>;
return (
<div class={cn('ui-command-bar__group', props.class)}>{props.children}</div>
);
}
export function CommandBarHint(props: { children: JSX.Element; class?: string }) {
return <span class={cn('ui-command-bar__hint', props.class)}>{props.children}</span>;
export function CommandBarHint(props: {
children: JSX.Element;
class?: string;
}) {
return (
<span class={cn('ui-command-bar__hint', props.class)}>
{props.children}
</span>
);
}

View file

@ -1,6 +1,7 @@
import type { JSX } from 'solid-js';
import { Button, Dialog } from '../index';
import type { JSX } from 'solid-js';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -16,7 +17,7 @@ interface ConfirmDialogProps {
export function ConfirmDialog(props: ConfirmDialogProps) {
return (
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<Dialog.Root onOpenChange={props.onOpenChange} open={props.open}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content class="ui-dialog__content ui-dialog__content--compact">
@ -28,10 +29,17 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
</div>
{props.details && <div class="ui-dialog__body">{props.details}</div>}
<div class="ui-dialog__footer">
<Button onClick={() => props.onOpenChange(false)} disabled={props.busy}>
<Button
disabled={props.busy}
onClick={() => props.onOpenChange(false)}
>
{props.cancelLabel ?? 'Cancel'}
</Button>
<Button variant={props.tone === 'danger' ? 'danger' : 'primary'} onClick={() => void props.onConfirm()} disabled={props.busy}>
<Button
disabled={props.busy}
onClick={() => void props.onConfirm()}
variant={props.tone === 'danger' ? 'danger' : 'primary'}
>
{props.confirmLabel ?? 'Confirm'}
</Button>
</div>

View file

@ -1,4 +1,5 @@
import { For, Show, createMemo } from 'solid-js';
import { MetaCluster } from './MetaCluster';
import { StatusBadge, type StatusTone } from './StatusBadge';
@ -22,7 +23,9 @@ function normalizePayload(value: unknown): Record<string, unknown> | null {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
@ -31,70 +34,102 @@ function normalizePayload(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
function normalizeMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
function normalizeMessages(
payload: Record<string, unknown> | null,
): ParsedMessage[] {
const messages = payload?.messages;
if (!Array.isArray(messages)) return [];
return messages
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object',
)
.map((item) => ({
role: typeof item.role === 'string' ? item.role : 'unknown',
content: typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? ''),
content:
typeof item.content === 'string'
? item.content
: JSON.stringify(item.content ?? ''),
}));
}
function normalizeAssistantMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
function normalizeAssistantMessages(
payload: Record<string, unknown> | null,
): ParsedMessage[] {
const choices = payload?.choices;
if (!Array.isArray(choices)) return [];
const messages: Array<ParsedMessage | null> = choices.map((choice) => {
if (!choice || typeof choice !== 'object') return null;
const message = (choice as Record<string, unknown>).message;
if (!message || typeof message !== 'object') return null;
if (!choice || typeof choice !== 'object') return null;
const message = (choice as Record<string, unknown>).message;
if (!message || typeof message !== 'object') return null;
const content = typeof (message as Record<string, unknown>).content === 'string'
const content =
typeof (message as Record<string, unknown>).content === 'string'
? String((message as Record<string, unknown>).content)
: JSON.stringify((message as Record<string, unknown>).content ?? '');
const metadata = [
(message as Record<string, unknown>).reasoning_content !== undefined
? {
key: 'Reasoning',
value:
typeof (message as Record<string, unknown>).reasoning_content === 'string'
? String((message as Record<string, unknown>).reasoning_content)
: JSON.stringify((message as Record<string, unknown>).reasoning_content),
}
: null,
(message as Record<string, unknown>).tool_calls !== undefined
? {
key: 'Tool Calls',
value: JSON.stringify((message as Record<string, unknown>).tool_calls),
}
: null,
(choice as Record<string, unknown>).finish_reason !== undefined
? { key: 'Finish', value: String((choice as Record<string, unknown>).finish_reason) }
: null,
(choice as Record<string, unknown>).matched_stop !== undefined
? { key: 'Matched Stop', value: String((choice as Record<string, unknown>).matched_stop) }
: null,
(choice as Record<string, unknown>).logprobs !== undefined
? { key: 'Logprobs', value: JSON.stringify((choice as Record<string, unknown>).logprobs) }
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
const metadata = [
(message as Record<string, unknown>).reasoning_content !== undefined
? {
key: 'Reasoning',
value:
typeof (message as Record<string, unknown>).reasoning_content ===
'string'
? String((message as Record<string, unknown>).reasoning_content)
: JSON.stringify(
(message as Record<string, unknown>).reasoning_content,
),
}
: null,
(message as Record<string, unknown>).tool_calls !== undefined
? {
key: 'Tool Calls',
value: JSON.stringify(
(message as Record<string, unknown>).tool_calls,
),
}
: null,
(choice as Record<string, unknown>).finish_reason !== undefined
? {
key: 'Finish',
value: String((choice as Record<string, unknown>).finish_reason),
}
: null,
(choice as Record<string, unknown>).matched_stop !== undefined
? {
key: 'Matched Stop',
value: String((choice as Record<string, unknown>).matched_stop),
}
: null,
(choice as Record<string, unknown>).logprobs !== undefined
? {
key: 'Logprobs',
value: JSON.stringify((choice as Record<string, unknown>).logprobs),
}
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
return {
role: 'assistant' as const,
content,
metadata,
};
});
return {
role: 'assistant' as const,
content,
metadata,
};
});
return messages.filter((message): message is ParsedMessage => message !== null);
return messages.filter(
(message): message is ParsedMessage => message !== null,
);
}
export function hasRenderableConversation(requestBody?: unknown, responseBody?: unknown): boolean {
export function hasRenderableConversation(
requestBody?: unknown,
responseBody?: unknown,
): boolean {
const requestMessages = normalizeMessages(normalizePayload(requestBody));
const responseMessages = normalizeAssistantMessages(normalizePayload(responseBody));
const responseMessages = normalizeAssistantMessages(
normalizePayload(responseBody),
);
return requestMessages.length > 0 || responseMessages.length > 0;
}
@ -123,21 +158,41 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
const responseMessages = createMemo(() => normalizeAssistantMessages(parsedResponse()));
const messages = createMemo(() => [...requestMessages(), ...responseMessages()]);
const responseMessages = createMemo(() =>
normalizeAssistantMessages(parsedResponse()),
);
const messages = createMemo(() => [
...requestMessages(),
...responseMessages(),
]);
const summaryItems = createMemo(() => {
const request = parsedRequest();
const response = parsedResponse();
const usage = response?.usage && typeof response.usage === 'object' ? response.usage as Record<string, unknown> : null;
const usage =
response?.usage && typeof response.usage === 'object'
? (response.usage as Record<string, unknown>)
: null;
return [
typeof request?.model === 'string' ? { key: 'Model', value: request.model } : null,
request?.temperature !== undefined ? { key: 'Temp', value: String(request.temperature) } : null,
typeof response?.created === 'number' ? { key: 'Created', value: String(response.created) } : null,
usage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(usage.prompt_tokens) } : null,
usage?.completion_tokens !== undefined ? { key: 'Completion', value: String(usage.completion_tokens) } : null,
usage?.total_tokens !== undefined ? { key: 'Total', value: String(usage.total_tokens) } : null,
typeof request?.model === 'string'
? { key: 'Model', value: request.model }
: null,
request?.temperature !== undefined
? { key: 'Temp', value: String(request.temperature) }
: null,
typeof response?.created === 'number'
? { key: 'Created', value: String(response.created) }
: null,
usage?.prompt_tokens !== undefined
? { key: 'Prompt', value: String(usage.prompt_tokens) }
: null,
usage?.completion_tokens !== undefined
? { key: 'Completion', value: String(usage.completion_tokens) }
: null,
usage?.total_tokens !== undefined
? { key: 'Total', value: String(usage.total_tokens) }
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
});
@ -148,16 +203,27 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
</Show>
<Show
fallback={
<div class="ui-conversation__empty">
{props.emptyMessage ??
'No parsed conversation available for this log.'}
</div>
}
when={messages().length > 0}
fallback={<div class="ui-conversation__empty">{props.emptyMessage ?? 'No parsed conversation available for this log.'}</div>}
>
<div class="ui-conversation__list">
<For each={messages()}>
{(message, index) => (
<article class={`ui-conversation__turn ${getRoleClass(message.role)}`}>
<article
class={`ui-conversation__turn ${getRoleClass(message.role)}`}
>
<header class="ui-conversation__turn-header">
<StatusBadge tone={getRoleTone(message.role)}>{message.role}</StatusBadge>
<span class="ui-conversation__turn-index">Turn {index() + 1}</span>
<StatusBadge tone={getRoleTone(message.role)}>
{message.role}
</StatusBadge>
<span class="ui-conversation__turn-index">
Turn {index() + 1}
</span>
</header>
<div class="ui-conversation__bubble">
<pre class="ui-conversation__content">{message.content}</pre>

View file

@ -1,7 +1,9 @@
import { For, Match, Show, Switch } from 'solid-js';
import type { JSX } from 'solid-js';
import { cn } from '../lib/cn';
import type { JSX } from 'solid-js';
export type DataMode = 'paged' | 'infinite';
export type DataDensity = 'dense' | 'regular';
@ -46,7 +48,10 @@ export interface DataGridProps<T> {
type PaginationToken = number | 'ellipsis';
function buildPaginationTokens(currentPage: number, totalPages: number): PaginationToken[] {
function buildPaginationTokens(
currentPage: number,
totalPages: number,
): PaginationToken[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
@ -89,7 +94,10 @@ function buildPaginationTokens(currentPage: number, totalPages: number): Paginat
export function DataGrid<T>(props: DataGridProps<T>) {
const pageCount = () => {
if (!props.pagination) return 1;
return Math.max(1, Math.ceil(props.pagination.total / props.pagination.pageSize));
return Math.max(
1,
Math.ceil(props.pagination.total / props.pagination.pageSize),
);
};
const paginationTokens = () => {
if (!props.pagination) return [] as PaginationToken[];
@ -97,7 +105,12 @@ export function DataGrid<T>(props: DataGridProps<T>) {
};
return (
<div class={cn('ui-data-grid', props.density === 'regular' && 'ui-data-grid--regular')}>
<div
class={cn(
'ui-data-grid',
props.density === 'regular' && 'ui-data-grid--regular',
)}
>
<div class="ui-data-grid__shell">
<table
class="ui-data-grid__table"
@ -113,9 +126,10 @@ export function DataGrid<T>(props: DataGridProps<T>) {
<th
class={column.class}
style={{
width: column.width,
'width': column.width,
'text-align': column.align ?? 'left',
position: props.stickyHeader === false ? 'static' : 'sticky',
'position':
props.stickyHeader === false ? 'static' : 'sticky',
}}
>
{column.header}
@ -131,21 +145,42 @@ export function DataGrid<T>(props: DataGridProps<T>) {
<Switch>
<Match when={props.loading}>
<tr>
<td class="ui-data-grid__status" colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
<td
class="ui-data-grid__status"
colSpan={
props.columns.length +
(props.rowActions ? 1 : 0) +
(props.onToggleRowSelection ? 1 : 0)
}
>
Loading rows...
</td>
</tr>
</Match>
<Match when={props.error}>
<tr>
<td class="ui-data-grid__status" colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
<td
class="ui-data-grid__status"
colSpan={
props.columns.length +
(props.rowActions ? 1 : 0) +
(props.onToggleRowSelection ? 1 : 0)
}
>
{props.error}
</td>
</tr>
</Match>
<Match when={props.rows.length === 0}>
<tr>
<td class="ui-data-grid__status" colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
<td
class="ui-data-grid__status"
colSpan={
props.columns.length +
(props.rowActions ? 1 : 0) +
(props.onToggleRowSelection ? 1 : 0)
}
>
{props.emptyMessage ?? 'No rows to display.'}
</td>
</tr>
@ -154,21 +189,30 @@ export function DataGrid<T>(props: DataGridProps<T>) {
<For each={props.rows}>
{(row) => {
const key = () => props.getRowKey(row);
const isSelected = () => props.selectedKeys?.has(key()) ?? false;
const isSelected = () =>
props.selectedKeys?.has(key()) ?? false;
return (
<>
<tr
class={cn('ui-data-grid__row', props.onRowClick && 'ui-data-grid__row--clickable')}
class={cn(
'ui-data-grid__row',
props.onRowClick && 'ui-data-grid__row--clickable',
)}
onClick={() => props.onRowClick?.(row)}
>
<Show when={props.onToggleRowSelection}>
<td>
<input
type="checkbox"
checked={isSelected()}
onChange={(event) =>
props.onToggleRowSelection?.(
row,
event.currentTarget.checked,
)
}
onClick={(event) => event.stopPropagation()}
onChange={(event) => props.onToggleRowSelection?.(row, event.currentTarget.checked)}
type="checkbox"
/>
</td>
</Show>
@ -181,7 +225,11 @@ export function DataGrid<T>(props: DataGridProps<T>) {
column.mono && 'ui-data-grid__cell--mono',
)}
style={{ 'text-align': column.align ?? 'left' }}
title={column.truncate ? String(props.getRowKey(row)) : undefined}
title={
column.truncate
? String(props.getRowKey(row))
: undefined
}
>
{column.cell(row)}
</td>
@ -193,7 +241,13 @@ export function DataGrid<T>(props: DataGridProps<T>) {
</tr>
<Show when={props.renderExpanded}>
<tr>
<td colSpan={props.columns.length + (props.rowActions ? 1 : 0) + (props.onToggleRowSelection ? 1 : 0)}>
<td
colSpan={
props.columns.length +
(props.rowActions ? 1 : 0) +
(props.onToggleRowSelection ? 1 : 0)
}
>
{props.renderExpanded?.(row)}
</td>
</tr>
@ -215,17 +269,27 @@ export function DataGrid<T>(props: DataGridProps<T>) {
Page {props.pagination!.page} / {pageCount()}
</span>
<span>
{props.pagination!.total} rows, {props.pagination!.pageSize} per page
{props.pagination!.total} rows, {props.pagination!.pageSize} per
page
</span>
</div>
<div class="ui-pagination__cluster">
<Show when={props.pagination!.onPageSizeChange && props.pagination!.pageSizeOptions?.length}>
<Show
when={
props.pagination!.onPageSizeChange &&
props.pagination!.pageSizeOptions?.length
}
>
<label class="ui-cluster">
<span>Page size</span>
<select
class="ui-input"
onChange={(event) =>
props.pagination!.onPageSizeChange?.(
Number(event.currentTarget.value),
)
}
value={String(props.pagination!.pageSize)}
onChange={(event) => props.pagination!.onPageSizeChange?.(Number(event.currentTarget.value))}
>
<For each={props.pagination!.pageSizeOptions}>
{(option) => <option value={option}>{option}</option>}
@ -234,35 +298,49 @@ export function DataGrid<T>(props: DataGridProps<T>) {
</label>
</Show>
<button
aria-label="Previous page"
class="ui-pagination__button"
disabled={props.pagination!.page <= 1}
onClick={() => props.pagination!.onPageChange(Math.max(1, props.pagination!.page - 1))}
aria-label="Previous page"
onClick={() =>
props.pagination!.onPageChange(
Math.max(1, props.pagination!.page - 1),
)
}
>
&lt;
</button>
<For each={paginationTokens()}>
{(token) => (
{(token) =>
token === 'ellipsis' ? (
<span class="ui-pagination__ellipsis" aria-hidden="true">
<span aria-hidden="true" class="ui-pagination__ellipsis">
...
</span>
) : (
<button
class={cn('ui-pagination__button', props.pagination!.page === token && 'ui-pagination__button--active')}
aria-current={props.pagination!.page === token ? 'page' : undefined}
aria-current={
props.pagination!.page === token ? 'page' : undefined
}
class={cn(
'ui-pagination__button',
props.pagination!.page === token &&
'ui-pagination__button--active',
)}
onClick={() => props.pagination!.onPageChange(token)}
>
{token}
</button>
)
)}
}
</For>
<button
aria-label="Next page"
class="ui-pagination__button"
disabled={props.pagination!.page >= pageCount()}
onClick={() => props.pagination!.onPageChange(Math.min(pageCount(), props.pagination!.page + 1))}
aria-label="Next page"
onClick={() =>
props.pagination!.onPageChange(
Math.min(pageCount(), props.pagination!.page + 1),
)
}
>
&gt;
</button>

View file

@ -1,6 +1,7 @@
import type { ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { ParentProps } from 'solid-js';
export function FieldRow(props: ParentProps<{ class?: string }>) {
return <div class={cn('ui-field-row', props.class)}>{props.children}</div>;
}

View file

@ -1,7 +1,8 @@
import type { JSX, ParentProps } from 'solid-js';
import { Dialog } from '../index';
import { cn } from '../lib/cn';
import type { JSX, ParentProps } from 'solid-js';
interface FormDialogProps extends ParentProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -13,14 +14,16 @@ interface FormDialogProps extends ParentProps {
export function FormDialog(props: FormDialogProps) {
return (
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<Dialog.Root onOpenChange={props.onOpenChange} open={props.open}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content class={cn('ui-dialog__content', props.class)}>
<div class="ui-dialog__header">
<div>
<Dialog.Title>{props.title}</Dialog.Title>
{props.description && <Dialog.Description>{props.description}</Dialog.Description>}
{props.description && (
<Dialog.Description>{props.description}</Dialog.Description>
)}
</div>
</div>
<div class="ui-dialog__body">{props.children}</div>

View file

@ -1,7 +1,9 @@
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { Button } from '../primitives/Button';
import { StatusBadge, type StatusTone } from './StatusBadge';
import { Button } from '../primitives/Button';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
export interface LogEntry {
@ -40,7 +42,9 @@ const allLevels: LogLevel[] = ['debug', 'info', 'warn', 'error', 'success'];
export function LogConsole(props: LogConsoleProps) {
let surfaceRef: HTMLDivElement | undefined;
const [internalLevels, setInternalLevels] = createSignal<LogLevel[]>(props.levelFilter ?? allLevels);
const [internalLevels, setInternalLevels] = createSignal<LogLevel[]>(
props.levelFilter ?? allLevels,
);
const activeLevels = createMemo(() => props.levelFilter ?? internalLevels());
@ -50,7 +54,11 @@ export function LogConsole(props: LogConsoleProps) {
}
});
const visibleEntries = createMemo(() => props.entries.filter((entry) => !entry.level || activeLevels().includes(entry.level)));
const visibleEntries = createMemo(() =>
props.entries.filter(
(entry) => !entry.level || activeLevels().includes(entry.level),
),
);
const copyText = async (text: string) => {
await navigator.clipboard.writeText(text);
@ -75,7 +83,10 @@ export function LogConsole(props: LogConsoleProps) {
<div class="ui-cluster">
<For each={allLevels}>
{(level) => (
<button class="ui-pagination__button" onClick={() => toggleLevel(level)}>
<button
class="ui-pagination__button"
onClick={() => toggleLevel(level)}
>
<StatusBadge tone={levelTones[level]}>{level}</StatusBadge>
</button>
)}
@ -88,7 +99,11 @@ export function LogConsole(props: LogConsoleProps) {
<Button
onClick={async () => {
props.onCopyAll?.();
await copyText(visibleEntries().map((entry) => entry.message).join('\n'));
await copyText(
visibleEntries()
.map((entry) => entry.message)
.join('\n'),
);
}}
>
Copy all
@ -103,22 +118,40 @@ export function LogConsole(props: LogConsoleProps) {
<Show when={!props.loading && props.error}>
<div>{props.error}</div>
</Show>
<Show when={!props.loading && !props.error && visibleEntries().length === 0}>
<Show
when={!props.loading && !props.error && visibleEntries().length === 0}
>
<div>{props.emptyMessage ?? 'No log entries.'}</div>
</Show>
<For each={visibleEntries()}>
{(entry, index) => (
<div class="ui-log-console__line" tabindex={0}>
<span class="ui-log-console__line-number">{String(index() + 1).padStart(3, '0')}</span>
<span class="ui-log-console__line-number">
{String(index() + 1).padStart(3, '0')}
</span>
<Show when={props.showTimestamp !== false}>
<span class="ui-log-console__timestamp">{entry.timestamp ?? '--:--:--'}</span>
<span class="ui-log-console__timestamp">
{entry.timestamp ?? '--:--:--'}
</span>
</Show>
<Show when={props.showLevel !== false}>
<StatusBadge tone={entry.level ? levelTones[entry.level] : 'neutral'}>{entry.level ?? 'info'}</StatusBadge>
<StatusBadge
tone={entry.level ? levelTones[entry.level] : 'neutral'}
>
{entry.level ?? 'info'}
</StatusBadge>
</Show>
<div class={props.wrapLines === false ? 'ui-log-console__message ui-log-console__message--nowrap' : 'ui-log-console__message'}>
<div
class={
props.wrapLines === false
? 'ui-log-console__message ui-log-console__message--nowrap'
: 'ui-log-console__message'
}
>
<Show when={entry.context}>
<span style={{ color: 'var(--color-text-soft)' }}>{entry.context} </span>
<span style={{ color: 'var(--color-text-soft)' }}>
{entry.context}{' '}
</span>
</Show>
{entry.message}
</div>

View file

@ -1,6 +1,7 @@
import type { JSX, ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { JSX, ParentProps } from 'solid-js';
interface PageHeaderProps extends ParentProps {
title: string;
description?: string;
@ -14,7 +15,9 @@ export function PageHeader(props: PageHeaderProps) {
<div class="page-header__copy">
<p class="page-header__eyebrow">Operations</p>
<h2 class="page-header__title">{props.title}</h2>
{props.description && <p class="page-header__description">{props.description}</p>}
{props.description && (
<p class="page-header__description">{props.description}</p>
)}
{props.children}
</div>
{props.actions && <div class="page-header__actions">{props.actions}</div>}

View file

@ -1,6 +1,7 @@
import type { JSX, ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { JSX, ParentProps } from 'solid-js';
interface PanelProps extends ParentProps {
title?: string;
description?: string;
@ -16,7 +17,9 @@ export function Panel(props: PanelProps) {
<div class="ui-panel__header">
<div class="ui-panel__header-copy">
{props.title && <h3 class="ui-panel__title">{props.title}</h3>}
{props.description && <p class="ui-subtitle">{props.description}</p>}
{props.description && (
<p class="ui-subtitle">{props.description}</p>
)}
</div>
{props.actions}
</div>

View file

@ -9,5 +9,15 @@ interface StatusBadgeProps {
}
export function StatusBadge(props: StatusBadgeProps) {
return <span class={cn('ui-badge', `ui-badge--${props.tone ?? 'neutral'}`, props.class)}>{props.children}</span>;
return (
<span
class={cn(
'ui-badge',
`ui-badge--${props.tone ?? 'neutral'}`,
props.class,
)}
>
{props.children}
</span>
);
}

View file

@ -1,6 +1,7 @@
import type { JSX, ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { JSX, ParentProps } from 'solid-js';
type AlertTone = 'info' | 'success' | 'warning' | 'danger';
interface AlertProps extends ParentProps {
@ -12,7 +13,10 @@ interface AlertProps extends ParentProps {
export function Alert(props: AlertProps) {
return (
<div class={cn('ui-alert', `ui-alert--${props.tone ?? 'info'}`, props.class)} role="alert">
<div
class={cn('ui-alert', `ui-alert--${props.tone ?? 'info'}`, props.class)}
role="alert"
>
{props.title && <strong>{props.title}</strong>}
<div>{props.children}</div>
{props.actions}

View file

@ -1,9 +1,15 @@
import { splitProps, type JSX, type ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
type ButtonVariant = 'neutral' | 'primary' | 'danger';
interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, 'class' | 'type' | 'onClick'> {
interface ButtonProps
extends ParentProps,
Omit<
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
'class' | 'type' | 'onClick'
> {
type?: 'button' | 'submit' | 'reset';
variant?: ButtonVariant;
class?: string;
@ -12,12 +18,18 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
}
export function Button(props: ButtonProps) {
const [local, rest] = splitProps(props, ['children', 'class', 'disabled', 'onClick', 'type', 'variant']);
const [local, rest] = splitProps(props, [
'children',
'class',
'disabled',
'onClick',
'type',
'variant',
]);
return (
<button
{...rest}
type={local.type ?? 'button'}
class={cn(
'ui-button',
local.variant === 'primary' && 'ui-button--primary',
@ -26,6 +38,7 @@ export function Button(props: ButtonProps) {
)}
disabled={local.disabled}
onClick={local.onClick}
type={local.type ?? 'button'}
>
{local.children}
</button>

View file

@ -1,4 +1,5 @@
import * as KCheckbox from '@kobalte/core/checkbox';
import { cn } from '../lib/cn';
interface CheckboxProps {
@ -14,19 +15,25 @@ interface CheckboxProps {
export function Checkbox(props: CheckboxProps) {
return (
<KCheckbox.Root
class={cn('ui-checkbox', props.class)}
checked={props.checked}
class={cn('ui-checkbox', props.class)}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
onChange={props.onChange}
>
<KCheckbox.Input />
<KCheckbox.Control class="ui-checkbox__control">
<KCheckbox.Indicator class="ui-checkbox__indicator"></KCheckbox.Indicator>
<KCheckbox.Indicator class="ui-checkbox__indicator">
</KCheckbox.Indicator>
</KCheckbox.Control>
<span>
<KCheckbox.Label>{props.label}</KCheckbox.Label>
{props.description && <KCheckbox.Description class="ui-field__description">{props.description}</KCheckbox.Description>}
{props.description && (
<KCheckbox.Description class="ui-field__description">
{props.description}
</KCheckbox.Description>
)}
</span>
</KCheckbox.Root>
);

View file

@ -1,31 +1,60 @@
import * as KDialog from '@kobalte/core/dialog';
import type { ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { ParentProps } from 'solid-js';
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Dialog = {
Root: (props: WrapperProps) => <KDialog.Root {...(props as KDialog.DialogRootProps)}>{props.children}</KDialog.Root>,
Root: (props: WrapperProps) => (
<KDialog.Root {...(props as KDialog.DialogRootProps)}>
{props.children}
</KDialog.Root>
),
Trigger: (props: WrapperProps) => (
<KDialog.Trigger {...(props as KDialog.DialogTriggerProps)} class={cn('ui-button', props.class)}>
<KDialog.Trigger
{...(props as KDialog.DialogTriggerProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KDialog.Trigger>
),
Portal: (props: WrapperProps) => <KDialog.Portal>{props.children}</KDialog.Portal>,
Overlay: (props: WrapperProps) => <KDialog.Overlay {...(props as KDialog.DialogOverlayProps)} class={cn('ui-dialog__overlay', props.class)} />,
Portal: (props: WrapperProps) => (
<KDialog.Portal>{props.children}</KDialog.Portal>
),
Overlay: (props: WrapperProps) => (
<KDialog.Overlay
{...(props as KDialog.DialogOverlayProps)}
class={cn('ui-dialog__overlay', props.class)}
/>
),
Content: (props: WrapperProps) => (
<KDialog.Content {...(props as KDialog.DialogContentProps)} class={cn('ui-dialog__content', props.class)}>
<KDialog.Content
{...(props as KDialog.DialogContentProps)}
class={cn('ui-dialog__content', props.class)}
>
{props.children}
</KDialog.Content>
),
Title: (props: WrapperProps) => <KDialog.Title {...(props as KDialog.DialogTitleProps)}>{props.children}</KDialog.Title>,
Title: (props: WrapperProps) => (
<KDialog.Title {...(props as KDialog.DialogTitleProps)}>
{props.children}
</KDialog.Title>
),
Description: (props: WrapperProps) => (
<KDialog.Description {...(props as KDialog.DialogDescriptionProps)} class={cn('ui-subtitle', props.class)}>
<KDialog.Description
{...(props as KDialog.DialogDescriptionProps)}
class={cn('ui-subtitle', props.class)}
>
{props.children}
</KDialog.Description>
),
CloseButton: (props: WrapperProps) => (
<KDialog.CloseButton {...(props as KDialog.DialogCloseButtonProps)} class={cn('ui-button', props.class)}>
<KDialog.CloseButton
{...(props as KDialog.DialogCloseButtonProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KDialog.CloseButton>
),

View file

@ -1,28 +1,48 @@
import * as KDropdownMenu from '@kobalte/core/dropdown-menu';
import type { ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { ParentProps } from 'solid-js';
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const DropdownMenu = {
Root: (props: WrapperProps) => <KDropdownMenu.Root {...(props as KDropdownMenu.DropdownMenuRootProps)}>{props.children}</KDropdownMenu.Root>,
Root: (props: WrapperProps) => (
<KDropdownMenu.Root {...(props as KDropdownMenu.DropdownMenuRootProps)}>
{props.children}
</KDropdownMenu.Root>
),
Trigger: (props: WrapperProps) => (
<KDropdownMenu.Trigger {...(props as KDropdownMenu.DropdownMenuTriggerProps)} class={cn('ui-button', props.class)}>
<KDropdownMenu.Trigger
{...(props as KDropdownMenu.DropdownMenuTriggerProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KDropdownMenu.Trigger>
),
Portal: (props: WrapperProps) => <KDropdownMenu.Portal>{props.children}</KDropdownMenu.Portal>,
Portal: (props: WrapperProps) => (
<KDropdownMenu.Portal>{props.children}</KDropdownMenu.Portal>
),
Content: (props: WrapperProps) => (
<KDropdownMenu.Content {...(props as KDropdownMenu.DropdownMenuContentProps)} class={cn('ui-dropdown__content', props.class)}>
<KDropdownMenu.Content
{...(props as KDropdownMenu.DropdownMenuContentProps)}
class={cn('ui-dropdown__content', props.class)}
>
{props.children}
</KDropdownMenu.Content>
),
Item: (props: WrapperProps) => (
<KDropdownMenu.Item {...(props as KDropdownMenu.DropdownMenuItemProps)} class={cn('ui-dropdown__item', props.class)}>
<KDropdownMenu.Item
{...(props as KDropdownMenu.DropdownMenuItemProps)}
class={cn('ui-dropdown__item', props.class)}
>
{props.children}
</KDropdownMenu.Item>
),
Separator: (props: WrapperProps) => (
<KDropdownMenu.Separator {...(props as KDropdownMenu.DropdownMenuSeparatorProps)} class={cn('ui-dropdown__separator', props.class)} />
<KDropdownMenu.Separator
{...(props as KDropdownMenu.DropdownMenuSeparatorProps)}
class={cn('ui-dropdown__separator', props.class)}
/>
),
};

View file

@ -1,7 +1,13 @@
import type { JSX, ParentProps } from 'solid-js';
import { Button } from './Button';
interface IconButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'class' | 'type' | 'onClick'> {
import type { JSX, ParentProps } from 'solid-js';
interface IconButtonProps
extends ParentProps,
Omit<
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
'children' | 'class' | 'type' | 'onClick'
> {
icon: JSX.Element;
label: JSX.Element;
class?: string;
@ -15,10 +21,12 @@ export function IconButton(props: IconButtonProps) {
return (
<Button
{...props}
aria-label={
typeof props.label === 'string' ? props.label : props['aria-label']
}
class={['ui-button--icon', props.class].filter(Boolean).join(' ')}
aria-label={typeof props.label === 'string' ? props.label : props['aria-label']}
>
<span class="ui-button__icon" aria-hidden="true">
<span aria-hidden="true" class="ui-button__icon">
{props.icon}
</span>
<span class="ui-button__label">{props.label}</span>

View file

@ -1,30 +1,54 @@
import * as KPopover from '@kobalte/core/popover';
import type { ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { ParentProps } from 'solid-js';
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Popover = {
Root: (props: WrapperProps) => <KPopover.Root {...(props as KPopover.PopoverRootProps)}>{props.children}</KPopover.Root>,
Root: (props: WrapperProps) => (
<KPopover.Root {...(props as KPopover.PopoverRootProps)}>
{props.children}
</KPopover.Root>
),
Trigger: (props: WrapperProps) => (
<KPopover.Trigger {...(props as KPopover.PopoverTriggerProps)} class={cn('ui-button', props.class)}>
<KPopover.Trigger
{...(props as KPopover.PopoverTriggerProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KPopover.Trigger>
),
Portal: (props: WrapperProps) => <KPopover.Portal>{props.children}</KPopover.Portal>,
Portal: (props: WrapperProps) => (
<KPopover.Portal>{props.children}</KPopover.Portal>
),
Content: (props: WrapperProps) => (
<KPopover.Content {...(props as KPopover.PopoverContentProps)} class={cn('ui-popover__content', props.class)}>
<KPopover.Content
{...(props as KPopover.PopoverContentProps)}
class={cn('ui-popover__content', props.class)}
>
{props.children}
</KPopover.Content>
),
Title: (props: WrapperProps) => <KPopover.Title {...(props as KPopover.PopoverTitleProps)}>{props.children}</KPopover.Title>,
Title: (props: WrapperProps) => (
<KPopover.Title {...(props as KPopover.PopoverTitleProps)}>
{props.children}
</KPopover.Title>
),
Description: (props: WrapperProps) => (
<KPopover.Description {...(props as KPopover.PopoverDescriptionProps)} class={cn('ui-subtitle', props.class)}>
<KPopover.Description
{...(props as KPopover.PopoverDescriptionProps)}
class={cn('ui-subtitle', props.class)}
>
{props.children}
</KPopover.Description>
),
CloseButton: (props: WrapperProps) => (
<KPopover.CloseButton {...(props as KPopover.PopoverCloseButtonProps)} class={cn('ui-button', props.class)}>
<KPopover.CloseButton
{...(props as KPopover.PopoverCloseButtonProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KPopover.CloseButton>
),

View file

@ -1,5 +1,6 @@
import * as KSelect from '@kobalte/core/select';
import { Show, createMemo } from 'solid-js';
import { cn } from '../lib/cn';
export interface SelectOption {
@ -18,30 +19,34 @@ interface SelectProps {
}
export function Select(props: SelectProps) {
const selected = createMemo(() => props.options.find((option) => option.value === props.value));
const selected = createMemo(() =>
props.options.find((option) => option.value === props.value),
);
return (
<KSelect.Root<SelectOption>
class={cn('ui-select', props.class)}
options={props.options}
optionValue="value"
optionTextValue="label"
value={selected()}
placeholder={props.placeholder ?? 'Select'}
onChange={(option) => props.onChange?.(option?.value ?? '')}
itemComponent={(itemProps) => (
<KSelect.Item item={itemProps.item} class="ui-select__item">
<KSelect.Item class="ui-select__item" item={itemProps.item}>
<KSelect.ItemLabel>{itemProps.item.rawValue.label}</KSelect.ItemLabel>
<KSelect.ItemIndicator></KSelect.ItemIndicator>
</KSelect.Item>
)}
onChange={(option) => props.onChange?.(option?.value ?? '')}
optionTextValue="label"
optionValue="value"
options={props.options}
placeholder={props.placeholder ?? 'Select'}
value={selected()}
>
<Show when={props.label}>
<KSelect.Label class="ui-field__label">{props.label}</KSelect.Label>
</Show>
<KSelect.Trigger class="ui-select__trigger">
<KSelect.Value<SelectOption> class="ui-select__value">
{(state) => state.selectedOption()?.label ?? props.placeholder ?? 'Select'}
{(state) =>
state.selectedOption()?.label ?? props.placeholder ?? 'Select'
}
</KSelect.Value>
<KSelect.Icon></KSelect.Icon>
</KSelect.Trigger>

View file

@ -1,4 +1,5 @@
import * as KSwitch from '@kobalte/core/switch';
import { cn } from '../lib/cn';
interface SwitchProps {
@ -14,8 +15,8 @@ interface SwitchProps {
export function Switch(props: SwitchProps) {
return (
<KSwitch.Root
class={cn('ui-switch', props.class)}
checked={props.checked}
class={cn('ui-switch', props.class)}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
onChange={props.onChange}
@ -26,7 +27,11 @@ export function Switch(props: SwitchProps) {
</KSwitch.Control>
<span>
<KSwitch.Label>{props.label}</KSwitch.Label>
{props.description && <KSwitch.Description class="ui-field__description">{props.description}</KSwitch.Description>}
{props.description && (
<KSwitch.Description class="ui-field__description">
{props.description}
</KSwitch.Description>
)}
</span>
</KSwitch.Root>
);

View file

@ -1,19 +1,41 @@
import * as KTabs from '@kobalte/core/tabs';
import type { ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { ParentProps } from 'solid-js';
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Tabs = {
Root: (props: WrapperProps) => <KTabs.Root {...(props as unknown as KTabs.TabsRootProps)} class={cn('ui-tabs', props.class)}>{props.children}</KTabs.Root>,
List: (props: WrapperProps) => <KTabs.List {...(props as unknown as KTabs.TabsListProps)} class={cn('ui-tabs__list', props.class)}>{props.children}</KTabs.List>,
Root: (props: WrapperProps) => (
<KTabs.Root
{...(props as unknown as KTabs.TabsRootProps)}
class={cn('ui-tabs', props.class)}
>
{props.children}
</KTabs.Root>
),
List: (props: WrapperProps) => (
<KTabs.List
{...(props as unknown as KTabs.TabsListProps)}
class={cn('ui-tabs__list', props.class)}
>
{props.children}
</KTabs.List>
),
Trigger: (props: WrapperProps) => (
<KTabs.Trigger {...(props as unknown as KTabs.TabsTriggerProps)} class={cn('ui-tabs__trigger', props.class)}>
<KTabs.Trigger
{...(props as unknown as KTabs.TabsTriggerProps)}
class={cn('ui-tabs__trigger', props.class)}
>
{props.children}
</KTabs.Trigger>
),
Content: (props: WrapperProps) => (
<KTabs.Content {...(props as unknown as KTabs.TabsContentProps)} class={cn('ui-tabs__content', props.class)}>
<KTabs.Content
{...(props as unknown as KTabs.TabsContentProps)}
class={cn('ui-tabs__content', props.class)}
>
{props.children}
</KTabs.Content>
),

View file

@ -1,7 +1,9 @@
import * as KTextField from '@kobalte/core/text-field';
import type { JSX, ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { JSX, ParentProps } from 'solid-js';
interface TextFieldProps extends ParentProps {
label: string;
value?: string;
@ -11,36 +13,60 @@ interface TextFieldProps extends ParentProps {
errorMessage?: string;
multiline?: boolean;
class?: string;
onInput?: JSX.EventHandlerUnion<HTMLInputElement | HTMLTextAreaElement, InputEvent>;
onInput?: JSX.EventHandlerUnion<
HTMLInputElement | HTMLTextAreaElement,
InputEvent
>;
}
export function TextField(props: TextFieldProps) {
return (
<KTextField.Root class={cn('ui-field', props.class)} validationState={props.errorMessage ? 'invalid' : 'valid'}>
<KTextField.Root
class={cn('ui-field', props.class)}
validationState={props.errorMessage ? 'invalid' : 'valid'}
>
<KTextField.Label class="ui-field__label">{props.label}</KTextField.Label>
<div class="ui-field__control-row">
<div class="ui-field__control-fill">
{props.multiline ? (
<KTextField.TextArea
class="ui-textarea"
value={props.value}
onInput={
props.onInput as JSX.EventHandlerUnion<
HTMLTextAreaElement,
InputEvent
>
}
placeholder={props.placeholder}
onInput={props.onInput as JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent>}
value={props.value}
/>
) : (
<KTextField.Input
class="ui-input"
onInput={
props.onInput as JSX.EventHandlerUnion<
HTMLInputElement,
InputEvent
>
}
placeholder={props.placeholder}
type={props.type ?? 'text'}
value={props.value}
placeholder={props.placeholder}
onInput={props.onInput as JSX.EventHandlerUnion<HTMLInputElement, InputEvent>}
/>
)}
</div>
{props.children}
</div>
{props.description && <KTextField.Description class="ui-field__description">{props.description}</KTextField.Description>}
{props.errorMessage && <KTextField.ErrorMessage class="ui-field__error">{props.errorMessage}</KTextField.ErrorMessage>}
{props.description && (
<KTextField.Description class="ui-field__description">
{props.description}
</KTextField.Description>
)}
{props.errorMessage && (
<KTextField.ErrorMessage class="ui-field__error">
{props.errorMessage}
</KTextField.ErrorMessage>
)}
</KTextField.Root>
);
}

View file

@ -1,33 +1,54 @@
import * as KToast from '@kobalte/core/toast';
import type { ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { ParentProps } from 'solid-js';
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Toast = {
Region: (props: WrapperProps) => (
<KToast.Region {...(props as KToast.ToastRegionProps)} class={cn('ui-toast-region', props.class)}>
<KToast.Region
{...(props as KToast.ToastRegionProps)}
class={cn('ui-toast-region', props.class)}
>
{props.children}
</KToast.Region>
),
List: (props: WrapperProps) => (
<KToast.List {...(props as KToast.ToastListProps)} class={cn('ui-toast-list', props.class)}>
<KToast.List
{...(props as KToast.ToastListProps)}
class={cn('ui-toast-list', props.class)}
>
{props.children}
</KToast.List>
),
Root: (props: WrapperProps) => (
<KToast.Root {...(props as unknown as KToast.ToastRootProps)} class={cn('ui-toast', props.class)}>
<KToast.Root
{...(props as unknown as KToast.ToastRootProps)}
class={cn('ui-toast', props.class)}
>
{props.children}
</KToast.Root>
),
Title: (props: WrapperProps) => <KToast.Title {...(props as KToast.ToastTitleProps)}>{props.children}</KToast.Title>,
Title: (props: WrapperProps) => (
<KToast.Title {...(props as KToast.ToastTitleProps)}>
{props.children}
</KToast.Title>
),
Description: (props: WrapperProps) => (
<KToast.Description {...(props as KToast.ToastDescriptionProps)} class={cn('ui-subtitle', props.class)}>
<KToast.Description
{...(props as KToast.ToastDescriptionProps)}
class={cn('ui-subtitle', props.class)}
>
{props.children}
</KToast.Description>
),
CloseButton: (props: WrapperProps) => (
<KToast.CloseButton {...(props as KToast.ToastCloseButtonProps)} class={cn('ui-button', props.class)}>
<KToast.CloseButton
{...(props as KToast.ToastCloseButtonProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KToast.CloseButton>
),

View file

@ -1,17 +1,37 @@
import * as KTooltip from '@kobalte/core/tooltip';
import type { ParentProps } from 'solid-js';
import { cn } from '../lib/cn';
import type { ParentProps } from 'solid-js';
type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Tooltip = {
Root: (props: WrapperProps) => <KTooltip.Root openDelay={150} {...(props as KTooltip.TooltipRootProps)}>{props.children}</KTooltip.Root>,
Trigger: (props: WrapperProps) => <KTooltip.Trigger {...(props as KTooltip.TooltipTriggerProps)} class={props.class}>{props.children}</KTooltip.Trigger>,
Portal: (props: WrapperProps) => <KTooltip.Portal>{props.children}</KTooltip.Portal>,
Root: (props: WrapperProps) => (
<KTooltip.Root openDelay={150} {...(props as KTooltip.TooltipRootProps)}>
{props.children}
</KTooltip.Root>
),
Trigger: (props: WrapperProps) => (
<KTooltip.Trigger
{...(props as KTooltip.TooltipTriggerProps)}
class={props.class}
>
{props.children}
</KTooltip.Trigger>
),
Portal: (props: WrapperProps) => (
<KTooltip.Portal>{props.children}</KTooltip.Portal>
),
Content: (props: WrapperProps) => (
<KTooltip.Content {...(props as KTooltip.TooltipContentProps)} class={cn('ui-tooltip__content', props.class)}>
<KTooltip.Content
{...(props as KTooltip.TooltipContentProps)}
class={cn('ui-tooltip__content', props.class)}
>
{props.children}
</KTooltip.Content>
),
Arrow: (props: WrapperProps) => <KTooltip.Arrow {...(props as KTooltip.TooltipArrowProps)} />,
Arrow: (props: WrapperProps) => (
<KTooltip.Arrow {...(props as KTooltip.TooltipArrowProps)} />
),
};

View file

@ -1,4 +1,5 @@
import { createSignal } from 'solid-js';
import {
Alert,
AppShell,
@ -46,7 +47,12 @@ const userRows: UserRow[] = [
const userColumns: DataGridColumn<UserRow>[] = [
{ id: 'id', header: 'ID', mono: true, cell: (row) => row.id },
{ id: 'name', header: 'Name', cell: (row) => row.name },
{ id: 'email', header: 'Email', truncate: true, cell: (row) => <span title={row.email}>{row.email}</span> },
{
id: 'email',
header: 'Email',
truncate: true,
cell: (row) => <span title={row.email}>{row.email}</span>,
},
{
id: 'apiKey',
header: 'API Key',
@ -63,7 +69,11 @@ const userColumns: DataGridColumn<UserRow>[] = [
{
id: 'status',
header: 'Status',
cell: (row) => <StatusBadge tone={row.status === 'Active' ? 'success' : 'warning'}>{row.status}</StatusBadge>,
cell: (row) => (
<StatusBadge tone={row.status === 'Active' ? 'success' : 'warning'}>
{row.status}
</StatusBadge>
),
},
];
@ -76,7 +86,11 @@ export const PageShell = {
render: () => (
<AppShell>
<div class="ui-app-page">
<PageHeader title="Users" description="Shared page shell with command header and compact panel structure." actions={<Button variant="primary">Add User</Button>} />
<PageHeader
actions={<Button variant="primary">Add User</Button>}
description="Shared page shell with command header and compact panel structure."
title="Users"
/>
<SummaryStrip
items={[
{ label: 'Users', value: 24, hint: 'Provisioned identities' },
@ -84,8 +98,14 @@ export const PageShell = {
{ label: 'Backends', value: 6, hint: 'Permission targets' },
]}
/>
<Panel title="Primary panel" description="This is the default panel surface used by route screens.">
<p class="ui-copy">Panels, headers, and summary strips now come from the same UI layer that powers the real app routes.</p>
<Panel
description="This is the default panel surface used by route screens."
title="Primary panel"
>
<p class="ui-copy">
Panels, headers, and summary strips now come from the same UI layer
that powers the real app routes.
</p>
</Panel>
</div>
</AppShell>
@ -95,7 +115,11 @@ export const PageShell = {
export const UsersTable = {
render: () => (
<div class="ui-workbench ui-stack">
<PageHeader title="Users" description="Dense table pattern with overflow-safe API key handling." actions={<Button variant="primary">Add User</Button>} />
<PageHeader
actions={<Button variant="primary">Add User</Button>}
description="Dense table pattern with overflow-safe API key handling."
title="Users"
/>
<CommandBar>
<CommandBarGroup>
<TextField label="Search users" value="ops" />
@ -104,8 +128,12 @@ export const UsersTable = {
<StatusBadge tone="success">18 active</StatusBadge>
</CommandBarGroup>
</CommandBar>
<Panel title="User registry" description="Route-ready table composition.">
<DataGrid rows={userRows} columns={userColumns} getRowKey={(row) => row.id} />
<Panel description="Route-ready table composition." title="User registry">
<DataGrid
columns={userColumns}
getRowKey={(row) => row.id}
rows={userRows}
/>
</Panel>
</div>
),
@ -117,34 +145,64 @@ export const ScriptsWorkspace = {
return (
<div class="ui-workbench ui-stack">
<PageHeader title="Scripts" description="Split workspace pattern with dense form controls and a test tab." actions={<Button variant="primary">Create Script</Button>} />
<PageHeader
actions={<Button variant="primary">Create Script</Button>}
description="Split workspace pattern with dense form controls and a test tab."
title="Scripts"
/>
<div class="ui-split-panel">
<Panel title="Script registry" description="Left-side selection list.">
<Panel
description="Left-side selection list."
title="Script registry"
>
<DataGrid
rows={[
{ id: 1, name: 'OpenAI request guard', target: 'ops-admin + OpenAI', status: 'Active' },
{ id: 2, name: 'Anthropic response logger', target: 'Anthropic', status: 'Inactive' },
]}
columns={[
{ id: 'name', header: 'Name', cell: (row) => row.name },
{ id: 'target', header: 'Target', cell: (row) => row.target },
{ id: 'status', header: 'Status', cell: (row) => <StatusBadge tone={row.status === 'Active' ? 'success' : 'warning'}>{row.status}</StatusBadge> },
{
id: 'status',
header: 'Status',
cell: (row) => (
<StatusBadge
tone={row.status === 'Active' ? 'success' : 'warning'}
>
{row.status}
</StatusBadge>
),
},
]}
getRowKey={(row) => row.id}
rows={[
{
id: 1,
name: 'OpenAI request guard',
target: 'ops-admin + OpenAI',
status: 'Active',
},
{
id: 2,
name: 'Anthropic response logger',
target: 'Anthropic',
status: 'Inactive',
},
]}
/>
</Panel>
<Panel title="Editing OpenAI request guard" description="Right-side editor panel.">
<Panel
description="Right-side editor panel."
title="Editing OpenAI request guard"
>
<div class="ui-stack">
<TextField label="Script name" value="OpenAI request guard" />
<Select
label="Scope"
value={scope()}
onChange={setScope}
options={[
{ value: 'per-user-backend', label: 'Per User + Backend' },
{ value: 'per-user', label: 'Per User' },
{ value: 'per-backend', label: 'Per Backend' },
]}
value={scope()}
/>
<MetaCluster
items={[
@ -159,12 +217,19 @@ export const ScriptsWorkspace = {
<Tabs.Trigger value="test">Test</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor">
<Panel title="Code editor" description="Monaco editor mounts inside the real route.">
<pre class="ui-copy ui-text-mono">{`export async function onRequest(ctx) {\n return ctx;\n}`}</pre>
<Panel
description="Monaco editor mounts inside the real route."
title="Code editor"
>
<pre class="ui-copy ui-text-mono">
{
'export async function onRequest(ctx) {\n return ctx;\n}'
}
</pre>
</Panel>
</Tabs.Content>
<Tabs.Content value="test">
<Alert tone="success" title="Test passed">
<Alert title="Test passed" tone="success">
Execution time: 12ms
</Alert>
</Tabs.Content>
@ -181,14 +246,18 @@ export const States = {
render: () => (
<div class="ui-workbench ui-stack">
<Panel title="Empty State">
<EmptyState title="No users yet" description="Create the first user to issue an API key and start routing traffic." action={<Button variant="primary">Add User</Button>} />
<EmptyState
action={<Button variant="primary">Add User</Button>}
description="Create the first user to issue an API key and start routing traffic."
title="No users yet"
/>
</Panel>
<Panel title="Loading and Error">
<div class="ui-stack">
<Alert tone="info" title="Loading">
<Alert title="Loading" tone="info">
Fetching identities and access state from the admin API.
</Alert>
<Alert tone="danger" title="Failed to load">
<Alert title="Failed to load" tone="danger">
Request failed while refreshing analytics snapshots.
</Alert>
</div>

View file

@ -1,5 +1,14 @@
import { createSignal } from 'solid-js';
import { Button, CommandBar, CommandBarGroup, CommandBarHint, DataGrid, StatusBadge, type DataGridColumn } from '../index';
import {
Button,
CommandBar,
CommandBarGroup,
CommandBarHint,
DataGrid,
StatusBadge,
type DataGridColumn,
} from '../index';
type GridRow = {
id: number;
@ -47,7 +56,11 @@ const columns: DataGridColumn<GridRow>[] = [
{
id: 'status',
header: 'Status',
cell: (row) => <StatusBadge tone={row.status === 'Active' ? 'success' : 'danger'}>{row.status}</StatusBadge>,
cell: (row) => (
<StatusBadge tone={row.status === 'Active' ? 'success' : 'danger'}>
{row.status}
</StatusBadge>
),
},
{
id: 'updatedAt',
@ -66,7 +79,9 @@ export const Paged = {
render: () => {
const [page, setPage] = createSignal(1);
const [pageSize, setPageSize] = createSignal(10);
const [selectedKeys, setSelectedKeys] = createSignal(new Set<string | number>([1, 3]));
const [selectedKeys, setSelectedKeys] = createSignal(
new Set<string | number>([1, 3]),
);
const pagedRows = () => {
const start = (page() - 1) * pageSize();
@ -77,7 +92,10 @@ export const Paged = {
<div class="ui-workbench ui-stack">
<div>
<h1 class="ui-title">DataGrid</h1>
<p class="ui-subtitle">Pagination-first dense table for users, backends, analytics, and scripts.</p>
<p class="ui-subtitle">
Pagination-first dense table for users, backends, analytics, and
scripts.
</p>
</div>
<CommandBar>
@ -93,23 +111,14 @@ export const Paged = {
</CommandBar>
<DataGrid
rows={pagedRows()}
columns={columns}
getRowKey={(row) => row.id}
stickyHeader
selectedKeys={selectedKeys()}
onToggleRowSelection={(row, nextSelected) => {
const next = new Set(selectedKeys());
if (nextSelected) next.add(row.id);
else next.delete(row.id);
setSelectedKeys(next);
}}
rowActions={(row) => (
<div class="ui-cluster">
<Button>Edit</Button>
<Button variant="danger">Disable</Button>
</div>
)}
pagination={{
page: page(),
pageSize: pageSize(),
@ -121,6 +130,15 @@ export const Paged = {
},
pageSizeOptions: [10, 20, 50],
}}
rowActions={(row) => (
<div class="ui-cluster">
<Button>Edit</Button>
<Button variant="danger">Disable</Button>
</div>
)}
rows={pagedRows()}
selectedKeys={selectedKeys()}
stickyHeader
/>
</div>
);
@ -132,9 +150,25 @@ export const States = {
<div class="ui-workbench ui-stack">
<div class="ui-stack">
<h1 class="ui-title">Grid States</h1>
<DataGrid rows={[]} columns={columns} getRowKey={(row) => row.id} loading emptyMessage="No data." />
<DataGrid rows={[]} columns={columns} getRowKey={(row) => row.id} error="Failed to fetch rows from analytics database." />
<DataGrid rows={[]} columns={columns} getRowKey={(row) => row.id} emptyMessage="No matching rows for this filter set." />
<DataGrid
columns={columns}
emptyMessage="No data."
getRowKey={(row) => row.id}
loading
rows={[]}
/>
<DataGrid
columns={columns}
error="Failed to fetch rows from analytics database."
getRowKey={(row) => row.id}
rows={[]}
/>
<DataGrid
columns={columns}
emptyMessage="No matching rows for this filter set."
getRowKey={(row) => row.id}
rows={[]}
/>
</div>
</div>
),

View file

@ -1,4 +1,5 @@
import { createSignal } from 'solid-js';
import { Button, LogConsole, type LogEntry } from '../index';
const entries: LogEntry[] = [
@ -54,8 +55,12 @@ export const Default = {
return (
<div class="ui-workbench ui-stack">
<div class="ui-cluster">
<Button onClick={() => setFollow((value) => !value)}>{follow() ? 'Follow: on' : 'Follow: off'}</Button>
<Button onClick={() => setWrapLines((value) => !value)}>{wrapLines() ? 'Wrap: on' : 'Wrap: off'}</Button>
<Button onClick={() => setFollow((value) => !value)}>
{follow() ? 'Follow: on' : 'Follow: off'}
</Button>
<Button onClick={() => setWrapLines((value) => !value)}>
{wrapLines() ? 'Wrap: on' : 'Wrap: off'}
</Button>
<Button
onClick={() =>
setLocalEntries((current) => [
@ -75,11 +80,11 @@ export const Default = {
</div>
<LogConsole
emptyMessage="No logs yet."
entries={localEntries()}
follow={follow()}
wrapLines={wrapLines()}
emptyMessage="No logs yet."
onClear={() => setLocalEntries([])}
wrapLines={wrapLines()}
/>
</div>
);
@ -91,7 +96,7 @@ export const States = {
<div class="ui-workbench ui-stack">
<LogConsole entries={[]} loading />
<LogConsole entries={[]} error="Failed to fetch script test logs." />
<LogConsole entries={[]} emptyMessage="No console output yet." />
<LogConsole emptyMessage="No console output yet." entries={[]} />
</div>
),
};

View file

@ -1,4 +1,5 @@
import { createSignal } from 'solid-js';
import {
Alert,
Button,
@ -32,7 +33,9 @@ export const Default = {
<div class="ui-workbench ui-stack">
<div>
<h1 class="ui-title">Kobalte Wrapper Workbench</h1>
<p class="ui-subtitle">Compact primitives for the router admin console.</p>
<p class="ui-subtitle">
Compact primitives for the router admin console.
</p>
</div>
<CommandBar>
@ -52,7 +55,9 @@ export const Default = {
<div class="ui-panel__header">
<div>
<h2 style={{ margin: '0 0 4px 0' }}>Controls</h2>
<p class="ui-subtitle">Dense inputs and Kobalte primitives with project styling.</p>
<p class="ui-subtitle">
Dense inputs and Kobalte primitives with project styling.
</p>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
@ -67,32 +72,38 @@ export const Default = {
</DropdownMenu.Root>
</div>
<div class="ui-panel__body ui-stack">
<TextField label="Backend Name" value="OpenAI Primary" description="Shown in routing and analytics views.">
<TextField
description="Shown in routing and analytics views."
label="Backend Name"
value="OpenAI Primary"
>
<Button variant="primary">Save</Button>
</TextField>
<FieldRow>
<Select
label="Primary Route"
value={selected()}
onChange={setSelected}
options={[
{ value: 'analytics', label: 'Analytics' },
{ value: 'users', label: 'Users' },
{ value: 'scripts', label: 'Scripts' },
]}
value={selected()}
/>
<Tooltip.Root>
<Tooltip.Trigger class="ui-button">Hover hint</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>Long values should still stay readable in dense layouts.</Tooltip.Content>
<Tooltip.Content>
Long values should still stay readable in dense layouts.
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</FieldRow>
<div class="ui-cluster">
<Checkbox label="Active only" defaultChecked />
<Switch label="Auto refresh" defaultChecked />
<Checkbox defaultChecked label="Active only" />
<Switch defaultChecked label="Auto refresh" />
</div>
<Tabs.Root defaultValue="request">
@ -101,9 +112,15 @@ export const Default = {
<Tabs.Trigger value="response">Response</Tabs.Trigger>
<Tabs.Trigger value="test">Test</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="request">Request transform settings and headers.</Tabs.Content>
<Tabs.Content value="response">Response inspection and fallback rules.</Tabs.Content>
<Tabs.Content value="test">Console output, sample payloads, and validation feedback.</Tabs.Content>
<Tabs.Content value="request">
Request transform settings and headers.
</Tabs.Content>
<Tabs.Content value="response">
Response inspection and fallback rules.
</Tabs.Content>
<Tabs.Content value="test">
Console output, sample payloads, and validation feedback.
</Tabs.Content>
</Tabs.Root>
<div class="ui-cluster">
@ -112,7 +129,10 @@ export const Default = {
<Popover.Portal>
<Popover.Content>
<Popover.Title>Backend metadata</Popover.Title>
<Popover.Description>Compact metadata clusters live in popovers when space is tight.</Popover.Description>
<Popover.Description>
Compact metadata clusters live in popovers when space is
tight.
</Popover.Description>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
@ -122,18 +142,21 @@ export const Default = {
</div>
</div>
<Alert tone="warning" title="Migration note">
Wrapper components should replace direct primitive usage before route-level refactors begin.
<Alert title="Migration note" tone="warning">
Wrapper components should replace direct primitive usage before
route-level refactors begin.
</Alert>
<Dialog.Root open={dialogOpen()} onOpenChange={setDialogOpen}>
<Dialog.Root onOpenChange={setDialogOpen} open={dialogOpen()}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<div class="ui-dialog__header">
<div>
<Dialog.Title>Compact Dialog</Dialog.Title>
<Dialog.Description>Dense forms should still remain keyboard-friendly.</Dialog.Description>
<Dialog.Description>
Dense forms should still remain keyboard-friendly.
</Dialog.Description>
</div>
</div>
<div class="ui-dialog__body ui-stack">
@ -146,7 +169,7 @@ export const Default = {
</div>
<div class="ui-dialog__footer">
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="primary" onClick={() => setDialogOpen(false)}>
<Button onClick={() => setDialogOpen(false)} variant="primary">
Save
</Button>
</div>

204
eslint.config.mjs Normal file
View file

@ -0,0 +1,204 @@
// @ts-check
import eslint from '@eslint/js';
import prettier from 'eslint-plugin-prettier/recommended';
import solid from 'eslint-plugin-solid/configs/recommended';
import stylistic from '@stylistic/eslint-plugin';
import tsEslint from 'typescript-eslint';
import * as importPlugin from 'eslint-plugin-import';
import globals from 'globals';
export default tsEslint.config(
eslint.configs.recommended,
tsEslint.configs.eslintRecommended,
...tsEslint.configs.recommendedTypeChecked,
prettier,
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/storybook-static/**',
'**/coverage/**',
'**/data/**',
'**/*.config.*js',
'**/*.test.*js',
'scripts/**',
'server/scripts/**',
'database/**',
'**/public/**',
'**/.storybook/**',
'**/vite.config.*',
'**/vitest.config.*',
],
},
// ── Common rules for TS/TSX across the monorepo ──
{
files: ['**/*.{ts,tsx,mts}'],
plugins: {
'@stylistic': stylistic,
import: importPlugin,
},
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
project: ['./server/tsconfig.eslint.json', './client/tsconfig.json'],
sourceType: 'module',
ecmaVersion: 'latest',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Stylistic rules from the youtube-music reference config
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/quotes': [
'error',
'single',
{ avoidEscape: true, allowTemplateLiterals: false },
],
'@stylistic/quote-props': ['error', 'consistent'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/no-mixed-operators': 'warn',
'@stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true }],
'@stylistic/no-tabs': 'error',
'@stylistic/lines-around-comment': [
'error',
{
beforeBlockComment: false,
afterBlockComment: false,
beforeLineComment: false,
afterLineComment: false,
},
],
'@stylistic/max-len': 'off',
'prettier/prettier': [
'error',
{
singleQuote: true,
semi: true,
tabWidth: 2,
trailingComma: 'all',
quoteProps: 'preserve',
},
],
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
'@typescript-eslint/no-base-to-string': 'off',
'@typescript-eslint/no-redundant-type-constituents': 'off',
'@typescript-eslint/no-duplicate-type-constituents': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/restrict-plus-operands': 'off',
'@typescript-eslint/await-thenable': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/only-throw-error': 'off',
'@typescript-eslint/prefer-promise-reject-errors': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-deprecated': 'off',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/prefer-optional-chain': 'off',
'@typescript-eslint/dot-notation': 'off',
'no-void': 'off',
'no-useless-escape': 'off',
'no-prototype-builtins': 'off',
'prefer-const': 'warn',
'@typescript-eslint/consistent-type-imports': [
'error',
{
fixStyle: 'inline-type-imports',
prefer: 'type-imports',
disallowTypeAnnotations: false,
},
],
'import/first': 'error',
'import/newline-after-import': 'off',
'import/no-default-export': 'off',
'import/no-duplicates': 'error',
'import/no-unresolved': [
'error',
{
ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'],
},
],
'import/order': [
'error',
{
groups: ['builtin', 'external', ['internal', 'index', 'sibling'], 'parent', 'type'],
'newlines-between': 'always-and-inside-groups',
alphabetize: { order: 'ignore', caseInsensitive: false },
},
],
'import/prefer-default-export': 'off',
camelcase: 'off',
'class-methods-use-this': 'off',
'no-empty': 'off',
'prefer-promise-reject-errors': 'off',
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
project: ['server/tsconfig.eslint.json', 'client/tsconfig.json'],
},
},
},
},
// ── Client-only (Solid.js + JSX) ──
{
files: ['client/**/*.{ts,tsx}'],
...solid,
languageOptions: {
...solid.languageOptions,
globals: { ...globals.browser },
parser: tsEslint.parser,
parserOptions: {
project: ['./client/tsconfig.json'],
sourceType: 'module',
ecmaVersion: 'latest',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
...solid.rules,
'@stylistic/jsx-pascal-case': 'error',
'@stylistic/jsx-curly-spacing': ['error', { when: 'never', children: true }],
'@stylistic/jsx-sort-props': 'error',
},
},
// ── Server-only (Node) ──
{
files: ['server/**/*.ts'],
languageOptions: {
globals: { ...globals.node },
},
},
// ── Shared types (loose - it's just type definitions) ──
{
files: ['shared/**/*.ts'],
languageOptions: {
globals: { ...globals.node, ...globals.browser },
},
},
);

View file

@ -8,7 +8,9 @@
"start": "pnpm --parallel start",
"test": "pnpm -r --filter server test",
"bench": "pnpm -r --filter server run bench",
"storybook": "pnpm -r --filter client storybook"
"storybook": "pnpm -r --filter client storybook",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"keywords": [
"llm",
@ -24,5 +26,20 @@
],
"engines": {
"node": ">=24.13.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@stylistic/eslint-plugin": "^2.12.1",
"@types/node": "^25.3.3",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-solid": "^0.14.5",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.18.2"
}
}

3134
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,15 @@
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { BenchmarkConfig, BenchmarkEnv, setupBenchmark, runBenchmark } from './runner';
import {
type BenchmarkConfig,
type BenchmarkEnv,
setupBenchmark,
runBenchmark,
} from './runner';
import { Scenarios, createRealBackendPayload } from './scenarios';
import { calculateStats, BenchmarkResult } from './stats';
import { calculateStats, type BenchmarkResult } from './stats';
import { printReport, exportToJSON } from './report';
// Utility: Normalize backend URL (remove trailing slash and /v1 prefix)
@ -21,7 +27,7 @@ function normalizeBackendUrl(url: string): string {
// Utility: Build request headers with optional authentication
function buildHeaders(authToken?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
@ -35,18 +41,18 @@ function buildUrls(
backendBaseUrl: string | undefined,
routerPort: number | undefined,
mockBackendPort: number | undefined,
endpoint: string
endpoint: string,
): { directUrl: string; routeUrl: string } {
if (backendType === 'real') {
const normalizedUrl = normalizeBackendUrl(backendBaseUrl || '');
return {
directUrl: `${normalizedUrl}${endpoint}`,
routeUrl: `http://localhost:${routerPort || 3099}${endpoint}`
routeUrl: `http://localhost:${routerPort || 3099}${endpoint}`,
};
} else {
return {
directUrl: `http://localhost:${mockBackendPort}${endpoint}`,
routeUrl: `http://localhost:${routerPort || 3099}${endpoint}`
routeUrl: `http://localhost:${routerPort || 3099}${endpoint}`,
};
}
}
@ -57,14 +63,14 @@ async function runScenarioBenchmark(
scenario: any,
config: BenchmarkConfig,
env: BenchmarkEnv | null,
backendApiKey?: string
backendApiKey?: string,
): Promise<{ directResults: any[]; routeResults: any[] }> {
const urls = buildUrls(
backendType,
config.backendUrl,
env?.routerPort,
env?.mockBackendPort,
scenario.endpoint
scenario.endpoint,
);
const directHeaders = buildHeaders(backendApiKey);
@ -73,7 +79,7 @@ async function runScenarioBenchmark(
const benchmarkConfig = {
concurrent: config.concurrentRequests,
total: config.totalRequests,
warmup: config.warmupRequests
warmup: config.warmupRequests,
};
if (backendType === 'real') {
@ -86,7 +92,7 @@ async function runScenarioBenchmark(
scenario.method,
scenario.payload,
directHeaders,
benchmarkConfig
benchmarkConfig,
);
console.log(chalk.yellow(' Running routed requests...'));
@ -95,7 +101,7 @@ async function runScenarioBenchmark(
scenario.method,
scenario.payload,
routeHeaders,
benchmarkConfig
benchmarkConfig,
);
return { directResults: directRaw, routeResults: routeRaw };
@ -111,7 +117,10 @@ program
.option('-r, --requests <number>', 'Total number of requests', '100')
.option('-w, --warmup <number>', 'Number of warmup requests', '5')
.option('-b, --backend <type>', 'Backend type (mock|real)', 'mock')
.option('-u, --backend-url <url>', 'Real backend URL (required for real backend)')
.option(
'-u, --backend-url <url>',
'Real backend URL (required for real backend)',
)
.option('-k, --backend-key <key>', 'Real backend API key (optional)')
.option('-o, --output <file>', 'Export results to JSON file')
.parse(process.argv);
@ -120,19 +129,21 @@ const options = program.opts();
async function main() {
console.log(chalk.bold.cyan('\n🚀 LLM Router Benchmark Tool\n'));
const config: BenchmarkConfig = {
concurrentRequests: parseInt(options.concurrent),
totalRequests: parseInt(options.requests),
warmupRequests: parseInt(options.warmup),
backendType: options.backend as 'mock' | 'real',
backendUrl: options.backendUrl,
backendApiKey: options.backendKey
backendApiKey: options.backendKey,
};
// Validate real backend options
if (config.backendType === 'real' && !config.backendUrl) {
console.error(chalk.red('Error: --backend-url is required for real backend'));
console.error(
chalk.red('Error: --backend-url is required for real backend'),
);
process.exit(1);
}
@ -148,7 +159,7 @@ async function main() {
const scenarios = [
Scenarios.smallPayload(),
Scenarios.largePayload(),
Scenarios.modelsEndpoint()
Scenarios.modelsEndpoint(),
];
if (config.backendType === 'real') {
@ -165,13 +176,17 @@ async function main() {
scenario,
config,
env,
config.backendApiKey
config.backendApiKey,
);
// Calculate statistics
const directStats = calculateStats(directResults, scenario.name, 'direct');
const directStats = calculateStats(
directResults,
scenario.name,
'direct',
);
const routeStats = calculateStats(routeResults, scenario.name, 'route');
allResults.push(directStats, routeStats);
}
@ -180,16 +195,19 @@ async function main() {
concurrent: config.concurrentRequests,
total: config.totalRequests,
warmup: config.warmupRequests,
backend: config.backendType
backend: config.backendType,
});
// Export to JSON if requested
if (options.output) {
exportToJSON(allResults, options.output);
}
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`));
console.error(
chalk.red(
`Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
),
);
process.exit(1);
} finally {
// Cleanup

View file

@ -1,28 +1,31 @@
import fs from 'node:fs';
import Table from 'cli-table3';
import chalk from 'chalk';
import { BenchmarkResult, calculateOverhead } from './stats';
import { type BenchmarkResult, calculateOverhead } from './stats';
export function printReport(results: BenchmarkResult[], config: any) {
console.log('\n' + '='.repeat(80));
console.log(chalk.bold.cyan(' BENCHMARK RESULTS'));
console.log('='.repeat(80));
console.log(`\nConfiguration:`);
console.log('\nConfiguration:');
console.log(` Concurrent Requests: ${config.concurrent}`);
console.log(` Total Requests: ${config.total}`);
console.log(` Warmup Requests: ${config.warmup}`);
console.log(` Backend Type: ${config.backend}`);
// Group results by scenario
const scenarios = [...new Set(results.map(r => r.scenario))];
const scenarios = [...new Set(results.map((r) => r.scenario))];
for (const scenario of scenarios) {
const scenarioResults = results.filter(r => r.scenario === scenario);
const direct = scenarioResults.find(r => r.mode === 'direct');
const route = scenarioResults.find(r => r.mode === 'route');
const scenarioResults = results.filter((r) => r.scenario === scenario);
const direct = scenarioResults.find((r) => r.mode === 'direct');
const route = scenarioResults.find((r) => r.mode === 'route');
console.log(`\n${chalk.bold.yellow(`\n${scenario}`)}`);
console.log('-'.repeat(80));
const table = new Table({
head: [
chalk.cyan('Mode'),
@ -34,13 +37,22 @@ export function printReport(results: BenchmarkResult[], config: any) {
chalk.cyan('P95 (ms)'),
chalk.cyan('P99 (ms)'),
chalk.cyan('Errors'),
chalk.cyan('Req/s')
chalk.cyan('Req/s'),
],
colAligns: [
'left', 'right', 'right', 'right', 'right', 'right', 'right', 'right', 'right', 'right'
]
'left',
'right',
'right',
'right',
'right',
'right',
'right',
'right',
'right',
'right',
],
});
if (direct) {
table.push([
chalk.green('Direct'),
@ -52,14 +64,15 @@ export function printReport(results: BenchmarkResult[], config: any) {
direct.p95ResponseTime.toFixed(2),
direct.p99ResponseTime.toFixed(2),
direct.errors,
direct.throughput.toFixed(2)
direct.throughput.toFixed(2),
]);
}
if (route) {
const overhead = direct ? calculateOverhead(direct, route) : 0;
const overheadColor = overhead > 20 ? chalk.red : overhead > 10 ? chalk.yellow : chalk.green;
const overheadColor =
overhead > 20 ? chalk.red : overhead > 10 ? chalk.yellow : chalk.green;
table.push([
chalk.blue('Route'),
`${route.successfulRequests}/${route.totalRequests}`,
@ -70,26 +83,25 @@ export function printReport(results: BenchmarkResult[], config: any) {
route.p95ResponseTime.toFixed(2),
route.p99ResponseTime.toFixed(2),
route.errors,
route.throughput.toFixed(2)
route.throughput.toFixed(2),
]);
console.log(table.toString());
console.log(`\n${overheadColor(` Overhead: ${overhead.toFixed(2)}%`)}`);
} else {
console.log(table.toString());
}
}
console.log('\n' + '='.repeat(80));
console.log(chalk.bold.green(' Benchmark completed!'));
console.log('='.repeat(80) + '\n');
}
export function exportToJSON(results: BenchmarkResult[], outputPath: string) {
const fs = require('fs');
const data = {
timestamp: new Date().toISOString(),
results
results,
};
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2));
console.log(chalk.green(`Results exported to ${outputPath}`));

View file

@ -1,10 +1,12 @@
import { serve, type ServerType } from '@hono/node-server';
import { createMockBackend } from '../tests/utils/mockBackend';
import express from 'express';
import { BackendModel } from '../src/models/Backend';
import { UserModel } from '../src/models/User';
import { PermissionModel } from '../src/models/Permission';
import { createServer } from '../src/index';
import { RawResult } from './stats';
import { createApp } from '../src/index';
import type { RawResult } from './stats';
export interface BenchmarkConfig {
concurrentRequests: number;
@ -23,26 +25,36 @@ export interface BenchmarkEnv {
cleanup: () => void;
}
export async function setupBenchmark(config: BenchmarkConfig): Promise<BenchmarkEnv> {
export async function setupBenchmark(
config: BenchmarkConfig,
): Promise<BenchmarkEnv> {
let mockBackendPort: number | undefined;
let routerPort: number | undefined;
let userApiKey: string | undefined;
let backendApiKey: string | undefined;
let mockServer: any = null;
let routerServer: any = null;
let mockServer: ServerType | null = null;
let routerServer: ServerType | null = null;
let backendId: number | undefined;
// Always start router server for both mock and real backend
if (config.backendType === 'mock') {
// Cleanup existing benchmark data
const existingBackend = BackendModel.findAll().find(b => b.name === 'benchmark-backend');
const existingBackend = BackendModel.findAll().find(
(b) => b.name === 'benchmark-backend',
);
if (existingBackend) {
BackendModel.delete(existingBackend.id);
}
const existingUser = UserModel.findAll().find(u => u.name === 'benchmark-user');
const existingUser = UserModel.findAll().find(
(u) => u.name === 'benchmark-user',
);
if (existingUser) {
const permissions = PermissionModel.findAll().filter(p => p.user_id === existingUser.id);
permissions.forEach(p => PermissionModel.delete(p.user_id, p.backend_id));
const permissions = PermissionModel.findAll().filter(
(p) => p.user_id === existingUser.id,
);
permissions.forEach((p) =>
PermissionModel.delete(p.user_id, p.backend_id),
);
UserModel.delete(existingUser.id);
}
@ -50,11 +62,13 @@ export async function setupBenchmark(config: BenchmarkConfig): Promise<Benchmark
mockServer = server;
mockBackendPort = port;
console.log(`Mock backend started on port ${port}`);
// Check if benchmark backend already exists
let backend = BackendModel.findAll().find(b => b.name === 'benchmark-backend');
const backend = BackendModel.findAll().find(
(b) => b.name === 'benchmark-backend',
);
let backendId: number;
if (backend) {
backendId = backend.id;
// Update the backend URL to point to new mock server (without /v1 prefix)
@ -63,97 +77,105 @@ export async function setupBenchmark(config: BenchmarkConfig): Promise<Benchmark
const newBackend = BackendModel.create({
name: 'benchmark-backend',
base_url: `http://localhost:${port}`,
api_key: 'mock-backend-key'
api_key: 'mock-backend-key',
});
backendId = newBackend.id;
}
backendApiKey = 'mock-backend-key';
// Check if benchmark user already exists
let user = UserModel.findAll().find(u => u.name === 'benchmark-user');
let user = UserModel.findAll().find((u) => u.name === 'benchmark-user');
let userId: number;
if (user) {
userId = user.id;
userApiKey = user.api_key;
console.log(` Using existing user: ${user.id}, API key: ${userApiKey?.substring(0, 15)}...`);
console.log(
` Using existing user: ${user.id}, API key: ${userApiKey?.substring(0, 15)}...`,
);
} else {
user = UserModel.create({ name: 'benchmark-user', email: 'benchmark@test.com' });
user = UserModel.create({
name: 'benchmark-user',
email: 'benchmark@test.com',
});
userId = user.id;
const newApiKey = UserModel.regenerateApiKey(userId);
if (newApiKey) {
userApiKey = newApiKey;
}
console.log(` Created new user: ${userId}, API key: ${userApiKey?.substring(0, 15)}...`);
console.log(
` Created new user: ${userId}, API key: ${userApiKey?.substring(0, 15)}...`,
);
}
// Check if permission already exists
const existingPermission = PermissionModel.findAll().find(
p => p.user_id === userId && p.backend_id === backendId
(p) => p.user_id === userId && p.backend_id === backendId,
);
if (!existingPermission) {
PermissionModel.create({ user_id: userId, backend_id: backendId });
}
routerPort = 3099;
const app = createServer();
routerServer = app.listen(routerPort, () => {
const app = createApp();
routerServer = serve({ fetch: app.fetch, port: routerPort }, () => {
console.log(`Router server listening on port ${routerPort}`);
}).on('error', (err: Error) => {
console.error(`Router server error on port ${routerPort}:`, err.message);
});
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
} else if (config.backendType === 'real') {
// For real backend, still need router server and a test user
routerPort = 3099;
const app = createServer();
routerServer = app.listen(routerPort, () => {
const app = createApp();
routerServer = serve({ fetch: app.fetch, port: routerPort }, () => {
console.log(`Router server listening on port ${routerPort}`);
}).on('error', (err: Error) => {
console.error(`Router server error on port ${routerPort}:`, err.message);
});
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// Create a test user for router authentication
let user = UserModel.findAll().find(u => u.name === 'benchmark-user');
let user = UserModel.findAll().find((u) => u.name === 'benchmark-user');
if (user) {
userApiKey = user.api_key;
} else {
user = UserModel.create({ name: 'benchmark-user', email: 'benchmark@test.com' });
user = UserModel.create({
name: 'benchmark-user',
email: 'benchmark@test.com',
});
const newApiKey = UserModel.regenerateApiKey(user.id);
if (newApiKey) {
userApiKey = newApiKey;
}
}
// Create backend entry for the real backend
let backend = BackendModel.findAll().find(b => b.name === 'real-backend');
const backend = BackendModel.findAll().find(
(b) => b.name === 'real-backend',
);
if (backend) {
BackendModel.update(backend.id, {
BackendModel.update(backend.id, {
base_url: config.backendUrl?.replace(/\/v1$/, '') || '',
api_key: config.backendApiKey || '',
is_active: true
is_active: true,
});
backendId = backend.id;
} else {
const newBackend = BackendModel.create({
name: 'real-backend',
base_url: config.backendUrl?.replace(/\/v1$/, '') || '',
api_key: config.backendApiKey || ''
api_key: config.backendApiKey || '',
});
backendId = newBackend.id;
// Ensure backend is active
BackendModel.update(backendId, { is_active: true });
}
// Grant permission to the user
const existingPermission = PermissionModel.findAll().find(
p => p.user_id === user!.id && p.backend_id === backendId!
(p) => p.user_id === user!.id && p.backend_id === backendId!,
);
if (!existingPermission && user && backendId) {
PermissionModel.create({ user_id: user.id, backend_id: backendId });
@ -168,7 +190,7 @@ export async function setupBenchmark(config: BenchmarkConfig): Promise<Benchmark
cleanup: () => {
if (mockServer) mockServer.close();
if (routerServer) routerServer.close();
}
},
};
}
@ -177,31 +199,37 @@ export async function runBenchmark(
method: string,
payload: any,
headers: Record<string, string>,
config: { concurrent: number; total: number; warmup: number }
config: { concurrent: number; total: number; warmup: number },
): Promise<RawResult[]> {
const allResults: RawResult[] = [];
// Warmup
console.log(` Warmup: ${config.warmup} requests...`);
for (let i = 0; i < config.warmup; i++) {
await makeRequest(url, method, payload, headers);
}
// Benchmark
console.log(` Running: ${config.total} requests (concurrent: ${config.concurrent})...`);
console.log(
` Running: ${config.total} requests (concurrent: ${config.concurrent})...`,
);
const batches = Math.ceil(config.total / config.concurrent);
for (let batch = 0; batch < batches; batch++) {
const batchPromises = [];
for (let i = 0; i < config.concurrent && (batch * config.concurrent + i) < config.total; i++) {
for (
let i = 0;
i < config.concurrent && batch * config.concurrent + i < config.total;
i++
) {
batchPromises.push(makeRequest(url, method, payload, headers));
}
const batchResults = await Promise.all(batchPromises);
allResults.push(...batchResults);
}
return allResults;
}
@ -209,40 +237,40 @@ async function makeRequest(
url: string,
method: string,
payload: any,
headers: Record<string, string>
headers: Record<string, string>,
): Promise<RawResult> {
const startTime = Date.now();
try {
const options: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000)
signal: AbortSignal.timeout(30000),
};
if (method !== 'GET' && payload && Object.keys(payload).length > 0) {
options.body = JSON.stringify(payload);
}
const response = await fetch(url, options);
const responseTime = Date.now() - startTime;
if (response.ok) {
return { responseTime, success: true };
} else {
const errorText = await response.text().catch(() => 'Unknown error');
return {
responseTime,
success: false,
error: `HTTP ${response.status}: ${errorText.substring(0, 100)}`
return {
responseTime,
success: false,
error: `HTTP ${response.status}: ${errorText.substring(0, 100)}`,
};
}
} catch (error) {
const responseTime = Date.now() - startTime;
return {
responseTime,
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
return {
responseTime,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

View file

@ -26,12 +26,10 @@ export const Scenarios = {
method: 'POST',
payload: {
model: 'test-model',
messages: [
{ role: 'user', content: 'Hello' }
],
messages: [{ role: 'user', content: 'Hello' }],
temperature: 0.7,
max_tokens: 100
}
max_tokens: 100,
},
}),
largePayload: (): Scenario => ({
@ -42,14 +40,30 @@ export const Scenarios = {
payload: {
model: 'test-model',
messages: [
{ role: 'system', content: 'You are a helpful assistant that provides detailed and accurate information to users. Always respond in a clear and concise manner.' },
{ role: 'user', content: 'Can you explain the difference between supervised and unsupervised learning in machine learning? Please provide examples of each and discuss their use cases.' },
{ role: 'assistant', content: 'Supervised learning uses labeled data to train models, while unsupervised learning finds patterns in unlabeled data.' },
{ role: 'user', content: 'That is helpful. Can you also explain reinforcement learning and how it differs from these approaches? What are some practical applications?' }
{
role: 'system',
content:
'You are a helpful assistant that provides detailed and accurate information to users. Always respond in a clear and concise manner.',
},
{
role: 'user',
content:
'Can you explain the difference between supervised and unsupervised learning in machine learning? Please provide examples of each and discuss their use cases.',
},
{
role: 'assistant',
content:
'Supervised learning uses labeled data to train models, while unsupervised learning finds patterns in unlabeled data.',
},
{
role: 'user',
content:
'That is helpful. Can you also explain reinforcement learning and how it differs from these approaches? What are some practical applications?',
},
],
temperature: 0.7,
max_tokens: 500
}
max_tokens: 500,
},
}),
modelsEndpoint: (): Scenario => ({
@ -57,8 +71,8 @@ export const Scenarios = {
description: 'GET /models request',
endpoint: '/v1/models',
method: 'GET',
payload: {} as ChatCompletionPayload
})
payload: {} as ChatCompletionPayload,
}),
};
export function createRealBackendPayload(): Scenario {
@ -69,11 +83,9 @@ export function createRealBackendPayload(): Scenario {
method: 'POST',
payload: {
model: process.env.REAL_MODEL || 'default-model',
messages: [
{ role: 'user', content: 'Hello, this is a benchmark test.' }
],
messages: [{ role: 'user', content: 'Hello, this is a benchmark test.' }],
temperature: 0.7,
max_tokens: 100
}
max_tokens: 100,
},
};
}

View file

@ -20,31 +20,47 @@ export interface RawResult {
error?: string;
}
export function calculateStats(results: RawResult[], scenario: string, mode: 'direct' | 'route'): BenchmarkResult {
const responseTimes = results.filter(r => r.success).map(r => r.responseTime);
const errors = results.filter(r => !r.success).length;
export function calculateStats(
results: RawResult[],
scenario: string,
mode: 'direct' | 'route',
): BenchmarkResult {
const responseTimes = results
.filter((r) => r.success)
.map((r) => r.responseTime);
const errors = results.filter((r) => !r.success).length;
const successfulRequests = responseTimes.length;
const totalDuration = Math.max(...responseTimes, 0);
const sortedTimes = [...responseTimes].sort((a, b) => a - b);
const avgResponseTime = sortedTimes.length > 0
? sortedTimes.reduce((a, b) => a + b, 0) / sortedTimes.length
: 0;
const avgResponseTime =
sortedTimes.length > 0
? sortedTimes.reduce((a, b) => a + b, 0) / sortedTimes.length
: 0;
const minResponseTime = sortedTimes.length > 0 ? sortedTimes[0] : 0;
const maxResponseTime = sortedTimes.length > 0 ? sortedTimes[sortedTimes.length - 1] : 0;
const maxResponseTime =
sortedTimes.length > 0 ? sortedTimes[sortedTimes.length - 1] : 0;
const p50Index = Math.floor(sortedTimes.length * 0.5);
const p95Index = Math.floor(sortedTimes.length * 0.95);
const p99Index = Math.floor(sortedTimes.length * 0.99);
const p50ResponseTime = sortedTimes.length > 0 ? sortedTimes[p50Index] || 0 : 0;
const p95ResponseTime = sortedTimes.length > 0 ? sortedTimes[Math.min(p95Index, sortedTimes.length - 1)] || 0 : 0;
const p99ResponseTime = sortedTimes.length > 0 ? sortedTimes[Math.min(p99Index, sortedTimes.length - 1)] || 0 : 0;
const throughput = totalDuration > 0 ? (successfulRequests / totalDuration) * 1000 : 0;
const p50ResponseTime =
sortedTimes.length > 0 ? sortedTimes[p50Index] || 0 : 0;
const p95ResponseTime =
sortedTimes.length > 0
? sortedTimes[Math.min(p95Index, sortedTimes.length - 1)] || 0
: 0;
const p99ResponseTime =
sortedTimes.length > 0
? sortedTimes[Math.min(p99Index, sortedTimes.length - 1)] || 0
: 0;
const throughput =
totalDuration > 0 ? (successfulRequests / totalDuration) * 1000 : 0;
return {
scenario,
mode,
@ -62,7 +78,14 @@ export function calculateStats(results: RawResult[], scenario: string, mode: 'di
};
}
export function calculateOverhead(direct: BenchmarkResult, route: BenchmarkResult): number {
export function calculateOverhead(
direct: BenchmarkResult,
route: BenchmarkResult,
): number {
if (direct.avgResponseTime === 0) return 0;
return ((route.avgResponseTime - direct.avgResponseTime) / direct.avgResponseTime) * 100;
return (
((route.avgResponseTime - direct.avgResponseTime) /
direct.avgResponseTime) *
100
);
}

View file

@ -2,11 +2,12 @@
"name": "server",
"version": "1.0.0",
"description": "LLM Router Server",
"main": "dist/index.js",
"main": "dist/server/src/index.js",
"type": "module",
"scripts": {
"build": "tsc && node scripts/copy-schemas.mjs",
"start": "node dist/server/src/index.js",
"dev": "tsx watch src",
"start": "node dist/server/src/main.js",
"dev": "tsx watch src/main.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
@ -17,24 +18,22 @@
"author": "",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.13.7",
"@hono/swagger-ui": "^0.5.0",
"@hono/zod-openapi": "^0.18.0",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"hono": "^4.6.14",
"isolated-vm": "^6.0.2",
"node-fetch": "^3.3.2",
"zod": "^4.3.6"
"zod": "^3.23.8"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.3.3",
"@types/supertest": "^7.2.0",
"chalk": "^5.6.0",
"cli-table3": "^0.6.5",
"commander": "^14.0.0",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"

View file

@ -1,5 +1,6 @@
import { createHash } from 'crypto';
import { AdminAuthMode } from '../../../shared/types';
import { createHash } from 'node:crypto';
import type { AdminAuthMode } from '../../../shared/types.js';
function normalizeAuthMode(value?: string): AdminAuthMode {
if (value === 'env' || value === 'oidc' || value === 'both') {
@ -38,7 +39,10 @@ export function getAdminPasswordHash(): string | null {
}
export function getAdminSessionSecret(): string {
return process.env.ADMIN_SESSION_SECRET?.trim() || 'development-admin-session-secret';
return (
process.env.ADMIN_SESSION_SECRET?.trim() ||
'development-admin-session-secret'
);
}
export function getAdminSessionTtlHours(): number {
@ -52,11 +56,16 @@ export function getAdminApiTokenTtlDays(): number {
}
export function getCookieSecure(): boolean {
return process.env.NODE_ENV === 'production' && process.env.ADMIN_COOKIE_SECURE !== 'false';
return (
process.env.NODE_ENV === 'production' &&
process.env.ADMIN_COOKIE_SECURE !== 'false'
);
}
export function getAllowedOidcEmails(): string[] {
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) => entry.toLowerCase());
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) =>
entry.toLowerCase(),
);
}
export function getTrustedProxyIps(): string[] {

View file

@ -1,10 +1,28 @@
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { ensureDir, getAnalyticsDbPath } from './db-paths';
import { ensureDir, getAnalyticsDbPath } from './db-paths.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let db: Database.Database;
function loadSchema(database: Database.Database): void {
const schemaPath = path.join(
__dirname,
'..',
'..',
'..',
'database',
'analytics-schema.sql',
);
const schema = fs.readFileSync(schemaPath, 'utf-8');
database.exec(schema);
}
export function getAnalyticsDb(): Database.Database {
if (!db) {
const analyticsDbPath = getAnalyticsDbPath();
@ -12,29 +30,22 @@ export function getAnalyticsDb(): Database.Database {
db = new Database(analyticsDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
loadSchema(db);
}
return db;
}
export function initAnalyticsDb(): Database.Database {
// Close existing connection if any
if (db) {
db.close();
}
const analyticsDbPath = getAnalyticsDbPath();
ensureDir(path.dirname(analyticsDbPath));
db = new Database(analyticsDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
loadSchema(db);
return db;
}

View file

@ -1,21 +1,47 @@
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { ensureDir, getCoreDbPath } from './db-paths';
import { ensureDir, getCoreDbPath } from './db-paths.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let db: Database.Database;
function hasColumn(database: Database.Database, tableName: string, columnName: string): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
function hasColumn(
database: Database.Database,
tableName: string,
columnName: string,
): boolean {
const columns = database
.prepare(`PRAGMA table_info(${tableName})`)
.all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
}
function runCoreMigrations(database: Database.Database): void {
if (hasColumn(database, 'model_rewrites', 'force') === false) {
database.exec('ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0');
database.exec(
'ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0',
);
}
}
function loadSchema(database: Database.Database): void {
const schemaPath = path.join(
__dirname,
'..',
'..',
'..',
'database',
'schema.sql',
);
const schema = fs.readFileSync(schemaPath, 'utf-8');
database.exec(schema);
}
export function getDb(): Database.Database {
if (!db) {
const coreDbPath = getCoreDbPath();
@ -24,29 +50,24 @@ export function getDb(): Database.Database {
db = new Database(coreDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
loadSchema(db);
runCoreMigrations(db);
}
return db;
}
export function initDb(): Database.Database {
// Close existing connection if any
if (db) {
db.close();
}
const coreDbPath = getCoreDbPath();
ensureDir(path.dirname(coreDbPath));
db = new Database(coreDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
loadSchema(db);
runCoreMigrations(db);
return db;

View file

@ -1,5 +1,5 @@
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
@ -28,4 +28,3 @@ export function getRequestLogsDir(): string {
export function getRequestLogsDbPath(monthKey: string): string {
return path.join(getRequestLogsDir(), `request_logs_${monthKey}.db`);
}

View file

@ -1,18 +1,41 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { ensureDir, getRequestLogsDbPath, getRequestLogsDir } from './db-paths';
import { getLocalMonthKey } from '../utils/time';
import {
ensureDir,
getRequestLogsDbPath,
getRequestLogsDir,
} from './db-paths.js';
import { getLocalMonthKey } from '../utils/time.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const connections = new Map<string, Database.Database>();
function hasColumn(database: Database.Database, tableName: string, columnName: string): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
function hasColumn(
database: Database.Database,
tableName: string,
columnName: string,
): boolean {
const columns = database
.prepare(`PRAGMA table_info(${tableName})`)
.all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
}
function initRequestLogsSchema(db: Database.Database): void {
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'request-logs-schema.sql');
const schemaPath = path.join(
__dirname,
'..',
'..',
'..',
'database',
'request-logs-schema.sql',
);
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
if (hasColumn(db, 'request_logs', 'routed_model') === false) {
@ -20,7 +43,9 @@ function initRequestLogsSchema(db: Database.Database): void {
}
}
export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
export function getRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
return existing;
@ -35,7 +60,9 @@ export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Databas
return db;
}
export function initRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
export function initRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
existing.close();

View file

@ -1,17 +1,25 @@
import express, { Application } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import adminRoutes from './routes/admin';
import adminAuthRoutes from './routes/admin-auth';
import apiRoutes from './routes/api';
import analyticsRoutes from './routes/analytics';
import { requireAdminAccess, requireSessionCsrf } from './utils/adminAuth';
import { logger } from './utils/logger';
import { getUtcTimestamp } from './utils/time';
import { ModelCatalogService } from './services/ModelCatalogService';
import { OpenAPIHono } from '@hono/zod-openapi';
import { swaggerUI } from '@hono/swagger-ui';
import { cors } from 'hono/cors';
import { bodyLimit } from 'hono/body-limit';
import { serveStatic } from '@hono/node-server/serve-static';
import dotenv from 'dotenv';
import adminRoutes from './routes/admin.js';
import adminAuthRoutes from './routes/admin-auth.js';
import apiRoutes from './routes/api.js';
import analyticsRoutes from './routes/analytics.js';
import { requireAdminAccess, requireSessionCsrf } from './utils/adminAuth.js';
import { getUtcTimestamp } from './utils/time.js';
import { ModelCatalogService } from './services/ModelCatalogService.js';
import type { AppEnv } from './types/hono.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const envPathCandidates = [
path.resolve(__dirname, '..', '..', '.env'),
@ -19,73 +27,118 @@ const envPathCandidates = [
path.resolve(process.cwd(), '.env'),
path.resolve(process.cwd(), '..', '.env'),
];
const resolvedEnvPath = envPathCandidates.find((candidate) => fs.existsSync(candidate));
const resolvedEnvPath = envPathCandidates.find((candidate) =>
fs.existsSync(candidate),
);
dotenv.config({
path: resolvedEnvPath,
quiet: true,
});
export function createServer(): Application {
const MAX_BODY_SIZE = 30 * 1024 * 1024; // 30mb
export function createApp(): OpenAPIHono<AppEnv> {
void ModelCatalogService.initialize();
const app = express();
const app = new OpenAPIHono<AppEnv>();
const adminDistCandidates = [
path.resolve(__dirname, '..', '..', '..', 'client', 'dist'),
path.resolve(__dirname, '..', '..', '..', '..', 'client', 'dist'),
];
const adminDistPath = adminDistCandidates.find((candidate) => fs.existsSync(candidate));
const adminDistPath = adminDistCandidates.find((candidate) =>
fs.existsSync(candidate),
);
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
: ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:3002', 'http://127.0.0.1:3002'];
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim())
: [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:3002',
'http://127.0.0.1:3002',
];
app.use(cors({
origin: corsOrigins,
credentials: true,
}));
app.use(express.json({
limit: '30mb',
}));
app.use(
'*',
cors({
origin: corsOrigins,
credentials: true,
}),
);
app.use('/admin/auth', adminAuthRoutes);
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
app.use('/admin', requireAdminAccess, requireSessionCsrf, adminRoutes);
app.use('/v1', apiRoutes);
app.use(
'*',
bodyLimit({
maxSize: MAX_BODY_SIZE,
}),
);
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
// Public admin auth endpoints
app.route('/admin/auth', adminAuthRoutes);
// Protected admin endpoints
app.use('/admin/analytics/*', requireAdminAccess, requireSessionCsrf);
app.route('/admin/analytics', analyticsRoutes);
app.use('/admin/*', requireAdminAccess, requireSessionCsrf);
app.route('/admin', adminRoutes);
// Public v1 API
app.route('/v1', apiRoutes);
app.get('/health', (c) =>
c.json({ status: 'ok', timestamp: getUtcTimestamp() }),
);
// OpenAPI document + Swagger UI (admin-only)
app.openAPIRegistry.registerComponent('securitySchemes', 'bearerAuth', {
type: 'http',
scheme: 'bearer',
});
app.openAPIRegistry.registerComponent('securitySchemes', 'adminSession', {
type: 'apiKey',
in: 'cookie',
name: 'kyush_admin_session',
});
if (adminDistPath) {
app.use('/dashboard', express.static(adminDistPath, { index: false, fallthrough: true }));
app.get(/^\/dashboard(?:\/.*)?$/, (req, res, next) => {
if (path.extname(req.path)) {
next();
return;
}
app.use('/admin/openapi.json', requireAdminAccess);
app.doc('/admin/openapi.json', {
openapi: '3.1.0',
info: { title: 'Kyush LLM Router', version: '1.0.0' },
servers: [{ url: '/' }],
});
res.sendFile(path.join(adminDistPath, 'index.html'));
app.use('/admin/docs', requireAdminAccess);
app.get('/admin/docs', swaggerUI({ url: '/admin/openapi.json' }));
// Static dashboard SPA
if (adminDistPath) {
const adminDistRel = path
.relative(process.cwd(), adminDistPath)
.replaceAll('\\', '/');
app.use(
'/dashboard/*',
serveStatic({
root: adminDistRel,
rewriteRequestPath: (p) => p.replace(/^\/dashboard/, ''),
}),
);
const indexHtml = (): string =>
fs.readFileSync(path.join(adminDistPath, 'index.html'), 'utf8');
app.get('/dashboard', (c) => c.html(indexHtml()));
app.get('/dashboard/*', (c) => {
// SPA fallback for routes without file extension
if (path.extname(c.req.path)) {
return c.notFound();
}
return c.html(indexHtml());
});
}
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
app.notFound((c) => c.json({ error: 'Not found' }, 404));
return app;
}
const app = createServer();
const PORT = process.env.SERVER_PORT || 3000;
// Only start server if this is the main module (not imported)
if (require.main === module) {
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin API: http://localhost:${PORT}/admin`);
logger.info(`Admin UI: http://localhost:${PORT}/dashboard`);
logger.info(`OpenAI API: http://localhost:${PORT}/v1`);
});
}
const app = createApp();
export default app;

20
server/src/main.ts Normal file
View file

@ -0,0 +1,20 @@
import { serve } from '@hono/node-server';
import app from './index.js';
import { logger } from './utils/logger.js';
const PORT = Number(process.env.SERVER_PORT) || 3000;
serve(
{
fetch: app.fetch,
port: PORT,
},
() => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin API: http://localhost:${PORT}/admin`);
logger.info(`Admin UI: http://localhost:${PORT}/dashboard`);
logger.info(`OpenAI API: http://localhost:${PORT}/v1`);
logger.info(`API Docs: http://localhost:${PORT}/admin/docs`);
},
);

View file

@ -1,6 +1,10 @@
import { AdminApiTokenSummary, AdminPrincipal } from '../../../shared/types';
import { getDb } from '../config/database';
import { getUtcTimestamp } from '../utils/time';
import { getDb } from '../config/database.js';
import { getUtcTimestamp } from '../utils/time.js';
import type {
AdminApiTokenSummary,
AdminPrincipal,
} from '../../../shared/types.js';
export interface AdminApiTokenRecord extends AdminApiTokenSummary {
token_hash: string;
@ -15,81 +19,109 @@ export class AdminApiTokenModel {
expiresAt: string;
}): AdminApiTokenRecord {
const timestamp = getUtcTimestamp();
const result = getDb().prepare(`
const result = getDb()
.prepare(
`
INSERT INTO admin_api_tokens (
token_hash, name, provider, subject, username, email, display_name, token_prefix,
expires_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.tokenHash,
data.name,
data.principal.provider,
data.principal.subject,
data.principal.username ?? null,
data.principal.email ?? null,
data.principal.displayName,
data.tokenPrefix,
data.expiresAt,
timestamp,
timestamp,
);
`,
)
.run(
data.tokenHash,
data.name,
data.principal.provider,
data.principal.subject,
data.principal.username ?? null,
data.principal.email ?? null,
data.principal.displayName,
data.tokenPrefix,
data.expiresAt,
timestamp,
timestamp,
);
return this.findById(Number(result.lastInsertRowid))!;
}
static findById(id: number): AdminApiTokenRecord | undefined {
return this.maybeRow(getDb().prepare('SELECT * FROM admin_api_tokens WHERE id = ?').get(id));
return this.maybeRow(
getDb().prepare('SELECT * FROM admin_api_tokens WHERE id = ?').get(id),
);
}
static findByTokenHash(tokenHash: string): AdminApiTokenRecord | undefined {
this.deleteExpired();
return this.maybeRow(
getDb().prepare(`
getDb()
.prepare(
`
SELECT * FROM admin_api_tokens
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ?
`).get(tokenHash, getUtcTimestamp())
`,
)
.get(tokenHash, getUtcTimestamp()),
);
}
static listBySubject(subject: string): AdminApiTokenSummary[] {
this.deleteExpired();
return getDb().prepare(`
return getDb()
.prepare(
`
SELECT id, name, provider, subject, username, email, display_name, token_prefix,
expires_at, last_used_at, revoked_at, created_at, updated_at
FROM admin_api_tokens
WHERE subject = ? AND revoked_at IS NULL AND expires_at > ?
ORDER BY created_at DESC
`).all(subject, getUtcTimestamp()) as AdminApiTokenSummary[];
`,
)
.all(subject, getUtcTimestamp()) as AdminApiTokenSummary[];
}
static touch(id: number): void {
const timestamp = getUtcTimestamp();
getDb().prepare('UPDATE admin_api_tokens SET last_used_at = ?, updated_at = ? WHERE id = ?')
getDb()
.prepare(
'UPDATE admin_api_tokens SET last_used_at = ?, updated_at = ? WHERE id = ?',
)
.run(timestamp, timestamp, id);
}
static revoke(id: number): boolean {
const timestamp = getUtcTimestamp();
const result = getDb().prepare(`
const result = getDb()
.prepare(
`
UPDATE admin_api_tokens
SET revoked_at = ?, updated_at = ?
WHERE id = ? AND revoked_at IS NULL
`).run(timestamp, timestamp, id);
`,
)
.run(timestamp, timestamp, id);
return result.changes > 0;
}
static revokeForSubject(id: number, subject: string): boolean {
const timestamp = getUtcTimestamp();
const result = getDb().prepare(`
const result = getDb()
.prepare(
`
UPDATE admin_api_tokens
SET revoked_at = ?, updated_at = ?
WHERE id = ? AND subject = ? AND revoked_at IS NULL
`).run(timestamp, timestamp, id, subject);
`,
)
.run(timestamp, timestamp, id, subject);
return result.changes > 0;
}
static deleteExpired(): void {
getDb().prepare('DELETE FROM admin_api_tokens WHERE expires_at <= ? OR revoked_at IS NOT NULL')
getDb()
.prepare(
'DELETE FROM admin_api_tokens WHERE expires_at <= ? OR revoked_at IS NOT NULL',
)
.run(getUtcTimestamp());
}

View file

@ -1,6 +1,7 @@
import { AdminPrincipal } from '../../../shared/types';
import { getDb } from '../config/database';
import { getUtcTimestamp } from '../utils/time';
import { getDb } from '../config/database.js';
import { getUtcTimestamp } from '../utils/time.js';
import type { AdminPrincipal } from '../../../shared/types.js';
export interface AdminSessionRecord {
id: number;
@ -26,55 +27,76 @@ export class AdminSessionModel {
expiresAt: string;
}): AdminSessionRecord {
const timestamp = getUtcTimestamp();
const result = getDb().prepare(`
const result = getDb()
.prepare(
`
INSERT INTO admin_sessions (
session_token_hash, provider, subject, username, email, display_name,
csrf_token, expires_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.sessionTokenHash,
data.principal.provider,
data.principal.subject,
data.principal.username ?? null,
data.principal.email ?? null,
data.principal.displayName,
data.csrfToken,
data.expiresAt,
timestamp,
timestamp,
);
`,
)
.run(
data.sessionTokenHash,
data.principal.provider,
data.principal.subject,
data.principal.username ?? null,
data.principal.email ?? null,
data.principal.displayName,
data.csrfToken,
data.expiresAt,
timestamp,
timestamp,
);
return this.findById(Number(result.lastInsertRowid))!;
}
static findById(id: number): AdminSessionRecord | undefined {
return this.maybeRow(getDb().prepare('SELECT * FROM admin_sessions WHERE id = ?').get(id));
return this.maybeRow(
getDb().prepare('SELECT * FROM admin_sessions WHERE id = ?').get(id),
);
}
static findByTokenHash(sessionTokenHash: string): AdminSessionRecord | undefined {
static findByTokenHash(
sessionTokenHash: string,
): AdminSessionRecord | undefined {
this.deleteExpired();
return this.maybeRow(
getDb().prepare(`
getDb()
.prepare(
`
SELECT * FROM admin_sessions
WHERE session_token_hash = ? AND revoked_at IS NULL AND expires_at > ?
`).get(sessionTokenHash, getUtcTimestamp())
`,
)
.get(sessionTokenHash, getUtcTimestamp()),
);
}
static touch(id: number): void {
const timestamp = getUtcTimestamp();
getDb().prepare('UPDATE admin_sessions SET last_used_at = ?, updated_at = ? WHERE id = ?')
getDb()
.prepare(
'UPDATE admin_sessions SET last_used_at = ?, updated_at = ? WHERE id = ?',
)
.run(timestamp, timestamp, id);
}
static revoke(id: number): void {
const timestamp = getUtcTimestamp();
getDb().prepare('UPDATE admin_sessions SET revoked_at = ?, updated_at = ? WHERE id = ? AND revoked_at IS NULL')
getDb()
.prepare(
'UPDATE admin_sessions SET revoked_at = ?, updated_at = ? WHERE id = ? AND revoked_at IS NULL',
)
.run(timestamp, timestamp, id);
}
static deleteExpired(): void {
getDb().prepare('DELETE FROM admin_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL')
getDb()
.prepare(
'DELETE FROM admin_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL',
)
.run(getUtcTimestamp());
}

View file

@ -1,6 +1,12 @@
import { getDb } from '../config/database';
import { Backend, CreateBackendData, UpdateBackendData } from '../../../shared/types';
import { getUtcTimestamp } from '../utils/time';
import { getDb } from '../config/database.js';
import { getUtcTimestamp } from '../utils/time.js';
import type {
Backend,
CreateBackendData,
UpdateBackendData,
} from '../../../shared/types.js';
export class BackendModel {
static asBackend(row: any): Backend {
@ -15,24 +21,39 @@ export class BackendModel {
}
static findAll(): Backend[] {
return getDb().prepare('SELECT * FROM backends ORDER BY created_at DESC').all().map(this.asBackend);
return getDb()
.prepare('SELECT * FROM backends ORDER BY created_at DESC')
.all()
.map(this.asBackend);
}
static findById(id: number): Backend | undefined {
return this.mightBeBackend(getDb().prepare('SELECT * FROM backends WHERE id = ?').get(id));
return this.mightBeBackend(
getDb().prepare('SELECT * FROM backends WHERE id = ?').get(id),
);
}
static findActive(): Backend[] {
return getDb().prepare('SELECT * FROM backends WHERE is_active = 1 ORDER BY name').all().map(this.asBackend);
return getDb()
.prepare('SELECT * FROM backends WHERE is_active = 1 ORDER BY name')
.all()
.map(this.asBackend);
}
static create(data: CreateBackendData): Backend {
const timestamp = getUtcTimestamp();
const detailLogging = data.detail_logging ?? false;
const stmt = getDb().prepare(
'INSERT INTO backends (name, base_url, api_key, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO backends (name, base_url, api_key, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
);
const result = stmt.run(
data.name,
data.base_url,
data.api_key || null,
detailLogging ? 1 : 0,
timestamp,
timestamp,
);
const result = stmt.run(data.name, data.base_url, data.api_key || null, detailLogging ? 1 : 0, timestamp, timestamp);
return {
id: result.lastInsertRowid as number,
@ -79,7 +100,9 @@ export class BackendModel {
values.push(getUtcTimestamp());
values.push(id);
getDb().prepare(`UPDATE backends SET ${updates.join(', ')} WHERE id = ?`).run(...values);
getDb()
.prepare(`UPDATE backends SET ${updates.join(', ')} WHERE id = ?`)
.run(...values);
return this.findById(id);
}
@ -89,7 +112,9 @@ export class BackendModel {
}
static deactivate(id: number): boolean {
const result = getDb().prepare('UPDATE backends SET is_active = 0, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
const result = getDb()
.prepare('UPDATE backends SET is_active = 0, updated_at = ? WHERE id = ?')
.run(getUtcTimestamp(), id);
return result.changes > 0;
}
}

View file

@ -1,6 +1,7 @@
import { BackendModelSnapshot } from '../../../shared/types';
import { getDb } from '../config/database';
import { getUtcTimestamp } from '../utils/time';
import { getDb } from '../config/database.js';
import { getUtcTimestamp } from '../utils/time.js';
import type { BackendModelSnapshot } from '../../../shared/types.js';
function asSnapshot(row: any): BackendModelSnapshot {
return row as BackendModelSnapshot;
@ -9,16 +10,24 @@ function asSnapshot(row: any): BackendModelSnapshot {
export class BackendModelSnapshotModel {
static findByBackendId(backendId: number): BackendModelSnapshot[] {
return getDb()
.prepare('SELECT * FROM backend_models WHERE backend_id = ? ORDER BY model_id')
.prepare(
'SELECT * FROM backend_models WHERE backend_id = ? ORDER BY model_id',
)
.all(backendId)
.map(asSnapshot);
}
static replaceForBackend(backendId: number, models: Array<{ model_id: string; raw_json?: string }>, fetchedAt: string): void {
static replaceForBackend(
backendId: number,
models: Array<{ model_id: string; raw_json?: string }>,
fetchedAt: string,
): void {
const db = getDb();
const timestamp = getUtcTimestamp();
const transaction = db.transaction(() => {
db.prepare('DELETE FROM backend_models WHERE backend_id = ?').run(backendId);
db.prepare('DELETE FROM backend_models WHERE backend_id = ?').run(
backendId,
);
const stmt = db.prepare(`
INSERT INTO backend_models (backend_id, model_id, raw_json, fetched_at, created_at, updated_at)
@ -32,7 +41,7 @@ export class BackendModelSnapshotModel {
model.raw_json || null,
fetchedAt,
timestamp,
timestamp
timestamp,
);
}
});

View file

@ -1,10 +1,11 @@
import {
import { getDb } from '../config/database.js';
import { getUtcTimestamp } from '../utils/time.js';
import type {
CreateModelRewriteData,
ModelRewriteRule,
UpdateModelRewriteData,
} from '../../../shared/types';
import { getDb } from '../config/database';
import { getUtcTimestamp } from '../utils/time';
} from '../../../shared/types.js';
function asRule(row: any): ModelRewriteRule {
row.is_active = !!row.is_active;
@ -21,17 +22,21 @@ export class ModelRewriteModel {
}
static findById(id: number): ModelRewriteRule | undefined {
const row = getDb().prepare('SELECT * FROM model_rewrites WHERE id = ?').get(id);
const row = getDb()
.prepare('SELECT * FROM model_rewrites WHERE id = ?')
.get(id);
return row ? asRule(row) : undefined;
}
static create(data: CreateModelRewriteData): ModelRewriteRule {
const timestamp = getUtcTimestamp();
const result = getDb()
.prepare(`
.prepare(
`
INSERT INTO model_rewrites (source_model, target_model, is_active, force, note, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
`,
)
.run(
data.source_model,
data.target_model,
@ -39,13 +44,16 @@ export class ModelRewriteModel {
data.force ? 1 : 0,
data.note || null,
timestamp,
timestamp
timestamp,
);
return this.findById(result.lastInsertRowid as number)!;
}
static update(id: number, data: UpdateModelRewriteData): ModelRewriteRule | undefined {
static update(
id: number,
data: UpdateModelRewriteData,
): ModelRewriteRule | undefined {
const updates: string[] = [];
const values: unknown[] = [];
@ -75,12 +83,16 @@ export class ModelRewriteModel {
updates.push('updated_at = ?');
values.push(getUtcTimestamp(), id);
getDb().prepare(`UPDATE model_rewrites SET ${updates.join(', ')} WHERE id = ?`).run(...values);
getDb()
.prepare(`UPDATE model_rewrites SET ${updates.join(', ')} WHERE id = ?`)
.run(...values);
return this.findById(id);
}
static delete(id: number): boolean {
const result = getDb().prepare('DELETE FROM model_rewrites WHERE id = ?').run(id);
const result = getDb()
.prepare('DELETE FROM model_rewrites WHERE id = ?')
.run(id);
return result.changes > 0;
}
}

View file

@ -1,29 +1,49 @@
import { getDb } from '../config/database';
import { Permission, CreatePermissionData } from '../../../shared/types';
import { getUtcTimestamp } from '../utils/time';
import { getDb } from '../config/database.js';
import { getUtcTimestamp } from '../utils/time.js';
import type {
Permission,
CreatePermissionData,
} from '../../../shared/types.js';
export class PermissionModel {
static findAll(): Permission[] {
return getDb().prepare('SELECT * FROM permissions ORDER BY created_at DESC').all() as Permission[];
return getDb()
.prepare('SELECT * FROM permissions ORDER BY created_at DESC')
.all() as Permission[];
}
static findByUserId(userId: number): Permission[] {
return getDb().prepare('SELECT * FROM permissions WHERE user_id = ? ORDER BY backend_id').all(userId) as Permission[];
return getDb()
.prepare(
'SELECT * FROM permissions WHERE user_id = ? ORDER BY backend_id',
)
.all(userId) as Permission[];
}
static findByBackendId(backendId: number): Permission[] {
return getDb().prepare('SELECT * FROM permissions WHERE backend_id = ? ORDER BY user_id').all(backendId) as Permission[];
return getDb()
.prepare(
'SELECT * FROM permissions WHERE backend_id = ? ORDER BY user_id',
)
.all(backendId) as Permission[];
}
static findUserBackendPermissions(userId: number, backendId: number): Permission | undefined {
return getDb().prepare('SELECT * FROM permissions WHERE user_id = ? AND backend_id = ?').get(userId, backendId) as Permission | undefined;
static findUserBackendPermissions(
userId: number,
backendId: number,
): Permission | undefined {
return getDb()
.prepare('SELECT * FROM permissions WHERE user_id = ? AND backend_id = ?')
.get(userId, backendId) as Permission | undefined;
}
static create(data: CreatePermissionData): Permission {
try {
const timestamp = getUtcTimestamp();
const stmt = getDb().prepare(
'INSERT INTO permissions (user_id, backend_id, created_at) VALUES (?, ?, ?)'
'INSERT INTO permissions (user_id, backend_id, created_at) VALUES (?, ?, ?)',
);
const result = stmt.run(data.user_id, data.backend_id, timestamp);
@ -34,7 +54,10 @@ export class PermissionModel {
created_at: timestamp,
};
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
if (
error instanceof Error &&
error.message.includes('UNIQUE constraint failed')
) {
throw new Error('Permission already exists for this user and backend');
}
throw error;
@ -42,22 +65,30 @@ export class PermissionModel {
}
static delete(user_id: number, backend_id: number): boolean {
const result = getDb().prepare('DELETE FROM permissions WHERE user_id = ? AND backend_id = ?').run(user_id, backend_id);
const result = getDb()
.prepare('DELETE FROM permissions WHERE user_id = ? AND backend_id = ?')
.run(user_id, backend_id);
return result.changes > 0;
}
static deleteByUserId(userId: number): number {
const result = getDb().prepare('DELETE FROM permissions WHERE user_id = ?').run(userId);
const result = getDb()
.prepare('DELETE FROM permissions WHERE user_id = ?')
.run(userId);
return result.changes;
}
static deleteByBackendId(backendId: number): number {
const result = getDb().prepare('DELETE FROM permissions WHERE backend_id = ?').run(backendId);
const result = getDb()
.prepare('DELETE FROM permissions WHERE backend_id = ?')
.run(backendId);
return result.changes;
}
static getUserBackendIds(userId: number): number[] {
const rows = getDb().prepare('SELECT backend_id FROM permissions WHERE user_id = ?').all(userId) as { backend_id: number }[];
return rows.map(row => row.backend_id);
const rows = getDb()
.prepare('SELECT backend_id FROM permissions WHERE user_id = ?')
.all(userId) as { backend_id: number }[];
return rows.map((row) => row.backend_id);
}
}

View file

@ -1,6 +1,12 @@
import { getDb } from '../config/database';
import { UserScript, CreateScriptData, UpdateScriptData } from '../../../shared/types';
import { getUtcTimestamp } from '../utils/time';
import { getDb } from '../config/database.js';
import { getUtcTimestamp } from '../utils/time.js';
import type {
UserScript,
CreateScriptData,
UpdateScriptData,
} from '../../../shared/types.js';
export class ScriptModel {
static asUserScript(row: any): UserScript {
@ -14,30 +20,47 @@ export class ScriptModel {
}
static findAll(): UserScript[] {
return getDb().prepare('SELECT * FROM user_scripts ORDER BY created_at DESC').all().map(this.asUserScript);
return getDb()
.prepare('SELECT * FROM user_scripts ORDER BY created_at DESC')
.all()
.map(this.asUserScript);
}
static findById(id: number): UserScript | undefined {
return this.mightBeUserScript(getDb().prepare('SELECT * FROM user_scripts WHERE id = ?').get(id));
return this.mightBeUserScript(
getDb().prepare('SELECT * FROM user_scripts WHERE id = ?').get(id),
);
}
static findByName(name: string): UserScript | undefined {
return this.mightBeUserScript(getDb().prepare('SELECT * FROM user_scripts WHERE name = ?').get(name));
return this.mightBeUserScript(
getDb().prepare('SELECT * FROM user_scripts WHERE name = ?').get(name),
);
}
static findByScriptType(scriptType: string): UserScript[] {
return getDb().prepare('SELECT * FROM user_scripts WHERE script_type = ? ORDER BY created_at DESC').all(scriptType).map(this.asUserScript);
return getDb()
.prepare(
'SELECT * FROM user_scripts WHERE script_type = ? ORDER BY created_at DESC',
)
.all(scriptType)
.map(this.asUserScript);
}
static findActive(): UserScript[] {
return getDb().prepare('SELECT * FROM user_scripts WHERE is_active = 1 ORDER BY created_at DESC').all().map(this.asUserScript);
return getDb()
.prepare(
'SELECT * FROM user_scripts WHERE is_active = 1 ORDER BY created_at DESC',
)
.all()
.map(this.asUserScript);
}
static create(data: CreateScriptData): UserScript {
try {
const timestamp = getUtcTimestamp();
const stmt = getDb().prepare(
'INSERT INTO user_scripts (name, script_type, target_user_id, target_backend_id, script_code, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO user_scripts (name, script_type, target_user_id, target_backend_id, script_code, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
const isActive = data.is_active ?? true;
const result = stmt.run(
@ -48,7 +71,7 @@ export class ScriptModel {
data.script_code,
isActive ? 1 : 0,
timestamp,
timestamp
timestamp,
);
return {
@ -63,7 +86,10 @@ export class ScriptModel {
updated_at: timestamp,
};
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
if (
error instanceof Error &&
error.message.includes('UNIQUE constraint failed')
) {
throw new Error('Script name already exists');
}
throw error;
@ -107,33 +133,51 @@ export class ScriptModel {
values.push(getUtcTimestamp());
values.push(id);
getDb().prepare(`UPDATE user_scripts SET ${updates.join(', ')} WHERE id = ?`).run(...values);
getDb()
.prepare(`UPDATE user_scripts SET ${updates.join(', ')} WHERE id = ?`)
.run(...values);
return this.findById(id);
}
static delete(id: number): boolean {
const result = getDb().prepare('DELETE FROM user_scripts WHERE id = ?').run(id);
const result = getDb()
.prepare('DELETE FROM user_scripts WHERE id = ?')
.run(id);
return result.changes > 0;
}
static activate(id: number): boolean {
const result = getDb().prepare('UPDATE user_scripts SET is_active = 1, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
const result = getDb()
.prepare(
'UPDATE user_scripts SET is_active = 1, updated_at = ? WHERE id = ?',
)
.run(getUtcTimestamp(), id);
return result.changes > 0;
}
static deactivate(id: number): boolean {
const result = getDb().prepare('UPDATE user_scripts SET is_active = 0, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
const result = getDb()
.prepare(
'UPDATE user_scripts SET is_active = 0, updated_at = ? WHERE id = ?',
)
.run(getUtcTimestamp(), id);
return result.changes > 0;
}
static getMatchingScripts(userId: number, backendId: number): UserScript[] {
const db = getDb();
const allScripts = db.prepare('SELECT * FROM user_scripts WHERE is_active = 1').all().map(this.asUserScript);
return allScripts.filter(script => {
const allScripts = db
.prepare('SELECT * FROM user_scripts WHERE is_active = 1')
.all()
.map(this.asUserScript);
return allScripts.filter((script) => {
if (script.script_type === 'per-user-backend') {
return script.target_user_id === userId && script.target_backend_id === backendId;
return (
script.target_user_id === userId &&
script.target_backend_id === backendId
);
} else if (script.script_type === 'per-backend') {
return script.target_backend_id === backendId;
} else if (script.script_type === 'per-user') {
@ -145,10 +189,13 @@ export class ScriptModel {
static getMatchingBackendScripts(backendId: number): UserScript[] {
const db = getDb();
const allScripts = db.prepare('SELECT * FROM user_scripts WHERE is_active = 1').all().map(this.asUserScript);
return allScripts.filter(script => {
const allScripts = db
.prepare('SELECT * FROM user_scripts WHERE is_active = 1')
.all()
.map(this.asUserScript);
return allScripts.filter((script) => {
if (script.script_type === 'per-backend') {
return script.target_backend_id === backendId;
} else if (script.script_type === 'per-user-backend') {
@ -160,10 +207,13 @@ export class ScriptModel {
static getMatchingUserScripts(userId: number): UserScript[] {
const db = getDb();
const allScripts = db.prepare('SELECT * FROM user_scripts WHERE is_active = 1').all().map(this.asUserScript);
return allScripts.filter(script => {
const allScripts = db
.prepare('SELECT * FROM user_scripts WHERE is_active = 1')
.all()
.map(this.asUserScript);
return allScripts.filter((script) => {
if (script.script_type === 'per-user') {
return script.target_user_id === userId;
} else if (script.script_type === 'per-user-backend') {

View file

@ -1,7 +1,13 @@
import { getDb } from '../config/database';
import { User, CreateUserData, UpdateUserData } from '../../../shared/types';
import { generateApiKey } from '../utils/apiKey';
import { getUtcTimestamp } from '../utils/time';
import { getDb } from '../config/database.js';
import { generateApiKey } from '../utils/apiKey.js';
import { getUtcTimestamp } from '../utils/time.js';
import type {
User,
CreateUserData,
UpdateUserData,
} from '../../../shared/types.js';
export class UserModel {
static asUser(row: any): User {
@ -16,15 +22,24 @@ export class UserModel {
}
static findAll(): User[] {
return getDb().prepare('SELECT * FROM users ORDER BY created_at DESC').all().map(this.asUser);
return getDb()
.prepare('SELECT * FROM users ORDER BY created_at DESC')
.all()
.map(this.asUser);
}
static findById(id: number): User | undefined {
return this.mightBeUser(getDb().prepare('SELECT * FROM users WHERE id = ?').get(id));
return this.mightBeUser(
getDb().prepare('SELECT * FROM users WHERE id = ?').get(id),
);
}
static findByApiKey(apiKey: string): User | undefined {
return this.mightBeUser(getDb().prepare('SELECT * FROM users WHERE api_key = ? AND is_active = 1').get(apiKey));
return this.mightBeUser(
getDb()
.prepare('SELECT * FROM users WHERE api_key = ? AND is_active = 1')
.get(apiKey),
);
}
static create(data: CreateUserData): User {
@ -32,10 +47,17 @@ export class UserModel {
const timestamp = getUtcTimestamp();
const detailLogging = data.detail_logging ?? false;
const stmt = getDb().prepare(
'INSERT INTO users (api_key, name, email, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO users (api_key, name, email, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
);
const result = stmt.run(apiKey, data.name, data.email || null, detailLogging ? 1 : 0, timestamp, timestamp);
const result = stmt.run(
apiKey,
data.name,
data.email || null,
detailLogging ? 1 : 0,
timestamp,
timestamp,
);
return {
id: result.lastInsertRowid as number,
api_key: apiKey,
@ -81,7 +103,9 @@ export class UserModel {
values.push(getUtcTimestamp());
values.push(id);
getDb().prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
getDb()
.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`)
.run(...values);
return this.findById(id);
}
@ -91,7 +115,9 @@ export class UserModel {
}
static deactivate(id: number): boolean {
const result = getDb().prepare('UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
const result = getDb()
.prepare('UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?')
.run(getUtcTimestamp(), id);
return result.changes > 0;
}
@ -100,7 +126,9 @@ export class UserModel {
if (!user) return null;
const newApiKey = generateApiKey();
getDb().prepare('UPDATE users SET api_key = ?, updated_at = ? WHERE id = ?').run(newApiKey, getUtcTimestamp(), id);
getDb()
.prepare('UPDATE users SET api_key = ?, updated_at = ? WHERE id = ?')
.run(newApiKey, getUtcTimestamp(), id);
return newApiKey;
}
}

View file

@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express';
import { AdminPrincipal, AdminSessionResponse } from '../../../shared/types';
import { AdminApiTokenModel } from '../models/AdminApiToken';
import { AdminSessionModel } from '../models/AdminSession';
import { Hono } from 'hono';
import { AdminApiTokenModel } from '../models/AdminApiToken.js';
import { AdminSessionModel } from '../models/AdminSession.js';
import {
getAdminApiTokenTtlDays,
getAdminAuthMode,
@ -11,8 +11,12 @@ import {
getOidcConfig,
isEnvAdminEnabled,
isOidcEnabled,
} from '../config/admin-auth';
import { AdminRequest, requireAdminAccess, requireSessionCsrf, resolveAdminAuth } from '../utils/adminAuth';
} from '../config/admin-auth.js';
import {
requireAdminAccess,
requireSessionCsrf,
resolveAdminAuth,
} from '../utils/adminAuth.js';
import {
clearAdminSessionCookie,
createCsrfToken,
@ -21,45 +25,56 @@ import {
issueAdminSessionCookie,
tokenPrefix,
verifyAdminPassword,
} from '../utils/adminSecurity';
} from '../utils/adminSecurity.js';
const router: Router = Router();
import type {
AdminPrincipal,
AdminSessionResponse,
} from '../../../shared/types.js';
import type { AppEnv, AdminAuthContext } from '../types/hono.js';
import type { Context } from 'hono';
const router = new Hono<AppEnv>();
const oidcStateStore = new Map<string, { next: string; expiresAt: number }>();
function isSafeNextPath(value?: string): string {
if (!value || value === '/') {
return '/dashboard';
}
if (!value.startsWith('/') || value.startsWith('//')) {
return '/dashboard';
}
if (value.startsWith('/admin/') || value === '/admin') {
return '/dashboard';
}
if (value === '/dashboard' || value.startsWith('/dashboard/')) {
return value;
}
return '/dashboard';
}
function buildSessionResponse(req: AdminRequest): AdminSessionResponse {
function buildSessionResponse(
adminAuth: AdminAuthContext | undefined,
): AdminSessionResponse {
return {
authenticated: !!req.adminAuth,
authenticated: !!adminAuth,
authMode: getAdminAuthMode(),
csrfToken: req.adminAuth?.method === 'session' ? req.adminAuth.csrfToken ?? null : null,
principal: req.adminAuth?.principal ?? null,
csrfToken:
adminAuth?.method === 'session' ? (adminAuth.csrfToken ?? null) : null,
principal: adminAuth?.principal ?? null,
};
}
function createAdminSession(res: Response, principal: AdminPrincipal): AdminSessionResponse {
function createAdminSession(
c: Context<AppEnv>,
principal: AdminPrincipal,
): AdminSessionResponse {
const sessionToken = generateOpaqueToken('adm_sess');
const csrfToken = createCsrfToken();
const ttlHours = getAdminSessionTtlHours();
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
const expiresAt = new Date(
Date.now() + ttlHours * 60 * 60 * 1000,
).toISOString();
AdminSessionModel.create({
sessionTokenHash: hashAdminToken(sessionToken),
@ -68,7 +83,7 @@ function createAdminSession(res: Response, principal: AdminPrincipal): AdminSess
expiresAt,
});
issueAdminSessionCookie(res, sessionToken, ttlHours * 60 * 60 * 1000);
issueAdminSessionCookie(c, sessionToken, ttlHours * 60 * 60 * 1000);
return {
authenticated: true,
authMode: getAdminAuthMode(),
@ -77,28 +92,26 @@ function createAdminSession(res: Response, principal: AdminPrincipal): AdminSess
};
}
router.get('/session', (req: AdminRequest, res: Response) => {
resolveAdminAuth(req);
res.json(buildSessionResponse(req));
router.get('/session', (c) => {
const adminAuth = resolveAdminAuth(c);
return c.json(buildSessionResponse(adminAuth));
});
router.post('/login', (req: Request, res: Response) => {
router.post('/login', async (c) => {
if (!isEnvAdminEnabled()) {
res.status(404).json({ error: 'ENV admin login is disabled' });
return;
return c.json({ error: 'ENV admin login is disabled' }, 404);
}
const { username, password } = req.body as { username?: string; password?: string };
const body = await c.req.json();
const { username, password } = body;
const configuredUsername = getAdminUsername();
if (!configuredUsername || !username || !password) {
res.status(401).json({ error: 'Invalid admin credentials' });
return;
return c.json({ error: 'Invalid admin credentials' }, 401);
}
if (username !== configuredUsername || !verifyAdminPassword(password)) {
res.status(401).json({ error: 'Invalid admin credentials' });
return;
return c.json({ error: 'Invalid admin credentials' }, 401);
}
const principal: AdminPrincipal = {
@ -108,78 +121,90 @@ router.post('/login', (req: Request, res: Response) => {
displayName: configuredUsername,
};
res.json(createAdminSession(res, principal));
return c.json(createAdminSession(c, principal));
});
router.post('/logout', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
if (req.adminAuth?.sessionId) {
AdminSessionModel.revoke(req.adminAuth.sessionId);
router.post('/logout', requireAdminAccess, requireSessionCsrf, (c) => {
const adminAuth = c.get('adminAuth');
if (adminAuth?.sessionId) {
AdminSessionModel.revoke(adminAuth.sessionId);
}
clearAdminSessionCookie(res);
res.status(204).send();
clearAdminSessionCookie(c);
return c.body(null, 204);
});
router.get('/oidc/start', async (req: Request, res: Response) => {
router.get('/oidc/start', async (c) => {
if (!isOidcEnabled()) {
res.status(404).json({ error: 'OIDC is disabled' });
return;
return c.json({ error: 'OIDC is disabled' }, 404);
}
const oidc = getOidcConfig();
if (!oidc.issuerUrl || !oidc.clientId || !oidc.redirectUri) {
res.status(500).json({ error: 'OIDC is not configured' });
return;
return c.json({ error: 'OIDC is not configured' }, 500);
}
const state = generateOpaqueToken('oidc_state');
const next = isSafeNextPath(typeof req.query.next === 'string' ? req.query.next : '/dashboard');
const next = isSafeNextPath(
typeof c.req.query('next') === 'string'
? c.req.query('next')
: '/dashboard',
);
oidcStateStore.set(state, { next, expiresAt: Date.now() + 10 * 60 * 1000 });
try {
const discoveryResponse = await fetch(`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`);
const discoveryResponse = await fetch(
`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`,
);
if (!discoveryResponse.ok) {
throw new Error('Failed to load OIDC discovery document');
}
const discovery = await discoveryResponse.json() as { authorization_endpoint: string };
const discovery = (await discoveryResponse.json()) as {
authorization_endpoint: string;
};
const redirect = new URL(discovery.authorization_endpoint);
redirect.searchParams.set('client_id', oidc.clientId);
redirect.searchParams.set('response_type', 'code');
redirect.searchParams.set('scope', oidc.scopes);
redirect.searchParams.set('redirect_uri', oidc.redirectUri);
redirect.searchParams.set('state', state);
res.redirect(redirect.toString());
return c.redirect(redirect.toString());
} catch (error) {
oidcStateStore.delete(state);
res.status(502).json({ error: error instanceof Error ? error.message : 'OIDC discovery failed' });
return c.json(
{
error: error instanceof Error ? error.message : 'OIDC discovery failed',
},
502,
);
}
});
router.get('/oidc/callback', async (req: Request, res: Response) => {
router.get('/oidc/callback', async (c) => {
if (!isOidcEnabled()) {
res.status(404).json({ error: 'OIDC is disabled' });
return;
return c.json({ error: 'OIDC is disabled' }, 404);
}
const state = typeof req.query.state === 'string' ? req.query.state : '';
const code = typeof req.query.code === 'string' ? req.query.code : '';
const state = c.req.query('state') ?? '';
const code = c.req.query('code') ?? '';
const stateRecord = oidcStateStore.get(state);
oidcStateStore.delete(state);
if (!stateRecord || stateRecord.expiresAt < Date.now() || !code) {
res.status(400).json({ error: 'Invalid OIDC callback state' });
return;
return c.json({ error: 'Invalid OIDC callback state' }, 400);
}
const oidc = getOidcConfig();
try {
const discoveryResponse = await fetch(`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`);
const discoveryResponse = await fetch(
`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`,
);
if (!discoveryResponse.ok) {
throw new Error('Failed to load OIDC discovery document');
}
const discovery = await discoveryResponse.json() as {
const discovery = (await discoveryResponse.json()) as {
token_endpoint: string;
userinfo_endpoint?: string;
};
@ -200,7 +225,10 @@ router.get('/oidc/callback', async (req: Request, res: Response) => {
throw new Error('Failed to exchange OIDC authorization code');
}
const tokenPayload = await tokenResponse.json() as { access_token?: string; id_token?: string };
const tokenPayload = (await tokenResponse.json()) as {
access_token?: string;
id_token?: string;
};
let email = '';
let subject = '';
@ -211,7 +239,7 @@ router.get('/oidc/callback', async (req: Request, res: Response) => {
headers: { Authorization: `Bearer ${tokenPayload.access_token}` },
});
if (userInfoResponse.ok) {
const userInfo = await userInfoResponse.json() as {
const userInfo = (await userInfoResponse.json()) as {
email?: string;
sub?: string;
name?: string;
@ -219,14 +247,17 @@ router.get('/oidc/callback', async (req: Request, res: Response) => {
};
email = userInfo.email ?? '';
subject = userInfo.sub ?? '';
displayName = userInfo.name ?? userInfo.preferred_username ?? email ?? subject;
displayName =
userInfo.name ?? userInfo.preferred_username ?? email ?? subject;
}
}
if ((!email || !subject) && tokenPayload.id_token) {
const parts = tokenPayload.id_token.split('.');
if (parts.length >= 2) {
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
const claims = JSON.parse(
Buffer.from(parts[1], 'base64url').toString('utf8'),
) as {
email?: string;
sub?: string;
name?: string;
@ -234,14 +265,25 @@ router.get('/oidc/callback', async (req: Request, res: Response) => {
};
email = email || claims.email || '';
subject = subject || claims.sub || '';
displayName = displayName || claims.name || claims.preferred_username || email || subject;
displayName =
displayName ||
claims.name ||
claims.preferred_username ||
email ||
subject;
}
}
const normalizedEmail = email.toLowerCase();
if (!normalizedEmail || !subject || !getAllowedOidcEmails().includes(normalizedEmail)) {
res.status(403).json({ error: 'OIDC account is not allowed for admin access' });
return;
if (
!normalizedEmail ||
!subject ||
!getAllowedOidcEmails().includes(normalizedEmail)
) {
return c.json(
{ error: 'OIDC account is not allowed for admin access' },
403,
);
}
const principal: AdminPrincipal = {
@ -251,54 +293,67 @@ router.get('/oidc/callback', async (req: Request, res: Response) => {
displayName: displayName || normalizedEmail,
};
createAdminSession(res, principal);
res.redirect(stateRecord.next);
createAdminSession(c, principal);
return c.redirect(stateRecord.next);
} catch (error) {
res.status(502).json({ error: error instanceof Error ? error.message : 'OIDC authentication failed' });
return c.json(
{
error:
error instanceof Error ? error.message : 'OIDC authentication failed',
},
502,
);
}
});
router.get('/tokens', requireAdminAccess, (req: AdminRequest, res: Response) => {
res.json(AdminApiTokenModel.listBySubject(req.adminAuth!.principal.subject));
router.get('/tokens', requireAdminAccess, (c) => {
const adminAuth = c.get('adminAuth')!;
return c.json(AdminApiTokenModel.listBySubject(adminAuth.principal.subject));
});
router.post('/tokens', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
const { name, expiresInDays } = req.body as { name?: string; expiresInDays?: number };
router.post('/tokens', requireAdminAccess, requireSessionCsrf, async (c) => {
const body = await c.req.json();
const { name, expiresInDays } = body;
const trimmedName = name?.trim();
if (!trimmedName) {
res.status(400).json({ error: 'Token name is required' });
return;
return c.json({ error: 'Token name is required' }, 400);
}
const ttlDays = Number.isFinite(expiresInDays) && Number(expiresInDays) > 0
? Number(expiresInDays)
: getAdminApiTokenTtlDays();
const ttlDays =
Number.isFinite(expiresInDays) && Number(expiresInDays) > 0
? Number(expiresInDays)
: getAdminApiTokenTtlDays();
const token = generateOpaqueToken('adm_tok');
const adminAuth = c.get('adminAuth')!;
const record = AdminApiTokenModel.create({
tokenHash: hashAdminToken(token),
tokenPrefix: tokenPrefix(token),
name: trimmedName,
principal: req.adminAuth!.principal,
expiresAt: new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString(),
principal: adminAuth.principal,
expiresAt: new Date(
Date.now() + ttlDays * 24 * 60 * 60 * 1000,
).toISOString(),
});
res.status(201).json({ token, record });
return c.json({ token, record }, 201);
});
router.delete('/tokens/:id', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
const tokenId = Number(req.params.id);
router.delete('/tokens/:id', requireAdminAccess, requireSessionCsrf, (c) => {
const tokenId = Number(c.req.param('id'));
if (!Number.isFinite(tokenId)) {
res.status(400).json({ error: 'Invalid token id' });
return;
return c.json({ error: 'Invalid token id' }, 400);
}
const success = AdminApiTokenModel.revokeForSubject(tokenId, req.adminAuth!.principal.subject);
const adminAuth = c.get('adminAuth')!;
const success = AdminApiTokenModel.revokeForSubject(
tokenId,
adminAuth.principal.subject,
);
if (!success) {
res.status(404).json({ error: 'Admin API token not found' });
return;
return c.json({ error: 'Admin API token not found' }, 404);
}
res.status(204).send();
return c.body(null, 204);
});
export default router;

View file

@ -1,10 +1,17 @@
import { Router, Request, Response } from 'express';
import { UserModel } from '../models/User';
import { BackendModel } from '../models/Backend';
import { ModelRewriteModel } from '../models/ModelRewrite';
import { PermissionModel } from '../models/Permission';
import scriptRoutes from './scripts';
import {
import { Hono } from 'hono';
import scriptRoutes from './scripts.js';
import { UserModel } from '../models/User.js';
import { BackendModel } from '../models/Backend.js';
import { ModelRewriteModel } from '../models/ModelRewrite.js';
import { PermissionModel } from '../models/Permission.js';
import { getUtcTimestamp } from '../utils/time.js';
import { ModelCatalogService } from '../services/ModelCatalogService.js';
import { AnalyticsService } from '../services/AnalyticsService.js';
import type {
CreateBackendData,
CreateModelRewriteData,
CreatePermissionData,
@ -12,33 +19,31 @@ import {
UpdateBackendData,
UpdateModelRewriteData,
UpdateUserData,
} from '../../../shared/types';
import { getUtcTimestamp } from '../utils/time';
import { ModelCatalogService } from '../services/ModelCatalogService';
import { AnalyticsService } from '../services/AnalyticsService';
} from '../../../shared/types.js';
const router: Router = Router();
import type { AppEnv } from '../types/hono.js';
router.use('/scripts', scriptRoutes);
const router = new Hono<AppEnv>();
router.get('/dashboard/summary', (req: Request, res: Response) => {
const days = req.query.days ? Number(req.query.days) : 30;
res.json(AnalyticsService.getDashboardSummary(days));
router.route('/scripts', scriptRoutes);
router.get('/dashboard/summary', (c) => {
const days = c.req.query('days') ? Number(c.req.query('days')) : 30;
return c.json(AnalyticsService.getDashboardSummary(days));
});
// ============ User Management ============
router.get('/users', (req: Request, res: Response) => {
const users = UserModel.findAll();
res.json(users);
router.get('/users', (c) => {
return c.json(UserModel.findAll());
});
router.post('/users', (req: Request, res: Response) => {
const { name, email, api_key, detail_logging } = req.body as CreateUserData;
router.post('/users', async (c) => {
const body = await c.req.json();
const { name, email, api_key, detail_logging } = body;
if (!name?.trim()) {
res.status(400).json({ error: 'Name is required' });
return;
return c.json({ error: 'Name is required' }, 400);
}
try {
@ -48,259 +53,252 @@ router.post('/users', (req: Request, res: Response) => {
api_key: api_key?.trim() || undefined,
detail_logging,
});
res.status(201).json(user);
return c.json(user, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
res.status(409).json({ error: 'API key already exists' });
return;
return c.json({ error: 'API key already exists' }, 409);
}
res.status(500).json({ error: 'Failed to create user' });
return c.json({ error: 'Failed to create user' }, 500);
}
});
router.get('/users/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.get('/users/:id', (c) => {
const id = Number(c.req.param('id'));
const user = UserModel.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
return c.json({ error: 'User not found' }, 404);
}
res.json(user);
return c.json(user);
});
router.put('/users/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.put('/users/:id', async (c) => {
const id = Number(c.req.param('id'));
const user = UserModel.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
return c.json({ error: 'User not found' }, 404);
}
const { name, email, api_key, is_active, detail_logging } = req.body as UpdateUserData;
const body = await c.req.json();
const { name, email, api_key, is_active, detail_logging } = body;
if (typeof name === 'string' && !name.trim()) {
res.status(400).json({ error: 'Name cannot be empty' });
return;
return c.json({ error: 'Name cannot be empty' }, 400);
}
try {
const updatedUser = UserModel.update(id, {
name: typeof name === 'string' ? name.trim() : undefined,
email: typeof email === 'string' ? email.trim() || undefined : undefined,
api_key: typeof api_key === 'string' ? api_key.trim() || undefined : undefined,
api_key:
typeof api_key === 'string' ? api_key.trim() || undefined : undefined,
is_active,
detail_logging,
});
res.json(updatedUser);
return c.json(updatedUser);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
res.status(409).json({ error: 'API key already exists' });
return;
return c.json({ error: 'API key already exists' }, 409);
}
res.status(500).json({ error: 'Failed to update user' });
return c.json({ error: 'Failed to update user' }, 500);
}
});
router.delete('/users/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.delete('/users/:id', (c) => {
const id = Number(c.req.param('id'));
const success = UserModel.delete(id);
if (!success) {
res.status(404).json({ error: 'User not found' });
return;
return c.json({ error: 'User not found' }, 404);
}
res.status(204).send();
return c.body(null, 204);
});
router.post('/users/:id/regenerate-api-key', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.post('/users/:id/regenerate-api-key', (c) => {
const id = Number(c.req.param('id'));
const user = UserModel.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
return c.json({ error: 'User not found' }, 404);
}
const newApiKey = UserModel.regenerateApiKey(id);
if (!newApiKey) {
res.status(500).json({ error: 'Failed to regenerate API key' });
return;
return c.json({ error: 'Failed to regenerate API key' }, 500);
}
res.json({ ...user, api_key: newApiKey });
return c.json({ ...user, api_key: newApiKey });
});
// ============ Backend Management ============
router.get('/backends', (req: Request, res: Response) => {
const backends = ModelCatalogService.getBackendsWithSummary();
res.json(backends);
router.get('/backends', (c) => {
return c.json(ModelCatalogService.getBackendsWithSummary());
});
router.post('/backends', (req: Request, res: Response) => {
const { name, base_url, api_key, detail_logging } = req.body as CreateBackendData;
router.post('/backends', async (c) => {
const body = await c.req.json();
const { name, base_url, api_key, detail_logging } = body;
if (!name || !base_url) {
res.status(400).json({ error: 'Name and base_url are required' });
return;
return c.json({ error: 'Name and base_url are required' }, 400);
}
const backend = BackendModel.create({ name, base_url, api_key, detail_logging });
res.status(201).json(backend);
const backend = BackendModel.create({
name,
base_url,
api_key,
detail_logging,
});
return c.json(backend, 201);
});
router.get('/backends/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const backend = ModelCatalogService.getBackendsWithSummary().find((item) => item.id === id);
router.get('/backends/:id', (c) => {
const id = Number(c.req.param('id'));
const backend = ModelCatalogService.getBackendsWithSummary().find(
(item) => item.id === id,
);
if (!backend) {
res.status(404).json({ error: 'Backend not found' });
return;
return c.json({ error: 'Backend not found' }, 404);
}
res.json(backend);
return c.json(backend);
});
router.put('/backends/:id', async (req: Request, res: Response) => {
const id = Number(req.params.id);
router.put('/backends/:id', async (c) => {
const id = Number(c.req.param('id'));
const backend = BackendModel.findById(id);
if (!backend) {
res.status(404).json({ error: 'Backend not found' });
return;
return c.json({ error: 'Backend not found' }, 404);
}
const { name, base_url, api_key, is_active, detail_logging } = req.body as UpdateBackendData;
const updatedBackend = BackendModel.update(id, { name, base_url, api_key, is_active, detail_logging });
const body = await c.req.json();
const { name, base_url, api_key, is_active, detail_logging } = body;
const updatedBackend = BackendModel.update(id, {
name,
base_url,
api_key,
is_active,
detail_logging,
});
await ModelCatalogService.handleBackendUpdated(id);
res.json(ModelCatalogService.getBackendsWithSummary().find((item) => item.id === id) || updatedBackend);
return c.json(
ModelCatalogService.getBackendsWithSummary().find(
(item) => item.id === id,
) || updatedBackend,
);
});
router.delete('/backends/:id', async (req: Request, res: Response) => {
const id = Number(req.params.id);
router.delete('/backends/:id', async (c) => {
const id = Number(c.req.param('id'));
const success = BackendModel.delete(id);
if (!success) {
res.status(404).json({ error: 'Backend not found' });
return;
return c.json({ error: 'Backend not found' }, 404);
}
await ModelCatalogService.handleBackendUpdated(id);
res.status(204).send();
return c.body(null, 204);
});
router.get('/backends/:id/models', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.get('/backends/:id/models', (c) => {
const id = Number(c.req.param('id'));
const payload = ModelCatalogService.getBackendModelsResponse(id);
if (!payload) {
res.status(404).json({ error: 'Backend not found' });
return;
return c.json({ error: 'Backend not found' }, 404);
}
res.json(payload);
return c.json(payload);
});
router.post('/backends/:id/models/refresh', async (req: Request, res: Response) => {
const id = Number(req.params.id);
router.post('/backends/:id/models/refresh', async (c) => {
const id = Number(c.req.param('id'));
const backend = BackendModel.findById(id);
if (!backend) {
res.status(404).json({ error: 'Backend not found' });
return;
return c.json({ error: 'Backend not found' }, 404);
}
if (!backend.is_active) {
res.status(409).json({ error: 'Inactive backends cannot refresh model cache' });
return;
return c.json(
{ error: 'Inactive backends cannot refresh model cache' },
409,
);
}
const cache = await ModelCatalogService.refreshBackendModels(id, { force: true, reason: 'admin-manual' });
res.json({
backend: ModelCatalogService.getBackendsWithSummary().find((item) => item.id === id) || backend,
const cache = await ModelCatalogService.refreshBackendModels(id, {
force: true,
reason: 'admin-manual',
});
return c.json({
backend:
ModelCatalogService.getBackendsWithSummary().find(
(item) => item.id === id,
) || backend,
cache,
snapshots: ModelCatalogService.getBackendModelsResponse(id)?.snapshots || [],
snapshots:
ModelCatalogService.getBackendModelsResponse(id)?.snapshots || [],
models: ModelCatalogService.getBackendModelsResponse(id)?.models || [],
});
});
router.get('/models/cache', (req: Request, res: Response) => {
res.json(ModelCatalogService.getCacheOverview());
router.get('/models/cache', (c) => {
return c.json(ModelCatalogService.getCacheOverview());
});
// ============ Permission Management ============
router.get('/permissions', (req: Request, res: Response) => {
const permissions = PermissionModel.findAll();
res.json(permissions);
router.get('/permissions', (c) => {
return c.json(PermissionModel.findAll());
});
router.get('/permissions/user/:userId', (req: Request, res: Response) => {
const userId = Number(req.params.userId);
const permissions = PermissionModel.findByUserId(userId);
res.json(permissions);
router.get('/permissions/user/:userId', (c) => {
const userId = Number(c.req.param('userId'));
return c.json(PermissionModel.findByUserId(userId));
});
router.get('/permissions/backend/:backendId', (req: Request, res: Response) => {
const backendId = Number(req.params.backendId);
const permissions = PermissionModel.findByBackendId(backendId);
res.json(permissions);
router.get('/permissions/backend/:backendId', (c) => {
const backendId = Number(c.req.param('backendId'));
return c.json(PermissionModel.findByBackendId(backendId));
});
router.post('/permissions', (req: Request, res: Response) => {
const { user_id, backend_id } = req.body as CreatePermissionData;
router.post('/permissions', async (c) => {
const body = await c.req.json();
const { user_id, backend_id } = body;
if (!user_id || !backend_id) {
res.status(400).json({ error: 'user_id and backend_id are required' });
return;
return c.json({ error: 'user_id and backend_id are required' }, 400);
}
try {
const permission = PermissionModel.create({ user_id, backend_id });
res.status(201).json(permission);
return c.json(permission, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
return;
return c.json({ error: error.message }, 409);
}
res.status(500).json({ error: 'Failed to create permission' });
return c.json({ error: 'Failed to create permission' }, 500);
}
});
router.delete('/permissions', (req: Request, res: Response) => {
const { user_id, backend_id } = req.query as { user_id?: string; backend_id?: string };
router.delete('/permissions', (c) => {
const user_id = c.req.query('user_id');
const backend_id = c.req.query('backend_id');
if (!user_id || !backend_id) {
res.status(400).json({ error: 'user_id and backend_id are required' });
return;
return c.json({ error: 'user_id and backend_id are required' }, 400);
}
const success = PermissionModel.delete(Number(user_id), Number(backend_id));
if (!success) {
res.status(404).json({ error: 'Permission not found' });
return;
return c.json({ error: 'Permission not found' }, 404);
}
res.status(204).send();
return c.body(null, 204);
});
router.get('/model-rewrites', (req: Request, res: Response) => {
res.json(ModelRewriteModel.findAll());
router.get('/model-rewrites', (c) => {
return c.json(ModelRewriteModel.findAll());
});
router.post('/model-rewrites', (req: Request, res: Response) => {
const { source_model, target_model, is_active, force, note } = req.body as CreateModelRewriteData;
router.post('/model-rewrites', async (c) => {
const body = await c.req.json();
const { source_model, target_model, is_active, force, note } = body;
if (!source_model?.trim() || !target_model?.trim()) {
res.status(400).json({ error: 'source_model and target_model are required' });
return;
return c.json({ error: 'source_model and target_model are required' }, 400);
}
try {
@ -312,54 +310,55 @@ router.post('/model-rewrites', (req: Request, res: Response) => {
note,
});
ModelCatalogService.loadRewriteMap();
res.status(201).json(rule);
return c.json(rule, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
res.status(409).json({ error: 'Rewrite rule already exists for this source_model' });
return;
return c.json(
{ error: 'Rewrite rule already exists for this source_model' },
409,
);
}
res.status(500).json({ error: 'Failed to create model rewrite rule' });
return c.json({ error: 'Failed to create model rewrite rule' }, 500);
}
});
router.put('/model-rewrites/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.put('/model-rewrites/:id', async (c) => {
const id = Number(c.req.param('id'));
const existing = ModelRewriteModel.findById(id);
if (!existing) {
res.status(404).json({ error: 'Model rewrite rule not found' });
return;
return c.json({ error: 'Model rewrite rule not found' }, 404);
}
try {
const updated = ModelRewriteModel.update(id, req.body as UpdateModelRewriteData);
const body = await c.req.json();
const updated = ModelRewriteModel.update(id, body);
ModelCatalogService.loadRewriteMap();
res.json(updated);
return c.json(updated);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
res.status(409).json({ error: 'Rewrite rule already exists for this source_model' });
return;
return c.json(
{ error: 'Rewrite rule already exists for this source_model' },
409,
);
}
res.status(500).json({ error: 'Failed to update model rewrite rule' });
return c.json({ error: 'Failed to update model rewrite rule' }, 500);
}
});
router.delete('/model-rewrites/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.delete('/model-rewrites/:id', (c) => {
const id = Number(c.req.param('id'));
const success = ModelRewriteModel.delete(id);
if (!success) {
res.status(404).json({ error: 'Model rewrite rule not found' });
return;
return c.json({ error: 'Model rewrite rule not found' }, 404);
}
ModelCatalogService.loadRewriteMap();
res.status(204).send();
return c.body(null, 204);
});
// ============ Health Check ============
router.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
router.get('/health', (c) => {
return c.json({ status: 'ok', timestamp: getUtcTimestamp() });
});
export default router;

View file

@ -1,88 +1,120 @@
import { Router, Request, Response } from 'express';
import { AnalyticsService } from '../services/AnalyticsService';
import { Hono } from 'hono';
const router: Router = Router();
import { AnalyticsService } from '../services/AnalyticsService.js';
router.get('/usage', (req: Request, res: Response) => {
const { userId, backendId, days } = req.query;
const result = AnalyticsService.getUsageStats(
userId ? Number(userId) : undefined,
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
import type { AppEnv } from '../types/hono.js';
const router = new Hono<AppEnv>();
router.get('/usage', (c) => {
const userId = c.req.query('userId');
const backendId = c.req.query('backendId');
const days = c.req.query('days');
return c.json(
AnalyticsService.getUsageStats(
userId ? Number(userId) : undefined,
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
),
);
res.json(result);
});
router.get('/requests', (req: Request, res: Response) => {
const { month, date, limit, offset, q, userId, backendId, endpoint, detailLogged } = req.query;
const result = AnalyticsService.getRequestLogs({
month: typeof month === 'string' ? month : undefined,
date: typeof date === 'string' ? date : undefined,
limit: limit ? Number(limit) : 100,
offset: offset ? Number(offset) : 0,
q: typeof q === 'string' ? q : undefined,
userId: userId ? Number(userId) : undefined,
backendId: backendId ? Number(backendId) : undefined,
endpoint: typeof endpoint === 'string' ? endpoint : undefined,
detailLogged: detailLogged === undefined ? undefined : detailLogged === '1' || detailLogged === 'true',
});
res.json(result);
router.get('/requests', (c) => {
const month = c.req.query('month');
const date = c.req.query('date');
const limit = c.req.query('limit');
const offset = c.req.query('offset');
const q = c.req.query('q');
const userId = c.req.query('userId');
const backendId = c.req.query('backendId');
const endpoint = c.req.query('endpoint');
const detailLogged = c.req.query('detailLogged');
return c.json(
AnalyticsService.getRequestLogs({
month: typeof month === 'string' ? month : undefined,
date: typeof date === 'string' ? date : undefined,
limit: limit ? Number(limit) : 100,
offset: offset ? Number(offset) : 0,
q: typeof q === 'string' ? q : undefined,
userId: userId ? Number(userId) : undefined,
backendId: backendId ? Number(backendId) : undefined,
endpoint: typeof endpoint === 'string' ? endpoint : undefined,
detailLogged:
detailLogged === undefined
? undefined
: detailLogged === '1' || detailLogged === 'true',
}),
);
});
router.get('/metrics', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getBackendMetrics(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
router.get('/metrics', (c) => {
const backendId = c.req.query('backendId');
const days = c.req.query('days');
return c.json(
AnalyticsService.getBackendMetrics(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
),
);
res.json(result);
});
router.get('/daily-totals', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getDailyTotals(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
router.get('/daily-totals', (c) => {
const backendId = c.req.query('backendId');
const days = c.req.query('days');
return c.json(
AnalyticsService.getDailyTotals(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
),
);
res.json(result);
});
router.get('/backend-quality', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getBackendQuality(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
router.get('/backend-quality', (c) => {
const backendId = c.req.query('backendId');
const days = c.req.query('days');
return c.json(
AnalyticsService.getBackendQuality(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
),
);
res.json(result);
});
router.get('/model-trends', (req: Request, res: Response) => {
const { backendId, days, limit } = req.query;
const result = AnalyticsService.getModelTrends(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
limit ? Number(limit) : 8
router.get('/model-trends', (c) => {
const backendId = c.req.query('backendId');
const days = c.req.query('days');
const limit = c.req.query('limit');
return c.json(
AnalyticsService.getModelTrends(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
limit ? Number(limit) : 8,
),
);
res.json(result);
});
router.get('/response-length-histogram', (req: Request, res: Response) => {
const { backendId, days, bins } = req.query;
const result = AnalyticsService.getResponseLengthHistogram(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
bins ? Number(bins) : 20
router.get('/response-length-histogram', (c) => {
const backendId = c.req.query('backendId');
const days = c.req.query('days');
const bins = c.req.query('bins');
return c.json(
AnalyticsService.getResponseLengthHistogram(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
bins ? Number(bins) : 20,
),
);
res.json(result);
});
router.get('/response-length-box-plot', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getResponseLengthBoxPlot(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
router.get('/response-length-box-plot', (c) => {
const backendId = c.req.query('backendId');
const days = c.req.query('days');
return c.json(
AnalyticsService.getResponseLengthBoxPlot(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30,
),
);
res.json(result);
});
export default router;

View file

@ -1,43 +1,118 @@
import { Router, Request, Response } from 'express';
import { authenticate, AuthenticatedRequest } from './auth';
import { BackendModel } from '../models/Backend';
import { RouterService } from '../services/RouterService';
import { AnalyticsService } from '../services/AnalyticsService';
import { ScriptEngine } from '../services/ScriptEngine';
import { logger } from '../utils/logger';
import { ModelCatalogService } from '../services/ModelCatalogService';
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { stream } from 'hono/streaming';
const router: Router = Router();
import { authenticate } from './auth.js';
router.use(authenticate);
import { BackendModel } from '../models/Backend.js';
import { RouterService } from '../services/RouterService.js';
import { AnalyticsService } from '../services/AnalyticsService.js';
import { ScriptEngine } from '../services/ScriptEngine.js';
import { logger } from '../utils/logger.js';
import { ModelCatalogService } from '../services/ModelCatalogService.js';
function normalizeHeaders(headers: Request['headers']): Record<string, string> {
return Object.entries(headers).reduce<Record<string, string>>((acc, [key, value]) => {
if (Array.isArray(value)) {
acc[key] = value.join(', ');
} else if (typeof value === 'string') {
acc[key] = value;
}
return acc;
}, {});
import {
ChatCompletionRequest,
ChatCompletionResponse,
ModelListResponse,
ModelNotAvailableResponse,
} from '../schemas/v1.js';
import { ErrorResponse } from '../schemas/common.js';
import type { AppEnv } from '../types/hono.js';
const router = new OpenAPIHono<AppEnv>();
router.use('*', authenticate);
function normalizeHeaders(
headers: Record<string, string | string[] | undefined>,
): Record<string, string> {
return Object.entries(headers).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (Array.isArray(value)) {
acc[key] = value.join(', ');
} else if (typeof value === 'string') {
acc[key] = value;
}
return acc;
},
{},
);
}
router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response) => {
function getRequestHeaders(
c: Parameters<Parameters<typeof router.openapi>[1]>[0],
): Record<string, string> {
const out: Record<string, string> = {};
c.req.raw.headers.forEach((value, key) => {
out[key] = value;
});
return out;
}
const chatCompletionsRoute = createRoute({
method: 'post',
path: '/chat/completions',
tags: ['v1'],
security: [{ bearerAuth: [] }],
request: {
body: {
content: {
'application/json': { schema: ChatCompletionRequest },
},
required: true,
},
},
responses: {
200: {
description:
'Chat completion (JSON for non-stream, text/event-stream for stream:true)',
content: {
'application/json': { schema: ChatCompletionResponse },
},
},
403: {
description: 'Forbidden',
content: { 'application/json': { schema: ErrorResponse } },
},
404: {
description: 'Requested model not available',
content: { 'application/json': { schema: ModelNotAvailableResponse } },
},
502: {
description: 'Backend error',
content: { 'application/json': { schema: ErrorResponse } },
},
},
});
// SSE responses don't match the typed openapi response shape, so cast the handler.
// The OpenAPI spec still documents the JSON shape; the runtime returns either JSON or text/event-stream.
router.openapi(chatCompletionsRoute, (async (c: any) => {
const startTime = Date.now();
const user = req.user!;
const allowedBackendIds = req.allowedBackendIds!;
const user = c.get('user')!;
const allowedBackendIds = c.get('allowedBackendIds')!;
const reqBody = c.req.valid('json') as Record<string, unknown> & {
model?: string;
messages?: unknown;
stream?: boolean;
};
const requestHeaders = getRequestHeaders(c);
if (allowedBackendIds.length === 0) {
res.status(403).json({ error: 'No backends available for your account' });
return;
return c.json({ error: 'No backends available for your account' }, 403);
}
const requestedModel = typeof req.body?.model === 'string' ? req.body.model : '';
const requestedModel = typeof reqBody.model === 'string' ? reqBody.model : '';
await ModelCatalogService.ensureInitializedForBackends(allowedBackendIds);
const resolution = ModelCatalogService.resolveRequestedModel(requestedModel, allowedBackendIds);
const resolution = ModelCatalogService.resolveRequestedModel(
requestedModel,
allowedBackendIds,
);
const activeAllowedBackendIds = BackendModel.findActive()
.map((item) => item.id)
.filter((backendId) => allowedBackendIds.includes(backendId));
if (activeAllowedBackendIds.length === 0) {
AnalyticsService.logRequest({
user_id: user.id,
@ -48,13 +123,18 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
status_code: 403,
error_message: 'No active backends available',
detail_logged: user.detail_logging,
request_headers: user.detail_logging ? normalizeHeaders(req.headers) : undefined,
request_body: user.detail_logging ? req.body : undefined,
request_headers: user.detail_logging
? normalizeHeaders(requestHeaders)
: undefined,
request_body: user.detail_logging ? reqBody : undefined,
});
res.status(403).json({ error: 'No active backends available' });
return;
return c.json({ error: 'No active backends available' }, 403);
}
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(resolution.routedModel, allowedBackendIds);
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(
resolution.routedModel,
allowedBackendIds,
);
const backend = RouterService.selectBackend(candidateBackendIds);
if (!backend) {
AnalyticsService.logRequest({
@ -66,58 +146,73 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
status_code: 404,
error_message: 'Requested model is not available for your account',
detail_logged: user.detail_logging,
request_headers: user.detail_logging ? normalizeHeaders(req.headers) : undefined,
request_body: user.detail_logging ? req.body : undefined,
request_headers: user.detail_logging
? normalizeHeaders(requestHeaders)
: undefined,
request_body: user.detail_logging ? reqBody : undefined,
});
res.status(404).json({
error: 'Requested model is not available for your account',
request_model: resolution.requestedModel,
routed_model: resolution.routedModel,
});
return;
return c.json(
{
error: 'Requested model is not available for your account',
request_model: resolution.requestedModel,
routed_model: resolution.routedModel,
},
404,
);
}
try {
const { model, messages, ...rest } = req.body;
const { model, messages, ...rest } = reqBody;
const detailLoggingEnabled = user.detail_logging || backend.detail_logging;
const rewrittenBody = { model: resolution.routedModel, messages, ...rest };
const execContext = {
user: { id: user.id, name: user.name, email: user.email },
backend: { id: backend.id, name: backend.name, base_url: backend.base_url },
backend: {
id: backend.id,
name: backend.name,
base_url: backend.base_url,
},
request: {
method: 'POST',
path: req.path,
path: c.req.path,
headers: {
...normalizeHeaders(req.headers),
'content-type': req.get('content-type') || 'application/json',
...normalizeHeaders(requestHeaders),
'content-type': c.req.header('content-type') || 'application/json',
},
body: rewrittenBody,
isStream: req.body.stream === true,
isStream: reqBody.stream === true,
},
};
const { context: modifiedContext, errors: requestErrors } = await ScriptEngine.applyOnRequestScripts(
execContext,
user.id,
backend.id
);
const { context: modifiedContext, errors: requestErrors } =
await ScriptEngine.applyOnRequestScripts(
execContext,
user.id,
backend.id,
);
if (requestErrors.length > 0) {
logger.warn(`Script warnings for user ${user.id}: ${requestErrors.join('; ')}`);
logger.warn(
`Script warnings for user ${user.id}: ${requestErrors.join('; ')}`,
);
}
// Stream path: pipe SSE response directly to client
if (modifiedContext.request.body && typeof modifiedContext.request.body === 'object' && 'stream' in modifiedContext.request.body && modifiedContext.request.body.stream === true) {
const isStreamRequest =
modifiedContext.request.body &&
typeof modifiedContext.request.body === 'object' &&
'stream' in modifiedContext.request.body &&
(modifiedContext.request.body as { stream?: boolean }).stream === true;
if (isStreamRequest) {
const streamResult = await RouterService.forwardStreamRequest(
backend,
'/v1/chat/completions',
'POST',
modifiedContext.request.headers,
modifiedContext.request.body
modifiedContext.request.body,
);
// Network error — return JSON error
if (!('response' in streamResult)) {
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
@ -130,22 +225,38 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
response_time_ms: responseTime,
error_message: JSON.stringify(streamResult.data),
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
local_date: undefined,
});
logger.error(`Backend error for user ${user.id} (stream): ${JSON.stringify(streamResult.data)}`);
logger.error(
`Backend error for user ${user.id} (stream): ${JSON.stringify(streamResult.data)}`,
);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
res.status(streamResult.status).json(streamResult.data);
return;
return c.json(
streamResult.data as Record<string, unknown>,
streamResult.status as 502,
);
}
const backendResponse = streamResult.response;
const backendResponseHeaders = Object.fromEntries(backendResponse.headers.entries());
const backendResponseHeaders = Object.fromEntries(
backendResponse.headers.entries(),
);
// Backend returned non-SSE response (e.g. JSON error)
if (!backendResponse.headers.get('content-type')?.includes('text/event-stream')) {
const data = await backendResponse.json().catch(() => ({}));
if (
!backendResponse.headers
.get('content-type')
?.includes('text/event-stream')
) {
const data = (await backendResponse.json().catch(() => ({}))) as Record<
string,
unknown
>;
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
@ -155,103 +266,133 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
routed_model: resolution.routedModel,
status_code: backendResponse.status,
response_time_ms: responseTime,
error_message: backendResponse.status >= 400 ? JSON.stringify(data) : undefined,
error_message:
backendResponse.status >= 400 ? JSON.stringify(data) : undefined,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
response_headers: detailLoggingEnabled ? backendResponseHeaders : undefined,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
response_headers: detailLoggingEnabled
? backendResponseHeaders
: undefined,
response_body: detailLoggingEnabled ? data : undefined,
local_date: undefined,
});
if (backendResponse.status >= 400) {
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
res.status(backendResponse.status).json(data);
return;
return c.json(data, backendResponse.status as 502);
}
// onResponse scripts (body not available for streams)
await ScriptEngine.applyOnResponseScripts(
execContext,
{ status: backendResponse.status, headers: backendResponseHeaders, body: null, isStream: true },
{
status: backendResponse.status,
headers: backendResponseHeaders,
body: null,
isStream: true,
},
user.id,
backend.id
backend.id,
);
// Set SSE headers and start piping
res.status(backendResponse.status);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const reader = backendResponse.body!.getReader();
const decoder = new TextDecoder();
req.on('close', () => reader.cancel());
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
c.header('Connection', 'keep-alive');
c.status(backendResponse.status as 200);
let responseModel: string | undefined;
let usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | undefined;
let usage:
| {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
}
| undefined;
const collectedChunks: string[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
return stream(c, async (s) => {
const reader = backendResponse.body!.getReader();
const decoder = new TextDecoder();
s.onAbort(() => {
void reader.cancel();
});
// Parse SSE chunks for model and usage metadata
const text = decoder.decode(value, { stream: true });
if (detailLoggingEnabled) collectedChunks.push(text);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await s.write(value);
for (const line of text.split('\n')) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.model && !responseModel) responseModel = parsed.model;
if (parsed.usage) usage = parsed.usage;
} catch { /* non-JSON data line, skip */ }
const text = decoder.decode(value, { stream: true });
if (detailLoggingEnabled) collectedChunks.push(text);
for (const line of text.split('\n')) {
if (!line.startsWith('data: ') || line === 'data: [DONE]')
continue;
try {
const parsed = JSON.parse(line.slice(6)) as {
model?: string;
usage?: typeof usage;
};
if (parsed.model && !responseModel)
responseModel = parsed.model;
if (parsed.usage) usage = parsed.usage;
} catch {
/* non-JSON data line, skip */
}
}
}
} catch (err) {
logger.error(
`Stream interrupted for user ${user.id}: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
response_model: responseModel,
prompt_tokens: usage?.prompt_tokens,
completion_tokens: usage?.completion_tokens,
total_tokens: usage?.total_tokens,
status_code: backendResponse.status,
response_time_ms: responseTime,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
response_headers: detailLoggingEnabled
? backendResponseHeaders
: undefined,
response_body: detailLoggingEnabled
? collectedChunks.join('')
: undefined,
local_date: undefined,
});
if (backendResponse.status >= 400) {
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
}
} catch (err) {
logger.error(`Stream interrupted for user ${user.id}: ${err instanceof Error ? err.message : String(err)}`);
} finally {
res.end();
}
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
response_model: responseModel,
prompt_tokens: usage?.prompt_tokens,
completion_tokens: usage?.completion_tokens,
total_tokens: usage?.total_tokens,
status_code: backendResponse.status,
response_time_ms: responseTime,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
response_headers: detailLoggingEnabled ? backendResponseHeaders : undefined,
response_body: detailLoggingEnabled ? collectedChunks.join('') : undefined,
local_date: undefined,
});
if (backendResponse.status >= 400) {
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
return;
}
// Non-stream path: buffer and return JSON (unchanged)
const response = await RouterService.forwardRequest(
backend,
'/v1/chat/completions',
'POST',
modifiedContext.request.headers,
modifiedContext.request.body
modifiedContext.request.body,
);
const responseTime = Date.now() - startTime;
@ -267,7 +408,7 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
execContext,
responseContext,
user.id,
backend.id
backend.id,
);
AnalyticsService.logRequest({
@ -276,48 +417,93 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
response_model: response.data && typeof response.data === 'object' && 'model' in response.data ? String(response.data.model) : undefined,
prompt_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { prompt_tokens?: number } }).usage === 'object' ? (response.data as { usage: { prompt_tokens: number } }).usage?.prompt_tokens : undefined,
completion_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { completion_tokens?: number } }).usage === 'object' ? (response.data as { usage: { completion_tokens: number } }).usage?.completion_tokens : undefined,
total_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { total_tokens?: number } }).usage === 'object' ? (response.data as { usage: { total_tokens: number } }).usage?.total_tokens : undefined,
response_model:
response.data &&
typeof response.data === 'object' &&
'model' in response.data
? String((response.data as { model?: unknown }).model)
: undefined,
prompt_tokens:
response.data &&
typeof response.data === 'object' &&
'usage' in response.data
? (response.data as { usage?: { prompt_tokens?: number } }).usage
?.prompt_tokens
: undefined,
completion_tokens:
response.data &&
typeof response.data === 'object' &&
'usage' in response.data
? (response.data as { usage?: { completion_tokens?: number } }).usage
?.completion_tokens
: undefined,
total_tokens:
response.data &&
typeof response.data === 'object' &&
'usage' in response.data
? (response.data as { usage?: { total_tokens?: number } }).usage
?.total_tokens
: undefined,
status_code: response.status,
response_time_ms: responseTime,
error_message: response.status >= 400 ? JSON.stringify(response.data) : undefined,
error_message:
response.status >= 400 ? JSON.stringify(response.data) : undefined,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
response_headers: detailLoggingEnabled ? response.headers : undefined,
response_body: detailLoggingEnabled ? response.data : undefined,
local_date: undefined,
});
if (response.status >= 400) {
const errorDetails = response.data as any;
const errorDetails = response.data as {
error?: string;
cause?: string;
backend?: string;
};
const errorInfo = errorDetails.error || 'Unknown error';
const causeInfo = errorDetails.cause ? ` (Cause: ${errorDetails.cause})` : '';
const backendInfo = errorDetails.backend ? ` [Backend: ${errorDetails.backend}]` : '';
logger.error(`Backend error for user ${user.id}: ${errorInfo}${causeInfo}${backendInfo}`);
const causeInfo = errorDetails.cause
? ` (Cause: ${errorDetails.cause})`
: '';
const backendInfo = errorDetails.backend
? ` [Backend: ${errorDetails.backend}]`
: '';
logger.error(
`Backend error for user ${user.id}: ${errorInfo}${causeInfo}${backendInfo}`,
);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
res.status(response.status).json(response.data);
return c.json(
response.data as Record<string, unknown>,
response.status as 200,
);
} catch (error) {
const responseTime = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: req.body.model,
request_model:
typeof reqBody.model === 'string' ? reqBody.model : undefined,
routed_model: resolution.routedModel,
status_code: 502,
response_time_ms: responseTime,
error_message: errorMsg,
detail_logged: user.detail_logging || backend.detail_logging,
request_headers: user.detail_logging || backend.detail_logging ? normalizeHeaders(req.headers) : undefined,
request_body: user.detail_logging || backend.detail_logging ? req.body : undefined,
request_headers:
user.detail_logging || backend.detail_logging
? normalizeHeaders(requestHeaders)
: undefined,
request_body:
user.detail_logging || backend.detail_logging ? reqBody : undefined,
response_headers: undefined,
response_body: undefined,
local_date: undefined,
@ -325,16 +511,32 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
logger.error(`Request failed for user ${user.id}: ${errorMsg}`);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
res.status(502).json({ error: 'Backend request failed', details: errorMsg });
return c.json({ error: 'Backend request failed', cause: errorMsg }, 502);
}
}) as any);
const modelsRoute = createRoute({
method: 'get',
path: '/models',
tags: ['v1'],
security: [{ bearerAuth: [] }],
responses: {
200: {
description: 'Models accessible to the authenticated user',
content: { 'application/json': { schema: ModelListResponse } },
},
403: {
description: 'No backends available',
content: { 'application/json': { schema: ErrorResponse } },
},
},
});
router.get('/models', async (req: AuthenticatedRequest, res: Response) => {
const allowedBackendIds = req.allowedBackendIds!;
router.openapi(modelsRoute, async (c) => {
const allowedBackendIds = c.get('allowedBackendIds')!;
if (allowedBackendIds.length === 0) {
res.status(403).json({ error: 'No backends available for your account' });
return;
return c.json({ error: 'No backends available for your account' }, 403);
}
await ModelCatalogService.ensureInitializedForBackends(allowedBackendIds);
@ -342,14 +544,18 @@ router.get('/models', async (req: AuthenticatedRequest, res: Response) => {
.map((item) => item.id)
.filter((backendId) => allowedBackendIds.includes(backendId));
if (activeAllowedBackendIds.length === 0) {
res.status(403).json({ error: 'No active backends available' });
return;
return c.json({ error: 'No active backends available' }, 403);
}
const models = ModelCatalogService.getModelsForAllowedBackends(activeAllowedBackendIds).map((entry) => ({
const models = ModelCatalogService.getModelsForAllowedBackends(
activeAllowedBackendIds,
).map((entry) => ({
id: entry.model_id,
object: 'model',
object: 'model' as const,
}));
res.json({ object: 'list', data: models });
return c.json({ object: 'list' as const, data: models }, 200);
});
// Re-export z for completeness (used by other modules importing from this route file).
export { z };
export default router;

View file

@ -1,48 +1,25 @@
import { Request, Response, NextFunction } from 'express';
import { UserModel } from '../models/User';
import { PermissionModel } from '../models/Permission';
import { User } from '../../../shared/types';
import { UserModel } from '../models/User.js';
import { PermissionModel } from '../models/Permission.js';
export interface AuthenticatedRequest extends Request {
user?: User;
allowedBackendIds?: number[];
}
import type { MiddlewareHandler } from 'hono';
import type { AppEnv } from '../types/hono.js';
export function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
export const authenticate: MiddlewareHandler<AppEnv> = async (c, next) => {
const authHeader = c.req.header('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
return c.json({ error: 'Missing or invalid authorization header' }, 401);
}
const apiKey = authHeader.substring(7);
const user = UserModel.findByApiKey(apiKey);
if (!user) {
res.status(401).json({ error: 'Invalid API key' });
return;
return c.json({ error: 'Invalid API key' }, 401);
}
req.user = user;
req.allowedBackendIds = PermissionModel.getUserBackendIds(user.id);
next();
}
export function requireBackendPermission(backendId?: number) {
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const targetBackendId = backendId || Number(req.params.backendId);
if (!req.allowedBackendIds?.includes(targetBackendId)) {
res.status(403).json({ error: 'Access denied to this backend' });
return;
}
next();
};
}
c.set('user', user);
c.set('allowedBackendIds', PermissionModel.getUserBackendIds(user.id));
await next();
return;
};

View file

@ -1,64 +1,82 @@
import { Router, Request, Response } from 'express';
import { ScriptModel } from '../models/Script';
import { UserModel } from '../models/User';
import { BackendModel } from '../models/Backend';
import { CompiledScript } from '../services/ScriptExecutor';
import { CreateScriptData, UpdateScriptData, ScriptContextData } from '../../../shared/types';
import { Hono } from 'hono';
const router: Router = Router();
import { ScriptModel } from '../models/Script.js';
import { CompiledScript } from '../services/ScriptExecutor.js';
import type {
CreateScriptData,
UpdateScriptData,
ScriptContextData,
} from '../../../shared/types.js';
import type { AppEnv } from '../types/hono.js';
const router = new Hono<AppEnv>();
// ============ Script Management ============
router.get('/', (req: Request, res: Response) => {
const scripts = ScriptModel.findAll();
res.json(scripts);
router.get('/', (c) => {
return c.json(ScriptModel.findAll());
});
router.get('/active', (req: Request, res: Response) => {
const scripts = ScriptModel.findActive();
res.json(scripts);
router.get('/active', (c) => {
return c.json(ScriptModel.findActive());
});
router.get('/type/:type', (req: Request, res: Response) => {
const scriptType = String(req.params.type);
const scripts = ScriptModel.findByScriptType(scriptType);
res.json(scripts);
router.get('/type/:type', (c) => {
const scriptType = String(c.req.param('type'));
return c.json(ScriptModel.findByScriptType(scriptType));
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.get('/:id', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
return c.json({ error: 'Script not found' }, 404);
}
res.json(script);
return c.json(script);
});
router.post('/', (req: Request, res: Response) => {
const { name, script_type, target_user_id, target_backend_id, script_code, is_active } = req.body as CreateScriptData;
router.post('/', async (c) => {
const body = await c.req.json();
const {
name,
script_type,
target_user_id,
target_backend_id,
script_code,
is_active,
} = body;
if (!name || !script_type || !script_code) {
res.status(400).json({ error: 'name, script_type, and script_code are required' });
return;
return c.json(
{ error: 'name, script_type, and script_code are required' },
400,
);
}
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
res.status(400).json({ error: 'target_user_id and target_backend_id are required for per-user-backend scripts' });
return;
return c.json(
{
error:
'target_user_id and target_backend_id are required for per-user-backend scripts',
},
400,
);
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
res.status(400).json({ error: 'target_backend_id is required for per-backend scripts' });
return;
return c.json(
{ error: 'target_backend_id is required for per-backend scripts' },
400,
);
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
res.status(400).json({ error: 'target_user_id is required for per-user scripts' });
return;
return c.json(
{ error: 'target_user_id is required for per-user scripts' },
400,
);
}
}
@ -71,44 +89,58 @@ router.post('/', (req: Request, res: Response) => {
script_code,
is_active: is_active ?? true,
});
res.status(201).json(script);
return c.json(script, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
return;
} else {
console.error('Unexpected error creating script:', error);
res.status(500).json({ error: 'Failed to create script' });
return c.json({ error: error.message }, 409);
}
console.error('Unexpected error creating script:', error);
return c.json({ error: 'Failed to create script' }, 500);
}
});
router.put('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.put('/:id', async (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
return c.json({ error: 'Script not found' }, 404);
}
const { name, script_type, target_user_id, target_backend_id, script_code, is_active } = req.body as UpdateScriptData;
const body = await c.req.json();
const {
name,
script_type,
target_user_id,
target_backend_id,
script_code,
is_active,
} = body;
if (script_type) {
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
res.status(400).json({ error: 'target_user_id and target_backend_id are required for per-user-backend scripts' });
return;
return c.json(
{
error:
'target_user_id and target_backend_id are required for per-user-backend scripts',
},
400,
);
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
res.status(400).json({ error: 'target_backend_id is required for per-backend scripts' });
return;
return c.json(
{ error: 'target_backend_id is required for per-backend scripts' },
400,
);
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
res.status(400).json({ error: 'target_user_id is required for per-user scripts' });
return;
return c.json(
{ error: 'target_user_id is required for per-user scripts' },
400,
);
}
}
}
@ -122,83 +154,67 @@ router.put('/:id', (req: Request, res: Response) => {
is_active,
});
res.json(updatedScript);
return c.json(updatedScript);
});
router.delete('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.delete('/:id', (c) => {
const id = Number(c.req.param('id'));
const success = ScriptModel.delete(id);
if (!success) {
res.status(404).json({ error: 'Script not found' });
return;
return c.json({ error: 'Script not found' }, 404);
}
res.status(204).send();
return c.body(null, 204);
});
router.post('/:id/activate', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.post('/:id/activate', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
return c.json({ error: 'Script not found' }, 404);
}
const success = ScriptModel.activate(id);
if (!success) {
res.status(500).json({ error: 'Failed to activate script' });
return;
return c.json({ error: 'Failed to activate script' }, 500);
}
res.json({ ...script, is_active: true });
return c.json({ ...script, is_active: true });
});
router.post('/:id/deactivate', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.post('/:id/deactivate', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
return c.json({ error: 'Script not found' }, 404);
}
const success = ScriptModel.deactivate(id);
if (!success) {
res.status(500).json({ error: 'Failed to deactivate script' });
return;
return c.json({ error: 'Failed to deactivate script' }, 500);
}
res.json({ ...script, is_active: false });
return c.json({ ...script, is_active: false });
});
// ============ Script Testing ============
router.post('/:id/test', async (req: Request, res: Response) => {
const id = Number(req.params.id);
router.post('/:id/test', async (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
return c.json({ error: 'Script not found' }, 404);
}
const { user, backend, request } = req.body as {
user?: { id: number; name: string; email?: string };
backend?: { id: number; name: string; base_url: string };
request: { method: string; path: string; headers: Record<string, string>; body: unknown; isStream: boolean };
};
const body = await c.req.json();
if (!request) {
res.status(400).json({ error: 'request is required' });
return;
if (!body.request) {
return c.json({ error: 'request is required' }, 400);
}
const testContext: ScriptContextData = {
user: user ?? null,
backend: backend ?? null,
request,
user: body.user ?? null,
backend: body.backend ?? null,
request: body.request,
};
let compiled: CompiledScript | null = null;
@ -213,17 +229,20 @@ router.post('/:id/test', async (req: Request, res: Response) => {
await compiled.callOnResponse(testContext);
}
res.json({
return c.json({
success: true,
executionTime: Date.now() - startTime,
hasOnRequest: compiled.hasOnRequest,
hasOnResponse: compiled.hasOnResponse,
});
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : String(error),
});
return c.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
},
400,
);
} finally {
compiled?.dispose();
}

View file

@ -0,0 +1,12 @@
import { z } from '@hono/zod-openapi';
export const ErrorResponse = z
.object({
error: z.string().openapi({ example: 'Something went wrong' }),
cause: z.string().optional(),
backend: z.string().optional(),
path: z.string().optional(),
})
.openapi('ErrorResponse');
export type ErrorResponseType = z.infer<typeof ErrorResponse>;

68
server/src/schemas/v1.ts Normal file
View file

@ -0,0 +1,68 @@
import { z } from '@hono/zod-openapi';
export const ChatMessage = z
.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
})
.openapi('ChatMessage');
export const ChatCompletionRequest = z
.object({
model: z.string().openapi({ example: 'gpt-4o-mini' }),
messages: z.array(ChatMessage),
temperature: z.number().min(0).max(2).optional(),
max_tokens: z.number().int().positive().optional(),
top_p: z.number().min(0).max(1).optional(),
frequency_penalty: z.number().optional(),
presence_penalty: z.number().optional(),
stream: z.boolean().optional(),
})
.passthrough()
.openapi('ChatCompletionRequest');
export const ChatCompletionChoice = z.object({
index: z.number().int(),
message: ChatMessage,
finish_reason: z.string(),
});
export const ChatCompletionUsage = z.object({
prompt_tokens: z.number().int(),
completion_tokens: z.number().int(),
total_tokens: z.number().int(),
});
export const ChatCompletionResponse = z
.object({
id: z.string(),
object: z.string(),
created: z.number().int(),
model: z.string(),
choices: z.array(ChatCompletionChoice),
usage: ChatCompletionUsage,
})
.passthrough()
.openapi('ChatCompletionResponse');
export const ModelEntry = z
.object({
id: z.string(),
object: z.literal('model'),
})
.openapi('ModelEntry');
export const ModelListResponse = z
.object({
object: z.literal('list'),
data: z.array(ModelEntry),
})
.openapi('ModelListResponse');
export const ModelNotAvailableResponse = z
.object({
error: z.string(),
request_model: z.string(),
routed_model: z.string(),
})
.openapi('ModelNotAvailableResponse');

View file

@ -1,12 +1,27 @@
import { getAnalyticsDb } from '../config/analytics-db';
import { DashboardSummaryResponse, RequestLogPage, ScriptType } from '../../../shared/types';
import { RequestLogInsert, RequestLogQuery, RequestLogService } from './RequestLogService';
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';
import {
type RequestLogInsert,
type RequestLogQuery,
RequestLogService,
} from './RequestLogService.js';
import { ModelCatalogService } from './ModelCatalogService.js';
import { getAnalyticsDb } from '../config/analytics-db.js';
import { getLocalDateKey, getUtcTimestamp } from '../utils/time.js';
import {
getRequestLogsDb,
listRequestLogMonths,
} from '../config/request-logs-db.js';
import { UserModel } from '../models/User.js';
import { PermissionModel } from '../models/Permission.js';
import { ScriptModel } from '../models/Script.js';
import type {
DashboardSummaryResponse,
RequestLogPage,
ScriptType,
} from '../../../shared/types.js';
type AnalyticsLogInput = RequestLogInsert;
type RequestLogFilter = {
@ -33,11 +48,16 @@ type RequestLogRangeRow = {
function getDateRange(days: number): { startDate: string; endDate: string } {
const normalizedDays = Math.max(1, days);
const endDate = getLocalDateKey();
const startDate = getLocalDateKey(new Date(Date.now() - (normalizedDays - 1) * 24 * 60 * 60 * 1000));
const startDate = getLocalDateKey(
new Date(Date.now() - (normalizedDays - 1) * 24 * 60 * 60 * 1000),
);
return { startDate, endDate };
}
function buildRequestLogRangeWhere(filter: RequestLogFilter): { whereClause: string; params: unknown[] } {
function buildRequestLogRangeWhere(filter: RequestLogFilter): {
whereClause: string;
params: unknown[];
} {
const clauses = ['local_date >= ?', 'local_date <= ?'];
const params: unknown[] = [filter.startDate, filter.endDate];
@ -52,10 +72,15 @@ function buildRequestLogRangeWhere(filter: RequestLogFilter): { whereClause: str
};
}
function getRequestLogMonthsForRange(startDate: string, endDate: string): string[] {
function getRequestLogMonthsForRange(
startDate: string,
endDate: string,
): string[] {
const startMonth = startDate.slice(0, 7);
const endMonth = endDate.slice(0, 7);
return listRequestLogMonths().filter((month) => month >= startMonth && month <= endMonth);
return listRequestLogMonths().filter(
(month) => month >= startMonth && month <= endMonth,
);
}
function groupByDate(rows: DailyTotalsRow[]): DailyTotalsRow[] {
@ -70,7 +95,9 @@ function groupByDate(rows: DailyTotalsRow[]): DailyTotalsRow[] {
}
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
}
function calculateQuantile(sortedValues: number[], ratio: number): number {
@ -86,7 +113,9 @@ function calculateQuantile(sortedValues: number[], ratio: number): number {
}
const weight = index - lowerIndex;
return sortedValues[lowerIndex] * (1 - weight) + sortedValues[upperIndex] * weight;
return (
sortedValues[lowerIndex] * (1 - weight) + sortedValues[upperIndex] * weight
);
}
function createScriptTypeCounts(): Record<ScriptType, number> {
@ -103,7 +132,11 @@ export class AnalyticsService {
RequestLogService.logRequest(logData);
if (logData.backend_id > 0) {
this.updateUsageStats(logData.user_id, logData.backend_id, logData.total_tokens || 0);
this.updateUsageStats(
logData.user_id,
logData.backend_id,
logData.total_tokens || 0,
);
this.updateBackendMetrics(logData.backend_id, logData);
}
} catch (error) {
@ -111,7 +144,11 @@ export class AnalyticsService {
}
}
private static updateUsageStats(userId: number, backendId: number, tokens: number): void {
private static updateUsageStats(
userId: number,
backendId: number,
tokens: number,
): void {
const db = getAnalyticsDb();
const today = getLocalDateKey();
@ -127,30 +164,42 @@ export class AnalyticsService {
upsertStmt.run(userId, backendId, today, tokens, tokens);
}
private static updateBackendMetrics(backendId: number, logData: AnalyticsLogInput): void {
private static updateBackendMetrics(
backendId: number,
logData: AnalyticsLogInput,
): void {
const db = getAnalyticsDb();
const today = getLocalDateKey();
const isSuccess = logData.status_code >= 200 && logData.status_code < 300;
const existing = db.prepare(
'SELECT * FROM backend_metrics WHERE backend_id = ? AND date = ?'
).get(backendId, today) as {
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
} | undefined;
const existing = db
.prepare(
'SELECT * FROM backend_metrics WHERE backend_id = ? AND date = ?',
)
.get(backendId, today) as
| {
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
}
| undefined;
if (existing) {
const newTotalRequests = existing.total_requests + 1;
const newTotalTokens = existing.total_tokens + (logData.total_tokens || 0);
const newTotalTokens =
existing.total_tokens + (logData.total_tokens || 0);
const newErrorCount = existing.error_count + (isSuccess ? 0 : 1);
const newAvgResponseTime = logData.response_time_ms
? (existing.avg_response_time_ms * existing.total_requests + logData.response_time_ms) / newTotalRequests
? (existing.avg_response_time_ms * existing.total_requests +
logData.response_time_ms) /
newTotalRequests
: existing.avg_response_time_ms;
const newSuccessRate = (newTotalRequests - newErrorCount) / newTotalRequests;
const newSuccessRate =
(newTotalRequests - newErrorCount) / newTotalRequests;
db.prepare(`
db.prepare(
`
UPDATE backend_metrics SET
total_requests = ?,
total_tokens = ?,
@ -158,20 +207,31 @@ export class AnalyticsService {
error_count = ?,
success_rate = ?
WHERE backend_id = ? AND date = ?
`).run(newTotalRequests, newTotalTokens, newAvgResponseTime, newErrorCount, newSuccessRate, backendId, today);
`,
).run(
newTotalRequests,
newTotalTokens,
newAvgResponseTime,
newErrorCount,
newSuccessRate,
backendId,
today,
);
} else {
db.prepare(`
db.prepare(
`
INSERT INTO backend_metrics (
backend_id, date, total_requests, total_tokens,
avg_response_time_ms, error_count, success_rate
) VALUES (?, ?, 1, ?, ?, ?, ?)
`).run(
`,
).run(
backendId,
today,
logData.total_tokens || 0,
logData.response_time_ms || 0,
isSuccess ? 0 : 1,
isSuccess ? 1.0 : 0.0
isSuccess ? 1.0 : 0.0,
);
}
}
@ -180,10 +240,16 @@ export class AnalyticsService {
return RequestLogService.getRequestLogs(query);
}
static getUsageStats(userId?: number, backendId?: number, days: number = 30): unknown[] {
static getUsageStats(
userId?: number,
backendId?: number,
days: number = 30,
): unknown[] {
const db = getAnalyticsDb();
const endDate = getLocalDateKey();
const startDate = getLocalDateKey(new Date(Date.now() - days * 24 * 60 * 60 * 1000));
const startDate = getLocalDateKey(
new Date(Date.now() - days * 24 * 60 * 60 * 1000),
);
let query = `
SELECT * FROM usage_stats
@ -225,27 +291,38 @@ export class AnalyticsService {
return db.prepare(query).all(...params);
}
static getDailyTotals(backendId?: number, days: number = 30): DailyTotalsRow[] {
static getDailyTotals(
backendId?: number,
days: number = 30,
): DailyTotalsRow[] {
const db = getAnalyticsDb();
const { startDate, endDate } = getDateRange(days);
if (backendId) {
return db.prepare(`
return db
.prepare(
`
SELECT date, SUM(total_requests) as total_requests, SUM(total_tokens) as total_tokens
FROM usage_stats
WHERE date >= ? AND date <= ? AND backend_id = ?
GROUP BY date
ORDER BY date ASC
`).all(startDate, endDate, backendId) as DailyTotalsRow[];
`,
)
.all(startDate, endDate, backendId) as DailyTotalsRow[];
}
return db.prepare(`
return db
.prepare(
`
SELECT date, SUM(total_requests) as total_requests, SUM(total_tokens) as total_tokens
FROM usage_stats
WHERE date >= ? AND date <= ?
GROUP BY date
ORDER BY date ASC
`).all(startDate, endDate) as DailyTotalsRow[];
`,
)
.all(startDate, endDate) as DailyTotalsRow[];
}
static getBackendQuality(backendId?: number, days: number = 30): unknown[] {
@ -269,45 +346,74 @@ export class AnalyticsService {
return db.prepare(query).all(...params);
}
private static collectRequestLogRangeRows(filter: RequestLogFilter): RequestLogRangeRow[] {
private static collectRequestLogRangeRows(
filter: RequestLogFilter,
): RequestLogRangeRow[] {
const { whereClause, params } = buildRequestLogRangeWhere(filter);
const rows: RequestLogRangeRow[] = [];
for (const month of getRequestLogMonthsForRange(filter.startDate, filter.endDate)) {
for (const month of getRequestLogMonthsForRange(
filter.startDate,
filter.endDate,
)) {
const db = getRequestLogsDb(month);
const monthRows = db.prepare(`
const monthRows = db
.prepare(
`
SELECT local_date, backend_id, request_model, routed_model, response_model, completion_tokens
FROM request_logs
${whereClause}
`).all(...params) as RequestLogRangeRow[];
`,
)
.all(...params) as RequestLogRangeRow[];
rows.push(...monthRows);
}
return rows;
}
static getModelTrends(backendId?: number, days: number = 30, limit: number = 8): unknown[] {
static getModelTrends(
backendId?: number,
days: number = 30,
limit: number = 8,
): unknown[] {
const { startDate, endDate } = getDateRange(days);
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
const rows = this.collectRequestLogRangeRows({
backendId,
startDate,
endDate,
});
const countsByModel = new Map<string, number>();
const countsByDateAndModel = new Map<string, number>();
for (const row of rows) {
const model = row.response_model || row.routed_model || row.request_model || 'unknown';
const model =
row.response_model ||
row.routed_model ||
row.request_model ||
'unknown';
countsByModel.set(model, (countsByModel.get(model) ?? 0) + 1);
const key = `${row.local_date}::${model}`;
countsByDateAndModel.set(key, (countsByDateAndModel.get(key) ?? 0) + 1);
}
const topModels = Array.from(countsByModel.entries())
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.sort(
(left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
)
.slice(0, Math.max(1, limit))
.map(([model]) => model);
const result: Array<{ date: string; model: string; request_count: number }> = [];
const result: Array<{
date: string;
model: string;
request_count: number;
}> = [];
const seenDates = new Set(rows.map((row) => row.local_date));
for (const date of Array.from(seenDates).sort((left, right) => left.localeCompare(right))) {
for (const date of Array.from(seenDates).sort((left, right) =>
left.localeCompare(right),
)) {
for (const model of topModels) {
result.push({
date,
@ -320,11 +426,22 @@ export class AnalyticsService {
return result;
}
static getResponseLengthHistogram(backendId?: number, days: number = 30, bins: number = 20): unknown[] {
static getResponseLengthHistogram(
backendId?: number,
days: number = 30,
bins: number = 20,
): unknown[] {
const { startDate, endDate } = getDateRange(days);
const values = this.collectRequestLogRangeRows({ backendId, startDate, endDate })
const values = this.collectRequestLogRangeRows({
backendId,
startDate,
endDate,
})
.map((row) => row.completion_tokens)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0);
.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0,
);
if (values.length === 0) {
return [];
@ -346,20 +463,34 @@ export class AnalyticsService {
}));
for (const value of values) {
const index = Math.min(safeBinCount - 1, Math.floor((value - min) / width));
const index = Math.min(
safeBinCount - 1,
Math.floor((value - min) / width),
);
histogram[index].count += 1;
}
return histogram;
}
static getResponseLengthBoxPlot(backendId?: number, days: number = 30): unknown[] {
static getResponseLengthBoxPlot(
backendId?: number,
days: number = 30,
): unknown[] {
const { startDate, endDate } = getDateRange(days);
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
const rows = this.collectRequestLogRangeRows({
backendId,
startDate,
endDate,
});
const valuesByDate = new Map<string, number[]>();
for (const row of rows) {
if (typeof row.completion_tokens !== 'number' || !Number.isFinite(row.completion_tokens) || row.completion_tokens < 0) {
if (
typeof row.completion_tokens !== 'number' ||
!Number.isFinite(row.completion_tokens) ||
row.completion_tokens < 0
) {
continue;
}
@ -393,7 +524,9 @@ export class AnalyticsService {
const cacheOverview = ModelCatalogService.getCacheOverview();
const now = getUtcTimestamp();
const staleThresholdMs = 24 * 60 * 60 * 1000;
const permissionsByUserId = new Set(permissions.map((permission) => permission.user_id));
const permissionsByUserId = new Set(
permissions.map((permission) => permission.user_id),
);
const totalByType = createScriptTypeCounts();
const activeByType = createScriptTypeCounts();
@ -414,7 +547,7 @@ export class AnalyticsService {
uninitialized: 0,
error: 0,
inactive: 0,
}
},
);
const staleBackends = backends
@ -423,7 +556,10 @@ export class AnalyticsService {
return false;
}
const lastSyncedAt = Date.parse(backend.last_model_sync_at);
return Number.isFinite(lastSyncedAt) && Date.now() - lastSyncedAt > staleThresholdMs;
return (
Number.isFinite(lastSyncedAt) &&
Date.now() - lastSyncedAt > staleThresholdMs
);
})
.map((backend) => ({
id: backend.id,
@ -458,8 +594,11 @@ export class AnalyticsService {
},
},
logging: {
users_with_detail_logging: users.filter((user) => user.detail_logging).length,
backends_with_detail_logging: backends.filter((backend) => backend.detail_logging).length,
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,
@ -467,12 +606,21 @@ export class AnalyticsService {
},
access: {
permission_assignments: permissions.length,
users_without_permissions: users.filter((user) => !permissionsByUserId.has(user.id)).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'],
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

@ -1,16 +1,17 @@
import {
import { BackendModel } from '../models/Backend.js';
import { BackendModelSnapshotModel } from '../models/BackendModelSnapshot.js';
import { ModelRewriteModel } from '../models/ModelRewrite.js';
import { getUtcTimestamp } from '../utils/time.js';
import { logger } from '../utils/logger.js';
import type {
Backend,
BackendModelCacheStatus,
BackendModelCatalogEntry,
BackendModelsResponse,
ModelCacheOverview,
ModelRewriteRule,
} from '../../../shared/types';
import { BackendModel } from '../models/Backend';
import { BackendModelSnapshotModel } from '../models/BackendModelSnapshot';
import { ModelRewriteModel } from '../models/ModelRewrite';
import { getUtcTimestamp } from '../utils/time';
import { logger } from '../utils/logger';
} from '../../../shared/types.js';
interface BackendCacheEntry {
backendId: number;
@ -46,17 +47,25 @@ interface RewriteConfig {
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
export class ModelCatalogService {
private static backendModelsByBackendId = new Map<number, BackendCacheEntry>();
private static backendModelsByBackendId = new Map<
number,
BackendCacheEntry
>();
private static backendIdsByModel = new Map<string, Set<number>>();
private static modelRewriteMap = new Map<string, RewriteConfig>();
private static inFlightRefreshes = new Map<number, Promise<BackendModelCacheStatus>>();
private static inFlightRefreshes = new Map<
number,
Promise<BackendModelCacheStatus>
>();
private static initialized = false;
private static getRefreshMinMs(): number {
const raw = process.env.MODEL_CATALOG_REFRESH_MIN_MS;
if (!raw) return DEFAULT_REFRESH_MIN_MS;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_REFRESH_MIN_MS;
return Number.isFinite(parsed) && parsed >= 0
? parsed
: DEFAULT_REFRESH_MIN_MS;
}
private static normalizeModelId(modelId: string): string {
@ -76,7 +85,10 @@ export class ModelCatalogService {
return created;
}
private static statusFromEntry(entry: BackendCacheEntry, backend?: Backend): BackendModelCacheStatus {
private static statusFromEntry(
entry: BackendCacheEntry,
backend?: Backend,
): BackendModelCacheStatus {
const active = backend ? backend.is_active : true;
let state: BackendModelCacheStatus['state'];
if (!active) {
@ -102,7 +114,9 @@ export class ModelCatalogService {
private static rebuildModelIndex(): void {
this.backendIdsByModel.clear();
const backends = new Map(BackendModel.findAll().map((backend) => [backend.id, backend]));
const backends = new Map(
BackendModel.findAll().map((backend) => [backend.id, backend]),
);
for (const entry of this.backendModelsByBackendId.values()) {
const backend = backends.get(entry.backendId);
@ -119,7 +133,9 @@ export class ModelCatalogService {
}
}
private static async fetchBackendModels(backend: Backend): Promise<FetchModelsResponse> {
private static async fetchBackendModels(
backend: Backend,
): Promise<FetchModelsResponse> {
let backendPath = '/v1/models';
if (backend.base_url.includes('/v1')) {
backendPath = '/models';
@ -132,13 +148,18 @@ export class ModelCatalogService {
const response = await fetch(url, { method: 'GET', headers });
if (!response.ok) {
throw new Error(`Backend model fetch failed with HTTP ${response.status}`);
throw new Error(
`Backend model fetch failed with HTTP ${response.status}`,
);
}
const payload = await response.json().catch(() => ({} as any));
const data = payload && typeof payload === 'object' && Array.isArray((payload as any).data)
? (payload as any).data
: [];
const payload = await response.json().catch(() => ({}) as any);
const data =
payload &&
typeof payload === 'object' &&
Array.isArray((payload as any).data)
? (payload as any).data
: [];
const seen = new Set<string>();
const rawModels: Array<{ model_id: string; raw_json?: string }> = [];
@ -173,7 +194,11 @@ export class ModelCatalogService {
this.initialized = true;
const activeBackends = BackendModel.findActive();
await Promise.allSettled(activeBackends.map((backend) => this.refreshBackendModels(backend.id, { reason: 'startup' })));
await Promise.allSettled(
activeBackends.map((backend) =>
this.refreshBackendModels(backend.id, { reason: 'startup' }),
),
);
}
static reset(): void {
@ -216,7 +241,10 @@ export class ModelCatalogService {
this.rebuildModelIndex();
}
static resolveRequestedModel(modelId: string, allowedBackendIds: number[]): RewriteResolution {
static resolveRequestedModel(
modelId: string,
allowedBackendIds: number[],
): RewriteResolution {
const requestedModel = this.normalizeModelId(modelId);
const rewrite = this.modelRewriteMap.get(requestedModel);
if (!rewrite) {
@ -237,7 +265,10 @@ export class ModelCatalogService {
};
}
const originalCandidates = this.getCandidateBackendIds(requestedModel, allowedBackendIds);
const originalCandidates = this.getCandidateBackendIds(
requestedModel,
allowedBackendIds,
);
if (originalCandidates.length > 0) {
return {
requestedModel,
@ -280,20 +311,27 @@ export class ModelCatalogService {
});
}
static async ensureInitializedForBackends(backendIds: number[]): Promise<void> {
static async ensureInitializedForBackends(
backendIds: number[],
): Promise<void> {
const refreshes: Promise<BackendModelCacheStatus>[] = [];
for (const backendId of backendIds) {
const backend = BackendModel.findById(backendId);
if (!backend?.is_active) continue;
const entry = this.getCacheEntry(backendId);
if (!entry.initialized) {
refreshes.push(this.refreshBackendModels(backendId, { reason: 'lazy-init' }));
refreshes.push(
this.refreshBackendModels(backendId, { reason: 'lazy-init' }),
);
}
}
await Promise.allSettled(refreshes);
}
static async refreshBackendModels(backendId: number, options: RefreshOptions = {}): Promise<BackendModelCacheStatus> {
static async refreshBackendModels(
backendId: number,
options: RefreshOptions = {},
): Promise<BackendModelCacheStatus> {
const backend = BackendModel.findById(backendId);
const entry = this.getCacheEntry(backendId);
@ -312,8 +350,14 @@ export class ModelCatalogService {
}
const now = Date.now();
const lastAttempt = entry.lastAttemptedAt ? Date.parse(entry.lastAttemptedAt) : 0;
if (!options.force && lastAttempt && now - lastAttempt < this.getRefreshMinMs()) {
const lastAttempt = entry.lastAttemptedAt
? Date.parse(entry.lastAttemptedAt)
: 0;
if (
!options.force &&
lastAttempt &&
now - lastAttempt < this.getRefreshMinMs()
) {
return this.statusFromEntry(entry, backend);
}
@ -331,15 +375,26 @@ export class ModelCatalogService {
entry.initialized = true;
entry.lastSyncedAt = fetchedAt;
entry.lastError = undefined;
BackendModelSnapshotModel.replaceForBackend(backendId, rawModels, fetchedAt);
BackendModelSnapshotModel.replaceForBackend(
backendId,
rawModels,
fetchedAt,
);
this.rebuildModelIndex();
logger.info(`Model catalog refreshed for backend ${backendId}${options.reason ? ` (${options.reason})` : ''}`);
logger.info(
`Model catalog refreshed for backend ${backendId}${options.reason ? ` (${options.reason})` : ''}`,
);
} catch (error) {
entry.initialized = true;
entry.modelIds = [];
entry.lastError = error instanceof Error ? error.message : 'Unknown model refresh error';
entry.lastError =
error instanceof Error
? error.message
: 'Unknown model refresh error';
this.rebuildModelIndex();
logger.warn(`Model catalog refresh failed for backend ${backendId}: ${entry.lastError}`);
logger.warn(
`Model catalog refresh failed for backend ${backendId}: ${entry.lastError}`,
);
} finally {
this.inFlightRefreshes.delete(backendId);
}
@ -374,39 +429,60 @@ export class ModelCatalogService {
return;
}
await this.refreshBackendModels(backendId, { force: true, reason: 'admin-update' });
await this.refreshBackendModels(backendId, {
force: true,
reason: 'admin-update',
});
}
static getCandidateBackendIds(modelId: string, allowedBackendIds: number[]): number[] {
static getCandidateBackendIds(
modelId: string,
allowedBackendIds: number[],
): number[] {
const normalized = this.normalizeModelId(modelId);
const backendIds = this.backendIdsByModel.get(normalized);
if (!backendIds) return [];
const allowed = new Set(allowedBackendIds);
const active = new Set(BackendModel.findActive().map((backend) => backend.id));
return Array.from(backendIds).filter((backendId) => allowed.has(backendId) && active.has(backendId));
const active = new Set(
BackendModel.findActive().map((backend) => backend.id),
);
return Array.from(backendIds).filter(
(backendId) => allowed.has(backendId) && active.has(backendId),
);
}
static getModelsForAllowedBackends(allowedBackendIds: number[]): BackendModelCatalogEntry[] {
static getModelsForAllowedBackends(
allowedBackendIds: number[],
): BackendModelCatalogEntry[] {
const allowed = new Set(allowedBackendIds);
const entries: BackendModelCatalogEntry[] = [];
for (const [modelId, backendIds] of this.backendIdsByModel.entries()) {
const matched = Array.from(backendIds).filter((backendId) => allowed.has(backendId));
const matched = Array.from(backendIds).filter((backendId) =>
allowed.has(backendId),
);
if (matched.length > 0) {
entries.push({ model_id: modelId, backend_ids: matched.sort((a, b) => a - b) });
entries.push({
model_id: modelId,
backend_ids: matched.sort((a, b) => a - b),
});
}
}
return entries.sort((a, b) => a.model_id.localeCompare(b.model_id));
}
static getBackendModelsResponse(backendId: number): BackendModelsResponse | null {
static getBackendModelsResponse(
backendId: number,
): BackendModelsResponse | null {
const backend = BackendModel.findById(backendId);
if (!backend) return null;
return {
backend: {
...backend,
...(this.getBackendsWithSummary().find((item) => item.id === backendId) || {}),
...(this.getBackendsWithSummary().find(
(item) => item.id === backendId,
) || {}),
},
cache: this.getBackendCacheStatus(backendId),
snapshots: BackendModelSnapshotModel.findByBackendId(backendId),
@ -416,7 +492,9 @@ export class ModelCatalogService {
static getCacheOverview(): ModelCacheOverview {
const backends = BackendModel.findAll()
.map((backend) => this.statusFromEntry(this.getCacheEntry(backend.id), backend))
.map((backend) =>
this.statusFromEntry(this.getCacheEntry(backend.id), backend),
)
.sort((a, b) => a.backend_id - b.backend_id);
const models = Array.from(this.backendIdsByModel.entries())

View file

@ -1,6 +1,16 @@
import { listRequestLogMonths, getRequestLogsDb } from '../config/request-logs-db';
import { RequestLog, RequestLogPage } from '../../../shared/types';
import { getLocalDateKey, getLocalMonthKey, getMonthKeyFromDateString, getUtcTimestamp } from '../utils/time';
import {
listRequestLogMonths,
getRequestLogsDb,
} from '../config/request-logs-db.js';
import {
getLocalDateKey,
getLocalMonthKey,
getMonthKeyFromDateString,
getUtcTimestamp,
} from '../utils/time.js';
import type { RequestLog, RequestLogPage } from '../../../shared/types.js';
export interface RequestLogInsert {
user_id: number;
@ -46,7 +56,10 @@ function normalizeRequestLog(row: any): RequestLog {
return row as RequestLog;
}
function buildWhereClause(query: RequestLogQuery): { whereClause: string; params: unknown[] } {
function buildWhereClause(query: RequestLogQuery): {
whereClause: string;
params: unknown[];
} {
const clauses: string[] = [];
const params: unknown[] = [];
@ -92,24 +105,43 @@ function buildWhereClause(query: RequestLogQuery): { whereClause: string; params
};
}
function getMonthRowCount(monthKey: string, whereClause: string, params: unknown[]): number {
function getMonthRowCount(
monthKey: string,
whereClause: string,
params: unknown[],
): number {
const db = getRequestLogsDb(monthKey);
const matchedInMonth = db.prepare(`
const matchedInMonth = db
.prepare(
`
SELECT COUNT(*) as count FROM request_logs
${whereClause}
`).get(...params) as { count: number };
`,
)
.get(...params) as { count: number };
return matchedInMonth.count;
}
function getMonthRows(monthKey: string, whereClause: string, params: unknown[], limit: number, offset: number): RequestLog[] {
function getMonthRows(
monthKey: string,
whereClause: string,
params: unknown[],
limit: number,
offset: number,
): RequestLog[] {
const db = getRequestLogsDb(monthKey);
return db.prepare(`
return db
.prepare(
`
SELECT * FROM request_logs
${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset).map(normalizeRequestLog);
`,
)
.all(...params, limit, offset)
.map(normalizeRequestLog);
}
function getQueryMonth(query: RequestLogQuery): string {
@ -148,14 +180,16 @@ export class RequestLogService {
const detailLogged = logData.detail_logged ?? false;
const db = getRequestLogsDb(monthKey);
db.prepare(`
db.prepare(
`
INSERT INTO request_logs (
user_id, backend_id, endpoint, request_model, routed_model, response_model,
prompt_tokens, completion_tokens, total_tokens,
status_code, response_time_ms, error_message, detail_logged,
local_date, request_headers, request_body, response_headers, response_body, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`,
).run(
logData.user_id,
logData.backend_id,
logData.endpoint,
@ -174,7 +208,7 @@ export class RequestLogService {
stringifySnapshot(logData.request_body),
stringifySnapshot(logData.response_headers),
stringifySnapshot(logData.response_body),
createdAt
createdAt,
);
}
@ -186,7 +220,10 @@ export class RequestLogService {
if (query.month || query.date) {
const monthKey = getQueryMonth(query);
const total = getMonthRowCount(monthKey, whereClause, params);
const rows = offset >= total ? [] : getMonthRows(monthKey, whereClause, params, limit, offset);
const rows =
offset >= total
? []
: getMonthRows(monthKey, whereClause, params, limit, offset);
return {
rows,
@ -216,7 +253,13 @@ export class RequestLogService {
if (results.length < limit) {
const remaining = limit - results.length;
const rows = getMonthRows(month, whereClause, params, remaining, offset);
const rows = getMonthRows(
month,
whereClause,
params,
remaining,
offset,
);
results.push(...rows);
offset = 0;
}

View file

@ -1,8 +1,11 @@
import { Backend } from '../../../shared/types';
import { BackendModel } from '../models/Backend';
import { BackendModel } from '../models/Backend.js';
import type { Backend } from '../../../shared/types.js';
export class RouterService {
private static prepareRequestBody(body?: unknown): string | Uint8Array | ArrayBuffer | undefined {
private static prepareRequestBody(
body?: unknown,
): string | Uint8Array | ArrayBuffer | undefined {
if (body === undefined || body === null) {
return undefined;
}
@ -24,8 +27,9 @@ export class RouterService {
}
const allBackends = BackendModel.findAll();
const backends = allBackends
.filter(b => (b.is_active === true) && allowedBackendIds.includes(b.id));
const backends = allBackends.filter(
(b) => b.is_active === true && allowedBackendIds.includes(b.id),
);
if (backends.length === 0) {
return null;
@ -40,7 +44,7 @@ export class RouterService {
path: string,
method: string,
headers: Record<string, string>,
body?: unknown
body?: unknown,
): Promise<Response> {
let backendPath = path;
if (backend.base_url.includes('/v1')) {
@ -83,10 +87,20 @@ export class RouterService {
path: string,
method: string,
headers: Record<string, string>,
body?: unknown
): Promise<{ status: number; data: unknown; headers: Record<string, string> }> {
body?: unknown,
): Promise<{
status: number;
data: unknown;
headers: Record<string, string>;
}> {
try {
const response = await this.rawFetch(backend, path, method, headers, body);
const response = await this.rawFetch(
backend,
path,
method,
headers,
body,
);
const data = await response.json().catch(() => ({}));
const responseHeaders = Object.fromEntries(response.headers.entries());
@ -98,37 +112,45 @@ export class RouterService {
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
// Extract detailed error information from error cause
let cause: string | undefined;
let errorType: string;
if (error instanceof Error && error.cause) {
const causeError = error.cause as any;
const causeCode = causeError.code || causeError.errno;
const causeSyscall = causeError.syscall;
const causeAddress = causeError.address || causeError.hostname;
const causePort = causeError.port;
if (causeCode === 'ECONNREFUSED') {
errorType = 'Backend connection refused';
cause = causeAddress && causePort
? `Backend server at ${causeAddress}:${causePort} is not accepting connections`
: 'Backend server is not accepting connections';
cause =
causeAddress && causePort
? `Backend server at ${causeAddress}:${causePort} is not accepting connections`
: 'Backend server is not accepting connections';
} else if (causeCode === 'ETIMEDOUT' || causeCode === 'ECONNABORTED') {
errorType = 'Backend request timeout';
cause = 'Connection to backend timed out';
} else if (causeCode === 'ENOTFOUND') {
errorType = 'Backend unreachable';
cause = causeAddress ? `Could not resolve hostname: ${causeAddress}` : 'Could not resolve backend hostname';
cause = causeAddress
? `Could not resolve hostname: ${causeAddress}`
: 'Could not resolve backend hostname';
} else if (causeCode === 'EPIPE' || causeSyscall === 'write') {
errorType = 'Backend connection lost';
cause = causeSyscall ? `Connection broken during ${causeSyscall} operation` : 'Connection broken during operation';
cause = causeSyscall
? `Connection broken during ${causeSyscall} operation`
: 'Connection broken during operation';
} else {
errorType = 'Backend connection error';
cause = `${causeCode || 'Unknown error'} during ${causeSyscall || 'connection'}`;
}
} else if (errorMsg.includes('ETIMEDOUT') || errorMsg.includes('ECONNABORTED')) {
} else if (
errorMsg.includes('ETIMEDOUT') ||
errorMsg.includes('ECONNABORTED')
) {
errorType = 'Backend request timeout';
cause = 'Connection timed out after 30s';
} else if (errorMsg.includes('aborted')) {
@ -159,13 +181,19 @@ export class RouterService {
path: string,
method: string,
headers: Record<string, string>,
body?: unknown
body?: unknown,
): Promise<
| { response: Response }
| { status: number; data: unknown; headers: Record<string, string> }
> {
try {
const response = await this.rawFetch(backend, path, method, headers, body);
const response = await this.rawFetch(
backend,
path,
method,
headers,
body,
);
return { response };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
@ -182,23 +210,31 @@ export class RouterService {
if (causeCode === 'ECONNREFUSED') {
errorType = 'Backend connection refused';
cause = causeAddress && causePort
? `Backend server at ${causeAddress}:${causePort} is not accepting connections`
: 'Backend server is not accepting connections';
cause =
causeAddress && causePort
? `Backend server at ${causeAddress}:${causePort} is not accepting connections`
: 'Backend server is not accepting connections';
} else if (causeCode === 'ETIMEDOUT' || causeCode === 'ECONNABORTED') {
errorType = 'Backend request timeout';
cause = 'Connection to backend timed out';
} else if (causeCode === 'ENOTFOUND') {
errorType = 'Backend unreachable';
cause = causeAddress ? `Could not resolve hostname: ${causeAddress}` : 'Could not resolve backend hostname';
cause = causeAddress
? `Could not resolve hostname: ${causeAddress}`
: 'Could not resolve backend hostname';
} else if (causeCode === 'EPIPE' || causeSyscall === 'write') {
errorType = 'Backend connection lost';
cause = causeSyscall ? `Connection broken during ${causeSyscall} operation` : 'Connection broken during operation';
cause = causeSyscall
? `Connection broken during ${causeSyscall} operation`
: 'Connection broken during operation';
} else {
errorType = 'Backend connection error';
cause = `${causeCode || 'Unknown error'} during ${causeSyscall || 'connection'}`;
}
} else if (errorMsg.includes('ETIMEDOUT') || errorMsg.includes('ECONNABORTED')) {
} else if (
errorMsg.includes('ETIMEDOUT') ||
errorMsg.includes('ECONNABORTED')
) {
errorType = 'Backend request timeout';
cause = 'Connection timed out after 30s';
} else if (errorMsg.includes('aborted')) {

View file

@ -1,7 +1,9 @@
import { ScriptContextData } from '../../../shared/types';
import { CompiledScript } from './ScriptExecutor';
import { ScriptModel } from '../models/Script';
import { logger } from '../utils/logger';
import { CompiledScript } from './ScriptExecutor.js';
import { ScriptModel } from '../models/Script.js';
import { logger } from '../utils/logger.js';
import type { ScriptContextData } from '../../../shared/types.js';
export interface ScriptChainResult {
success: boolean;
@ -14,8 +16,12 @@ export class ScriptEngine {
static async applyOnRequestScripts(
context: ScriptContextData,
userId: number,
backendId: number
): Promise<{ context: ScriptContextData; errors: string[]; executionTimes: number[] }> {
backendId: number,
): Promise<{
context: ScriptContextData;
errors: string[];
executionTimes: number[];
}> {
const scripts = ScriptModel.getMatchingScripts(userId, backendId);
const errors: string[] = [];
const executionTimes: number[] = [];
@ -28,7 +34,9 @@ export class ScriptEngine {
compiled = await CompiledScript.compile(script.script_code);
if (compiled.hasOnRequest) {
current = await compiled.callOnRequest(current);
logger.info(`Script "${script.name}" onRequest executed in ${Date.now() - startTime}ms`);
logger.info(
`Script "${script.name}" onRequest executed in ${Date.now() - startTime}ms`,
);
}
} catch (error) {
const msg = `Script "${script.name}" onRequest failed: ${error instanceof Error ? error.message : String(error)}`;
@ -45,10 +53,19 @@ export class ScriptEngine {
static async applyOnResponseScripts(
context: ScriptContextData,
response: { status: number; headers: Record<string, string>; body: unknown; isStream: boolean },
response: {
status: number;
headers: Record<string, string>;
body: unknown;
isStream: boolean;
},
userId: number,
backendId: number
): Promise<{ context: ScriptContextData; errors: string[]; executionTimes: number[] }> {
backendId: number,
): Promise<{
context: ScriptContextData;
errors: string[];
executionTimes: number[];
}> {
const scripts = ScriptModel.getMatchingScripts(userId, backendId);
const errors: string[] = [];
const executionTimes: number[] = [];
@ -61,7 +78,9 @@ export class ScriptEngine {
compiled = await CompiledScript.compile(script.script_code);
if (compiled.hasOnResponse) {
current = await compiled.callOnResponse(current);
logger.info(`Script "${script.name}" onResponse executed in ${Date.now() - startTime}ms`);
logger.info(
`Script "${script.name}" onResponse executed in ${Date.now() - startTime}ms`,
);
}
} catch (error) {
const msg = `Script "${script.name}" onResponse failed: ${error instanceof Error ? error.message : String(error)}`;
@ -81,14 +100,28 @@ export class ScriptEngine {
backendId: number,
phase: 'onRequest' | 'onResponse',
context: ScriptContextData,
response?: { status: number; headers: Record<string, string>; body: unknown; isStream: boolean }
response?: {
status: number;
headers: Record<string, string>;
body: unknown;
isStream: boolean;
},
): Promise<ScriptChainResult> {
if (phase === 'onRequest') {
const result = await this.applyOnRequestScripts(context, userId, backendId);
const result = await this.applyOnRequestScripts(
context,
userId,
backendId,
);
return { success: result.errors.length === 0, ...result };
}
if (phase === 'onResponse' && response) {
const result = await this.applyOnResponseScripts(context, response, userId, backendId);
const result = await this.applyOnResponseScripts(
context,
response,
userId,
backendId,
);
return { success: result.errors.length === 0, ...result };
}
return { success: true, context, errors: [], executionTimes: [] };

View file

@ -1,15 +1,23 @@
import ivmImport from 'isolated-vm';
import { logger } from '../utils/logger.js';
import type { Context, Isolate, Reference } from 'isolated-vm';
import { ScriptContextData } from '../../../shared/types';
import { logger } from '../utils/logger';
import type { ScriptContextData } from '../../../shared/types.js';
const SCRIPT_TIMEOUT_MS = 5000;
const MEMORY_LIMIT_MB = 50;
type IsolatedVmModule = typeof import('isolated-vm');
export function resolveIsolatedVmModule(moduleValue: unknown): IsolatedVmModule {
if (moduleValue && typeof moduleValue === 'object' && 'Isolate' in moduleValue) {
export function resolveIsolatedVmModule(
moduleValue: unknown,
): IsolatedVmModule {
if (
moduleValue &&
typeof moduleValue === 'object' &&
'Isolate' in moduleValue
) {
return moduleValue as IsolatedVmModule;
}
@ -68,16 +76,27 @@ export class CompiledScript {
// Provide console via Reference callbacks (only primitives can cross applySync boundary)
const logFns = {
_logLog: new ivm.Reference((...args: string[]) => logger.log(`[script] ${args.join(' ')}`)),
_logDebug: new ivm.Reference((...args: string[]) => logger.debug(`[script] ${args.join(' ')}`)),
_logInfo: new ivm.Reference((...args: string[]) => logger.info(`[script] ${args.join(' ')}`)),
_logWarn: new ivm.Reference((...args: string[]) => logger.warn(`[script] ${args.join(' ')}`)),
_logError: new ivm.Reference((...args: string[]) => logger.error(`[script] ${args.join(' ')}`)),
_logLog: new ivm.Reference((...args: string[]) =>
logger.log(`[script] ${args.join(' ')}`),
),
_logDebug: new ivm.Reference((...args: string[]) =>
logger.debug(`[script] ${args.join(' ')}`),
),
_logInfo: new ivm.Reference((...args: string[]) =>
logger.info(`[script] ${args.join(' ')}`),
),
_logWarn: new ivm.Reference((...args: string[]) =>
logger.warn(`[script] ${args.join(' ')}`),
),
_logError: new ivm.Reference((...args: string[]) =>
logger.error(`[script] ${args.join(' ')}`),
),
};
for (const [name, ref] of Object.entries(logFns)) {
await jail.set(name, ref);
}
await ctx.eval(`
await ctx.eval(
`
globalThis.console = {
log: (...a) => _logLog.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
debug: (...a) => _logDebug.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
@ -85,27 +104,33 @@ export class CompiledScript {
warn: (...a) => _logWarn.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
error: (...a) => _logError.applySync(undefined, a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v))),
};
`, { timeout: SCRIPT_TIMEOUT_MS });
`,
{ timeout: SCRIPT_TIMEOUT_MS },
);
// Evaluate user script (with export keywords stripped)
const processedCode = preprocessScript(code);
await ctx.eval(processedCode, { timeout: SCRIPT_TIMEOUT_MS });
// Check which hooks exist, then grab References only for defined ones
const hasOnRequest = await ctx.eval(
'typeof onRequest === "function"',
{ timeout: SCRIPT_TIMEOUT_MS },
) as boolean;
const hasOnResponse = await ctx.eval(
'typeof onResponse === "function"',
{ timeout: SCRIPT_TIMEOUT_MS },
) as boolean;
const hasOnRequest = (await ctx.eval('typeof onRequest === "function"', {
timeout: SCRIPT_TIMEOUT_MS,
})) as boolean;
const hasOnResponse = (await ctx.eval('typeof onResponse === "function"', {
timeout: SCRIPT_TIMEOUT_MS,
})) as boolean;
const onRequestRef = hasOnRequest
? await ctx.eval('onRequest', { timeout: SCRIPT_TIMEOUT_MS, reference: true }) as Reference<Function>
? ((await ctx.eval('onRequest', {
timeout: SCRIPT_TIMEOUT_MS,
reference: true,
})) as Reference<Function>)
: null;
const onResponseRef = hasOnResponse
? await ctx.eval('onResponse', { timeout: SCRIPT_TIMEOUT_MS, reference: true }) as Reference<Function>
? ((await ctx.eval('onResponse', {
timeout: SCRIPT_TIMEOUT_MS,
reference: true,
})) as Reference<Function>)
: null;
return new CompiledScript(isolate, ctx, onRequestRef, onResponseRef);
@ -143,7 +168,11 @@ export class CompiledScript {
}
dispose(): void {
try { this.ctx.release(); } catch {}
try { this.isolate.dispose(); } catch {}
try {
this.ctx.release();
} catch {}
try {
this.isolate.dispose();
} catch {}
}
}

19
server/src/types/hono.ts Normal file
View file

@ -0,0 +1,19 @@
import type { AdminPrincipal, User } from '../../../shared/types.js';
export interface AdminAuthContext {
principal: AdminPrincipal;
method: 'session' | 'token';
csrfToken?: string;
sessionId?: number;
tokenId?: number;
}
export type AppVariables = {
user: User;
allowedBackendIds: number[];
adminAuth: AdminAuthContext;
};
export type AppEnv = {
Variables: Partial<AppVariables>;
};

View file

@ -1,21 +1,23 @@
import { NextFunction, Request, Response } from 'express';
import { AdminPrincipal } from '../../../shared/types';
import { getTrustedProxyIps } from '../config/admin-auth';
import { AdminApiTokenModel } from '../models/AdminApiToken';
import { AdminSessionModel } from '../models/AdminSession';
import { getSessionTokenFromCookies, hashAdminToken } from './adminSecurity';
import { getSessionTokenFromCookies, hashAdminToken } from './adminSecurity.js';
export interface AdminRequest extends Request {
adminAuth?: {
principal: AdminPrincipal;
method: 'session' | 'token';
csrfToken?: string;
sessionId?: number;
tokenId?: number;
};
}
import { getTrustedProxyIps } from '../config/admin-auth.js';
function toPrincipal(data: { provider: 'env' | 'oidc'; subject: string; username?: string; email?: string; display_name: string }): AdminPrincipal {
import { AdminApiTokenModel } from '../models/AdminApiToken.js';
import { AdminSessionModel } from '../models/AdminSession.js';
import type { Context, MiddlewareHandler } from 'hono';
import type { AdminPrincipal } from '../../../shared/types.js';
import type { AdminAuthContext, AppEnv } from '../types/hono.js';
function toPrincipal(data: {
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
display_name: string;
}): AdminPrincipal {
return {
provider: data.provider,
subject: data.subject,
@ -25,83 +27,115 @@ function toPrincipal(data: { provider: 'env' | 'oidc'; subject: string; username
};
}
function passesTrustedProxyGuard(req: Request): boolean {
function getRemoteIp(c: Context): string {
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0]?.trim() ?? '';
}
// @hono/node-server exposes the underlying IncomingMessage as c.env.incoming
const incoming = (
c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined
)?.incoming;
return incoming?.socket?.remoteAddress ?? '';
}
function passesTrustedProxyGuard(c: Context): boolean {
const allowedIps = getTrustedProxyIps();
if (allowedIps.length === 0) {
return true;
}
const remoteIp = req.ip || req.socket.remoteAddress || '';
const remoteIp = getRemoteIp(c);
return allowedIps.includes(remoteIp);
}
export function resolveAdminAuth(req: AdminRequest): void {
const authHeader = req.headers.authorization;
export function resolveAdminAuth(
c: Context<AppEnv>,
): AdminAuthContext | undefined {
const authHeader = c.req.header('authorization');
if (authHeader?.startsWith('Bearer ')) {
const bearerToken = authHeader.slice('Bearer '.length).trim();
const adminToken = AdminApiTokenModel.findByTokenHash(hashAdminToken(bearerToken));
const adminToken = AdminApiTokenModel.findByTokenHash(
hashAdminToken(bearerToken),
);
if (adminToken) {
AdminApiTokenModel.touch(adminToken.id);
req.adminAuth = {
const ctx: AdminAuthContext = {
principal: toPrincipal(adminToken),
method: 'token',
tokenId: adminToken.id,
};
return;
c.set('adminAuth', ctx);
return ctx;
}
}
const sessionToken = getSessionTokenFromCookies(req.headers.cookie);
const sessionToken = getSessionTokenFromCookies(c.req.header('cookie'));
if (!sessionToken) {
return;
return undefined;
}
const session = AdminSessionModel.findByTokenHash(hashAdminToken(sessionToken));
const session = AdminSessionModel.findByTokenHash(
hashAdminToken(sessionToken),
);
if (!session) {
return;
return undefined;
}
AdminSessionModel.touch(session.id);
req.adminAuth = {
const ctx: AdminAuthContext = {
principal: toPrincipal(session),
method: 'session',
csrfToken: session.csrf_token,
sessionId: session.id,
};
c.set('adminAuth', ctx);
return ctx;
}
export function requireAdminAccess(req: AdminRequest, res: Response, next: NextFunction): void {
if (!passesTrustedProxyGuard(req)) {
res.status(403).json({ error: 'Admin access is restricted to trusted proxy IPs' });
return;
export const requireAdminAccess: MiddlewareHandler<AppEnv> = async (
c,
next,
) => {
if (!passesTrustedProxyGuard(c)) {
return c.json(
{ error: 'Admin access is restricted to trusted proxy IPs' },
403,
);
}
resolveAdminAuth(req);
if (!req.adminAuth) {
res.status(401).json({ error: 'Admin authentication required' });
return;
const auth = resolveAdminAuth(c);
if (!auth) {
return c.json({ error: 'Admin authentication required' }, 401);
}
next();
}
await next();
return;
};
export function requireSessionCsrf(req: AdminRequest, res: Response, next: NextFunction): void {
const unsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes(req.method.toUpperCase());
export const requireSessionCsrf: MiddlewareHandler<AppEnv> = async (
c,
next,
) => {
const unsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes(
c.req.method.toUpperCase(),
);
if (!unsafeMethod) {
next();
await next();
return;
}
if (req.adminAuth?.method !== 'session') {
next();
const adminAuth = c.get('adminAuth');
if (adminAuth?.method !== 'session') {
await next();
return;
}
const csrfHeader = req.get('X-CSRF-Token');
if (!csrfHeader || csrfHeader !== req.adminAuth.csrfToken) {
res.status(403).json({ error: 'Invalid CSRF token' });
return;
const csrfHeader = c.req.header('X-CSRF-Token');
if (!csrfHeader || csrfHeader !== adminAuth.csrfToken) {
return c.json({ error: 'Invalid CSRF token' }, 403);
}
next();
}
await next();
return;
};

View file

@ -1,6 +1,20 @@
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
import { Response } from 'express';
import { getCookieSecure, getAdminPasswordHash, getAdminSessionTtlHours, hashOpaqueToken } from '../config/admin-auth';
import {
createHash,
randomBytes,
scryptSync,
timingSafeEqual,
} from 'node:crypto';
import { setCookie, deleteCookie } from 'hono/cookie';
import {
getCookieSecure,
getAdminPasswordHash,
getAdminSessionTtlHours,
hashOpaqueToken,
} from '../config/admin-auth.js';
import type { Context } from 'hono';
const SESSION_COOKIE_NAME = 'kyush_admin_session';
@ -24,39 +38,30 @@ export function tokenPrefix(token: string): string {
return token.slice(0, 12);
}
export function issueAdminSessionCookie(res: Response, sessionToken: string, maxAgeMs: number): void {
const parts = [
`${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionToken)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Max-Age=${Math.max(1, Math.floor(maxAgeMs / 1000))}`,
];
if (getCookieSecure()) {
parts.push('Secure');
}
res.append('Set-Cookie', parts.join('; '));
export function issueAdminSessionCookie(
c: Context,
sessionToken: string,
maxAgeMs: number,
): void {
setCookie(c, SESSION_COOKIE_NAME, sessionToken, {
path: '/',
httpOnly: true,
sameSite: 'Lax',
secure: getCookieSecure(),
maxAge: Math.max(1, Math.floor(maxAgeMs / 1000)),
});
}
export function clearAdminSessionCookie(res: Response): void {
const parts = [
`${SESSION_COOKIE_NAME}=`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=0',
];
if (getCookieSecure()) {
parts.push('Secure');
}
res.append('Set-Cookie', parts.join('; '));
export function clearAdminSessionCookie(c: Context): void {
deleteCookie(c, SESSION_COOKIE_NAME, {
path: '/',
secure: getCookieSecure(),
});
}
export function parseCookies(cookieHeader?: string): Record<string, string> {
export function parseCookies(
cookieHeader?: string | null,
): Record<string, string> {
if (!cookieHeader) {
return {};
}
@ -73,7 +78,9 @@ export function parseCookies(cookieHeader?: string): Record<string, string> {
}, {});
}
export function getSessionTokenFromCookies(cookieHeader?: string): string | null {
export function getSessionTokenFromCookies(
cookieHeader?: string | null,
): string | null {
const cookies = parseCookies(cookieHeader);
return cookies[SESSION_COOKIE_NAME] || null;
}
@ -95,14 +102,20 @@ export function verifyAdminPassword(password: string): boolean {
if (!saltHex || !expectedHex) {
return false;
}
const derived = scryptSync(password, Buffer.from(saltHex, 'hex'), expectedHex.length / 2);
const derived = scryptSync(
password,
Buffer.from(saltHex, 'hex'),
expectedHex.length / 2,
);
return timingSafeEqual(derived, Buffer.from(expectedHex, 'hex'));
}
return false;
}
export function computeExpiry(hours: number = getAdminSessionTtlHours()): string {
export function computeExpiry(
hours: number = getAdminSessionTtlHours(),
): string {
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}

View file

@ -1,4 +1,4 @@
import { randomBytes } from 'crypto';
import { randomBytes } from 'node:crypto';
export function generateApiKey(): string {
const timestamp = Date.now().toString(36);

View file

@ -1,4 +1,4 @@
import { getUtcTimestamp } from './time';
import { getUtcTimestamp } from './time.js';
const colors = {
reset: '\x1b[0m',
@ -10,7 +10,11 @@ const colors = {
gray: '\x1b[90m',
};
export function log(level: 'log' | 'debug' | 'info' | 'warn' | 'error', message: string, meta?: unknown): void {
export function log(
level: 'log' | 'debug' | 'info' | 'warn' | 'error',
message: string,
meta?: unknown,
): void {
const timestamp = getUtcTimestamp();
const levelColor = {
log: colors.blue,

View file

@ -2,7 +2,7 @@ const DEFAULT_TIME_ZONE = 'UTC';
function getFormatter(
timeZone: string,
options: Intl.DateTimeFormatOptions
options: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
return new Intl.DateTimeFormat('en-CA', {
timeZone,
@ -21,12 +21,14 @@ function getParts(date: Date, timeZone: string): Record<string, string> {
hourCycle: 'h23',
});
return formatter.formatToParts(date).reduce<Record<string, string>>((acc, part) => {
if (part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, {});
return formatter
.formatToParts(date)
.reduce<Record<string, string>>((acc, part) => {
if (part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, {});
}
export function getConfiguredTimeZone(): string {
@ -37,12 +39,18 @@ export function getUtcTimestamp(date: Date = new Date()): string {
return date.toISOString();
}
export function getLocalDateKey(date: Date = new Date(), timeZone: string = getConfiguredTimeZone()): string {
export function getLocalDateKey(
date: Date = new Date(),
timeZone: string = getConfiguredTimeZone(),
): string {
const parts = getParts(date, timeZone);
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function getLocalMonthKey(date: Date = new Date(), timeZone: string = getConfiguredTimeZone()): string {
export function getLocalMonthKey(
date: Date = new Date(),
timeZone: string = getConfiguredTimeZone(),
): string {
const parts = getParts(date, timeZone);
return `${parts.year}-${parts.month}`;
}
@ -50,4 +58,3 @@ export function getLocalMonthKey(date: Date = new Date(), timeZone: string = get
export function getMonthKeyFromDateString(dateString: string): string {
return dateString.slice(0, 7);
}

View file

@ -1,5 +1,6 @@
import { beforeAll, describe, expect, it } from 'vitest';
import request from 'supertest';
import { request } from '../utils/httpClient';
import { createTestApp } from '../utils/testApp';
import { createAdminClient } from '../utils/adminClient';
@ -27,9 +28,14 @@ describe('Admin Authentication', () => {
it('should require CSRF for session-based writes', async () => {
const agent = request.agent(app);
await agent.post('/admin/auth/login').send({ username: 'admin', password: 'password' }).expect(200);
await agent
.post('/admin/auth/login')
.send({ username: 'admin', password: 'password' })
.expect(200);
const response = await agent.post('/admin/users').send({ name: 'Blocked By Csrf' });
const response = await agent
.post('/admin/users')
.send({ name: 'Blocked By Csrf' });
expect(response.status).toBe(403);
expect(response.body.error).toBe('Invalid CSRF token');
});

View file

@ -1,4 +1,5 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { createTestApp } from '../utils/testApp';
import { createAdminClient } from '../utils/adminClient';
import { createMockBackend } from '../utils/mockBackend';
@ -27,7 +28,7 @@ describe('Admin API - User Management', () => {
it('should create a new user', async () => {
const userData = { name: 'Test User', email: 'test@example.com' };
const response = await admin.post('/admin/users').send(userData);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(userData.name);
@ -37,7 +38,10 @@ describe('Admin API - User Management', () => {
});
it('should create a user with a manually supplied api key', async () => {
const userData = { name: 'Migrated User', api_key: 'legacy-user-key-001' };
const userData = {
name: 'Migrated User',
api_key: 'legacy-user-key-001',
};
const response = await admin.post('/admin/users').send(userData);
expect(response.status).toBe(201);
@ -45,16 +49,24 @@ describe('Admin API - User Management', () => {
});
it('should return 409 if a manually supplied api key already exists', async () => {
await admin.post('/admin/users').send({ name: 'Duplicate Key Source', api_key: 'legacy-duplicate-key' });
const response = await admin.post('/admin/users').send({ name: 'Duplicate Key Target', api_key: 'legacy-duplicate-key' });
await admin.post('/admin/users').send({
name: 'Duplicate Key Source',
api_key: 'legacy-duplicate-key',
});
const response = await admin.post('/admin/users').send({
name: 'Duplicate Key Target',
api_key: 'legacy-duplicate-key',
});
expect(response.status).toBe(409);
expect(response.body.error).toBe('API key already exists');
});
it('should return 400 if name is missing', async () => {
const response = await admin.post('/admin/users').send({ email: 'test@example.com' });
const response = await admin
.post('/admin/users')
.send({ email: 'test@example.com' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
@ -62,15 +74,17 @@ describe('Admin API - User Management', () => {
describe('GET /admin/users/:id', () => {
let userId: number;
beforeAll(async () => {
const response = await admin.post('/admin/users').send({ name: 'User for Get' });
const response = await admin
.post('/admin/users')
.send({ name: 'User for Get' });
userId = response.body.id;
});
it('should return a user by id', async () => {
const response = await admin.get(`/admin/users/${userId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(userId);
expect(response.body).toHaveProperty('api_key');
@ -78,7 +92,7 @@ describe('Admin API - User Management', () => {
it('should return 404 for non-existent user', async () => {
const response = await admin.get('/admin/users/99999');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error');
});
@ -86,9 +100,11 @@ describe('Admin API - User Management', () => {
describe('PUT /admin/users/:id', () => {
let userId: number;
beforeAll(async () => {
const response = await admin.post('/admin/users').send({ name: 'User for Update' });
const response = await admin
.post('/admin/users')
.send({ name: 'User for Update' });
userId = response.body.id;
});
@ -96,7 +112,7 @@ describe('Admin API - User Management', () => {
const response = await admin
.put(`/admin/users/${userId}`)
.send({ name: 'Updated Name', email: 'updated@example.com' });
expect(response.status).toBe(200);
expect(response.body.name).toBe('Updated Name');
expect(response.body.email).toBe('updated@example.com');
@ -112,8 +128,10 @@ describe('Admin API - User Management', () => {
});
it('should return 404 for non-existent user', async () => {
const response = await admin.put('/admin/users/99999').send({ name: 'Test' });
const response = await admin
.put('/admin/users/99999')
.send({ name: 'Test' });
expect(response.status).toBe(404);
});
});
@ -121,16 +139,20 @@ describe('Admin API - User Management', () => {
describe('POST /admin/users/:id/regenerate-api-key', () => {
let userId: number;
let oldApiKey: string;
beforeAll(async () => {
const response = await admin.post('/admin/users').send({ name: 'User for Key Regen' });
const response = await admin
.post('/admin/users')
.send({ name: 'User for Key Regen' });
userId = response.body.id;
oldApiKey = response.body.api_key;
});
it('should regenerate API key', async () => {
const response = await admin.post(`/admin/users/${userId}/regenerate-api-key`);
const response = await admin.post(
`/admin/users/${userId}/regenerate-api-key`,
);
expect(response.status).toBe(200);
expect(response.body.api_key).toMatch(/^sk-/);
expect(response.body.api_key).not.toBe(oldApiKey);
@ -139,21 +161,23 @@ describe('Admin API - User Management', () => {
describe('DELETE /admin/users/:id', () => {
let userId: number;
beforeAll(async () => {
const response = await admin.post('/admin/users').send({ name: 'User for Delete' });
const response = await admin
.post('/admin/users')
.send({ name: 'User for Delete' });
userId = response.body.id;
});
it('should delete a user', async () => {
const response = await admin.delete(`/admin/users/${userId}`);
expect(response.status).toBe(204);
});
it('should return 404 for already deleted user', async () => {
const response = await admin.delete(`/admin/users/${userId}`);
expect(response.status).toBe(404);
});
});
@ -170,13 +194,13 @@ describe('Admin API - Backend Management', () => {
describe('POST /admin/backends', () => {
it('should create a new backend', async () => {
const backendData = {
name: 'Test Backend',
const backendData = {
name: 'Test Backend',
base_url: 'http://localhost:8000/v1',
api_key: 'backend-key-123'
api_key: 'backend-key-123',
};
const response = await admin.post('/admin/backends').send(backendData);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(backendData.name);
@ -185,8 +209,10 @@ describe('Admin API - Backend Management', () => {
});
it('should return 400 if name or base_url is missing', async () => {
const response = await admin.post('/admin/backends').send({ name: 'Test' });
const response = await admin
.post('/admin/backends')
.send({ name: 'Test' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
@ -194,36 +220,36 @@ describe('Admin API - Backend Management', () => {
describe('GET /admin/backends/:id', () => {
let backendId: number;
beforeAll(async () => {
const response = await admin.post('/admin/backends').send({
name: 'Backend for Get',
base_url: 'http://localhost:8001/v1'
const response = await admin.post('/admin/backends').send({
name: 'Backend for Get',
base_url: 'http://localhost:8001/v1',
});
backendId = response.body.id;
});
it('should return a backend by id', async () => {
const response = await admin.get(`/admin/backends/${backendId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(backendId);
});
it('should return 404 for non-existent backend', async () => {
const response = await admin.get('/admin/backends/99999');
expect(response.status).toBe(404);
});
});
describe('PUT /admin/backends/:id', () => {
let backendId: number;
beforeAll(async () => {
const response = await admin.post('/admin/backends').send({
name: 'Backend for Update',
base_url: 'http://localhost:8002/v1'
const response = await admin.post('/admin/backends').send({
name: 'Backend for Update',
base_url: 'http://localhost:8002/v1',
});
backendId = response.body.id;
});
@ -232,7 +258,7 @@ describe('Admin API - Backend Management', () => {
const response = await admin
.put(`/admin/backends/${backendId}`)
.send({ name: 'Updated Backend', is_active: false });
expect(response.status).toBe(200);
expect(response.body.name).toBe('Updated Backend');
expect(response.body.is_active).toBe(false);
@ -240,32 +266,34 @@ describe('Admin API - Backend Management', () => {
});
it('should return 404 for non-existent backend', async () => {
const response = await admin.put('/admin/backends/99999').send({ name: 'Test' });
const response = await admin
.put('/admin/backends/99999')
.send({ name: 'Test' });
expect(response.status).toBe(404);
});
});
describe('DELETE /admin/backends/:id', () => {
let backendId: number;
beforeAll(async () => {
const response = await admin.post('/admin/backends').send({
name: 'Backend for Delete',
base_url: 'http://localhost:8003/v1'
const response = await admin.post('/admin/backends').send({
name: 'Backend for Delete',
base_url: 'http://localhost:8003/v1',
});
backendId = response.body.id;
});
it('should delete a backend', async () => {
const response = await admin.delete(`/admin/backends/${backendId}`);
expect(response.status).toBe(204);
});
it('should return 404 for already deleted backend', async () => {
const response = await admin.delete(`/admin/backends/${backendId}`);
expect(response.status).toBe(404);
});
});
@ -282,11 +310,15 @@ describe('Admin API - Backend Management', () => {
});
const backendId = backendResponse.body.id;
const beforeRefresh = await admin.get(`/admin/backends/${backendId}/models`);
const beforeRefresh = await admin.get(
`/admin/backends/${backendId}/models`,
);
expect(beforeRefresh.status).toBe(200);
expect(beforeRefresh.body.cache.state).toBe('uninitialized');
const refreshResponse = await admin.post(`/admin/backends/${backendId}/models/refresh`);
const refreshResponse = await admin.post(
`/admin/backends/${backendId}/models/refresh`,
);
expect(refreshResponse.status).toBe(200);
expect(refreshResponse.body.models).toContain('admin-refresh-model');
@ -303,8 +335,12 @@ describe('Admin API - Backend Management', () => {
base_url: 'http://localhost:8041',
});
await admin.put(`/admin/backends/${backendResponse.body.id}`).send({ is_active: false });
const refreshResponse = await admin.post(`/admin/backends/${backendResponse.body.id}/models/refresh`);
await admin
.put(`/admin/backends/${backendResponse.body.id}`)
.send({ is_active: false });
const refreshResponse = await admin.post(
`/admin/backends/${backendResponse.body.id}/models/refresh`,
);
expect(refreshResponse.status).toBe(409);
});
});
@ -325,19 +361,27 @@ describe('Admin API - Model Rewrite Management', () => {
const listResponse = await admin.get('/admin/model-rewrites');
expect(listResponse.status).toBe(200);
expect(listResponse.body.some((rule: any) => rule.source_model === 'gpt-3.5-turbo-admin-test')).toBe(true);
expect(
listResponse.body.some(
(rule: any) => rule.source_model === 'gpt-3.5-turbo-admin-test',
),
).toBe(true);
const updateResponse = await admin.put(`/admin/model-rewrites/${createResponse.body.id}`).send({
target_model: 'gpt-3.5-mini-admin-test',
is_active: false,
force: false,
});
const updateResponse = await admin
.put(`/admin/model-rewrites/${createResponse.body.id}`)
.send({
target_model: 'gpt-3.5-mini-admin-test',
is_active: false,
force: false,
});
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.target_model).toBe('gpt-3.5-mini-admin-test');
expect(updateResponse.body.is_active).toBe(false);
expect(updateResponse.body.force).toBe(false);
const deleteResponse = await admin.delete(`/admin/model-rewrites/${createResponse.body.id}`);
const deleteResponse = await admin.delete(
`/admin/model-rewrites/${createResponse.body.id}`,
);
expect(deleteResponse.status).toBe(204);
});
});
@ -347,12 +391,14 @@ describe('Admin API - Permission Management', () => {
let backendId: number;
beforeAll(async () => {
const userResponse = await admin.post('/admin/users').send({ name: 'User for Permission' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'User for Permission' });
userId = userResponse.body.id;
const backendResponse = await admin.post('/admin/backends').send({
name: 'Backend for Permission',
base_url: 'http://localhost:8004/v1'
const backendResponse = await admin.post('/admin/backends').send({
name: 'Backend for Permission',
base_url: 'http://localhost:8004/v1',
});
backendId = backendResponse.body.id;
});
@ -370,7 +416,7 @@ describe('Admin API - Permission Management', () => {
const response = await admin
.post('/admin/permissions')
.send({ user_id: userId, backend_id: backendId });
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.user_id).toBe(userId);
@ -381,14 +427,16 @@ describe('Admin API - Permission Management', () => {
const response = await admin
.post('/admin/permissions')
.send({ user_id: userId, backend_id: backendId });
expect(response.status).toBe(409);
expect(response.body).toHaveProperty('error');
});
it('should return 400 if user_id or backend_id is missing', async () => {
const response = await admin.post('/admin/permissions').send({ user_id: userId });
const response = await admin
.post('/admin/permissions')
.send({ user_id: userId });
expect(response.status).toBe(400);
});
});
@ -396,7 +444,7 @@ describe('Admin API - Permission Management', () => {
describe('GET /admin/permissions/user/:userId', () => {
it('should return permissions for user', async () => {
const response = await admin.get(`/admin/permissions/user/${userId}`);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
@ -405,8 +453,10 @@ describe('Admin API - Permission Management', () => {
describe('GET /admin/permissions/backend/:backendId', () => {
it('should return permissions for backend', async () => {
const response = await admin.get(`/admin/permissions/backend/${backendId}`);
const response = await admin.get(
`/admin/permissions/backend/${backendId}`,
);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
@ -415,16 +465,18 @@ describe('Admin API - Permission Management', () => {
describe('DELETE /admin/permissions', () => {
it('should delete a permission', async () => {
const response = await admin
.delete(`/admin/permissions?user_id=${userId}&backend_id=${backendId}`);
const response = await admin.delete(
`/admin/permissions?user_id=${userId}&backend_id=${backendId}`,
);
expect(response.status).toBe(204);
});
it('should return 404 for already deleted permission', async () => {
const response = await admin
.delete(`/admin/permissions?user_id=${userId}&backend_id=${backendId}`);
const response = await admin.delete(
`/admin/permissions?user_id=${userId}&backend_id=${backendId}`,
);
expect(response.status).toBe(404);
});
});

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { request } from '../utils/httpClient';
import { createTestApp } from '../utils/testApp';
import { initDb } from '../../src/config/database';
import { RequestLogService } from '../../src/services/RequestLogService';
@ -24,16 +25,18 @@ describe('Auth & Proxy API', () => {
beforeAll(async () => {
// Create a user
const userResponse = await admin.post('/admin/users').send({ name: 'Test User for API' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Test User for API' });
userApiKey = userResponse.body.api_key;
// Create a backend
const backendResponse = await admin.post('/admin/backends').send({
name: 'Backend for API Test',
base_url: 'http://localhost:8005/v1'
const backendResponse = await admin.post('/admin/backends').send({
name: 'Backend for API Test',
base_url: 'http://localhost:8005/v1',
});
backendId = backendResponse.body.id;
// Grant permission
await admin
.post('/admin/permissions')
@ -43,7 +46,7 @@ describe('Auth & Proxy API', () => {
describe('GET /health', () => {
it('should return health status', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('status', 'ok');
expect(response.body).toHaveProperty('timestamp');
@ -55,7 +58,7 @@ describe('Auth & Proxy API', () => {
const response = await request(app)
.post('/v1/chat/completions')
.send({ model: 'test', messages: [] });
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('error');
});
@ -65,7 +68,7 @@ describe('Auth & Proxy API', () => {
.post('/v1/chat/completions')
.set('Authorization', 'Bearer invalid-key')
.send({ model: 'test', messages: [] });
expect(response.status).toBe(401);
});
});
@ -75,11 +78,11 @@ describe('Auth & Proxy API', () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({
model: 'test-model',
messages: [{ role: 'user', content: 'Hello' }]
.send({
model: 'test-model',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('request_model', 'test-model');
@ -89,13 +92,15 @@ describe('Auth & Proxy API', () => {
describe('GET /v1/models without permission', () => {
it('should return 403 for user without backend permission', async () => {
// Create a user without permissions
const userResponse = await admin.post('/admin/users').send({ name: 'User Without Permission' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'User Without Permission' });
const invalidApiKey = userResponse.body.api_key;
const response = await request(app)
.get('/v1/models')
.set('Authorization', `Bearer ${invalidApiKey}`);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('error');
});
@ -107,23 +112,26 @@ describe('Auth & Proxy API', () => {
await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({
model: 'test-model',
messages: [{ role: 'user', content: 'Test message' }]
.send({
model: 'test-model',
messages: [{ role: 'user', content: 'Test message' }],
});
// Check analytics
const analyticsResponse = await admin.get('/admin/analytics/requests?limit=10');
const analyticsResponse = await admin.get(
'/admin/analytics/requests?limit=10',
);
expect(analyticsResponse.status).toBe(200);
expect(Array.isArray(analyticsResponse.body.rows)).toBe(true);
expect(typeof analyticsResponse.body.total).toBe('number');
// Find our logged request
const loggedRequest = analyticsResponse.body.rows.find((r: any) =>
r.status_code === 404 && r.endpoint === '/v1/chat/completions'
const loggedRequest = analyticsResponse.body.rows.find(
(r: any) =>
r.status_code === 404 && r.endpoint === '/v1/chat/completions',
);
expect(loggedRequest).toBeDefined();
});
@ -152,8 +160,12 @@ describe('Auth & Proxy API', () => {
created_at: '2026-03-20T10:00:00.000Z',
});
const firstPage = await admin.get('/admin/analytics/requests?limit=1&offset=0&q=cross-month-test-marker');
const secondPage = await admin.get('/admin/analytics/requests?limit=1&offset=1&q=cross-month-test-marker');
const firstPage = await admin.get(
'/admin/analytics/requests?limit=1&offset=0&q=cross-month-test-marker',
);
const secondPage = await admin.get(
'/admin/analytics/requests?limit=1&offset=1&q=cross-month-test-marker',
);
expect(firstPage.status).toBe(200);
expect(secondPage.status).toBe(200);
@ -195,33 +207,62 @@ describe('Auth & Proxy API', () => {
created_at: '2026-03-11T03:00:00.000Z',
});
const [dailyTotals, backendQuality, modelTrends, histogram, boxPlot] = await Promise.all([
admin.get(`/admin/analytics/daily-totals?backendId=${backendId}&days=30`),
admin.get(`/admin/analytics/backend-quality?backendId=${backendId}&days=30`),
admin.get(`/admin/analytics/model-trends?backendId=${backendId}&days=30&limit=8`),
admin.get(`/admin/analytics/response-length-histogram?backendId=${backendId}&days=30&bins=6`),
admin.get(`/admin/analytics/response-length-box-plot?backendId=${backendId}&days=30`),
]);
const [dailyTotals, backendQuality, modelTrends, histogram, boxPlot] =
await Promise.all([
admin.get(
`/admin/analytics/daily-totals?backendId=${backendId}&days=30`,
),
admin.get(
`/admin/analytics/backend-quality?backendId=${backendId}&days=30`,
),
admin.get(
`/admin/analytics/model-trends?backendId=${backendId}&days=30&limit=8`,
),
admin.get(
`/admin/analytics/response-length-histogram?backendId=${backendId}&days=30&bins=6`,
),
admin.get(
`/admin/analytics/response-length-box-plot?backendId=${backendId}&days=30`,
),
]);
expect(dailyTotals.status).toBe(200);
expect(Array.isArray(dailyTotals.body)).toBe(true);
expect(dailyTotals.body.some((row: any) => row.total_requests >= 1 && typeof row.total_tokens === 'number')).toBe(true);
expect(
dailyTotals.body.some(
(row: any) =>
row.total_requests >= 1 && typeof row.total_tokens === 'number',
),
).toBe(true);
expect(backendQuality.status).toBe(200);
expect(Array.isArray(backendQuality.body)).toBe(true);
expect(backendQuality.body.some((row: any) => row.backend_id === backendId && typeof row.error_count === 'number')).toBe(true);
expect(
backendQuality.body.some(
(row: any) =>
row.backend_id === backendId && typeof row.error_count === 'number',
),
).toBe(true);
expect(modelTrends.status).toBe(200);
expect(Array.isArray(modelTrends.body)).toBe(true);
expect(modelTrends.body.some((row: any) => row.model === 'gpt-4o-mini')).toBe(true);
expect(
modelTrends.body.some((row: any) => row.model === 'gpt-4o-mini'),
).toBe(true);
expect(histogram.status).toBe(200);
expect(Array.isArray(histogram.body)).toBe(true);
expect(histogram.body.every((row: any) => typeof row.count === 'number')).toBe(true);
expect(
histogram.body.every((row: any) => typeof row.count === 'number'),
).toBe(true);
expect(boxPlot.status).toBe(200);
expect(Array.isArray(boxPlot.body)).toBe(true);
expect(boxPlot.body.some((row: any) => row.date === '2026-03-10' && row.median === 120)).toBe(true);
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 () => {
@ -231,19 +272,27 @@ describe('Auth & Proxy API', () => {
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_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');
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 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',
@ -256,8 +305,12 @@ describe('Auth & Proxy API', () => {
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);
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

@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import { request } from '../utils/httpClient';
import { createTestApp } from '../utils/testApp';
import { createMockBackend } from '../utils/mockBackend';
import { initDb } from '../../src/config/database';
@ -20,13 +21,15 @@ describe('Permission-based Routing', () => {
describe('Scenario 1: Authorized backend routing', () => {
it('should return model-not-available when catalog refresh fails for an authorized backend', async () => {
const userResponse = await admin.post('/admin/users').send({ name: 'Auth User 1-1' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Auth User 1-1' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
const backendResponse = await admin.post('/admin/backends').send({
name: 'Auth Backend 1-1',
base_url: 'http://localhost:8000/v1'
base_url: 'http://localhost:8000/v1',
});
const backendId = backendResponse.body.id;
@ -37,7 +40,10 @@ describe('Permission-based Routing', () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({ model: 'test', messages: [{ role: 'user', content: 'Hello' }] });
.send({
model: 'test',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error');
@ -50,21 +56,25 @@ describe('Permission-based Routing', () => {
let backendBId: number;
beforeAll(async () => {
const userAResponse = await admin.post('/admin/users').send({ name: 'User A 2-2' });
const userAResponse = await admin
.post('/admin/users')
.send({ name: 'User A 2-2' });
userAApiKey = userAResponse.body.api_key;
const userBResponse = await admin.post('/admin/users').send({ name: 'User B 2-2' });
const userBResponse = await admin
.post('/admin/users')
.send({ name: 'User B 2-2' });
userBApiKey = userBResponse.body.api_key;
const userBId = userBResponse.body.id;
await admin.post('/admin/backends').send({
name: 'Backend A 2-2',
base_url: 'http://localhost:8001/v1'
base_url: 'http://localhost:8001/v1',
});
const backendBResponse = await admin.post('/admin/backends').send({
name: 'Backend B 2-2',
base_url: 'http://localhost:8002/v1'
base_url: 'http://localhost:8002/v1',
});
backendBId = backendBResponse.body.id;
@ -77,17 +87,25 @@ describe('Permission-based Routing', () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userAApiKey}`)
.send({ model: 'test', messages: [{ role: 'user', content: 'Hello' }] });
.send({
model: 'test',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('No backends available for your account');
expect(response.body.error).toBe(
'No backends available for your account',
);
});
it('should return model-not-available when the permitted backend has no cached model match', async () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userBApiKey}`)
.send({ model: 'test', messages: [{ role: 'user', content: 'Hello' }] });
.send({
model: 'test',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(404);
});
@ -95,16 +113,23 @@ describe('Permission-based Routing', () => {
describe('Scenario 3: User without any permissions', () => {
it('should return 403 when user has no permissions', async () => {
const userResponse = await admin.post('/admin/users').send({ name: 'No Permission User 3-3' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'No Permission User 3-3' });
const apiKey = userResponse.body.api_key;
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${apiKey}`)
.send({ model: 'test', messages: [{ role: 'user', content: 'Hello' }] });
.send({
model: 'test',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('No backends available for your account');
expect(response.body.error).toBe(
'No backends available for your account',
);
});
});
});
@ -124,7 +149,9 @@ describe('Multi-backend Routing', () => {
describe('Scenario 4: Model-aware candidate selection', () => {
it('should use only backends that serve the requested model', async () => {
const userResponse = await admin.post('/admin/users').send({ name: 'Multi Backend User 4-4' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Multi Backend User 4-4' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
@ -132,7 +159,13 @@ describe('Multi-backend Routing', () => {
chatResponse: {
id: 'candidate-a',
model: 'model-a',
choices: [{ index: 0, message: { role: 'assistant', content: 'A' }, finish_reason: 'stop' }],
choices: [
{
index: 0,
message: { role: 'assistant', content: 'A' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
modelsResponse: [{ id: 'model-a', object: 'model' }],
@ -141,7 +174,13 @@ describe('Multi-backend Routing', () => {
chatResponse: {
id: 'candidate-b',
model: 'model-b',
choices: [{ index: 0, message: { role: 'assistant', content: 'B' }, finish_reason: 'stop' }],
choices: [
{
index: 0,
message: { role: 'assistant', content: 'B' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
modelsResponse: [{ id: 'model-b', object: 'model' }],
@ -149,13 +188,13 @@ describe('Multi-backend Routing', () => {
const backend1Response = await admin.post('/admin/backends').send({
name: 'Multi Backend 4-4-1',
base_url: `http://localhost:${backendServerA.port}`
base_url: `http://localhost:${backendServerA.port}`,
});
const backend1Id = backend1Response.body.id;
const backend2Response = await admin.post('/admin/backends').send({
name: 'Multi Backend 4-4-2',
base_url: `http://localhost:${backendServerB.port}`
base_url: `http://localhost:${backendServerB.port}`,
});
const backend2Id = backend2Response.body.id;
@ -181,8 +220,12 @@ describe('Multi-backend Routing', () => {
expect(responseB.status).toBe(200);
expect(responseB.body.id).toBe('candidate-b');
await new Promise<void>((resolve) => backendServerA.server.close(() => resolve()));
await new Promise<void>((resolve) => backendServerB.server.close(() => resolve()));
await new Promise<void>((resolve) =>
backendServerA.server.close(() => resolve()),
);
await new Promise<void>((resolve) =>
backendServerB.server.close(() => resolve()),
);
});
});
});
@ -207,7 +250,9 @@ describe('Inactive Backend Routing', () => {
const allBackends = allBackendsResponse.body;
for (const backend of allBackends) {
if (backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: false });
}
}
});
@ -218,25 +263,31 @@ describe('Inactive Backend Routing', () => {
const allBackends = allBackendsResponse.body;
for (const backend of allBackends) {
if (!backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: true });
}
}
});
it('should return 403 when only inactive backends are available', async () => {
const userResponse = await admin.post('/admin/users').send({ name: 'Inactive Test User 5-5-5' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Inactive Test User 5-5-5' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
// Create backend first (default is_active=true)
const backendResponse = await admin.post('/admin/backends').send({
name: 'Inactive Backend 5-5-5',
base_url: 'http://localhost:8020/v1'
base_url: 'http://localhost:8020/v1',
});
const inactiveBackendId = backendResponse.body.id;
// Then deactivate it
await admin.put(`/admin/backends/${inactiveBackendId}`).send({ is_active: false });
await admin
.put(`/admin/backends/${inactiveBackendId}`)
.send({ is_active: false });
await admin
.post('/admin/permissions')
@ -245,7 +296,10 @@ describe('Inactive Backend Routing', () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({ model: 'test', messages: [{ role: 'user', content: 'Hello' }] });
.send({
model: 'test',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('No active backends available');
@ -274,14 +328,16 @@ describe('OpenAI Compatible Backend Integration', () => {
const allBackends = allBackendsResponse.body;
for (const backend of allBackends) {
if (!backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: true });
}
}
});
afterEach(async () => {
if (mockServer) {
await new Promise<void>(resolve => mockServer.close(resolve));
await new Promise<void>((resolve) => mockServer.close(resolve));
mockServer = undefined;
}
});
@ -293,7 +349,9 @@ describe('OpenAI Compatible Backend Integration', () => {
const allBackends = allBackendsResponse.body;
for (const backend of allBackends) {
if (backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: false });
}
}
@ -301,13 +359,15 @@ describe('OpenAI Compatible Backend Integration', () => {
mockServer = server;
mockPort = port;
const userResponse = await admin.post('/admin/users').send({ name: 'Mock Integration User 6-6' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Mock Integration User 6-6' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
const backendResponse = await admin.post('/admin/backends').send({
name: 'Mock Backend 6-6',
base_url: `http://localhost:${mockPort}`
base_url: `http://localhost:${mockPort}`,
});
const backendId = backendResponse.body.id;
@ -318,15 +378,17 @@ describe('OpenAI Compatible Backend Integration', () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({
model: 'mock-model',
messages: [{ role: 'user', content: 'Hello' }]
.send({
model: 'mock-model',
messages: [{ role: 'user', content: 'Hello' }],
});
// Re-activate backends for other tests
for (const backend of allBackends) {
if (backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: true });
}
}
@ -347,7 +409,9 @@ describe('OpenAI Compatible Backend Integration', () => {
mockServer = server;
mockPort = port;
const userResponse = await admin.post('/admin/users').send({ name: 'Auth Rewrite User 6-6' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Auth Rewrite User 6-6' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
@ -383,23 +447,30 @@ describe('OpenAI Compatible Backend Integration', () => {
const allBackends = allBackendsResponse.body;
for (const backend of allBackends) {
if (backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: false });
}
}
const { server, port } = createMockBackend({
modelsResponse: [{ id: 'test-model-1', object: 'model' }, { id: 'test-model-2', object: 'model' }]
modelsResponse: [
{ id: 'test-model-1', object: 'model' },
{ id: 'test-model-2', object: 'model' },
],
});
mockServer = server;
mockPort = port;
const userResponse = await admin.post('/admin/users').send({ name: 'Models Test User 7-7' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Models Test User 7-7' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
const backendResponse = await admin.post('/admin/backends').send({
name: 'Models Backend 7-7',
base_url: `http://localhost:${port}`
base_url: `http://localhost:${port}`,
});
const backendId = backendResponse.body.id;
@ -414,7 +485,9 @@ describe('OpenAI Compatible Backend Integration', () => {
// Re-activate backends for other tests
for (const backend of allBackends) {
if (backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: true });
}
}
@ -422,11 +495,16 @@ describe('OpenAI Compatible Backend Integration', () => {
expect(response.body).toHaveProperty('data');
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(2);
expect(response.body.data.map((item: any) => item.id)).toEqual(['test-model-1', 'test-model-2']);
expect(response.body.data.map((item: any) => item.id)).toEqual([
'test-model-1',
'test-model-2',
]);
});
it('should return 403 for models when user has no permissions', async () => {
const userResponse = await admin.post('/admin/users').send({ name: 'No Permission Models User 7-7' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'No Permission Models User 7-7' });
const userApiKey = userResponse.body.api_key;
const response = await request(app)
@ -434,7 +512,9 @@ describe('OpenAI Compatible Backend Integration', () => {
.set('Authorization', `Bearer ${userApiKey}`);
expect(response.status).toBe(403);
expect(response.body.error).toBe('No backends available for your account');
expect(response.body.error).toBe(
'No backends available for your account',
);
});
it('should not forward router Authorization when backend API key is absent', async () => {
@ -447,7 +527,9 @@ describe('OpenAI Compatible Backend Integration', () => {
mockServer = server;
mockPort = port;
const userResponse = await admin.post('/admin/users').send({ name: 'No Upstream Auth User 7-7' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'No Upstream Auth User 7-7' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
@ -486,7 +568,13 @@ describe('OpenAI Compatible Backend Integration', () => {
chatResponse: {
id: 'rewrite-success',
model: 'gpt-3.5',
choices: [{ index: 0, message: { role: 'assistant', content: 'rewritten' }, finish_reason: 'stop' }],
choices: [
{
index: 0,
message: { role: 'assistant', content: 'rewritten' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
modelsResponse: [{ id: 'gpt-3.5', object: 'model' }],
@ -494,7 +582,9 @@ describe('OpenAI Compatible Backend Integration', () => {
mockServer = server;
mockPort = port;
const userResponse = await admin.post('/admin/users').send({ name: 'Rewrite Route User 8-8' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Rewrite Route User 8-8' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
@ -504,7 +594,9 @@ describe('OpenAI Compatible Backend Integration', () => {
});
const backendId = backendResponse.body.id;
await admin.post('/admin/permissions').send({ user_id: userId, backend_id: backendId });
await admin
.post('/admin/permissions')
.send({ user_id: userId, backend_id: backendId });
const rewriteResponse = await admin.post('/admin/model-rewrites').send({
source_model: 'gpt-3.5-turbo',
target_model: 'gpt-3.5',
@ -515,7 +607,10 @@ describe('OpenAI Compatible Backend Integration', () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({ model: 'gpt-3.5-turbo', messages: [{ role: 'user', content: 'Hello' }] });
.send({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(200);
expect(receivedModel).toBe('gpt-3.5');
@ -533,7 +628,13 @@ describe('OpenAI Compatible Backend Integration', () => {
chatResponse: {
id: 'fallback-success',
model: 'fallback-model',
choices: [{ index: 0, message: { role: 'assistant', content: 'fallback' }, finish_reason: 'stop' }],
choices: [
{
index: 0,
message: { role: 'assistant', content: 'fallback' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
modelsResponse: [{ id: 'fallback-model', object: 'model' }],
@ -541,7 +642,9 @@ describe('OpenAI Compatible Backend Integration', () => {
mockServer = server;
mockPort = port;
const userResponse = await admin.post('/admin/users').send({ name: 'Fallback Route User 8-9' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Fallback Route User 8-9' });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
@ -551,7 +654,9 @@ describe('OpenAI Compatible Backend Integration', () => {
});
const backendId = backendResponse.body.id;
await admin.post('/admin/permissions').send({ user_id: userId, backend_id: backendId });
await admin
.post('/admin/permissions')
.send({ user_id: userId, backend_id: backendId });
const rewriteResponse = await admin.post('/admin/model-rewrites').send({
source_model: 'missing-model',
target_model: 'fallback-model',
@ -562,7 +667,10 @@ describe('OpenAI Compatible Backend Integration', () => {
const response = await request(app)
.post('/v1/chat/completions')
.set('Authorization', `Bearer ${userApiKey}`)
.send({ model: 'missing-model', messages: [{ role: 'user', content: 'Hello' }] });
.send({
model: 'missing-model',
messages: [{ role: 'user', content: 'Hello' }],
});
expect(response.status).toBe(200);
expect(receivedModel).toBe('fallback-model');

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { request } from '../utils/httpClient';
import { createTestApp } from '../utils/testApp';
import { initDb } from '../../src/config/database';
import { createAdminClient } from '../utils/adminClient';
@ -22,12 +23,14 @@ describe('Script API Endpoints', () => {
// Setup: Create user and backend for testing
beforeAll(async () => {
const userResponse = await admin.post('/admin/users').send({ name: 'Script Test User' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Script Test User' });
userId = userResponse.body.id;
const backendResponse = await admin.post('/admin/backends').send({
name: 'Script Test Backend',
base_url: 'http://localhost:8006/v1'
base_url: 'http://localhost:8006/v1',
});
backendId = backendResponse.body.id;
});
@ -41,7 +44,7 @@ describe('Script API Endpoints', () => {
describe('GET /admin/scripts', () => {
it('should return empty array initially', async () => {
const response = await admin.get('/admin/scripts');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
});
@ -65,11 +68,11 @@ export const onResponse = (context) => {
return context;
};
`,
is_active: true
is_active: true,
};
const response = await admin.post('/admin/scripts').send(scriptData);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(scriptData.name);
@ -77,7 +80,7 @@ export const onResponse = (context) => {
expect(response.body.target_user_id).toBe(userId);
expect(response.body.target_backend_id).toBe(backendId);
expect(response.body.is_active).toBe(true);
scriptId = response.body.id;
});
@ -91,11 +94,11 @@ export const onRequest = (context) => {
return context;
};
`,
is_active: true
is_active: true,
};
const response = await admin.post('/admin/scripts').send(scriptData);
expect(response.status).toBe(201);
expect(response.body.script_type).toBe(scriptData.script_type);
expect(response.body.target_user_id).toBeNull();
@ -111,11 +114,11 @@ export const onResponse = (context) => {
return context;
};
`,
is_active: true
is_active: true,
};
const response = await admin.post('/admin/scripts').send(scriptData);
expect(response.status).toBe(201);
expect(response.body.script_type).toBe(scriptData.script_type);
expect(response.body.target_backend_id).toBeNull();
@ -125,9 +128,9 @@ export const onResponse = (context) => {
const response = await admin.post('/admin/scripts').send({
script_type: 'per-backend',
target_backend_id: backendId,
script_code: 'export const onRequest = (context) => context;'
script_code: 'export const onRequest = (context) => context;',
});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('error');
});
@ -136,18 +139,18 @@ export const onResponse = (context) => {
const response = await admin.post('/admin/scripts').send({
name: 'Missing Code',
script_type: 'per-backend',
target_backend_id: backendId
target_backend_id: backendId,
});
expect(response.status).toBe(400);
});
it('should return 400 if script_type is missing', async () => {
const response = await admin.post('/admin/scripts').send({
name: 'Missing Target Type',
script_code: 'export const onRequest = (context) => context;'
script_code: 'export const onRequest = (context) => context;',
});
expect(response.status).toBe(400);
});
@ -155,9 +158,9 @@ export const onResponse = (context) => {
const response = await admin.post('/admin/scripts').send({
name: 'Invalid Per-User-Backend',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-user-backend'
script_type: 'per-user-backend',
});
expect(response.status).toBe(400);
});
@ -166,11 +169,11 @@ export const onResponse = (context) => {
name: 'Invalid Code',
script_code: 'this is invalid javascript {{{',
script_type: 'per-backend',
target_backend_id: backendId
target_backend_id: backendId,
};
const response = await admin.post('/admin/scripts').send(scriptData);
// Code is saved, but will fail at execution time
expect(response.status).toBe(201);
});
@ -184,7 +187,7 @@ export const onResponse = (context) => {
name: 'Script for Get Test',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-backend',
target_backend_id: backendId
target_backend_id: backendId,
});
testScriptId = response.body.id;
});
@ -195,7 +198,7 @@ export const onResponse = (context) => {
it('should return a script by id', async () => {
const response = await admin.get(`/admin/scripts/${testScriptId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(testScriptId);
expect(response.body).toHaveProperty('name');
@ -204,7 +207,7 @@ export const onResponse = (context) => {
it('should return 404 for non-existent script', async () => {
const response = await admin.get('/admin/scripts/99999');
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('error');
});
@ -218,7 +221,7 @@ export const onResponse = (context) => {
name: 'Script for Update Test',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-backend',
target_backend_id: backendId
target_backend_id: backendId,
});
testScriptId = response.body.id;
});
@ -228,39 +231,39 @@ export const onResponse = (context) => {
});
it('should update script name', async () => {
const response = await admin
.put(`/admin/scripts/${testScriptId}`)
.send({
name: 'Updated Script Name'
});
const response = await admin.put(`/admin/scripts/${testScriptId}`).send({
name: 'Updated Script Name',
});
expect(response.status).toBe(200);
expect(response.body.name).toBe('Updated Script Name');
});
it('should update script code', async () => {
const response = await admin
.put(`/admin/scripts/${testScriptId}`)
.send({
script_code: 'export const onResponse = async (context) => context;'
});
const response = await admin.put(`/admin/scripts/${testScriptId}`).send({
script_code: 'export const onResponse = async (context) => context;',
});
expect(response.status).toBe(200);
expect(response.body.script_code).toBe('export const onResponse = async (context) => context;');
expect(response.body.script_code).toBe(
'export const onResponse = async (context) => context;',
);
});
it('should toggle is_active', async () => {
const response = await admin
.put(`/admin/scripts/${testScriptId}`)
.send({ is_active: false });
expect(response.status).toBe(200);
expect(response.body.is_active).toBe(false);
});
it('should return 404 for non-existent script', async () => {
const response = await admin.put('/admin/scripts/99999').send({ name: 'Test' });
const response = await admin
.put('/admin/scripts/99999')
.send({ name: 'Test' });
expect(response.status).toBe(404);
});
@ -268,7 +271,7 @@ export const onResponse = (context) => {
const response = await admin
.put(`/admin/scripts/${testScriptId}`)
.send({ script_code: 'invalid javascript {{{' });
// Code is saved, but will fail at execution time
expect(response.status).toBe(200);
});
@ -290,7 +293,7 @@ export const onResponse = (context) => {
};
`,
script_type: 'per-backend',
target_backend_id: backendId
target_backend_id: backendId,
});
testScriptId = response.body.id;
});
@ -302,17 +305,21 @@ export const onResponse = (context) => {
it('should load and validate script syntax', async () => {
const testPayload = {
user: { id: userId, name: 'Test User' },
backend: { id: backendId, name: 'Test Backend', base_url: 'http://localhost:8006/v1' },
backend: {
id: backendId,
name: 'Test Backend',
base_url: 'http://localhost:8006/v1',
},
request: {
method: 'POST',
path: '/v1/chat/completions',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'test-model',
messages: [{ role: 'user', content: 'Hello' }]
messages: [{ role: 'user', content: 'Hello' }],
}),
isStream: false
}
isStream: false,
},
};
const response = await admin
@ -328,7 +335,7 @@ export const onResponse = (context) => {
const response = await admin
.post('/admin/scripts/99999/test')
.send({ request: {} });
expect(response.status).toBe(404);
});
});
@ -341,20 +348,20 @@ export const onResponse = (context) => {
name: 'Script for Delete',
script_code: 'export const onRequest = (context) => context;',
script_type: 'per-backend',
target_backend_id: backendId
target_backend_id: backendId,
});
testScriptId = response.body.id;
});
it('should delete a script', async () => {
const response = await admin.delete(`/admin/scripts/${testScriptId}`);
expect(response.status).toBe(204);
});
it('should return 404 for already deleted script', async () => {
const response = await admin.delete(`/admin/scripts/${testScriptId}`);
expect(response.status).toBe(404);
});
});
@ -381,14 +388,16 @@ export const onResponse = (context) => {
`,
script_type: 'per-backend',
target_backend_id: backendId,
is_active: true
is_active: true,
});
testScriptId = scriptResponse.body.id;
// Create user with permission
const userResponse = await admin.post('/admin/users').send({ name: 'Integration User' });
const userResponse = await admin
.post('/admin/users')
.send({ name: 'Integration User' });
userApiKey = userResponse.body.api_key;
await admin
.post('/admin/permissions')
.send({ user_id: userResponse.body.id, backend_id: backendId });
@ -407,19 +416,23 @@ export const onResponse = (context) => {
.set('Authorization', `Bearer ${userApiKey}`)
.send({
model: 'test-model',
messages: [{ role: 'user', content: 'Hello' }]
messages: [{ role: 'user', content: 'Hello' }],
});
// Request will fail (502) because backend is not actually running,
// but we can verify the script was executed by checking logs
expect(response.status).toBe(502); // Backend unreachable
// Check that request was logged with script execution
const analyticsResponse = await admin.get('/admin/analytics/requests?limit=10');
const loggedRequest = analyticsResponse.body.rows.find((r: any) =>
r.user_id === parseInt(userApiKey.split('-')[1]) || r.endpoint === '/v1/chat/completions'
const analyticsResponse = await admin.get(
'/admin/analytics/requests?limit=10',
);
const loggedRequest = analyticsResponse.body.rows.find(
(r: any) =>
r.user_id === parseInt(userApiKey.split('-')[1]) ||
r.endpoint === '/v1/chat/completions',
);
expect(loggedRequest).toBeDefined();
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import http from 'http';
import { request } from '../utils/httpClient';
import { createTestApp } from '../utils/testApp';
import { createMockBackend } from '../utils/mockBackend';
import { initDb } from '../../src/config/database';
@ -23,13 +23,21 @@ describe('Streaming Response Proxying', () => {
id: 'chatcmpl-stream-1',
object: 'chat.completion.chunk',
model: 'mock-model',
choices: [{ index: 0, delta: { role: 'assistant', content: 'Hello' }, finish_reason: null }],
choices: [
{
index: 0,
delta: { role: 'assistant', content: 'Hello' },
finish_reason: null,
},
],
}),
JSON.stringify({
id: 'chatcmpl-stream-1',
object: 'chat.completion.chunk',
model: 'mock-model',
choices: [{ index: 0, delta: { content: ' world' }, finish_reason: null }],
choices: [
{ index: 0, delta: { content: ' world' }, finish_reason: null },
],
}),
JSON.stringify({
id: 'chatcmpl-stream-1',
@ -51,14 +59,16 @@ describe('Streaming Response Proxying', () => {
const allBackendsResponse = await admin.get('/admin/backends');
for (const backend of allBackendsResponse.body) {
if (backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: false });
}
}
});
afterEach(async () => {
if (mockServer) {
await new Promise<void>(resolve => mockServer.close(resolve));
await new Promise<void>((resolve) => mockServer.close(resolve));
mockServer = undefined;
}
});
@ -68,7 +78,9 @@ describe('Streaming Response Proxying', () => {
const allBackendsResponse = await admin.get('/admin/backends');
for (const backend of allBackendsResponse.body) {
if (!backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: true });
}
}
});
@ -78,11 +90,15 @@ describe('Streaming Response Proxying', () => {
const allBackendsResponse = await admin.get('/admin/backends');
for (const backend of allBackendsResponse.body) {
if (backend.is_active) {
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
await admin
.put(`/admin/backends/${backend.id}`)
.send({ is_active: false });
}
}
const userResponse = await admin.post('/admin/users').send({ name: `Stream Test User ${Date.now()}` });
const userResponse = await admin
.post('/admin/users')
.send({ name: `Stream Test User ${Date.now()}` });
const userApiKey = userResponse.body.api_key;
const userId = userResponse.body.id;
@ -92,7 +108,9 @@ describe('Streaming Response Proxying', () => {
});
const backendId = backendResponse.body.id;
await admin.post('/admin/permissions').send({ user_id: userId, backend_id: backendId });
await admin
.post('/admin/permissions')
.send({ user_id: userId, backend_id: backendId });
return { userApiKey, userId, backendId };
}
@ -140,7 +158,9 @@ describe('Streaming Response Proxying', () => {
const body = response.text;
// Should contain all three data chunks plus [DONE]
const dataLines = body.split('\n').filter((line: string) => line.startsWith('data: '));
const dataLines = body
.split('\n')
.filter((line: string) => line.startsWith('data: '));
expect(dataLines.length).toBe(4); // 3 chunks + [DONE]
// Verify first chunk
@ -187,7 +207,13 @@ describe('Streaming Response Proxying', () => {
chatResponse: {
id: 'non-stream-1',
model: 'mock-model',
choices: [{ index: 0, message: { role: 'assistant', content: 'Hello' }, finish_reason: 'stop' }],
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
},
modelsResponse: [{ id: 'mock-model', object: 'model' }],

View file

@ -1,15 +1,23 @@
import { beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const workerId = process.env.VITEST_POOL_ID || process.env.VITEST_WORKER_ID || String(process.pid);
import { beforeAll, afterAll } from 'vitest';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workerId =
process.env.VITEST_POOL_ID ||
process.env.VITEST_WORKER_ID ||
String(process.pid);
const TEST_DB_DIR = path.join(__dirname, '..', 'data', `test-db-${workerId}`);
process.env.DB_DIR = TEST_DB_DIR;
process.env.TZ = 'Asia/Seoul';
process.env.ADMIN_AUTH_MODE = 'env';
process.env.ADMIN_USERNAME = 'admin';
process.env.ADMIN_PASSWORD_HASH = 'sha256$5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8';
process.env.ADMIN_PASSWORD_HASH =
'sha256$5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8';
process.env.ADMIN_SESSION_SECRET = 'test-admin-session-secret';
process.env.ADMIN_SESSION_TTL_HOURS = '12';
@ -20,11 +28,12 @@ beforeAll(() => {
});
afterAll(async () => {
const [{ closeDb }, { closeAnalyticsDb }, { closeRequestLogsDbs }] = await Promise.all([
import('../src/config/database'),
import('../src/config/analytics-db'),
import('../src/config/request-logs-db'),
]);
const [{ closeDb }, { closeAnalyticsDb }, { closeRequestLogsDbs }] =
await Promise.all([
import('../src/config/database'),
import('../src/config/analytics-db'),
import('../src/config/request-logs-db'),
]);
closeDb();
closeAnalyticsDb();

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { resolveIsolatedVmModule } from '../../src/services/ScriptExecutor';
describe('resolveIsolatedVmModule', () => {

View file

@ -1,6 +1,17 @@
import request from 'supertest';
import { request, type TestAgent, type TestRequest } from './httpClient';
export async function createAdminClient(app: Parameters<typeof request.agent>[0]) {
import type { Hono } from 'hono';
export interface AdminTestClient {
agent: TestAgent;
csrfToken: string;
get: (url: string) => TestRequest;
post: (url: string) => TestRequest;
put: (url: string) => TestRequest;
delete: (url: string) => TestRequest;
}
export async function createAdminClient(app: Hono): Promise<AdminTestClient> {
const agent = request.agent(app);
await agent

View file

@ -0,0 +1,183 @@
import type { Hono } from 'hono';
export interface TestResponse {
status: number;
body: any;
text: string;
headers: Record<string, string>;
}
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface CookieJar {
get(): Map<string, string>;
update(setCookie: string[]): void;
}
function parseSetCookie(
setCookie: string[],
): { key: string; value: string; expired: boolean }[] {
return setCookie.map((sc) => {
const eq = sc.indexOf('=');
const semi = sc.indexOf(';');
const key = sc.slice(0, eq);
const value = sc.slice(eq + 1, semi === -1 ? undefined : semi);
const expired = /Max-Age=0\b/.test(sc);
return { key, value, expired };
});
}
function createCookieJar(): CookieJar {
const cookies = new Map<string, string>();
return {
get: () => cookies,
update: (setCookie: string[]) => {
for (const { key, value, expired } of parseSetCookie(setCookie)) {
if (expired || value === '') {
cookies.delete(key);
} else {
cookies.set(key, value);
}
}
},
};
}
export class TestRequest implements PromiseLike<TestResponse> {
private _headers: Record<string, string> = {};
private _body?: unknown;
private _expectedStatus?: number;
constructor(
private app: Hono,
private method: HttpMethod,
private path: string,
private cookieJar?: CookieJar,
) {}
set(key: string, value: string): this {
this._headers[key.toLowerCase()] = value;
return this;
}
send(body: unknown): this {
this._body = body;
return this;
}
expect(status: number): this {
this._expectedStatus = status;
return this;
}
private buildRequestInit(): RequestInit {
const headers: Record<string, string> = { ...this._headers };
if (this.cookieJar) {
const jar = this.cookieJar.get();
if (jar.size > 0) {
headers.cookie = Array.from(jar.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
}
const init: RequestInit = { method: this.method, headers };
if (this._body !== undefined && this._body !== null) {
headers['content-type'] = headers['content-type'] || 'application/json';
init.body =
typeof this._body === 'string'
? this._body
: JSON.stringify(this._body);
}
return init;
}
private async execute(): Promise<TestResponse> {
const init = this.buildRequestInit();
const url = `http://localhost${this.path.startsWith('/') ? this.path : `/${this.path}`}`;
const res = await this.app.request(url, init);
const text = await res.text();
let body: unknown;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = text;
}
const headers: Record<string, string> = {};
res.headers.forEach((v, k) => {
headers[k] = v;
});
if (this.cookieJar) {
const setCookie =
typeof res.headers.getSetCookie === 'function'
? res.headers.getSetCookie()
: res.headers.get('set-cookie')
? [res.headers.get('set-cookie')!]
: [];
if (setCookie.length > 0) {
this.cookieJar.update(setCookie);
}
}
if (
this._expectedStatus !== undefined &&
res.status !== this._expectedStatus
) {
throw new Error(
`Expected status ${this._expectedStatus} but got ${res.status}. Body: ${text.slice(0, 500)}`,
);
}
return { status: res.status, body, text, headers };
}
then<TResult1 = TestResponse, TResult2 = never>(
onFulfilled?:
| ((value: TestResponse) => TResult1 | PromiseLike<TResult1>)
| null,
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onFulfilled ?? null, onRejected ?? null);
}
}
export interface TestAgent {
get: (path: string) => TestRequest;
post: (path: string) => TestRequest;
put: (path: string) => TestRequest;
delete: (path: string) => TestRequest;
}
function createAgent(app: Hono): TestAgent {
const jar = createCookieJar();
return {
get: (path: string) => new TestRequest(app, 'GET', path, jar),
post: (path: string) => new TestRequest(app, 'POST', path, jar),
put: (path: string) => new TestRequest(app, 'PUT', path, jar),
delete: (path: string) => new TestRequest(app, 'DELETE', path, jar),
};
}
interface RequestFactory {
(app: Hono): {
get: (path: string) => TestRequest;
post: (path: string) => TestRequest;
put: (path: string) => TestRequest;
delete: (path: string) => TestRequest;
};
agent: (app: Hono) => TestAgent;
}
const request: RequestFactory = ((app: Hono) => ({
get: (path: string) => new TestRequest(app, 'GET', path),
post: (path: string) => new TestRequest(app, 'POST', path),
put: (path: string) => new TestRequest(app, 'PUT', path),
delete: (path: string) => new TestRequest(app, 'DELETE', path),
})) as RequestFactory;
request.agent = createAgent;
export default request;
export { request };

Some files were not shown because too many files have changed in this diff Show more