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:
parent
b06f4f0c62
commit
66261474d2
105 changed files with 9804 additions and 3340 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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] ?? '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render } from 'solid-js/web';
|
||||
|
||||
import App from './App';
|
||||
import './ui/styles.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
>
|
||||
<
|
||||
</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),
|
||||
)
|
||||
}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
204
eslint.config.mjs
Normal 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 },
|
||||
},
|
||||
},
|
||||
);
|
||||
19
package.json
19
package.json
|
|
@ -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
3134
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
20
server/src/main.ts
Normal 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`);
|
||||
},
|
||||
);
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
12
server/src/schemas/common.ts
Normal file
12
server/src/schemas/common.ts
Normal 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
68
server/src/schemas/v1.ts
Normal 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');
|
||||
|
|
@ -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'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
|
|
|
|||
|
|
@ -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
19
server/src/types/hono.ts
Normal 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>;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export function generateApiKey(): string {
|
||||
const timestamp = Date.now().toString(36);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveIsolatedVmModule } from '../../src/services/ScriptExecutor';
|
||||
|
||||
describe('resolveIsolatedVmModule', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
183
server/tests/utils/httpClient.ts
Normal file
183
server/tests/utils/httpClient.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue