Compare commits
18 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df262e0bbe |
||
|
|
db58054fdb | ||
| f97e67382b | |||
| f72de32d29 | |||
| 12ca255867 | |||
| 59bf6e6df6 | |||
| ba36b58641 | |||
|
ce38ca0f71 |
|||
|
|
18edad1085 |
||
|
|
0f4950739a |
||
|
|
9ecdd41a33 |
||
|
|
43f4eb1531 |
||
|
|
58959fdf74 |
||
|
|
988b351419 |
||
|
|
6b6b92ca45 |
||
|
|
435ffdce42 |
||
|
|
17c4286b6a |
||
|
|
66261474d2 |
122 changed files with 11790 additions and 5719 deletions
|
|
@ -13,6 +13,7 @@ RUN corepack enable
|
|||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY server/package.json ./server/package.json
|
||||
COPY client/package.json ./client/package.json
|
||||
COPY shared/package.json ./shared/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -39,9 +40,9 @@ ENV DB_DIR=/data
|
|||
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/server/package.json ./server/package.json
|
||||
COPY --from=build /app/server/node_modules ./server/node_modules
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=build /app/server ./server
|
||||
COPY --from=build /app/shared ./shared
|
||||
COPY --from=build /app/client/dist ./client/dist
|
||||
COPY --from=build /app/database ./database
|
||||
|
||||
|
|
@ -49,4 +50,4 @@ RUN mkdir -p /data
|
|||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server/dist/server/src/index.js"]
|
||||
CMD ["pnpm", "--filter", "server", "start"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Kyush LLM Router
|
||||
|
||||
다중 사용자 LLM 라우팅 프록시 — API 키 인증, 백엔드 라우팅, 스크립트 기반 요청/응답 조작, 사용량 모니터링.
|
||||
다중 사용자 LLM 라우팅 프록시
|
||||
API 키 인증, 백엔드 라우팅, 스크립트 기반 요청/응답 조작, 사용량 모니터링.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -16,23 +16,31 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@kyush/shared": "workspace:*",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tanstack/solid-query": "^5.62.7",
|
||||
"d3": "^7.9.0",
|
||||
"es-toolkit": "^1.32.0",
|
||||
"ky": "^1.7.5",
|
||||
"lucide-solid": "^1.1.0",
|
||||
"solid-js": "^1.9.11",
|
||||
"solid-monaco": "^0.3.0"
|
||||
"solid-monaco": "^0.3.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.3.3",
|
||||
"@storybook/addon-docs": "^10.3.3",
|
||||
"@storybook/addon-links": "^10.3.3",
|
||||
"@storybook/addon-vitest": "^10.3.3",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"@vitest/browser": "^4.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"playwright": "^1.58.2",
|
||||
"storybook": "^10.3.3",
|
||||
"storybook-solidjs-vite": "^10.0.11",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-solid": "^2.11.10",
|
||||
|
|
|
|||
1219
client/pnpm-lock.yaml
generated
1219
client/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,26 @@
|
|||
import { Router, Route } from '@solidjs/router';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { lazy, Show, Suspense } from 'solid-js';
|
||||
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
import { LoginGate } from './components/LoginGate';
|
||||
import LoginGate from './components/login-gate';
|
||||
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'));
|
||||
const Users = lazy(() => import('./routes/Users'));
|
||||
const Backends = lazy(() => import('./routes/Backends'));
|
||||
const Analytics = lazy(() => import('./routes/Analytics'));
|
||||
const DetailLogs = lazy(() => import('./routes/DetailLogs'));
|
||||
const Models = lazy(() => import('./routes/Models'));
|
||||
const Scripts = lazy(() => import('./routes/Scripts'));
|
||||
|
||||
function RouteLoadingFallback() {
|
||||
function FullScreenPanel(props: { title: string; description: string }) {
|
||||
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={props.description}
|
||||
title={props.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,23 +30,31 @@ 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." />
|
||||
</div>
|
||||
<FullScreenPanel
|
||||
description="Restoring the current administrator session."
|
||||
title="Loading Admin Session"
|
||||
/>
|
||||
}
|
||||
when={!auth.loading()}
|
||||
>
|
||||
<Show when={auth.session()?.authenticated} fallback={<LoginGate />}>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Show fallback={<LoginGate />} when={auth.session()?.authenticated}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<FullScreenPanel
|
||||
description="Preparing the selected dashboard view."
|
||||
title="Loading Admin Page"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,52 @@
|
|||
import { omitBy } from 'es-toolkit';
|
||||
import ky, {
|
||||
HTTPError,
|
||||
type KyInstance,
|
||||
type Options as KyOptions,
|
||||
type ResponsePromise,
|
||||
} from 'ky';
|
||||
|
||||
import type {
|
||||
User,
|
||||
AdminApiTokenSummary,
|
||||
AdminSessionResponse,
|
||||
AnalyticsBackendQualityPoint,
|
||||
AnalyticsBoxPlotPoint,
|
||||
AnalyticsDailyTotalsPoint,
|
||||
AnalyticsHistogramBin,
|
||||
AnalyticsModelTrendPoint,
|
||||
Backend,
|
||||
BackendMetrics,
|
||||
BackendModelsResponse,
|
||||
CreateBackendInput,
|
||||
CreateModelRewriteInput,
|
||||
CreatePermissionInput,
|
||||
CreateScriptInput,
|
||||
CreateUserInput,
|
||||
DashboardSummaryResponse,
|
||||
ModelCacheOverview,
|
||||
ModelRewriteRule,
|
||||
Permission,
|
||||
RequestLogPage,
|
||||
UpdateBackendInput,
|
||||
UpdateModelRewriteInput,
|
||||
UpdateScriptInput,
|
||||
UpdateUserInput,
|
||||
UsageStats,
|
||||
BackendMetrics,
|
||||
AnalyticsDailyTotalsPoint,
|
||||
AnalyticsBackendQualityPoint,
|
||||
AnalyticsModelTrendPoint,
|
||||
AnalyticsHistogramBin,
|
||||
AnalyticsBoxPlotPoint,
|
||||
DashboardSummaryResponse,
|
||||
User,
|
||||
UserScript,
|
||||
CreateScriptData,
|
||||
UpdateScriptData,
|
||||
AdminApiTokenSummary,
|
||||
AdminSessionResponse,
|
||||
} from '../types';
|
||||
} from '@kyush/shared';
|
||||
|
||||
/**
|
||||
* Base URL prepended by ky to every request.
|
||||
*
|
||||
* - In dev, the Vite proxy forwards `/admin/*` to the backend, so `'/'` is the right default.
|
||||
* - In prod (single-binary deploy), the dashboard is served from the same origin as the API.
|
||||
* - Override at build time with `VITE_API_BASE_URL` (e.g. `https://router.example.com/`)
|
||||
* when the dashboard and API live on different origins.
|
||||
*/
|
||||
const API_BASE_URL: string =
|
||||
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/';
|
||||
|
||||
const API_BASE = '';
|
||||
let csrfToken: string | null = null;
|
||||
let unauthorizedHandler: (() => void) | null = null;
|
||||
|
||||
|
|
@ -43,195 +68,322 @@ 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());
|
||||
const nextHeaders: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
if (isUnsafeMethod) {
|
||||
nextHeaders['Content-Type'] = nextHeaders['Content-Type'] ?? 'application/json';
|
||||
const httpClient: KyInstance = ky.extend({
|
||||
prefixUrl: API_BASE_URL,
|
||||
credentials: 'include',
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
if (!UNSAFE_METHODS.has(request.method.toUpperCase())) return;
|
||||
if (!csrfToken) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/admin')) {
|
||||
request.headers.set('X-CSRF-Token', csrfToken);
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
type SearchParamsInit = Exclude<KyOptions['searchParams'], undefined>;
|
||||
type Primitive = string | number | boolean | undefined | null;
|
||||
|
||||
/**
|
||||
* Drop `undefined`/`null` keys so callers can pass `{ userId: maybeUndefined }`
|
||||
* without polluting the query string with empty values. Returns `undefined`
|
||||
* when nothing is left so ky skips the search parameter step entirely.
|
||||
*/
|
||||
function compactSearchParams(
|
||||
params: Record<string, Primitive>,
|
||||
): SearchParamsInit | undefined {
|
||||
const cleaned = omitBy(
|
||||
params,
|
||||
(value) => value === undefined || value === null,
|
||||
);
|
||||
const entries = Object.entries(cleaned);
|
||||
if (entries.length === 0) return undefined;
|
||||
return Object.fromEntries(entries.map(([k, v]) => [k, String(v)]));
|
||||
}
|
||||
|
||||
async function toApiError(error: HTTPError): Promise<ApiError> {
|
||||
const { response, request } = error;
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.clone().json()) as { error?: string };
|
||||
if (payload.error) message = payload.error;
|
||||
} catch {
|
||||
// body wasn't JSON; keep the default message
|
||||
}
|
||||
|
||||
if (isUnsafeMethod && url.startsWith('/admin') && csrfToken) {
|
||||
nextHeaders['X-CSRF-Token'] = csrfToken;
|
||||
const url = new URL(request.url);
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!url.pathname.endsWith('/admin/auth/session')
|
||||
) {
|
||||
unauthorizedHandler?.();
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...nextHeaders,
|
||||
},
|
||||
});
|
||||
const apiError = new ApiError(response.status, message);
|
||||
apiError.stack = error.stack;
|
||||
return apiError;
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
async function unwrap<T>(promise: ResponsePromise): Promise<T> {
|
||||
try {
|
||||
const response = await promise;
|
||||
if (response.status === 204) return {} as T;
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPError) throw await toApiError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
function getJson<T>(path: string, searchParams?: SearchParamsInit): Promise<T> {
|
||||
return unwrap<T>(
|
||||
httpClient.get(path, searchParams ? { searchParams } : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !url.endsWith('/admin/auth/session')) {
|
||||
unauthorizedHandler?.();
|
||||
function postJson<T>(path: string, body?: unknown): Promise<T> {
|
||||
return unwrap<T>(
|
||||
httpClient.post(path, body !== undefined ? { json: body } : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function putJson<T>(path: string, body?: unknown): Promise<T> {
|
||||
return unwrap<T>(
|
||||
httpClient.put(path, body !== undefined ? { json: body } : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteJson<T>(
|
||||
path: string,
|
||||
searchParams?: SearchParamsInit,
|
||||
): Promise<T> {
|
||||
return unwrap<T>(
|
||||
httpClient.delete(path, searchParams ? { searchParams } : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully-qualified URL using the same prefix as the API client.
|
||||
* Used for window.location-style redirects (OIDC) where ky can't be invoked.
|
||||
*/
|
||||
function buildUrl(path: string, searchParams?: Record<string, string>): string {
|
||||
const base =
|
||||
API_BASE_URL.startsWith('http://') || API_BASE_URL.startsWith('https://')
|
||||
? API_BASE_URL
|
||||
: new URL(API_BASE_URL, window.location.origin).toString();
|
||||
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
|
||||
if (searchParams) {
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
throw new ApiError(response.status, payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
interface AnalyticsRequestParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
month?: string;
|
||||
date?: string;
|
||||
q?: string;
|
||||
userId?: number;
|
||||
backendId?: number;
|
||||
endpoint?: string;
|
||||
detailLogged?: boolean;
|
||||
}
|
||||
|
||||
interface ModelTrendsParams {
|
||||
backendId?: number;
|
||||
days?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface HistogramParams {
|
||||
backendId?: number;
|
||||
days?: number;
|
||||
bins?: number;
|
||||
}
|
||||
|
||||
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: () => getJson<AdminSessionResponse>('admin/auth/session'),
|
||||
login: (username: string, password: string) =>
|
||||
postJson<AdminSessionResponse>('admin/auth/login', {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
logout: () => postJson<void>('admin/auth/logout'),
|
||||
beginOidc: (next: string = window.location.pathname) => {
|
||||
const search = new URLSearchParams({ next });
|
||||
window.location.href = `${API_BASE}/admin/auth/oidc/start?${search.toString()}`;
|
||||
window.location.href = buildUrl('admin/auth/oidc/start', { next });
|
||||
},
|
||||
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: () => getJson<AdminApiTokenSummary[]>('admin/auth/tokens'),
|
||||
createToken: (name: string, expiresInDays?: number) =>
|
||||
postJson<{ token: string; record: AdminApiTokenSummary }>(
|
||||
'admin/auth/tokens',
|
||||
{ name, expiresInDays },
|
||||
),
|
||||
deleteToken: (id: number) => deleteJson<void>(`admin/auth/tokens/${id}`),
|
||||
},
|
||||
|
||||
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) }),
|
||||
update: (id: number, data: Partial<User>): Promise<User> =>
|
||||
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' }),
|
||||
getAll: () => getJson<User[]>('admin/users'),
|
||||
getById: (id: number) => getJson<User>(`admin/users/${id}`),
|
||||
create: (data: CreateUserInput) => postJson<User>('admin/users', data),
|
||||
update: (id: number, data: UpdateUserInput) =>
|
||||
putJson<User>(`admin/users/${id}`, data),
|
||||
delete: (id: number) => deleteJson<void>(`admin/users/${id}`),
|
||||
regenerateApiKey: (id: number) =>
|
||||
postJson<User>(`admin/users/${id}/regenerate-api-key`),
|
||||
},
|
||||
|
||||
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`),
|
||||
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) }),
|
||||
update: (id: number, data: Partial<Backend>): Promise<Backend> =>
|
||||
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' }),
|
||||
getAll: () => getJson<Backend[]>('admin/backends'),
|
||||
getById: (id: number) => getJson<Backend>(`admin/backends/${id}`),
|
||||
getModels: (id: number) =>
|
||||
getJson<BackendModelsResponse>(`admin/backends/${id}/models`),
|
||||
refreshModels: (id: number) =>
|
||||
postJson<BackendModelsResponse>(`admin/backends/${id}/models/refresh`),
|
||||
create: (data: CreateBackendInput) =>
|
||||
postJson<Backend>('admin/backends', data),
|
||||
update: (id: number, data: UpdateBackendInput) =>
|
||||
putJson<Backend>(`admin/backends/${id}`, data),
|
||||
delete: (id: number) => deleteJson<void>(`admin/backends/${id}`),
|
||||
},
|
||||
|
||||
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) }),
|
||||
delete: (userId: number, backendId: number): Promise<void> =>
|
||||
fetchJson<void>(`${API_BASE}/admin/permissions?user_id=${userId}&backend_id=${backendId}`, { method: 'DELETE' }),
|
||||
getAll: () => getJson<Permission[]>('admin/permissions'),
|
||||
getByUser: (userId: number) =>
|
||||
getJson<Permission[]>(`admin/permissions/user/${userId}`),
|
||||
getByBackend: (backendId: number) =>
|
||||
getJson<Permission[]>(`admin/permissions/backend/${backendId}`),
|
||||
create: (data: CreatePermissionInput) =>
|
||||
postJson<Permission>('admin/permissions', data),
|
||||
delete: (userId: number, backendId: number) =>
|
||||
deleteJson<void>('admin/permissions', {
|
||||
user_id: String(userId),
|
||||
backend_id: String(backendId),
|
||||
}),
|
||||
},
|
||||
|
||||
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) }),
|
||||
delete: (id: number): Promise<void> =>
|
||||
fetchJson<void>(`${API_BASE}/admin/model-rewrites/${id}`, { method: 'DELETE' }),
|
||||
getAll: () => getJson<ModelRewriteRule[]>('admin/model-rewrites'),
|
||||
create: (data: CreateModelRewriteInput) =>
|
||||
postJson<ModelRewriteRule>('admin/model-rewrites', data),
|
||||
update: (id: number, data: UpdateModelRewriteInput) =>
|
||||
putJson<ModelRewriteRule>(`admin/model-rewrites/${id}`, data),
|
||||
delete: (id: number) => deleteJson<void>(`admin/model-rewrites/${id}`),
|
||||
},
|
||||
|
||||
modelCache: {
|
||||
getOverview: (): Promise<ModelCacheOverview> => fetchJson<ModelCacheOverview>(`${API_BASE}/admin/models/cache`),
|
||||
getOverview: () => getJson<ModelCacheOverview>('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}`),
|
||||
create: (data: CreateScriptData): Promise<UserScript> =>
|
||||
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) }),
|
||||
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' }),
|
||||
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) }),
|
||||
getAll: () => getJson<UserScript[]>('admin/scripts'),
|
||||
getById: (id: number) => getJson<UserScript>(`admin/scripts/${id}`),
|
||||
create: (data: CreateScriptInput) =>
|
||||
postJson<UserScript>('admin/scripts', data),
|
||||
update: (id: number, data: UpdateScriptInput) =>
|
||||
putJson<UserScript>(`admin/scripts/${id}`, data),
|
||||
delete: (id: number) => deleteJson<void>(`admin/scripts/${id}`),
|
||||
activate: (id: number) =>
|
||||
postJson<UserScript>(`admin/scripts/${id}/activate`),
|
||||
deactivate: (id: number) =>
|
||||
postJson<UserScript>(`admin/scripts/${id}/deactivate`),
|
||||
test: (
|
||||
id: number,
|
||||
context: {
|
||||
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;
|
||||
};
|
||||
},
|
||||
) =>
|
||||
postJson<{ success: boolean; error?: string; executionTime?: number }>(
|
||||
`admin/scripts/${id}/test`,
|
||||
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}`);
|
||||
},
|
||||
getSummary: (days: number = 30) =>
|
||||
getJson<DashboardSummaryResponse>('admin/dashboard/summary', { days }),
|
||||
},
|
||||
|
||||
analytics: {
|
||||
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}`);
|
||||
},
|
||||
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));
|
||||
if (params.month) search.set('month', params.month);
|
||||
if (params.date) search.set('date', params.date);
|
||||
if (params.q) search.set('q', params.q);
|
||||
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}`);
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
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}`);
|
||||
},
|
||||
getUsage: (userId?: number, backendId?: number, days: number = 30) =>
|
||||
getJson<UsageStats[]>(
|
||||
'admin/analytics/usage',
|
||||
compactSearchParams({ userId, backendId, days }),
|
||||
),
|
||||
getRequests: (params: AnalyticsRequestParams = {}) =>
|
||||
getJson<RequestLogPage>(
|
||||
'admin/analytics/requests',
|
||||
compactSearchParams({
|
||||
limit: params.limit ?? 100,
|
||||
offset: params.offset ?? 0,
|
||||
month: params.month,
|
||||
date: params.date,
|
||||
q: params.q,
|
||||
userId: params.userId,
|
||||
backendId: params.backendId,
|
||||
endpoint: params.endpoint,
|
||||
detailLogged:
|
||||
params.detailLogged === undefined
|
||||
? undefined
|
||||
: params.detailLogged
|
||||
? '1'
|
||||
: '0',
|
||||
}),
|
||||
),
|
||||
getMetrics: (backendId?: number, days: number = 30) =>
|
||||
getJson<BackendMetrics[]>(
|
||||
'admin/analytics/metrics',
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
getDailyTotals: (backendId?: number, days: number = 30) =>
|
||||
getJson<AnalyticsDailyTotalsPoint[]>(
|
||||
'admin/analytics/daily-totals',
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
getBackendQuality: (backendId?: number, days: number = 30) =>
|
||||
getJson<AnalyticsBackendQualityPoint[]>(
|
||||
'admin/analytics/backend-quality',
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
getModelTrends: (params: ModelTrendsParams = {}) =>
|
||||
getJson<AnalyticsModelTrendPoint[]>(
|
||||
'admin/analytics/model-trends',
|
||||
compactSearchParams({
|
||||
backendId: params.backendId,
|
||||
days: params.days ?? 30,
|
||||
limit: params.limit ?? 8,
|
||||
}),
|
||||
),
|
||||
getResponseLengthHistogram: (params: HistogramParams = {}) =>
|
||||
getJson<AnalyticsHistogramBin[]>(
|
||||
'admin/analytics/response-length-histogram',
|
||||
compactSearchParams({
|
||||
backendId: params.backendId,
|
||||
days: params.days ?? 30,
|
||||
bins: params.bins ?? 20,
|
||||
}),
|
||||
),
|
||||
getResponseLengthBoxPlot: (backendId?: number, days: number = 30) =>
|
||||
getJson<AnalyticsBoxPlotPoint[]>(
|
||||
'admin/analytics/response-length-box-plot',
|
||||
compactSearchParams({ backendId, days }),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
57
client/src/api/query-keys.ts
Normal file
57
client/src/api/query-keys.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Centralised TanStack Query keys.
|
||||
*
|
||||
* Each scope returns a literal `as const` tuple so query invalidation
|
||||
* patterns like `queryClient.invalidateQueries({ queryKey: queryKeys.users.all() })`
|
||||
* stay type-safe.
|
||||
*/
|
||||
export const queryKeys = {
|
||||
auth: {
|
||||
session: () => ['auth', 'session'] as const,
|
||||
},
|
||||
users: {
|
||||
all: () => ['users'] as const,
|
||||
detail: (id: number) => ['users', id] as const,
|
||||
},
|
||||
backends: {
|
||||
all: () => ['backends'] as const,
|
||||
detail: (id: number) => ['backends', id] as const,
|
||||
models: (id: number) => ['backends', id, 'models'] as const,
|
||||
},
|
||||
permissions: {
|
||||
all: () => ['permissions'] as const,
|
||||
byUser: (userId: number) => ['permissions', 'user', userId] as const,
|
||||
byBackend: (backendId: number) =>
|
||||
['permissions', 'backend', backendId] as const,
|
||||
},
|
||||
modelRewrites: {
|
||||
all: () => ['model-rewrites'] as const,
|
||||
},
|
||||
modelCache: {
|
||||
overview: () => ['models', 'cache'] as const,
|
||||
},
|
||||
scripts: {
|
||||
all: () => ['scripts'] as const,
|
||||
detail: (id: number) => ['scripts', id] as const,
|
||||
},
|
||||
dashboard: {
|
||||
summary: (days: number) => ['dashboard', 'summary', days] as const,
|
||||
},
|
||||
analytics: {
|
||||
requests: (params: Record<string, unknown>) =>
|
||||
['analytics', 'requests', params] as const,
|
||||
dailyTotals: (backendId: number | undefined, days: number) =>
|
||||
['analytics', 'daily-totals', backendId, days] as const,
|
||||
backendQuality: (backendId: number | undefined, days: number) =>
|
||||
['analytics', 'backend-quality', backendId, days] as const,
|
||||
modelTrends: (params: {
|
||||
backendId?: number;
|
||||
days?: number;
|
||||
limit?: number;
|
||||
}) => ['analytics', 'model-trends', params] as const,
|
||||
histogram: (params: { backendId?: number; days?: number; bins?: number }) =>
|
||||
['analytics', 'response-length-histogram', params] as const,
|
||||
boxPlot: (backendId: number | undefined, days: number) =>
|
||||
['analytics', 'response-length-box-plot', backendId, days] as const,
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -1,10 +1,84 @@
|
|||
import { createContext, createSignal, onMount, useContext, type Accessor, type JSX, type ParentComponent } from 'solid-js';
|
||||
import type { AdminSessionResponse } from './types';
|
||||
import { api, setAdminCsrfToken, setUnauthorizedHandler } from './api/client';
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/solid-query';
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
onCleanup,
|
||||
onMount,
|
||||
useContext,
|
||||
type JSX,
|
||||
type ParentComponent,
|
||||
} from 'solid-js';
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
setAdminCsrfToken,
|
||||
setUnauthorizedHandler,
|
||||
} from './api/client';
|
||||
|
||||
import type { AdminSessionResponse } from '@kyush/shared';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* QueryClient
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function makeQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Treat 401 specially via a global onError pipeline; never retry auth
|
||||
// failures, since the unauthorizedHandler will reset the session.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 401) return false;
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.status >= 400 &&
|
||||
error.status < 500
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
throwOnError: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Query keys
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const authKeys = {
|
||||
session: ['auth', 'session'] as const,
|
||||
} as const;
|
||||
|
||||
const UNAUTHENTICATED_FALLBACK: AdminSessionResponse = {
|
||||
authenticated: false,
|
||||
authMode: 'both',
|
||||
csrfToken: null,
|
||||
principal: null,
|
||||
};
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Auth context (thin wrapper around the session query + mutations)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Accessor<AdminSessionResponse | null>;
|
||||
loading: Accessor<boolean>;
|
||||
session: () => AdminSessionResponse | null;
|
||||
loading: () => boolean;
|
||||
refreshSession: () => Promise<AdminSessionResponse>;
|
||||
login: (username: string, password: string) => Promise<AdminSessionResponse>;
|
||||
logout: () => Promise<void>;
|
||||
|
|
@ -12,63 +86,141 @@ interface AuthContextValue {
|
|||
|
||||
const AuthContext = createContext<AuthContextValue>();
|
||||
|
||||
function unauthenticatedState(previous: AdminSessionResponse | null): AdminSessionResponse {
|
||||
return {
|
||||
authenticated: false,
|
||||
authMode: previous?.authMode ?? 'both',
|
||||
csrfToken: null,
|
||||
principal: null,
|
||||
};
|
||||
function useSessionQuery() {
|
||||
return useQuery(() => ({
|
||||
queryKey: authKeys.session,
|
||||
queryFn: () => api.auth.getSession(),
|
||||
// The session is already authoritative for the dashboard's lifecycle,
|
||||
// so cache it forever and let mutations invalidate it explicitly.
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (props) => {
|
||||
const [session, setSession] = createSignal<AdminSessionResponse | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
/**
|
||||
* Tag a query as belonging to the auth namespace so we can keep it across
|
||||
* sign-in/sign-out transitions while wiping every other cached query.
|
||||
*
|
||||
* `removeQueries`/`invalidateQueries` accept a `predicate` that runs against
|
||||
* each Query in the cache — anchoring on the first key segment is the
|
||||
* cheapest stable identifier we have.
|
||||
*/
|
||||
const isAuthQuery = (queryKey: readonly unknown[]) => queryKey[0] === 'auth';
|
||||
|
||||
const refreshSession = async () => {
|
||||
const nextSession = await api.auth.getSession();
|
||||
setSession(nextSession);
|
||||
setAdminCsrfToken(nextSession.csrfToken);
|
||||
setLoading(false);
|
||||
return nextSession;
|
||||
};
|
||||
/**
|
||||
* Replace the cached session with the unauthenticated fallback (preserving
|
||||
* the configured auth mode so the login gate keeps showing the right form),
|
||||
* then evict every non-auth query so stale user-scoped data doesn't leak
|
||||
* across sign-out/401 boundaries.
|
||||
*/
|
||||
function clearAuthenticatedState(queryClient: QueryClient): void {
|
||||
queryClient.setQueryData<AdminSessionResponse>(
|
||||
authKeys.session,
|
||||
(previous) => ({
|
||||
...UNAUTHENTICATED_FALLBACK,
|
||||
authMode: previous?.authMode ?? UNAUTHENTICATED_FALLBACK.authMode,
|
||||
}),
|
||||
);
|
||||
setAdminCsrfToken(null);
|
||||
queryClient.removeQueries({
|
||||
predicate: (query) => !isAuthQuery(query.queryKey),
|
||||
});
|
||||
}
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const nextSession = await api.auth.login(username, password);
|
||||
setSession(nextSession);
|
||||
setAdminCsrfToken(nextSession.csrfToken);
|
||||
return nextSession;
|
||||
};
|
||||
/**
|
||||
* After a successful login the cached results from the previous (anonymous
|
||||
* or different-user) session are stale. Mark every non-auth query stale so
|
||||
* mounted components refetch under the new session.
|
||||
*/
|
||||
function refreshAfterLogin(queryClient: QueryClient): Promise<void> {
|
||||
return queryClient.invalidateQueries({
|
||||
predicate: (query) => !isAuthQuery(query.queryKey),
|
||||
});
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await api.auth.logout();
|
||||
setSession((previous) => unauthenticatedState(previous));
|
||||
setAdminCsrfToken(null);
|
||||
};
|
||||
function AuthContextProvider(props: { children: JSX.Element }) {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionQuery = useSessionQuery();
|
||||
|
||||
onMount(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
setSession((previous) => unauthenticatedState(previous));
|
||||
setAdminCsrfToken(null);
|
||||
});
|
||||
|
||||
void refreshSession().catch(() => {
|
||||
setSession({ authenticated: false, authMode: 'both', csrfToken: null, principal: null });
|
||||
setLoading(false);
|
||||
});
|
||||
// Mirror the CSRF token into the api client whenever the session updates.
|
||||
createEffect(() => {
|
||||
const data = sessionQuery.data;
|
||||
setAdminCsrfToken(data?.csrfToken ?? null);
|
||||
});
|
||||
|
||||
// Wire the api client's 401 handler once at mount — when an unauthorized
|
||||
// response surfaces, immediately collapse the cached session to the
|
||||
// unauthenticated fallback AND wipe every other cached query so we never
|
||||
// render data that was fetched under a now-revoked session. There are no
|
||||
// reactive reads in this block, so `onMount` (one-shot) is a more honest
|
||||
// fit than `createEffect`.
|
||||
onMount(() => {
|
||||
setUnauthorizedHandler(() => clearAuthenticatedState(queryClient));
|
||||
onCleanup(() => setUnauthorizedHandler(null));
|
||||
});
|
||||
|
||||
const loginMutation = useMutation(() => ({
|
||||
mutationFn: ({
|
||||
username,
|
||||
password,
|
||||
}: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) => api.auth.login(username, password),
|
||||
onSuccess: async (next) => {
|
||||
queryClient.setQueryData(authKeys.session, next);
|
||||
// Invalidate (don't remove) so any currently-mounted view kicks off
|
||||
// a refetch under the new session — `removeQueries` here would leave
|
||||
// the dashboard staring at empty fallbacks until each query mounted.
|
||||
await refreshAfterLogin(queryClient);
|
||||
},
|
||||
}));
|
||||
|
||||
const logoutMutation = useMutation(() => ({
|
||||
mutationFn: () => api.auth.logout(),
|
||||
onSuccess: () => clearAuthenticatedState(queryClient),
|
||||
}));
|
||||
|
||||
const value: AuthContextValue = {
|
||||
session: () => sessionQuery.data ?? null,
|
||||
// Only treat the very first fetch as "loading" — once we have any data
|
||||
// (or an error), the gate should resolve to login or to the dashboard.
|
||||
loading: () =>
|
||||
sessionQuery.isPending && sessionQuery.fetchStatus !== 'idle',
|
||||
refreshSession: async () => {
|
||||
const next = await queryClient.fetchQuery({
|
||||
queryKey: authKeys.session,
|
||||
queryFn: () => api.auth.getSession(),
|
||||
staleTime: 0,
|
||||
});
|
||||
return next;
|
||||
},
|
||||
login: (username, password) =>
|
||||
loginMutation.mutateAsync({ username, password }),
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ session, loading, refreshSession, login, logout }}>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
<AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const AuthProvider: ParentComponent = (props) => {
|
||||
const queryClient = makeQueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthContextProvider>{props.children}</AuthContextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAuth() {
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('Auth context is not available');
|
||||
throw new Error('useAuth must be used inside <AuthProvider>');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,16 +1,21 @@
|
|||
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';
|
||||
|
||||
export const LoginGate: Component = () => {
|
||||
import { ApiError, api } from '../api/client';
|
||||
import { useAuth } from '../auth';
|
||||
import { Alert, Button, Panel, TextField } from '../ui';
|
||||
|
||||
const LoginGate: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
const authMode = () => auth.session()?.authMode ?? 'both';
|
||||
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
|
||||
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
|
||||
|
||||
const handleSubmit = async (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
|
@ -18,29 +23,43 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
const authMode = () => auth.session()?.authMode ?? 'both';
|
||||
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
|
||||
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
|
||||
|
||||
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>
|
||||
|
|
@ -59,3 +84,5 @@ export const LoginGate: Component = () => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginGate;
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { Dynamic } from 'solid-js/web';
|
||||
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
|
||||
import { Dynamic } from 'solid-js/web';
|
||||
|
||||
const THEME_STORAGE_KEY = 'kyush-theme';
|
||||
const MonacoEditor = lazy(() => import('solid-monaco').then((module) => ({ default: module.MonacoEditor })));
|
||||
|
||||
const MonacoEditor = lazy(async () => {
|
||||
const module = await import('solid-monaco');
|
||||
return { default: module.MonacoEditor };
|
||||
});
|
||||
|
||||
interface ScriptEditorProps {
|
||||
value: string;
|
||||
|
|
@ -11,8 +15,7 @@ interface ScriptEditorProps {
|
|||
path?: string;
|
||||
}
|
||||
|
||||
export function ScriptEditor(props: ScriptEditorProps) {
|
||||
const defaultCode = `// User-defined middleware script
|
||||
const DEFAULT_CODE = `// User-defined middleware script
|
||||
// Available functions: onRequest, onResponse
|
||||
|
||||
/**
|
||||
|
|
@ -26,13 +29,12 @@ export async function onRequest(ctx) {
|
|||
|
||||
// Example: Edit body
|
||||
// if (typeof ctx.request.body === 'object') {
|
||||
// if (typeof ctx.request.body['chat_template_kwargs'] !== 'object') {
|
||||
// ctx.request.body['chat_template_kwargs'] = {};
|
||||
// }
|
||||
|
||||
// ctx.request.body['chat_template_kwargs'] ??= {};
|
||||
// }
|
||||
|
||||
// Example: Log request
|
||||
// console.log('Request:', ctx.request.method, ctx.request.path);
|
||||
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
|
@ -44,51 +46,72 @@ export async function onRequest(ctx) {
|
|||
export async function onResponse(ctx) {
|
||||
// Example: Log response
|
||||
// console.log('Response status:', ctx.response?.status);
|
||||
|
||||
// Example: Handle streaming responses
|
||||
// if (ctx.response?.isStream && ctx.onChunk) {
|
||||
// const originalOnChunk = ctx.onChunk;
|
||||
// ctx.onChunk = (chunk) => {
|
||||
// console.log('Stream chunk:', chunk);
|
||||
// originalOnChunk(chunk);
|
||||
// };
|
||||
// }
|
||||
|
||||
|
||||
return ctx;
|
||||
}
|
||||
`;
|
||||
|
||||
const [editorTheme, setEditorTheme] = createSignal<'vs' | 'vs-dark'>('vs-dark');
|
||||
type EditorTheme = 'vs' | 'vs-dark';
|
||||
|
||||
function readThemePreference(): EditorTheme {
|
||||
const root = document.documentElement;
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const explicit = root.dataset.theme;
|
||||
const preferred = stored === 'light' || stored === 'dark' ? stored : explicit;
|
||||
const isDark = preferred
|
||||
? preferred === 'dark'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return isDark ? 'vs-dark' : 'vs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the user's preferred Monaco theme. Watches three sources of truth:
|
||||
* 1. `localStorage[kyush-theme]` (explicit user choice)
|
||||
* 2. `<html data-theme="…">` (set by the app shell)
|
||||
* 3. `prefers-color-scheme` media query (system fallback)
|
||||
*
|
||||
* Both the MutationObserver and the media-query listener are wired up in
|
||||
* `onMount` (so they're guaranteed to run only on the client) and torn down
|
||||
* via the matching `onCleanup` registered in the same effect — that
|
||||
* registration is owned by the surrounding component scope, so it always
|
||||
* fires on unmount.
|
||||
*/
|
||||
function createEditorThemeSignal() {
|
||||
const [theme, setTheme] = createSignal<EditorTheme>('vs-dark');
|
||||
|
||||
onMount(() => {
|
||||
const root = document.documentElement;
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
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;
|
||||
setEditorTheme(isDark ? 'vs-dark' : 'vs');
|
||||
};
|
||||
const sync = () => setTheme(readThemePreference());
|
||||
|
||||
const observer = new MutationObserver(syncTheme);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
mediaQuery.addEventListener('change', syncTheme);
|
||||
syncTheme();
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
mediaQuery.addEventListener('change', sync);
|
||||
sync();
|
||||
|
||||
onCleanup(() => {
|
||||
// Both subscriptions are paired with their teardown in the same closure
|
||||
// so it's impossible to add one without removing the other.
|
||||
observer.disconnect();
|
||||
mediaQuery.removeEventListener('change', syncTheme);
|
||||
mediaQuery.removeEventListener('change', sync);
|
||||
});
|
||||
});
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
function ScriptEditor(props: ScriptEditorProps) {
|
||||
const editorTheme = createEditorThemeSignal();
|
||||
|
||||
return (
|
||||
<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 +124,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()}
|
||||
onChange={props.onChange}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
|
|
@ -114,8 +134,13 @@ export async function onResponse(ctx) {
|
|||
scrollBeyondLastLine: false,
|
||||
padding: { top: 16, bottom: 16 },
|
||||
}}
|
||||
path={props.path}
|
||||
theme={editorTheme()}
|
||||
value={props.value || DEFAULT_CODE}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScriptEditor;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { render } from 'solid-js/web';
|
||||
|
||||
import App from './App';
|
||||
import './ui/styles.css';
|
||||
|
||||
|
|
|
|||
5
client/src/reset.d.ts
vendored
Normal file
5
client/src/reset.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Activate ts-reset's improved built-in types globally for the client.
|
||||
// See https://www.totaltypescript.com/ts-reset for the rules this enables —
|
||||
// it sharpens types like `JSON.parse`, `Array.prototype.filter`, `fetch`,
|
||||
// `Object.entries`, etc., so we don't need to widen them with manual casts.
|
||||
import '@total-typescript/ts-reset';
|
||||
|
|
@ -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 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>
|
||||
|
|
@ -305,3 +419,5 @@ export const Analytics: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -37,19 +46,31 @@ const emptyForm = (): BackendFormState => ({
|
|||
detail_logging: false,
|
||||
});
|
||||
|
||||
export const Backends: Component = () => {
|
||||
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,61 +432,100 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Backends;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { useQuery } from '@tanstack/solid-query';
|
||||
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
|
||||
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
|
||||
import { Show, createSignal, type Component, For } from 'solid-js';
|
||||
|
||||
import { api } from '../api/client';
|
||||
import { queryKeys } from '../api/query-keys';
|
||||
import { Layout } from '../components/Layout';
|
||||
import {
|
||||
Button,
|
||||
ChartLegend,
|
||||
ComboChart,
|
||||
CommandBar,
|
||||
|
|
@ -22,38 +26,68 @@ 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 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 [backends] = createResource(() => api.backends.getAll());
|
||||
// Reactive data: TanStack Query handles fetching, caching, and refetch.
|
||||
// The query key tracks `windowDays()` so changing the time window kicks off
|
||||
// a fresh fetch automatically.
|
||||
const windowDays = () => Number(days());
|
||||
const summaryQuery = useQuery(() => ({
|
||||
queryKey: queryKeys.dashboard.summary(windowDays()),
|
||||
queryFn: () => api.dashboard.getSummary(windowDays()),
|
||||
}));
|
||||
const backendsQuery = useQuery(() => ({
|
||||
queryKey: queryKeys.backends.all(),
|
||||
queryFn: () => api.backends.getAll(),
|
||||
}));
|
||||
|
||||
const backendNameById = createMemo(() => {
|
||||
const summary = () => summaryQuery.data;
|
||||
const backends = () => backendsQuery.data;
|
||||
const refetch = () => summaryQuery.refetch();
|
||||
|
||||
// Inline derivations — Solid's reactive prop reads keep these cheap, no
|
||||
// need to wrap in createMemo for what amounts to a single Map build.
|
||||
const backendNameById = (): ReadonlyMap<number, string> => {
|
||||
const entries = new Map<number, string>();
|
||||
for (const backend of backends() ?? []) {
|
||||
entries.set(backend.id, backend.name);
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
};
|
||||
|
||||
const trafficRows = createMemo(() =>
|
||||
const trafficRows = () =>
|
||||
(summary()?.series.daily_totals ?? []).map((row) => ({
|
||||
date: row.date,
|
||||
requests: row.total_requests,
|
||||
tokens: row.total_tokens,
|
||||
}))
|
||||
);
|
||||
}));
|
||||
|
||||
const reliabilityRows = createMemo(() => {
|
||||
const reliabilityRows = () => {
|
||||
const grouped = new Map<string, { requests: number; errors: number }>();
|
||||
for (const row of summary()?.series.backend_quality ?? []) {
|
||||
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
|
||||
|
|
@ -66,62 +100,100 @@ 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,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const latencyRows = createMemo(() => {
|
||||
const latencyRows = (): DashboardChartRow[] => {
|
||||
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 latencySeries = () => {
|
||||
const ids = Array.from(
|
||||
new Set(
|
||||
(summary()?.series.backend_quality ?? []).map((row) => row.backend_id),
|
||||
),
|
||||
).sort((left, right) => left - right);
|
||||
return ids.map((backendId, index) => ({
|
||||
key: `backend_${backendId}`,
|
||||
label: backendNameById().get(backendId) ?? `Backend ${backendId}`,
|
||||
color: palette[index % palette.length],
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const modelRows = createMemo(() => {
|
||||
const modelRows = (): DashboardChartRow[] => {
|
||||
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 modelSeries = () => {
|
||||
const models = Array.from(
|
||||
new Set((summary()?.series.model_trends ?? []).map((row) => row.model)),
|
||||
);
|
||||
return models.map((model, index) => ({
|
||||
key: `model_${model}`,
|
||||
label: model,
|
||||
color: palette[index % palette.length],
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const summaryItems = createMemo(() => {
|
||||
const summaryItems = () => {
|
||||
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',
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const cacheStateItems = createMemo(() => {
|
||||
const cacheStateItems = () => {
|
||||
const counts = summary()?.health.cache_state_counts;
|
||||
if (!counts) return [];
|
||||
return [
|
||||
|
|
@ -130,33 +202,56 @@ export const Dashboard: Component = () => {
|
|||
{ key: 'Error', value: String(counts.error) },
|
||||
{ key: 'Inactive', value: String(counts.inactive) },
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const scriptItems = createMemo(() => {
|
||||
const scriptItems = () => {
|
||||
const payload = summary();
|
||||
if (!payload) return [];
|
||||
|
||||
return [
|
||||
{ key: 'Per User', value: `${payload.scripts.active_by_type['per-user']} active / ${payload.scripts.total_by_type['per-user']} total` },
|
||||
{ key: 'Per Backend', value: `${payload.scripts.active_by_type['per-backend']} active / ${payload.scripts.total_by_type['per-backend']} total` },
|
||||
{ key: 'Scoped', value: `${payload.scripts.active_by_type['per-user-backend']} active / ${payload.scripts.total_by_type['per-user-backend']} total` },
|
||||
{
|
||||
key: 'Per User',
|
||||
value: `${payload.scripts.active_by_type['per-user']} active / ${payload.scripts.total_by_type['per-user']} total`,
|
||||
},
|
||||
{
|
||||
key: 'Per Backend',
|
||||
value: `${payload.scripts.active_by_type['per-backend']} active / ${payload.scripts.total_by_type['per-backend']} total`,
|
||||
},
|
||||
{
|
||||
key: 'Scoped',
|
||||
value: `${payload.scripts.active_by_type['per-user-backend']} active / ${payload.scripts.total_by_type['per-user-backend']} total`,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const accessItems = createMemo(() => {
|
||||
const accessItems = () => {
|
||||
const payload = summary();
|
||||
if (!payload) return [];
|
||||
|
||||
return [
|
||||
{ key: 'Assignments', value: formatInteger.format(payload.access.permission_assignments) },
|
||||
{ key: 'No Backend Access', value: String(payload.access.users_without_permissions) },
|
||||
{ key: 'User Detail Logs', value: String(payload.logging.users_with_detail_logging) },
|
||||
{ key: 'Backend Detail Logs', value: String(payload.logging.backends_with_detail_logging) },
|
||||
{
|
||||
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 +269,49 @@ export const Dashboard: Component = () => {
|
|||
<Layout>
|
||||
<div class="ui-app-page">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
actions={
|
||||
<Button onClick={() => void refetch()} type="button">
|
||||
<RefreshCcw aria-hidden="true" size={14} />
|
||||
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={
|
||||
summaryQuery.error instanceof Error
|
||||
? summaryQuery.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={!summaryQuery.isError}
|
||||
>
|
||||
<div class="ui-section-grid">
|
||||
<Panel
|
||||
title="Traffic Volume"
|
||||
description="Daily request and token totals for the selected window."
|
||||
actions={
|
||||
<ChartLegend
|
||||
items={[
|
||||
|
|
@ -199,38 +319,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 +383,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>
|
||||
|
|
@ -309,3 +498,5 @@ export const Dashboard: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
|
|
|||
|
|
@ -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 '-';
|
||||
}
|
||||
|
|
@ -61,7 +85,7 @@ function prettyPrint(value?: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
export const DetailLogs: Component = () => {
|
||||
const DetailLogs: Component = () => {
|
||||
const [filters, setFilters] = createSignal<FilterState>(emptyFilters());
|
||||
const [page, setPage] = createSignal(1);
|
||||
const [pageSize, setPageSize] = createSignal(25);
|
||||
|
|
@ -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>
|
||||
|
|
@ -350,3 +527,5 @@ export const DetailLogs: Component = () => {
|
|||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailLogs;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -37,17 +46,27 @@ const emptyForm = (): RewriteFormState => ({
|
|||
note: '',
|
||||
});
|
||||
|
||||
export const Models: Component = () => {
|
||||
const [overview, { refetch: refetchOverview }] = createResource(() => api.modelCache.getOverview());
|
||||
const Models: Component = () => {
|
||||
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,95 +342,191 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Models;
|
||||
|
|
|
|||
|
|
@ -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,15 @@ import {
|
|||
TextField,
|
||||
} from '../ui';
|
||||
|
||||
import type {
|
||||
CreateScriptInput,
|
||||
ScriptType,
|
||||
UpdateScriptInput,
|
||||
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/script-editor'));
|
||||
|
||||
interface ScriptFormState {
|
||||
id?: number;
|
||||
|
|
@ -103,24 +120,56 @@ const scriptTypeLabels: Record<ScriptType, string> = {
|
|||
'per-user': 'Per User',
|
||||
};
|
||||
|
||||
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: 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 [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 +184,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 +233,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) {
|
||||
|
|
@ -183,6 +248,51 @@ export const Scripts: Component = () => {
|
|||
return null;
|
||||
};
|
||||
|
||||
const buildCreatePayload = (current: ScriptFormState): CreateScriptInput => {
|
||||
const base = {
|
||||
name: current.name.trim(),
|
||||
script_code: current.script_code,
|
||||
is_active: current.is_active,
|
||||
};
|
||||
|
||||
// Use type narrowing on script_type so the discriminated union picks the
|
||||
// right variant — no `as` casting required.
|
||||
switch (current.script_type) {
|
||||
case 'per-user-backend':
|
||||
return {
|
||||
...base,
|
||||
script_type: 'per-user-backend',
|
||||
target_user_id: Number(current.target_user_id),
|
||||
target_backend_id: Number(current.target_backend_id),
|
||||
};
|
||||
case 'per-backend':
|
||||
return {
|
||||
...base,
|
||||
script_type: 'per-backend',
|
||||
target_backend_id: Number(current.target_backend_id),
|
||||
};
|
||||
case 'per-user':
|
||||
return {
|
||||
...base,
|
||||
script_type: 'per-user',
|
||||
target_user_id: Number(current.target_user_id),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const buildUpdatePayload = (current: ScriptFormState): UpdateScriptInput => ({
|
||||
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,
|
||||
script_code: current.script_code,
|
||||
is_active: current.is_active,
|
||||
});
|
||||
|
||||
const saveScript = async () => {
|
||||
const error = validateForm();
|
||||
if (error) {
|
||||
|
|
@ -191,23 +301,18 @@ export const Scripts: Component = () => {
|
|||
}
|
||||
|
||||
const current = form();
|
||||
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,
|
||||
script_code: current.script_code,
|
||||
is_active: current.is_active,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (current.id) {
|
||||
const updated = await api.scripts.update(current.id, payload);
|
||||
const updated = await api.scripts.update(
|
||||
current.id,
|
||||
buildUpdatePayload(current),
|
||||
);
|
||||
setNotice({ tone: 'success', message: 'Script updated.' });
|
||||
syncForm(updated);
|
||||
} else {
|
||||
const created = await api.scripts.create(payload);
|
||||
const created = await api.scripts.create(buildCreatePayload(current));
|
||||
setNotice({ tone: 'success', message: 'Script created.' });
|
||||
syncForm(created);
|
||||
}
|
||||
|
|
@ -215,7 +320,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 +339,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 +376,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 +389,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 +406,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 +428,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 +452,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 +508,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 +521,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 +534,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 +553,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 +695,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: string) =>
|
||||
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 +758,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,8 +778,14 @@ export const Scripts: Component = () => {
|
|||
</Show>
|
||||
}
|
||||
onConfirm={() => void deleteScript()}
|
||||
onOpenChange={setConfirmOpen}
|
||||
open={confirmOpen()}
|
||||
title="Delete script"
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Scripts;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -50,22 +60,34 @@ 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: Component = () => {
|
||||
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,65 +793,109 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
|
|
|
|||
|
|
@ -1,290 +1,4 @@
|
|||
export type User = {
|
||||
id: number;
|
||||
api_key: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Backend = {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
cached_model_count?: number;
|
||||
last_model_sync_at?: string;
|
||||
model_cache_initialized?: boolean;
|
||||
model_cache_state?: 'ready' | 'uninitialized' | 'error' | 'inactive';
|
||||
};
|
||||
|
||||
export type BackendModelSnapshot = {
|
||||
id: number;
|
||||
backend_id: number;
|
||||
model_id: string;
|
||||
raw_json?: string;
|
||||
fetched_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type BackendModelCacheStatus = {
|
||||
backend_id: number;
|
||||
initialized: boolean;
|
||||
state: 'ready' | 'uninitialized' | 'error' | 'inactive';
|
||||
model_count: number;
|
||||
last_synced_at?: string;
|
||||
last_attempted_at?: string;
|
||||
last_error?: string;
|
||||
};
|
||||
|
||||
export type BackendModelsResponse = {
|
||||
backend: Backend;
|
||||
cache: BackendModelCacheStatus;
|
||||
snapshots: BackendModelSnapshot[];
|
||||
models: string[];
|
||||
};
|
||||
|
||||
export type BackendModelCatalogEntry = {
|
||||
model_id: string;
|
||||
backend_ids: number[];
|
||||
};
|
||||
|
||||
export type ModelCacheOverview = {
|
||||
backends: BackendModelCacheStatus[];
|
||||
models: BackendModelCatalogEntry[];
|
||||
};
|
||||
|
||||
export type ModelRewriteRule = {
|
||||
id: number;
|
||||
source_model: string;
|
||||
target_model: string;
|
||||
is_active: boolean;
|
||||
force: boolean;
|
||||
note?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Permission = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type RequestLog = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
endpoint: string;
|
||||
request_model?: string;
|
||||
routed_model?: string;
|
||||
response_model?: string;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
status_code: number;
|
||||
response_time_ms?: number;
|
||||
error_message?: string;
|
||||
detail_logged: boolean;
|
||||
local_date: string;
|
||||
request_headers?: string;
|
||||
request_body?: string;
|
||||
response_headers?: string;
|
||||
response_body?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type RequestLogPage = {
|
||||
rows: RequestLog[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type UsageStats = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
date: string;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
|
||||
export type BackendMetrics = {
|
||||
id: number;
|
||||
backend_id: number;
|
||||
date: string;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_response_time_ms: number;
|
||||
error_count: number;
|
||||
success_rate: number;
|
||||
};
|
||||
|
||||
export type AnalyticsDailyTotalsPoint = {
|
||||
date: string;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
|
||||
export type AnalyticsBackendQualityPoint = {
|
||||
date: string;
|
||||
backend_id: number;
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_response_time_ms: number;
|
||||
error_count: number;
|
||||
success_rate: number;
|
||||
};
|
||||
|
||||
export type AnalyticsModelTrendPoint = {
|
||||
date: string;
|
||||
model: string;
|
||||
request_count: number;
|
||||
};
|
||||
|
||||
export type AnalyticsHistogramBin = {
|
||||
bin_start: number;
|
||||
bin_end: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type AnalyticsBoxPlotPoint = {
|
||||
date: string;
|
||||
min: number;
|
||||
q1: number;
|
||||
median: number;
|
||||
q3: number;
|
||||
max: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type DashboardHealthStatus = {
|
||||
status: 'ok';
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type DashboardOverviewSummary = {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_backends: number;
|
||||
active_backends: number;
|
||||
total_permissions: number;
|
||||
total_scripts: number;
|
||||
active_scripts: number;
|
||||
};
|
||||
|
||||
export type DashboardHealthSummary = {
|
||||
cache_state_counts: Record<Backend['model_cache_state'] extends infer T ? Extract<T, string> : never, number>;
|
||||
stale_backends: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
state: NonNullable<Backend['model_cache_state']>;
|
||||
last_synced_at?: string;
|
||||
}>;
|
||||
public_health: DashboardHealthStatus;
|
||||
admin_health: DashboardHealthStatus;
|
||||
};
|
||||
|
||||
export type DashboardLoggingSummary = {
|
||||
users_with_detail_logging: number;
|
||||
backends_with_detail_logging: number;
|
||||
};
|
||||
|
||||
export type DashboardScriptSummary = {
|
||||
active_by_type: Record<ScriptType, number>;
|
||||
total_by_type: Record<ScriptType, number>;
|
||||
};
|
||||
|
||||
export type DashboardAccessSummary = {
|
||||
permission_assignments: number;
|
||||
users_without_permissions: number;
|
||||
};
|
||||
|
||||
export type DashboardSummaryResponse = {
|
||||
window_days: number;
|
||||
generated_at: string;
|
||||
overview: DashboardOverviewSummary;
|
||||
health: DashboardHealthSummary;
|
||||
logging: DashboardLoggingSummary;
|
||||
scripts: DashboardScriptSummary;
|
||||
access: DashboardAccessSummary;
|
||||
series: {
|
||||
daily_totals: AnalyticsDailyTotalsPoint[];
|
||||
backend_quality: AnalyticsBackendQualityPoint[];
|
||||
model_trends: AnalyticsModelTrendPoint[];
|
||||
};
|
||||
};
|
||||
|
||||
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
|
||||
|
||||
export type UserScript = {
|
||||
id: number;
|
||||
name: string;
|
||||
script_type: ScriptType;
|
||||
target_user_id: number | null;
|
||||
target_backend_id: number | null;
|
||||
script_code: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CreateScriptData = {
|
||||
name: string;
|
||||
script_type: ScriptType;
|
||||
target_user_id?: number | null;
|
||||
target_backend_id?: number | null;
|
||||
script_code: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateScriptData = {
|
||||
name?: string;
|
||||
script_type?: ScriptType;
|
||||
target_user_id?: number | null;
|
||||
target_backend_id?: number | null;
|
||||
script_code?: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type AdminAuthMode = 'env' | 'oidc' | 'both';
|
||||
|
||||
export type AdminPrincipal = {
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type AdminSessionResponse = {
|
||||
authenticated: boolean;
|
||||
authMode: AdminAuthMode;
|
||||
csrfToken: string | null;
|
||||
principal: AdminPrincipal | null;
|
||||
};
|
||||
|
||||
export type AdminApiTokenSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
display_name: string;
|
||||
token_prefix: string;
|
||||
expires_at: string;
|
||||
last_used_at?: string;
|
||||
revoked_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
// Re-export every shared domain type so existing client code can keep
|
||||
// importing from `../types`. The shared package is the single source of
|
||||
// truth for the schemas and types that travel across the wire.
|
||||
export * from '@kyush/shared';
|
||||
|
|
|
|||
|
|
@ -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,13 +1,27 @@
|
|||
import { For, Show, createMemo } from 'solid-js';
|
||||
import {
|
||||
LooseChatCompletionRequestSchema,
|
||||
LooseChatCompletionResponseSchema,
|
||||
} from '@kyush/shared';
|
||||
import { For, Show, type Component } from 'solid-js';
|
||||
|
||||
import { MetaCluster } from './MetaCluster';
|
||||
import { StatusBadge, type StatusTone } from './StatusBadge';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Types
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
type KnownChatRole = 'system' | 'user' | 'assistant';
|
||||
|
||||
interface MetaItem {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
metadata?: MetaItem[];
|
||||
}
|
||||
|
||||
interface ConversationTimelineProps {
|
||||
|
|
@ -16,130 +30,191 @@ interface ConversationTimelineProps {
|
|||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function normalizePayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Parsing helpers
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function parseJsonLike(value: unknown): unknown {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
return value;
|
||||
}
|
||||
|
||||
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')
|
||||
.map((item) => ({
|
||||
role: typeof item.role === 'string' ? item.role : 'unknown',
|
||||
content: typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? ''),
|
||||
}));
|
||||
function stringifyContent(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssistantMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
|
||||
const choices = payload?.choices;
|
||||
if (!Array.isArray(choices)) return [];
|
||||
function parseRequest(body: unknown) {
|
||||
const raw = parseJsonLike(body);
|
||||
if (raw == null) return null;
|
||||
const result = LooseChatCompletionRequestSchema.safeParse(raw);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
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;
|
||||
function parseResponse(body: unknown) {
|
||||
const raw = parseJsonLike(body);
|
||||
if (raw == null) return null;
|
||||
const result = LooseChatCompletionResponseSchema.safeParse(raw);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
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));
|
||||
/**
|
||||
* Lift the user/system/assistant messages out of an OpenAI-shaped request body.
|
||||
* Returns an empty array when nothing parsable is present.
|
||||
*/
|
||||
function extractRequestMessages(
|
||||
request: ReturnType<typeof parseRequest>,
|
||||
): ParsedMessage[] {
|
||||
if (!request?.messages) return [];
|
||||
return request.messages.map((message) => ({
|
||||
role: typeof message.role === 'string' ? message.role : 'unknown',
|
||||
content: stringifyContent(message.content),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift the assistant turns out of an OpenAI-shaped response body, including the
|
||||
* extra metadata fields (reasoning, tool calls, finish_reason, etc.) that show
|
||||
* up in chat completion choices.
|
||||
*/
|
||||
function extractResponseMessages(
|
||||
response: ReturnType<typeof parseResponse>,
|
||||
): ParsedMessage[] {
|
||||
if (!response?.choices) return [];
|
||||
|
||||
return response.choices
|
||||
.map((choice): ParsedMessage | null => {
|
||||
const message = choice.message;
|
||||
if (!message) return null;
|
||||
|
||||
const metadata: MetaItem[] = [];
|
||||
if (message.reasoning_content !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Reasoning',
|
||||
value: stringifyContent(message.reasoning_content),
|
||||
});
|
||||
}
|
||||
if (message.tool_calls !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Tool Calls',
|
||||
value: stringifyContent(message.tool_calls),
|
||||
});
|
||||
}
|
||||
if (choice.finish_reason !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Finish',
|
||||
value: String(choice.finish_reason),
|
||||
});
|
||||
}
|
||||
if (choice.matched_stop !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Matched Stop',
|
||||
value: String(choice.matched_stop),
|
||||
});
|
||||
}
|
||||
if (choice.logprobs !== undefined) {
|
||||
metadata.push({
|
||||
key: 'Logprobs',
|
||||
value: stringifyContent(choice.logprobs),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
metadata,
|
||||
role: 'assistant',
|
||||
content: stringifyContent(message.content),
|
||||
metadata: metadata.length > 0 ? metadata : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return messages.filter((message): message is ParsedMessage => message !== null);
|
||||
})
|
||||
.filter((message): message is ParsedMessage => message !== null);
|
||||
}
|
||||
|
||||
export function hasRenderableConversation(requestBody?: unknown, responseBody?: unknown): boolean {
|
||||
const requestMessages = normalizeMessages(normalizePayload(requestBody));
|
||||
const responseMessages = normalizeAssistantMessages(normalizePayload(responseBody));
|
||||
return requestMessages.length > 0 || responseMessages.length > 0;
|
||||
function buildSummaryItems(
|
||||
request: ReturnType<typeof parseRequest>,
|
||||
response: ReturnType<typeof parseResponse>,
|
||||
): MetaItem[] {
|
||||
const items: MetaItem[] = [];
|
||||
|
||||
if (typeof request?.model === 'string') {
|
||||
items.push({ key: 'Model', value: request.model });
|
||||
}
|
||||
if (request?.temperature !== undefined) {
|
||||
items.push({ key: 'Temp', value: String(request.temperature) });
|
||||
}
|
||||
if (typeof response?.created === 'number') {
|
||||
items.push({ key: 'Created', value: String(response.created) });
|
||||
}
|
||||
|
||||
const usage = response?.usage;
|
||||
if (usage) {
|
||||
if (usage.prompt_tokens !== undefined) {
|
||||
items.push({ key: 'Prompt', value: String(usage.prompt_tokens) });
|
||||
}
|
||||
if (usage.completion_tokens !== undefined) {
|
||||
items.push({ key: 'Completion', value: String(usage.completion_tokens) });
|
||||
}
|
||||
if (usage.total_tokens !== undefined) {
|
||||
items.push({ key: 'Total', value: String(usage.total_tokens) });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const roleTone: Record<KnownChatRole, StatusTone> = {
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Public helpers + component
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const ROLE_TONES: Record<KnownChatRole, StatusTone> = {
|
||||
system: 'info',
|
||||
user: 'warning',
|
||||
assistant: 'success',
|
||||
};
|
||||
|
||||
function getRoleTone(role: string): StatusTone {
|
||||
if (role === 'system' || role === 'user' || role === 'assistant') {
|
||||
return roleTone[role];
|
||||
}
|
||||
return 'neutral';
|
||||
function isKnownRole(role: string): role is KnownChatRole {
|
||||
return role === 'system' || role === 'user' || role === 'assistant';
|
||||
}
|
||||
|
||||
function getRoleClass(role: string): string {
|
||||
if (role === 'system' || role === 'user' || role === 'assistant') {
|
||||
return `ui-conversation__turn--${role}`;
|
||||
}
|
||||
return 'ui-conversation__turn--unknown';
|
||||
const getRoleTone = (role: string): StatusTone =>
|
||||
isKnownRole(role) ? ROLE_TONES[role] : 'neutral';
|
||||
|
||||
const getRoleClass = (role: string): string =>
|
||||
isKnownRole(role)
|
||||
? `ui-conversation__turn--${role}`
|
||||
: 'ui-conversation__turn--unknown';
|
||||
|
||||
export function hasRenderableConversation(
|
||||
requestBody?: unknown,
|
||||
responseBody?: unknown,
|
||||
): boolean {
|
||||
const requestMessages = extractRequestMessages(parseRequest(requestBody));
|
||||
const responseMessages = extractResponseMessages(parseResponse(responseBody));
|
||||
return requestMessages.length > 0 || responseMessages.length > 0;
|
||||
}
|
||||
|
||||
export function ConversationTimeline(props: ConversationTimelineProps) {
|
||||
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
|
||||
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
|
||||
|
||||
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
|
||||
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;
|
||||
|
||||
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,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
});
|
||||
export const ConversationTimeline: Component<ConversationTimelineProps> = (
|
||||
props,
|
||||
) => {
|
||||
// Inline derivations — Solid's reactive prop reads make extra createMemo
|
||||
// wrappers unnecessary for cheap shape transforms like these.
|
||||
const request = () => parseRequest(props.requestBody);
|
||||
const response = () => parseResponse(props.responseBody);
|
||||
const messages = () => [
|
||||
...extractRequestMessages(request()),
|
||||
...extractResponseMessages(response()),
|
||||
];
|
||||
const summaryItems = () => buildSummaryItems(request(), response());
|
||||
|
||||
return (
|
||||
<div class="ui-conversation">
|
||||
|
|
@ -148,16 +223,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>
|
||||
|
|
@ -174,4 +260,4 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
|
|||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,7 @@
|
|||
import * as KCheckbox from '@kobalte/core/checkbox';
|
||||
import * as CheckboxPrimitive from '@kobalte/core/checkbox';
|
||||
import Check from 'lucide-solid/icons/check';
|
||||
import { Show, type Component } from 'solid-js';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
interface CheckboxProps {
|
||||
|
|
@ -11,23 +14,29 @@ interface CheckboxProps {
|
|||
onChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function Checkbox(props: CheckboxProps) {
|
||||
export const Checkbox: Component<CheckboxProps> = (props) => {
|
||||
return (
|
||||
<KCheckbox.Root
|
||||
class={cn('ui-checkbox', props.class)}
|
||||
<CheckboxPrimitive.Root
|
||||
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.Control>
|
||||
<CheckboxPrimitive.Input />
|
||||
<CheckboxPrimitive.Control class="ui-checkbox__control">
|
||||
<CheckboxPrimitive.Indicator class="ui-checkbox__indicator">
|
||||
<Check aria-hidden="true" size={14} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Control>
|
||||
<span>
|
||||
<KCheckbox.Label>{props.label}</KCheckbox.Label>
|
||||
{props.description && <KCheckbox.Description class="ui-field__description">{props.description}</KCheckbox.Description>}
|
||||
<CheckboxPrimitive.Label>{props.label}</CheckboxPrimitive.Label>
|
||||
<Show when={props.description}>
|
||||
<CheckboxPrimitive.Description class="ui-field__description">
|
||||
{props.description}
|
||||
</CheckboxPrimitive.Description>
|
||||
</Show>
|
||||
</span>
|
||||
</KCheckbox.Root>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,32 +1,61 @@
|
|||
import * as KDialog from '@kobalte/core/dialog';
|
||||
import type { ParentProps } from 'solid-js';
|
||||
import * as DialogPrimitive from '@kobalte/core/dialog';
|
||||
|
||||
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) => (
|
||||
<DialogPrimitive.Root {...(props as DialogPrimitive.DialogRootProps)}>
|
||||
{props.children}
|
||||
</DialogPrimitive.Root>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KDialog.Trigger {...(props as KDialog.DialogTriggerProps)} class={cn('ui-button', props.class)}>
|
||||
<DialogPrimitive.Trigger
|
||||
{...(props as DialogPrimitive.DialogTriggerProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDialog.Trigger>
|
||||
</DialogPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<DialogPrimitive.Portal>{props.children}</DialogPrimitive.Portal>
|
||||
),
|
||||
Overlay: (props: WrapperProps) => (
|
||||
<DialogPrimitive.Overlay
|
||||
{...(props as DialogPrimitive.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)}>
|
||||
<DialogPrimitive.Content
|
||||
{...(props as DialogPrimitive.DialogContentProps)}
|
||||
class={cn('ui-dialog__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDialog.Content>
|
||||
</DialogPrimitive.Content>
|
||||
),
|
||||
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)}>
|
||||
Title: (props: WrapperProps) => (
|
||||
<DialogPrimitive.Title {...(props as DialogPrimitive.DialogTitleProps)}>
|
||||
{props.children}
|
||||
</KDialog.Description>
|
||||
</DialogPrimitive.Title>
|
||||
),
|
||||
Description: (props: WrapperProps) => (
|
||||
<DialogPrimitive.Description
|
||||
{...(props as DialogPrimitive.DialogDescriptionProps)}
|
||||
class={cn('ui-subtitle', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</DialogPrimitive.Description>
|
||||
),
|
||||
CloseButton: (props: WrapperProps) => (
|
||||
<KDialog.CloseButton {...(props as KDialog.DialogCloseButtonProps)} class={cn('ui-button', props.class)}>
|
||||
<DialogPrimitive.CloseButton
|
||||
{...(props as DialogPrimitive.DialogCloseButtonProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDialog.CloseButton>
|
||||
</DialogPrimitive.CloseButton>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,52 @@
|
|||
import * as KDropdownMenu from '@kobalte/core/dropdown-menu';
|
||||
import type { ParentProps } from 'solid-js';
|
||||
import * as DropdownMenuPrimitive from '@kobalte/core/dropdown-menu';
|
||||
|
||||
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>,
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Trigger {...(props as KDropdownMenu.DropdownMenuTriggerProps)} class={cn('ui-button', props.class)}>
|
||||
Root: (props: WrapperProps) => (
|
||||
<DropdownMenuPrimitive.Root
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuRootProps)}
|
||||
>
|
||||
{props.children}
|
||||
</KDropdownMenu.Trigger>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
),
|
||||
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)}>
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuTriggerProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDropdownMenu.Content>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<DropdownMenuPrimitive.Content
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuContentProps)}
|
||||
class={cn('ui-dropdown__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
),
|
||||
Item: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Item {...(props as KDropdownMenu.DropdownMenuItemProps)} class={cn('ui-dropdown__item', props.class)}>
|
||||
<DropdownMenuPrimitive.Item
|
||||
{...(props as DropdownMenuPrimitive.DropdownMenuItemProps)}
|
||||
class={cn('ui-dropdown__item', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KDropdownMenu.Item>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
),
|
||||
Separator: (props: WrapperProps) => (
|
||||
<KDropdownMenu.Separator {...(props as KDropdownMenu.DropdownMenuSeparatorProps)} class={cn('ui-dropdown__separator', props.class)} />
|
||||
<DropdownMenuPrimitive.Separator
|
||||
{...(props as DropdownMenuPrimitive.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,31 +1,55 @@
|
|||
import * as KPopover from '@kobalte/core/popover';
|
||||
import type { ParentProps } from 'solid-js';
|
||||
import * as PopoverPrimitive from '@kobalte/core/popover';
|
||||
|
||||
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) => (
|
||||
<PopoverPrimitive.Root {...(props as PopoverPrimitive.PopoverRootProps)}>
|
||||
{props.children}
|
||||
</PopoverPrimitive.Root>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KPopover.Trigger {...(props as KPopover.PopoverTriggerProps)} class={cn('ui-button', props.class)}>
|
||||
<PopoverPrimitive.Trigger
|
||||
{...(props as PopoverPrimitive.PopoverTriggerProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KPopover.Trigger>
|
||||
</PopoverPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<PopoverPrimitive.Portal>{props.children}</PopoverPrimitive.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)}>
|
||||
<PopoverPrimitive.Content
|
||||
{...(props as PopoverPrimitive.PopoverContentProps)}
|
||||
class={cn('ui-popover__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KPopover.Content>
|
||||
</PopoverPrimitive.Content>
|
||||
),
|
||||
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)}>
|
||||
Title: (props: WrapperProps) => (
|
||||
<PopoverPrimitive.Title {...(props as PopoverPrimitive.PopoverTitleProps)}>
|
||||
{props.children}
|
||||
</KPopover.Description>
|
||||
</PopoverPrimitive.Title>
|
||||
),
|
||||
Description: (props: WrapperProps) => (
|
||||
<PopoverPrimitive.Description
|
||||
{...(props as PopoverPrimitive.PopoverDescriptionProps)}
|
||||
class={cn('ui-subtitle', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</PopoverPrimitive.Description>
|
||||
),
|
||||
CloseButton: (props: WrapperProps) => (
|
||||
<KPopover.CloseButton {...(props as KPopover.PopoverCloseButtonProps)} class={cn('ui-button', props.class)}>
|
||||
<PopoverPrimitive.CloseButton
|
||||
{...(props as PopoverPrimitive.PopoverCloseButtonProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KPopover.CloseButton>
|
||||
</PopoverPrimitive.CloseButton>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import * as KSelect from '@kobalte/core/select';
|
||||
import { Show, createMemo } from 'solid-js';
|
||||
import * as SelectPrimitive from '@kobalte/core/select';
|
||||
import Check from 'lucide-solid/icons/check';
|
||||
import ChevronDown from 'lucide-solid/icons/chevron-down';
|
||||
import { Show, type Component } from 'solid-js';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
export interface SelectOption {
|
||||
|
|
@ -17,39 +20,53 @@ interface SelectProps {
|
|||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Select(props: SelectProps) {
|
||||
const selected = createMemo(() => props.options.find((option) => option.value === props.value));
|
||||
export const Select: Component<SelectProps> = (props) => {
|
||||
// Note: derive `selected` inline (no createMemo) — Solid's reactive primitives
|
||||
// already cache prop reads, and `find` over a typically tiny option list is
|
||||
// cheaper than the memo bookkeeping.
|
||||
const selected = () =>
|
||||
props.options.find((option) => option.value === props.value);
|
||||
|
||||
return (
|
||||
<KSelect.Root<SelectOption>
|
||||
<SelectPrimitive.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.ItemLabel>{itemProps.item.rawValue.label}</KSelect.ItemLabel>
|
||||
<KSelect.ItemIndicator>✓</KSelect.ItemIndicator>
|
||||
</KSelect.Item>
|
||||
<SelectPrimitive.Item class="ui-select__item" item={itemProps.item}>
|
||||
<SelectPrimitive.ItemLabel>
|
||||
{itemProps.item.rawValue.label}
|
||||
</SelectPrimitive.ItemLabel>
|
||||
<SelectPrimitive.ItemIndicator class="ui-select__item-indicator">
|
||||
<Check aria-hidden="true" size={14} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.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>
|
||||
<SelectPrimitive.Label class="ui-field__label">
|
||||
{props.label}
|
||||
</SelectPrimitive.Label>
|
||||
</Show>
|
||||
<KSelect.Trigger class="ui-select__trigger">
|
||||
<KSelect.Value<SelectOption> class="ui-select__value">
|
||||
{(state) => state.selectedOption()?.label ?? props.placeholder ?? 'Select'}
|
||||
</KSelect.Value>
|
||||
<KSelect.Icon>▾</KSelect.Icon>
|
||||
</KSelect.Trigger>
|
||||
<KSelect.Portal>
|
||||
<KSelect.Content class="ui-select__content">
|
||||
<KSelect.Listbox />
|
||||
</KSelect.Content>
|
||||
</KSelect.Portal>
|
||||
</KSelect.Root>
|
||||
<SelectPrimitive.Trigger class="ui-select__trigger">
|
||||
<SelectPrimitive.Value<SelectOption> class="ui-select__value">
|
||||
{(state) =>
|
||||
state.selectedOption()?.label ?? props.placeholder ?? 'Select'
|
||||
}
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon class="ui-select__icon">
|
||||
<ChevronDown aria-hidden="true" size={14} />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content class="ui-select__content">
|
||||
<SelectPrimitive.Listbox />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as KSwitch from '@kobalte/core/switch';
|
||||
import * as SwitchPrimitive from '@kobalte/core/switch';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
interface SwitchProps {
|
||||
|
|
@ -13,21 +14,25 @@ interface SwitchProps {
|
|||
|
||||
export function Switch(props: SwitchProps) {
|
||||
return (
|
||||
<KSwitch.Root
|
||||
class={cn('ui-switch', props.class)}
|
||||
<SwitchPrimitive.Root
|
||||
checked={props.checked}
|
||||
class={cn('ui-switch', props.class)}
|
||||
defaultChecked={props.defaultChecked}
|
||||
disabled={props.disabled}
|
||||
onChange={props.onChange}
|
||||
>
|
||||
<KSwitch.Input />
|
||||
<KSwitch.Control class="ui-switch__control">
|
||||
<KSwitch.Thumb class="ui-switch__thumb" />
|
||||
</KSwitch.Control>
|
||||
<SwitchPrimitive.Input />
|
||||
<SwitchPrimitive.Control class="ui-switch__control">
|
||||
<SwitchPrimitive.Thumb class="ui-switch__thumb" />
|
||||
</SwitchPrimitive.Control>
|
||||
<span>
|
||||
<KSwitch.Label>{props.label}</KSwitch.Label>
|
||||
{props.description && <KSwitch.Description class="ui-field__description">{props.description}</KSwitch.Description>}
|
||||
<SwitchPrimitive.Label>{props.label}</SwitchPrimitive.Label>
|
||||
{props.description && (
|
||||
<SwitchPrimitive.Description class="ui-field__description">
|
||||
{props.description}
|
||||
</SwitchPrimitive.Description>
|
||||
)}
|
||||
</span>
|
||||
</KSwitch.Root>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,42 @@
|
|||
import * as KTabs from '@kobalte/core/tabs';
|
||||
import type { ParentProps } from 'solid-js';
|
||||
import * as TabsPrimitive from '@kobalte/core/tabs';
|
||||
|
||||
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>,
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<KTabs.Trigger {...(props as unknown as KTabs.TabsTriggerProps)} class={cn('ui-tabs__trigger', props.class)}>
|
||||
Root: (props: WrapperProps) => (
|
||||
<TabsPrimitive.Root
|
||||
{...(props as unknown as TabsPrimitive.TabsRootProps)}
|
||||
class={cn('ui-tabs', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KTabs.Trigger>
|
||||
</TabsPrimitive.Root>
|
||||
),
|
||||
List: (props: WrapperProps) => (
|
||||
<TabsPrimitive.List
|
||||
{...(props as unknown as TabsPrimitive.TabsListProps)}
|
||||
class={cn('ui-tabs__list', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</TabsPrimitive.List>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<TabsPrimitive.Trigger
|
||||
{...(props as unknown as TabsPrimitive.TabsTriggerProps)}
|
||||
class={cn('ui-tabs__trigger', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</TabsPrimitive.Trigger>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<KTabs.Content {...(props as unknown as KTabs.TabsContentProps)} class={cn('ui-tabs__content', props.class)}>
|
||||
<TabsPrimitive.Content
|
||||
{...(props as unknown as TabsPrimitive.TabsContentProps)}
|
||||
class={cn('ui-tabs__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KTabs.Content>
|
||||
</TabsPrimitive.Content>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import * as KTextField from '@kobalte/core/text-field';
|
||||
import type { JSX, ParentProps } from 'solid-js';
|
||||
import * as TextFieldPrimitive from '@kobalte/core/text-field';
|
||||
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
import type { JSX, ParentProps } from 'solid-js';
|
||||
|
||||
interface TextFieldProps extends ParentProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
|
|
@ -11,36 +13,62 @@ 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.Label class="ui-field__label">{props.label}</KTextField.Label>
|
||||
<TextFieldPrimitive.Root
|
||||
class={cn('ui-field', props.class)}
|
||||
validationState={props.errorMessage ? 'invalid' : 'valid'}
|
||||
>
|
||||
<TextFieldPrimitive.Label class="ui-field__label">
|
||||
{props.label}
|
||||
</TextFieldPrimitive.Label>
|
||||
<div class="ui-field__control-row">
|
||||
<div class="ui-field__control-fill">
|
||||
{props.multiline ? (
|
||||
<KTextField.TextArea
|
||||
<TextFieldPrimitive.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
|
||||
<TextFieldPrimitive.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>}
|
||||
</KTextField.Root>
|
||||
{props.description && (
|
||||
<TextFieldPrimitive.Description class="ui-field__description">
|
||||
{props.description}
|
||||
</TextFieldPrimitive.Description>
|
||||
)}
|
||||
{props.errorMessage && (
|
||||
<TextFieldPrimitive.ErrorMessage class="ui-field__error">
|
||||
{props.errorMessage}
|
||||
</TextFieldPrimitive.ErrorMessage>
|
||||
)}
|
||||
</TextFieldPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,56 @@
|
|||
import * as KToast from '@kobalte/core/toast';
|
||||
import type { ParentProps } from 'solid-js';
|
||||
import * as ToastPrimitive from '@kobalte/core/toast';
|
||||
|
||||
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)}>
|
||||
<ToastPrimitive.Region
|
||||
{...(props as ToastPrimitive.ToastRegionProps)}
|
||||
class={cn('ui-toast-region', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.Region>
|
||||
</ToastPrimitive.Region>
|
||||
),
|
||||
List: (props: WrapperProps) => (
|
||||
<KToast.List {...(props as KToast.ToastListProps)} class={cn('ui-toast-list', props.class)}>
|
||||
<ToastPrimitive.List
|
||||
{...(props as ToastPrimitive.ToastListProps)}
|
||||
class={cn('ui-toast-list', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.List>
|
||||
</ToastPrimitive.List>
|
||||
),
|
||||
Root: (props: WrapperProps) => (
|
||||
<KToast.Root {...(props as unknown as KToast.ToastRootProps)} class={cn('ui-toast', props.class)}>
|
||||
<ToastPrimitive.Root
|
||||
{...(props as unknown as ToastPrimitive.ToastRootProps)}
|
||||
class={cn('ui-toast', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.Root>
|
||||
</ToastPrimitive.Root>
|
||||
),
|
||||
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)}>
|
||||
Title: (props: WrapperProps) => (
|
||||
<ToastPrimitive.Title {...(props as ToastPrimitive.ToastTitleProps)}>
|
||||
{props.children}
|
||||
</KToast.Description>
|
||||
</ToastPrimitive.Title>
|
||||
),
|
||||
Description: (props: WrapperProps) => (
|
||||
<ToastPrimitive.Description
|
||||
{...(props as ToastPrimitive.ToastDescriptionProps)}
|
||||
class={cn('ui-subtitle', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</ToastPrimitive.Description>
|
||||
),
|
||||
CloseButton: (props: WrapperProps) => (
|
||||
<KToast.CloseButton {...(props as KToast.ToastCloseButtonProps)} class={cn('ui-button', props.class)}>
|
||||
<ToastPrimitive.CloseButton
|
||||
{...(props as ToastPrimitive.ToastCloseButtonProps)}
|
||||
class={cn('ui-button', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</KToast.CloseButton>
|
||||
</ToastPrimitive.CloseButton>
|
||||
),
|
||||
toaster: KToast.toaster,
|
||||
toaster: ToastPrimitive.toaster,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,42 @@
|
|||
import * as KTooltip from '@kobalte/core/tooltip';
|
||||
import type { ParentProps } from 'solid-js';
|
||||
import * as TooltipPrimitive from '@kobalte/core/tooltip';
|
||||
|
||||
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>,
|
||||
Content: (props: WrapperProps) => (
|
||||
<KTooltip.Content {...(props as KTooltip.TooltipContentProps)} class={cn('ui-tooltip__content', props.class)}>
|
||||
Root: (props: WrapperProps) => (
|
||||
<TooltipPrimitive.Root
|
||||
openDelay={150}
|
||||
{...(props as TooltipPrimitive.TooltipRootProps)}
|
||||
>
|
||||
{props.children}
|
||||
</KTooltip.Content>
|
||||
</TooltipPrimitive.Root>
|
||||
),
|
||||
Trigger: (props: WrapperProps) => (
|
||||
<TooltipPrimitive.Trigger
|
||||
{...(props as TooltipPrimitive.TooltipTriggerProps)}
|
||||
class={props.class}
|
||||
>
|
||||
{props.children}
|
||||
</TooltipPrimitive.Trigger>
|
||||
),
|
||||
Portal: (props: WrapperProps) => (
|
||||
<TooltipPrimitive.Portal>{props.children}</TooltipPrimitive.Portal>
|
||||
),
|
||||
Content: (props: WrapperProps) => (
|
||||
<TooltipPrimitive.Content
|
||||
{...(props as TooltipPrimitive.TooltipContentProps)}
|
||||
class={cn('ui-tooltip__content', props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</TooltipPrimitive.Content>
|
||||
),
|
||||
Arrow: (props: WrapperProps) => (
|
||||
<TooltipPrimitive.Arrow
|
||||
{...(props as TooltipPrimitive.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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,32 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
@import './styles/tokens.css';
|
||||
@import './styles/base.css';
|
||||
@import './styles/primitives.css';
|
||||
@import './styles/patterns.css';
|
||||
@import './styles/layout.css';
|
||||
@import './styles/pages.css';
|
||||
|
||||
/*
|
||||
* Tailwind v4 theme bridging — expose existing CSS custom properties as theme tokens
|
||||
* so utility classes (e.g. `text-(--color-text)`, `bg-(--color-surface)`) and the
|
||||
* `theme()` function resolve to the same design tokens that the legacy stylesheets use.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-bg: var(--color-bg);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-text: var(--color-text);
|
||||
--color-text-muted: var(--color-text-muted);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-success: var(--color-success);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-danger: var(--color-danger);
|
||||
--color-border: var(--color-border);
|
||||
|
||||
--radius-1: var(--radius-1);
|
||||
--radius-2: var(--radius-2);
|
||||
--radius-3: var(--radius-3);
|
||||
|
||||
--font-sans: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
|
||||
--font-mono: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
|
|
|||
9
client/src/vite-env.d.ts
vendored
Normal file
9
client/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import solidPlugin from 'vite-plugin-solid';
|
||||
|
||||
|
|
@ -5,6 +6,7 @@ export default defineConfig({
|
|||
base: '/dashboard/',
|
||||
plugins: [
|
||||
solidPlugin(),
|
||||
tailwindcss(),
|
||||
{
|
||||
name: 'dashboard-trailing-slash-redirect',
|
||||
configureServer(server) {
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3577
pnpm-lock.yaml
generated
3577
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
packages:
|
||||
- shared
|
||||
- server
|
||||
- client
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "src/main.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-schemas.mjs",
|
||||
"start": "node dist/server/src/index.js",
|
||||
"dev": "tsx watch src",
|
||||
"build": "tsc --noEmit",
|
||||
"start": "tsx src/main.ts",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
|
@ -17,25 +18,27 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/swagger-ui": "^0.5.0",
|
||||
"@hono/zod-openapi": "^0.18.0",
|
||||
"@hono/zod-validator": "^0.4.2",
|
||||
"@kyush/shared": "workspace:*",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"es-toolkit": "^1.32.0",
|
||||
"hono": "^4.6.14",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"zod": "^4.3.6"
|
||||
"tsx": "^4.21.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@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,16 +0,0 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const serverRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(serverRoot, '..');
|
||||
const sourceDir = path.join(repoRoot, 'database');
|
||||
const targetDir = path.join(serverRoot, 'dist', 'database');
|
||||
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
for (const fileName of ['schema.sql', 'analytics-schema.sql', 'request-logs-schema.sql']) {
|
||||
fs.copyFileSync(path.join(sourceDir, fileName), path.join(targetDir, fileName));
|
||||
}
|
||||
|
|
@ -1,81 +1,68 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { AdminAuthMode } from '../../../shared/types';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
function normalizeAuthMode(value?: string): AdminAuthMode {
|
||||
if (value === 'env' || value === 'oidc' || value === 'both') {
|
||||
return value;
|
||||
}
|
||||
return 'both';
|
||||
}
|
||||
import { env } from './env';
|
||||
|
||||
function parseList(value?: string): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
import type { AdminAuthMode } from '../../../shared/types';
|
||||
|
||||
export function getAdminAuthMode(): AdminAuthMode {
|
||||
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
|
||||
return env.ADMIN_AUTH_MODE;
|
||||
}
|
||||
|
||||
export function isEnvAdminEnabled(): boolean {
|
||||
const mode = getAdminAuthMode();
|
||||
const mode = env.ADMIN_AUTH_MODE;
|
||||
return mode === 'env' || mode === 'both';
|
||||
}
|
||||
|
||||
export function isOidcEnabled(): boolean {
|
||||
const mode = getAdminAuthMode();
|
||||
const mode = env.ADMIN_AUTH_MODE;
|
||||
return mode === 'oidc' || mode === 'both';
|
||||
}
|
||||
|
||||
export function getAdminUsername(): string | null {
|
||||
return process.env.ADMIN_USERNAME?.trim() || null;
|
||||
return env.ADMIN_USERNAME;
|
||||
}
|
||||
|
||||
export function getAdminPasswordHash(): string | null {
|
||||
return process.env.ADMIN_PASSWORD_HASH?.trim() || null;
|
||||
return env.ADMIN_PASSWORD_HASH;
|
||||
}
|
||||
|
||||
export function getAdminSessionSecret(): string {
|
||||
return process.env.ADMIN_SESSION_SECRET?.trim() || 'development-admin-session-secret';
|
||||
return env.ADMIN_SESSION_SECRET;
|
||||
}
|
||||
|
||||
export function getAdminSessionTtlHours(): number {
|
||||
const parsed = Number(process.env.ADMIN_SESSION_TTL_HOURS ?? 12);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 12;
|
||||
return env.ADMIN_SESSION_TTL_HOURS;
|
||||
}
|
||||
|
||||
export function getAdminApiTokenTtlDays(): number {
|
||||
const parsed = Number(process.env.ADMIN_API_TOKEN_TTL_DAYS ?? 30);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
|
||||
return env.ADMIN_API_TOKEN_TTL_DAYS;
|
||||
}
|
||||
|
||||
export function getCookieSecure(): boolean {
|
||||
return process.env.NODE_ENV === 'production' && process.env.ADMIN_COOKIE_SECURE !== 'false';
|
||||
return env.ADMIN_COOKIE_SECURE;
|
||||
}
|
||||
|
||||
export function getAllowedOidcEmails(): string[] {
|
||||
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) => entry.toLowerCase());
|
||||
return env.OIDC_ALLOWED_EMAILS;
|
||||
}
|
||||
|
||||
export function getTrustedProxyIps(): string[] {
|
||||
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
|
||||
return env.ADMIN_TRUSTED_PROXY_IPS;
|
||||
}
|
||||
|
||||
export function getOidcConfig() {
|
||||
return {
|
||||
issuerUrl: process.env.OIDC_ISSUER_URL?.trim() || '',
|
||||
clientId: process.env.OIDC_CLIENT_ID?.trim() || '',
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET?.trim() || '',
|
||||
redirectUri: process.env.OIDC_REDIRECT_URI?.trim() || '',
|
||||
scopes: process.env.OIDC_SCOPES?.trim() || 'openid profile email',
|
||||
issuerUrl: env.OIDC_ISSUER_URL,
|
||||
clientId: env.OIDC_CLIENT_ID,
|
||||
clientSecret: env.OIDC_CLIENT_SECRET,
|
||||
redirectUri: env.OIDC_REDIRECT_URI,
|
||||
scopes: env.OIDC_SCOPES,
|
||||
};
|
||||
}
|
||||
|
||||
export function hashOpaqueToken(token: string): string {
|
||||
return createHash('sha256')
|
||||
.update(getAdminSessionSecret())
|
||||
.update(env.ADMIN_SESSION_SECRET)
|
||||
.update(':')
|
||||
.update(token)
|
||||
.digest('hex');
|
||||
|
|
|
|||
|
|
@ -1,47 +1,41 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { ensureDir, getAnalyticsDbPath } from './db-paths';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
let db: Database.Database;
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { ensureDir, getAnalyticsDbPath, getSchemaPath } from './db-paths';
|
||||
|
||||
let db: Database.Database | undefined;
|
||||
|
||||
function loadSchema(database: Database.Database): void {
|
||||
const schema = fs.readFileSync(
|
||||
getSchemaPath('analytics-schema.sql'),
|
||||
'utf-8',
|
||||
);
|
||||
database.exec(schema);
|
||||
}
|
||||
|
||||
function openDb(): Database.Database {
|
||||
const analyticsDbPath = getAnalyticsDbPath();
|
||||
ensureDir(path.dirname(analyticsDbPath));
|
||||
const handle = new Database(analyticsDbPath);
|
||||
handle.pragma('foreign_keys = ON');
|
||||
loadSchema(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function getAnalyticsDb(): Database.Database {
|
||||
if (!db) {
|
||||
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);
|
||||
}
|
||||
db ??= openDb();
|
||||
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);
|
||||
|
||||
db?.close();
|
||||
db = openDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeAnalyticsDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = undefined as unknown as Database.Database;
|
||||
}
|
||||
db?.close();
|
||||
db = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +1,70 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { ensureDir, getCoreDbPath } from './db-paths';
|
||||
|
||||
let db: Database.Database;
|
||||
import { ensureDir, getCoreDbPath, getSchemaPath } from './db-paths';
|
||||
|
||||
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);
|
||||
// Lazy singleton — `getDb()` instantiates on first access, `closeDb()` resets
|
||||
// it back to `undefined` so the next `getDb()` reopens a fresh handle.
|
||||
let db: Database.Database | undefined;
|
||||
|
||||
function isPragmaColumnRow(value: unknown): value is { name: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'name' in value &&
|
||||
typeof (value as { name: unknown }).name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(
|
||||
database: Database.Database,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean {
|
||||
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
return columns.some(
|
||||
(column) => isPragmaColumnRow(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 schema = fs.readFileSync(getSchemaPath('schema.sql'), 'utf-8');
|
||||
database.exec(schema);
|
||||
}
|
||||
|
||||
function openDb(): Database.Database {
|
||||
const coreDbPath = getCoreDbPath();
|
||||
ensureDir(path.dirname(coreDbPath));
|
||||
|
||||
const handle = new Database(coreDbPath);
|
||||
handle.pragma('foreign_keys = ON');
|
||||
loadSchema(handle);
|
||||
runCoreMigrations(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
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);
|
||||
runCoreMigrations(db);
|
||||
}
|
||||
db ??= openDb();
|
||||
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);
|
||||
runCoreMigrations(db);
|
||||
|
||||
db?.close();
|
||||
db = openDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = undefined as unknown as Database.Database;
|
||||
}
|
||||
db?.close();
|
||||
db = undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,31 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
|
||||
import { env } from './env';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Schema files (the .sql sources baked into the repo)
|
||||
*
|
||||
* Resolved relative to *this module's URL* — counted in one place so the
|
||||
* other DB modules don't each carry their own `'..'/'..'/'..'/'database'`
|
||||
* path math. Anchoring on `import.meta.url` also means it doesn't care
|
||||
* whether we're running from src (tsx) or some future bundler output, as
|
||||
* long as this file's relative position to `<repo>/database/` is preserved.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const SCHEMA_DIR_URL = new URL('../../../database/', import.meta.url);
|
||||
|
||||
export function getSchemaPath(filename: string): string {
|
||||
return fileURLToPath(new URL(filename, SCHEMA_DIR_URL));
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Runtime data directories (configurable via env.DB_DIR)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function getDbRootDir(): string {
|
||||
return process.env.DB_DIR || process.env.DB_PATH || DEFAULT_DB_DIR;
|
||||
return env.DB_DIR;
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
|
|
@ -28,4 +49,3 @@ export function getRequestLogsDir(): string {
|
|||
export function getRequestLogsDbPath(monthKey: string): string {
|
||||
return path.join(getRequestLogsDir(), `request_logs_${monthKey}.db`);
|
||||
}
|
||||
|
||||
|
|
|
|||
165
server/src/config/env.ts
Normal file
165
server/src/config/env.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Single source of truth for every environment variable the server reads.
|
||||
*
|
||||
* - All `process.env` access is centralised here.
|
||||
* - Each value is parsed/validated/normalised once at module load and exposed
|
||||
* via `env`, an immutable object.
|
||||
* - Importers should grab a typed value (`env.SERVER_PORT`) instead of touching
|
||||
* `process.env` directly. This makes mistakes loud (typos surface as TS
|
||||
* errors) and concentrates default values in one place.
|
||||
*
|
||||
* Tests can mutate the underlying `process.env` before importing this module
|
||||
* (see tests/setup.ts) — values that may legitimately change at runtime are
|
||||
* exposed as functions instead of frozen primitives.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AdminAuthMode } from '../../../shared/types';
|
||||
|
||||
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
|
||||
const DEFAULT_TIME_ZONE = 'UTC';
|
||||
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_CORS_ORIGINS = [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://localhost:3002',
|
||||
'http://127.0.0.1:3002',
|
||||
];
|
||||
|
||||
function trimmed(value: string | undefined): string | undefined {
|
||||
const result = value?.trim();
|
||||
return result && result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseList(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function parsePositiveNumber(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseNonNegativeNumber(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeAuthMode(value: string | undefined): AdminAuthMode {
|
||||
return value === 'env' || value === 'oidc' || value === 'both'
|
||||
? value
|
||||
: 'both';
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
* Eagerly-resolved values (read once at boot)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const env = {
|
||||
// Server runtime
|
||||
get SERVER_PORT(): number {
|
||||
return parsePositiveNumber(process.env.SERVER_PORT, 3000);
|
||||
},
|
||||
get NODE_ENV(): string | undefined {
|
||||
return process.env.NODE_ENV;
|
||||
},
|
||||
get IS_PRODUCTION(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
},
|
||||
|
||||
// Storage paths
|
||||
get DB_DIR(): string {
|
||||
return (
|
||||
trimmed(process.env.DB_DIR) ??
|
||||
trimmed(process.env.DB_PATH) ??
|
||||
DEFAULT_DB_DIR
|
||||
);
|
||||
},
|
||||
|
||||
// Time zone (used for daily/monthly bucket math)
|
||||
get TIME_ZONE(): string {
|
||||
return trimmed(process.env.TZ) ?? DEFAULT_TIME_ZONE;
|
||||
},
|
||||
|
||||
// CORS
|
||||
get CORS_ORIGINS(): string[] {
|
||||
const raw = trimmed(process.env.CORS_ORIGINS);
|
||||
return raw
|
||||
? raw.split(',').map((origin) => origin.trim())
|
||||
: DEFAULT_CORS_ORIGINS;
|
||||
},
|
||||
|
||||
// Admin auth mode + ENV credentials
|
||||
get ADMIN_AUTH_MODE(): AdminAuthMode {
|
||||
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
|
||||
},
|
||||
get ADMIN_USERNAME(): string | null {
|
||||
return trimmed(process.env.ADMIN_USERNAME) ?? null;
|
||||
},
|
||||
get ADMIN_PASSWORD_HASH(): string | null {
|
||||
return trimmed(process.env.ADMIN_PASSWORD_HASH) ?? null;
|
||||
},
|
||||
get ADMIN_SESSION_SECRET(): string {
|
||||
return (
|
||||
trimmed(process.env.ADMIN_SESSION_SECRET) ??
|
||||
'development-admin-session-secret'
|
||||
);
|
||||
},
|
||||
get ADMIN_SESSION_TTL_HOURS(): number {
|
||||
return parsePositiveNumber(process.env.ADMIN_SESSION_TTL_HOURS, 12);
|
||||
},
|
||||
get ADMIN_API_TOKEN_TTL_DAYS(): number {
|
||||
return parsePositiveNumber(process.env.ADMIN_API_TOKEN_TTL_DAYS, 30);
|
||||
},
|
||||
get ADMIN_COOKIE_SECURE(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.ADMIN_COOKIE_SECURE !== 'false'
|
||||
);
|
||||
},
|
||||
get ADMIN_TRUSTED_PROXY_IPS(): string[] {
|
||||
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
|
||||
},
|
||||
|
||||
// OIDC
|
||||
get OIDC_ISSUER_URL(): string {
|
||||
return trimmed(process.env.OIDC_ISSUER_URL) ?? '';
|
||||
},
|
||||
get OIDC_CLIENT_ID(): string {
|
||||
return trimmed(process.env.OIDC_CLIENT_ID) ?? '';
|
||||
},
|
||||
get OIDC_CLIENT_SECRET(): string {
|
||||
return trimmed(process.env.OIDC_CLIENT_SECRET) ?? '';
|
||||
},
|
||||
get OIDC_REDIRECT_URI(): string {
|
||||
return trimmed(process.env.OIDC_REDIRECT_URI) ?? '';
|
||||
},
|
||||
get OIDC_SCOPES(): string {
|
||||
return trimmed(process.env.OIDC_SCOPES) ?? 'openid profile email';
|
||||
},
|
||||
get OIDC_ALLOWED_EMAILS(): string[] {
|
||||
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) =>
|
||||
entry.toLowerCase(),
|
||||
);
|
||||
},
|
||||
|
||||
// Catalog refresh
|
||||
get MODEL_CATALOG_REFRESH_MIN_MS(): number {
|
||||
return parseNonNegativeNumber(
|
||||
process.env.MODEL_CATALOG_REFRESH_MIN_MS,
|
||||
DEFAULT_REFRESH_MIN_MS,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,30 +1,55 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ensureDir, getRequestLogsDbPath, getRequestLogsDir } from './db-paths';
|
||||
|
||||
import {
|
||||
ensureDir,
|
||||
getRequestLogsDbPath,
|
||||
getRequestLogsDir,
|
||||
getSchemaPath,
|
||||
} from './db-paths';
|
||||
|
||||
import { getLocalMonthKey } from '../utils/time';
|
||||
|
||||
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 }>;
|
||||
return columns.some((column) => column.name === columnName);
|
||||
function isPragmaColumnRow(value: unknown): value is { name: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'name' in value &&
|
||||
typeof (value as { name: unknown }).name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(
|
||||
database: Database.Database,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): boolean {
|
||||
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
return columns.some(
|
||||
(column) => isPragmaColumnRow(column) && column.name === columnName,
|
||||
);
|
||||
}
|
||||
|
||||
function initRequestLogsSchema(db: Database.Database): void {
|
||||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'request-logs-schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
const schema = fs.readFileSync(
|
||||
getSchemaPath('request-logs-schema.sql'),
|
||||
'utf-8',
|
||||
);
|
||||
db.exec(schema);
|
||||
if (hasColumn(db, 'request_logs', 'routed_model') === false) {
|
||||
if (!hasColumn(db, 'request_logs', 'routed_model')) {
|
||||
db.exec('ALTER TABLE request_logs ADD COLUMN routed_model TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (existing) return existing;
|
||||
|
||||
const dbPath = getRequestLogsDbPath(monthKey);
|
||||
ensureDir(path.dirname(dbPath));
|
||||
|
|
@ -35,24 +60,24 @@ export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Databas
|
|||
return db;
|
||||
}
|
||||
|
||||
export function initRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
|
||||
const existing = connections.get(monthKey);
|
||||
if (existing) {
|
||||
existing.close();
|
||||
connections.delete(monthKey);
|
||||
}
|
||||
|
||||
export function initRequestLogsDb(
|
||||
monthKey: string = getLocalMonthKey(),
|
||||
): Database.Database {
|
||||
connections.get(monthKey)?.close();
|
||||
connections.delete(monthKey);
|
||||
return getRequestLogsDb(monthKey);
|
||||
}
|
||||
|
||||
const REQUEST_LOG_FILENAME_PATTERN = /^request_logs_(\d{4}-\d{2})\.db$/;
|
||||
|
||||
export function listRequestLogMonths(): string[] {
|
||||
const requestLogsDir = getRequestLogsDir();
|
||||
ensureDir(requestLogsDir);
|
||||
|
||||
return fs
|
||||
.readdirSync(requestLogsDir)
|
||||
.map((entry) => /^request_logs_(\d{4}-\d{2})\.db$/.exec(entry)?.[1])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((entry) => REQUEST_LOG_FILENAME_PATTERN.exec(entry)?.[1])
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,91 +1,136 @@
|
|||
import express, { Application } from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
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 { env } from './config/env';
|
||||
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 type { AppEnv } from './types/hono';
|
||||
|
||||
const moduleDir = import.meta.dirname;
|
||||
|
||||
const envPathCandidates = [
|
||||
path.resolve(__dirname, '..', '..', '.env'),
|
||||
path.resolve(__dirname, '..', '..', '..', '..', '.env'),
|
||||
path.resolve(moduleDir, '..', '..', '.env'),
|
||||
path.resolve(moduleDir, '..', '..', '..', '..', '.env'),
|
||||
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'),
|
||||
path.resolve(moduleDir, '..', '..', 'client', 'dist'),
|
||||
path.resolve(moduleDir, '..', '..', '..', 'client', 'dist'),
|
||||
path.resolve(moduleDir, '..', '..', '..', '..', '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'];
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: env.CORS_ORIGINS,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(cors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({
|
||||
limit: '30mb',
|
||||
}));
|
||||
app.use(
|
||||
'*',
|
||||
bodyLimit({
|
||||
maxSize: MAX_BODY_SIZE,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use('/admin/auth', adminAuthRoutes);
|
||||
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
|
||||
app.use('/admin', requireAdminAccess, requireSessionCsrf, adminRoutes);
|
||||
app.use('/v1', apiRoutes);
|
||||
// Public admin auth endpoints
|
||||
app.route('/admin/auth', adminAuthRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
||||
// 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;
|
||||
|
|
|
|||
19
server/src/main.ts
Normal file
19
server/src/main.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { serve } from '@hono/node-server';
|
||||
|
||||
import { env } from './config/env';
|
||||
import app from './index';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: env.SERVER_PORT,
|
||||
},
|
||||
() => {
|
||||
logger.info(`Server running on port ${env.SERVER_PORT}`);
|
||||
logger.info(`Admin API: http://localhost:${env.SERVER_PORT}/admin`);
|
||||
logger.info(`Admin UI: http://localhost:${env.SERVER_PORT}/dashboard`);
|
||||
logger.info(`OpenAI API: http://localhost:${env.SERVER_PORT}/v1`);
|
||||
logger.info(`API Docs: http://localhost:${env.SERVER_PORT}/admin/docs`);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { AdminApiTokenSummary, AdminPrincipal } from '../../../shared/types';
|
||||
import { getDb } from '../config/database';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type {
|
||||
AdminApiTokenSummary,
|
||||
AdminPrincipal,
|
||||
} from '../../../shared/types';
|
||||
|
||||
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,7 +1,8 @@
|
|||
import { AdminPrincipal } from '../../../shared/types';
|
||||
import { getDb } from '../config/database';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type { AdminPrincipal } from '../../../shared/types';
|
||||
|
||||
export interface AdminSessionRecord {
|
||||
id: number;
|
||||
session_token_hash: string;
|
||||
|
|
@ -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,7 +1,13 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { Backend, CreateBackendData, UpdateBackendData } from '../../../shared/types';
|
||||
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type {
|
||||
Backend,
|
||||
CreateBackendData,
|
||||
UpdateBackendData,
|
||||
} from '../../../shared/types';
|
||||
|
||||
export class BackendModel {
|
||||
static asBackend(row: any): Backend {
|
||||
row.is_active = !!row.is_active;
|
||||
|
|
@ -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,7 +1,8 @@
|
|||
import { BackendModelSnapshot } from '../../../shared/types';
|
||||
import { getDb } from '../config/database';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type { BackendModelSnapshot } from '../../../shared/types';
|
||||
|
||||
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';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type {
|
||||
CreateModelRewriteData,
|
||||
ModelRewriteRule,
|
||||
UpdateModelRewriteData,
|
||||
} from '../../../shared/types';
|
||||
import { getDb } from '../config/database';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
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,46 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { Permission, CreatePermissionData } from '../../../shared/types';
|
||||
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type { Permission, CreatePermissionData } from '../../../shared/types';
|
||||
|
||||
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 +51,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 +62,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,7 +1,13 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { UserScript, CreateScriptData, UpdateScriptData } from '../../../shared/types';
|
||||
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type {
|
||||
UserScript,
|
||||
CreateScriptData,
|
||||
UpdateScriptData,
|
||||
} from '../../../shared/types';
|
||||
|
||||
export class ScriptModel {
|
||||
static asUserScript(row: any): UserScript {
|
||||
row.is_active = !!row.is_active;
|
||||
|
|
@ -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,8 +1,14 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { User, CreateUserData, UpdateUserData } from '../../../shared/types';
|
||||
|
||||
import { generateApiKey } from '../utils/apiKey';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
import type {
|
||||
User,
|
||||
CreateUserData,
|
||||
UpdateUserData,
|
||||
} from '../../../shared/types';
|
||||
|
||||
export class UserModel {
|
||||
static asUser(row: any): User {
|
||||
row.is_active = !!row.is_active;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
server/src/reset.d.ts
vendored
Normal file
3
server/src/reset.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Activate ts-reset's improved built-in types globally for the server.
|
||||
// See https://www.totaltypescript.com/ts-reset
|
||||
import '@total-typescript/ts-reset';
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { AdminPrincipal, AdminSessionResponse } from '../../../shared/types';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
AdminLoginInputSchema,
|
||||
CreateAdminTokenInputSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
import { AdminApiTokenModel } from '../models/AdminApiToken';
|
||||
import { AdminSessionModel } from '../models/AdminSession';
|
||||
import {
|
||||
|
|
@ -12,7 +18,11 @@ import {
|
|||
isEnvAdminEnabled,
|
||||
isOidcEnabled,
|
||||
} from '../config/admin-auth';
|
||||
import { AdminRequest, requireAdminAccess, requireSessionCsrf, resolveAdminAuth } from '../utils/adminAuth';
|
||||
import {
|
||||
requireAdminAccess,
|
||||
requireSessionCsrf,
|
||||
resolveAdminAuth,
|
||||
} from '../utils/adminAuth';
|
||||
import {
|
||||
clearAdminSessionCookie,
|
||||
createCsrfToken,
|
||||
|
|
@ -23,43 +33,54 @@ import {
|
|||
verifyAdminPassword,
|
||||
} from '../utils/adminSecurity';
|
||||
|
||||
const router: Router = Router();
|
||||
import type {
|
||||
AdminPrincipal,
|
||||
AdminSessionResponse,
|
||||
} from '../../../shared/types';
|
||||
import type { AppEnv, AdminAuthContext } from '../types/hono';
|
||||
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 +89,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 +98,25 @@ 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', zValidator('json', AdminLoginInputSchema), (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 { username, password } = c.req.valid('json');
|
||||
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 +126,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 +230,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 +244,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 +252,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 +270,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 +298,64 @@ 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 };
|
||||
const trimmedName = name?.trim();
|
||||
if (!trimmedName) {
|
||||
res.status(400).json({ error: 'Token name is required' });
|
||||
return;
|
||||
}
|
||||
router.post(
|
||||
'/tokens',
|
||||
requireAdminAccess,
|
||||
requireSessionCsrf,
|
||||
zValidator('json', CreateAdminTokenInputSchema),
|
||||
(c) => {
|
||||
const { name: trimmedName, expiresInDays } = c.req.valid('json');
|
||||
const ttlDays = 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: adminAuth.principal,
|
||||
expiresAt: new Date(
|
||||
Date.now() + ttlDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
});
|
||||
|
||||
const ttlDays = Number.isFinite(expiresInDays) && Number(expiresInDays) > 0
|
||||
? Number(expiresInDays)
|
||||
: getAdminApiTokenTtlDays();
|
||||
const token = generateOpaqueToken('adm_tok');
|
||||
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(),
|
||||
});
|
||||
return c.json({ token, record }, 201);
|
||||
},
|
||||
);
|
||||
|
||||
res.status(201).json({ token, record });
|
||||
});
|
||||
|
||||
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,365 +1,316 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
CreateBackendInputSchema,
|
||||
CreateModelRewriteInputSchema,
|
||||
CreatePermissionInputSchema,
|
||||
CreateUserInputSchema,
|
||||
UpdateBackendInputSchema,
|
||||
UpdateModelRewriteInputSchema,
|
||||
UpdateUserInputSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
import scriptRoutes from './scripts';
|
||||
|
||||
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 {
|
||||
CreateBackendData,
|
||||
CreateModelRewriteData,
|
||||
CreatePermissionData,
|
||||
CreateUserData,
|
||||
UpdateBackendData,
|
||||
UpdateModelRewriteData,
|
||||
UpdateUserData,
|
||||
} from '../../../shared/types';
|
||||
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
import { ModelCatalogService } from '../services/ModelCatalogService';
|
||||
import { AnalyticsService } from '../services/AnalyticsService';
|
||||
|
||||
const router: Router = Router();
|
||||
import type { AppEnv } from '../types/hono';
|
||||
|
||||
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;
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
router.post('/users', zValidator('json', CreateUserInputSchema), (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const user = UserModel.create({
|
||||
name: name.trim(),
|
||||
email: email?.trim() || undefined,
|
||||
api_key: api_key?.trim() || undefined,
|
||||
detail_logging,
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
const user = UserModel.create(data);
|
||||
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) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
return c.json(user);
|
||||
});
|
||||
|
||||
router.put('/users/:id', zValidator('json', UpdateUserInputSchema), (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const user = UserModel.findById(id);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
router.put('/users/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const user = UserModel.findById(id);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, email, api_key, is_active, detail_logging } = req.body as UpdateUserData;
|
||||
|
||||
if (typeof name === 'string' && !name.trim()) {
|
||||
res.status(400).json({ error: 'Name cannot be empty' });
|
||||
return;
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
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,
|
||||
is_active,
|
||||
detail_logging,
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
const updatedUser = UserModel.update(id, c.req.valid('json'));
|
||||
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;
|
||||
|
||||
if (!name || !base_url) {
|
||||
res.status(400).json({ error: 'Name and base_url are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = BackendModel.create({ name, base_url, api_key, detail_logging });
|
||||
res.status(201).json(backend);
|
||||
router.post('/backends', zValidator('json', CreateBackendInputSchema), (c) => {
|
||||
const backend = BackendModel.create(c.req.valid('json'));
|
||||
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);
|
||||
const backend = BackendModel.findById(id);
|
||||
router.put(
|
||||
'/backends/:id',
|
||||
zValidator('json', UpdateBackendInputSchema),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const backend = BackendModel.findById(id);
|
||||
if (!backend) {
|
||||
return c.json({ error: 'Backend not found' }, 404);
|
||||
}
|
||||
|
||||
if (!backend) {
|
||||
res.status(404).json({ error: 'Backend not found' });
|
||||
return;
|
||||
}
|
||||
const updatedBackend = BackendModel.update(id, c.req.valid('json'));
|
||||
await ModelCatalogService.handleBackendUpdated(id);
|
||||
return c.json(
|
||||
ModelCatalogService.getBackendsWithSummary().find(
|
||||
(item) => item.id === id,
|
||||
) || updatedBackend,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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 });
|
||||
await ModelCatalogService.handleBackendUpdated(id);
|
||||
res.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;
|
||||
|
||||
if (!user_id || !backend_id) {
|
||||
res.status(400).json({ error: 'user_id and backend_id are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = PermissionModel.create({ user_id, backend_id });
|
||||
res.status(201).json(permission);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({ error: error.message });
|
||||
return;
|
||||
router.post(
|
||||
'/permissions',
|
||||
zValidator('json', CreatePermissionInputSchema),
|
||||
(c) => {
|
||||
try {
|
||||
const permission = PermissionModel.create(c.req.valid('json'));
|
||||
return c.json(permission, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
return c.json({ error: error.message }, 409);
|
||||
}
|
||||
return c.json({ error: 'Failed to create permission' }, 500);
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to create permission' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
if (!source_model?.trim() || !target_model?.trim()) {
|
||||
res.status(400).json({ error: 'source_model and target_model are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = ModelRewriteModel.create({
|
||||
source_model: source_model.trim(),
|
||||
target_model: target_model.trim(),
|
||||
is_active,
|
||||
force,
|
||||
note,
|
||||
});
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
res.status(201).json(rule);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
res.status(409).json({ error: 'Rewrite rule already exists for this source_model' });
|
||||
return;
|
||||
router.post(
|
||||
'/model-rewrites',
|
||||
zValidator('json', CreateModelRewriteInputSchema),
|
||||
(c) => {
|
||||
try {
|
||||
const rule = ModelRewriteModel.create(c.req.valid('json'));
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
return c.json(rule, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
return c.json(
|
||||
{ error: 'Rewrite rule already exists for this source_model' },
|
||||
409,
|
||||
);
|
||||
}
|
||||
return c.json({ error: 'Failed to create model rewrite rule' }, 500);
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to create model rewrite rule' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.put('/model-rewrites/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = ModelRewriteModel.findById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Model rewrite rule not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = ModelRewriteModel.update(id, req.body as UpdateModelRewriteData);
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
res.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;
|
||||
router.put(
|
||||
'/model-rewrites/:id',
|
||||
zValidator('json', UpdateModelRewriteInputSchema),
|
||||
(c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const existing = ModelRewriteModel.findById(id);
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Model rewrite rule not found' }, 404);
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to update model rewrite rule' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/model-rewrites/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
try {
|
||||
const updated = ModelRewriteModel.update(id, c.req.valid('json'));
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
||||
return c.json(
|
||||
{ error: 'Rewrite rule already exists for this source_model' },
|
||||
409,
|
||||
);
|
||||
}
|
||||
return c.json({ error: 'Failed to update model rewrite rule' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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 { Hono } from 'hono';
|
||||
|
||||
import { AnalyticsService } from '../services/AnalyticsService';
|
||||
|
||||
const router: Router = Router();
|
||||
import type { AppEnv } from '../types/hono';
|
||||
|
||||
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
|
||||
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,5 +1,14 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate, AuthenticatedRequest } from './auth';
|
||||
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { stream } from 'hono/streaming';
|
||||
|
||||
import {
|
||||
ChatCompletionRequestSchema,
|
||||
type ChatCompletionRequest as ChatCompletionRequestType,
|
||||
} from '@kyush/shared';
|
||||
|
||||
import { authenticate } from './auth';
|
||||
|
||||
import { BackendModel } from '../models/Backend';
|
||||
import { RouterService } from '../services/RouterService';
|
||||
import { AnalyticsService } from '../services/AnalyticsService';
|
||||
|
|
@ -7,334 +16,587 @@ import { ScriptEngine } from '../services/ScriptEngine';
|
|||
import { logger } from '../utils/logger';
|
||||
import { ModelCatalogService } from '../services/ModelCatalogService';
|
||||
|
||||
const router: Router = Router();
|
||||
import {
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
ModelListResponse,
|
||||
ModelNotAvailableResponse,
|
||||
} from '../schemas/v1';
|
||||
import { ErrorResponse } from '../schemas/common';
|
||||
|
||||
router.use(authenticate);
|
||||
import type { AppEnv } from '../types/hono';
|
||||
|
||||
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;
|
||||
}, {});
|
||||
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) => {
|
||||
const startTime = Date.now();
|
||||
const user = req.user!;
|
||||
const allowedBackendIds = req.allowedBackendIds!;
|
||||
function getRequestHeaders(c: {
|
||||
req: { raw: Request };
|
||||
}): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
c.req.raw.headers.forEach((value, key) => {
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
if (allowedBackendIds.length === 0) {
|
||||
res.status(403).json({ error: 'No backends available for your account' });
|
||||
return;
|
||||
interface CompletionUsageShape {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
interface CompletionMetadata {
|
||||
model?: string;
|
||||
usage?: CompletionUsageShape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull `model` and `usage` out of an upstream chat-completion JSON body
|
||||
* without trusting the shape. Type guards keep this honest — no `as` cast
|
||||
* fallbacks for the structurally narrow lookups we need for analytics logging.
|
||||
*/
|
||||
interface ErrorDetails {
|
||||
error: string;
|
||||
cause: string;
|
||||
backend: string;
|
||||
}
|
||||
|
||||
function extractErrorDetails(data: unknown): ErrorDetails {
|
||||
const result: ErrorDetails = {
|
||||
error: 'Unknown error',
|
||||
cause: '',
|
||||
backend: '',
|
||||
};
|
||||
if (!isObject(data)) return result;
|
||||
if (typeof data.error === 'string') {
|
||||
result.error = data.error;
|
||||
}
|
||||
|
||||
const requestedModel = typeof req.body?.model === 'string' ? req.body.model : '';
|
||||
await ModelCatalogService.ensureInitializedForBackends(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,
|
||||
backend_id: 0,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: requestedModel,
|
||||
routed_model: resolution.routedModel,
|
||||
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,
|
||||
});
|
||||
res.status(403).json({ error: 'No active backends available' });
|
||||
return;
|
||||
if (typeof data.cause === 'string') {
|
||||
result.cause = ` (Cause: ${data.cause})`;
|
||||
}
|
||||
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(resolution.routedModel, allowedBackendIds);
|
||||
const backend = RouterService.selectBackend(candidateBackendIds);
|
||||
if (!backend) {
|
||||
AnalyticsService.logRequest({
|
||||
user_id: user.id,
|
||||
backend_id: 0,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: resolution.requestedModel,
|
||||
routed_model: resolution.routedModel,
|
||||
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,
|
||||
});
|
||||
res.status(404).json({
|
||||
error: 'Requested model is not available for your account',
|
||||
request_model: resolution.requestedModel,
|
||||
routed_model: resolution.routedModel,
|
||||
});
|
||||
return;
|
||||
if (typeof data.backend === 'string') {
|
||||
result.backend = ` [Backend: ${data.backend}]`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const { model, messages, ...rest } = req.body;
|
||||
const detailLoggingEnabled = user.detail_logging || backend.detail_logging;
|
||||
const rewrittenBody = { model: resolution.routedModel, messages, ...rest };
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
const execContext = {
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
backend: { id: backend.id, name: backend.name, base_url: backend.base_url },
|
||||
request: {
|
||||
method: 'POST',
|
||||
path: req.path,
|
||||
headers: {
|
||||
...normalizeHeaders(req.headers),
|
||||
'content-type': req.get('content-type') || 'application/json',
|
||||
},
|
||||
body: rewrittenBody,
|
||||
isStream: req.body.stream === true,
|
||||
function extractCompletionMetadata(data: unknown): CompletionMetadata {
|
||||
if (!isObject(data)) return {};
|
||||
const meta: CompletionMetadata = {};
|
||||
if (typeof data.model === 'string') {
|
||||
meta.model = data.model;
|
||||
}
|
||||
if (isObject(data.usage)) {
|
||||
const usage = data.usage;
|
||||
const result: CompletionUsageShape = {};
|
||||
if (typeof usage.prompt_tokens === 'number') {
|
||||
result.prompt_tokens = usage.prompt_tokens;
|
||||
}
|
||||
if (typeof usage.completion_tokens === 'number') {
|
||||
result.completion_tokens = usage.completion_tokens;
|
||||
}
|
||||
if (typeof usage.total_tokens === 'number') {
|
||||
result.total_tokens = usage.total_tokens;
|
||||
}
|
||||
meta.usage = result;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { context: modifiedContext, errors: requestErrors } = await ScriptEngine.applyOnRequestScripts(
|
||||
execContext,
|
||||
user.id,
|
||||
backend.id
|
||||
);
|
||||
// SSE branch returns a stream Response that doesn't fit the typed-response
|
||||
// generic `router.openapi()` enforces. We document the route through the
|
||||
// openapi registry, then register the actual handler via plain `router.post`
|
||||
// with `zValidator` so the streaming branch is type-checked normally and
|
||||
// `c.req.valid('json')` is fully typed off the shared schema.
|
||||
router.openAPIRegistry.registerPath({
|
||||
...chatCompletionsRoute,
|
||||
responses: chatCompletionsRoute.responses,
|
||||
});
|
||||
|
||||
if (requestErrors.length > 0) {
|
||||
logger.warn(`Script warnings for user ${user.id}: ${requestErrors.join('; ')}`);
|
||||
router.post(
|
||||
'/chat/completions',
|
||||
zValidator('json', ChatCompletionRequestSchema),
|
||||
async (c) => {
|
||||
const startTime = Date.now();
|
||||
const user = c.get('user')!;
|
||||
const allowedBackendIds = c.get('allowedBackendIds')!;
|
||||
const reqBody: ChatCompletionRequestType = c.req.valid('json');
|
||||
const requestHeaders = getRequestHeaders(c);
|
||||
|
||||
if (allowedBackendIds.length === 0) {
|
||||
return c.json({ error: 'No backends available for your account' }, 403);
|
||||
}
|
||||
|
||||
// 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 streamResult = await RouterService.forwardStreamRequest(
|
||||
const requestedModel =
|
||||
typeof reqBody.model === 'string' ? reqBody.model : '';
|
||||
await ModelCatalogService.ensureInitializedForBackends(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,
|
||||
backend_id: 0,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: requestedModel,
|
||||
routed_model: resolution.routedModel,
|
||||
status_code: 403,
|
||||
error_message: 'No active backends available',
|
||||
detail_logged: user.detail_logging,
|
||||
request_headers: user.detail_logging
|
||||
? normalizeHeaders(requestHeaders)
|
||||
: undefined,
|
||||
request_body: user.detail_logging ? reqBody : undefined,
|
||||
});
|
||||
return c.json({ error: 'No active backends available' }, 403);
|
||||
}
|
||||
|
||||
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(
|
||||
resolution.routedModel,
|
||||
allowedBackendIds,
|
||||
);
|
||||
const backend = RouterService.selectBackend(candidateBackendIds);
|
||||
if (!backend) {
|
||||
AnalyticsService.logRequest({
|
||||
user_id: user.id,
|
||||
backend_id: 0,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: resolution.requestedModel,
|
||||
routed_model: resolution.routedModel,
|
||||
status_code: 404,
|
||||
error_message: 'Requested model is not available for your account',
|
||||
detail_logged: user.detail_logging,
|
||||
request_headers: user.detail_logging
|
||||
? normalizeHeaders(requestHeaders)
|
||||
: undefined,
|
||||
request_body: user.detail_logging ? reqBody : undefined,
|
||||
});
|
||||
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 } = 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,
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
path: c.req.path,
|
||||
headers: {
|
||||
...normalizeHeaders(requestHeaders),
|
||||
'content-type': c.req.header('content-type') || 'application/json',
|
||||
},
|
||||
body: rewrittenBody,
|
||||
isStream: reqBody.stream === true,
|
||||
},
|
||||
};
|
||||
|
||||
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('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
if (!('response' in streamResult)) {
|
||||
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,
|
||||
status_code: streamResult.status,
|
||||
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,
|
||||
local_date: undefined,
|
||||
});
|
||||
logger.error(
|
||||
`Backend error for user ${user.id} (stream): ${JSON.stringify(streamResult.data)}`,
|
||||
);
|
||||
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
|
||||
return new Response(JSON.stringify(streamResult.data ?? {}), {
|
||||
status: streamResult.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const backendResponse = streamResult.response;
|
||||
const backendResponseHeaders = Object.fromEntries(
|
||||
backendResponse.headers.entries(),
|
||||
);
|
||||
|
||||
if (
|
||||
!backendResponse.headers
|
||||
.get('content-type')
|
||||
?.includes('text/event-stream')
|
||||
) {
|
||||
const data: unknown = await backendResponse.json().catch(() => ({}));
|
||||
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,
|
||||
status_code: backendResponse.status,
|
||||
response_time_ms: responseTime,
|
||||
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,
|
||||
response_body: detailLoggingEnabled ? data : undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
if (backendResponse.status >= 400) {
|
||||
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
|
||||
}
|
||||
return new Response(JSON.stringify(data ?? {}), {
|
||||
status: backendResponse.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
await ScriptEngine.applyOnResponseScripts(
|
||||
execContext,
|
||||
{
|
||||
status: backendResponse.status,
|
||||
headers: backendResponseHeaders,
|
||||
body: null,
|
||||
isStream: true,
|
||||
},
|
||||
user.id,
|
||||
backend.id,
|
||||
);
|
||||
|
||||
c.header('Content-Type', 'text/event-stream');
|
||||
c.header('Cache-Control', 'no-cache');
|
||||
c.header('Connection', 'keep-alive');
|
||||
// We've already short-circuited every non-SSE upstream above, so the
|
||||
// streaming branch is always a successful 200 by the time we get here.
|
||||
c.status(200);
|
||||
|
||||
let responseModel: string | undefined;
|
||||
let usage:
|
||||
| {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
| undefined;
|
||||
const collectedChunks: string[] = [];
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = backendResponse.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
s.onAbort(() => {
|
||||
void reader.cancel();
|
||||
});
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await s.write(value);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await RouterService.forwardRequest(
|
||||
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({
|
||||
user_id: user.id,
|
||||
backend_id: backend.id,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: model,
|
||||
routed_model: resolution.routedModel,
|
||||
status_code: streamResult.status,
|
||||
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,
|
||||
local_date: undefined,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const backendResponse = streamResult.response;
|
||||
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(() => ({}));
|
||||
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,
|
||||
status_code: backendResponse.status,
|
||||
response_time_ms: responseTime,
|
||||
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,
|
||||
response_body: detailLoggingEnabled ? data : undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
if (backendResponse.status >= 400) {
|
||||
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
|
||||
}
|
||||
res.status(backendResponse.status).json(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// onResponse scripts (body not available for streams)
|
||||
await ScriptEngine.applyOnResponseScripts(
|
||||
execContext,
|
||||
{ status: backendResponse.status, headers: backendResponseHeaders, body: null, isStream: true },
|
||||
user.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());
|
||||
|
||||
let responseModel: string | 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);
|
||||
|
||||
// Parse SSE chunks for model and usage metadata
|
||||
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));
|
||||
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 {
|
||||
res.end();
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
const responseContext = {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body: response.data,
|
||||
isStream: false,
|
||||
};
|
||||
|
||||
await ScriptEngine.applyOnResponseScripts(
|
||||
execContext,
|
||||
responseContext,
|
||||
user.id,
|
||||
backend.id,
|
||||
);
|
||||
|
||||
const completionMeta = extractCompletionMetadata(response.data);
|
||||
|
||||
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_model: completionMeta.model,
|
||||
prompt_tokens: completionMeta.usage?.prompt_tokens,
|
||||
completion_tokens: completionMeta.usage?.completion_tokens,
|
||||
total_tokens: completionMeta.usage?.total_tokens,
|
||||
status_code: response.status,
|
||||
response_time_ms: responseTime,
|
||||
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,
|
||||
response_headers: detailLoggingEnabled ? backendResponseHeaders : undefined,
|
||||
response_body: detailLoggingEnabled ? collectedChunks.join('') : 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 (backendResponse.status >= 400) {
|
||||
if (response.status >= 400) {
|
||||
const details = extractErrorDetails(response.data);
|
||||
logger.error(
|
||||
`Backend error for user ${user.id}: ${details.error}${details.cause}${details.backend}`,
|
||||
);
|
||||
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
|
||||
);
|
||||
// The upstream JSON body comes back as `unknown`; build a Response by
|
||||
// hand so we don't lean on `c.json`'s typed status union.
|
||||
return new Response(JSON.stringify(response.data ?? {}), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
AnalyticsService.logRequest({
|
||||
user_id: user.id,
|
||||
backend_id: backend.id,
|
||||
endpoint: '/v1/chat/completions',
|
||||
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(requestHeaders)
|
||||
: undefined,
|
||||
request_body:
|
||||
user.detail_logging || backend.detail_logging ? reqBody : undefined,
|
||||
response_headers: undefined,
|
||||
response_body: undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
|
||||
const responseContext = {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body: response.data,
|
||||
isStream: false,
|
||||
};
|
||||
|
||||
await ScriptEngine.applyOnResponseScripts(
|
||||
execContext,
|
||||
responseContext,
|
||||
user.id,
|
||||
backend.id
|
||||
);
|
||||
|
||||
AnalyticsService.logRequest({
|
||||
user_id: user.id,
|
||||
backend_id: backend.id,
|
||||
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,
|
||||
status_code: response.status,
|
||||
response_time_ms: responseTime,
|
||||
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,
|
||||
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 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}`);
|
||||
logger.error(`Request failed for user ${user.id}: ${errorMsg}`);
|
||||
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
|
||||
return c.json({ error: 'Backend request failed', cause: errorMsg }, 502);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
res.status(response.status).json(response.data);
|
||||
} 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,
|
||||
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,
|
||||
response_headers: undefined,
|
||||
response_body: undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
|
||||
logger.error(`Request failed for user ${user.id}: ${errorMsg}`);
|
||||
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
|
||||
res.status(502).json({ error: 'Backend request failed', details: errorMsg });
|
||||
}
|
||||
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 +604,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';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: User;
|
||||
allowedBackendIds?: number[];
|
||||
}
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { AppEnv } from '../types/hono';
|
||||
|
||||
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,232 +1,162 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
CreateScriptInputSchema,
|
||||
ScriptTestInputSchema,
|
||||
UpdateScriptInputSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
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';
|
||||
|
||||
const router: Router = Router();
|
||||
import type { ScriptContextData } from '../../../shared/types';
|
||||
import type { AppEnv } from '../types/hono';
|
||||
|
||||
// ============ Script Management ============
|
||||
const router = new Hono<AppEnv>();
|
||||
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const scripts = ScriptModel.findAll();
|
||||
res.json(scripts);
|
||||
router.get('/', (c) => c.json(ScriptModel.findAll()));
|
||||
router.get('/active', (c) => c.json(ScriptModel.findActive()));
|
||||
|
||||
router.get('/type/:type', (c) => {
|
||||
const scriptType = c.req.param('type');
|
||||
return c.json(ScriptModel.findByScriptType(scriptType));
|
||||
});
|
||||
|
||||
router.get('/active', (req: Request, res: Response) => {
|
||||
const scripts = ScriptModel.findActive();
|
||||
res.json(scripts);
|
||||
});
|
||||
|
||||
router.get('/type/:type', (req: Request, res: Response) => {
|
||||
const scriptType = String(req.params.type);
|
||||
const scripts = ScriptModel.findByScriptType(scriptType);
|
||||
res.json(scripts);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
res.json(script);
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
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;
|
||||
|
||||
if (!name || !script_type || !script_code) {
|
||||
res.status(400).json({ error: 'name, script_type, and script_code are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/', zValidator('json', CreateScriptInputSchema), (c) => {
|
||||
const data = c.req.valid('json');
|
||||
try {
|
||||
const script = ScriptModel.create({
|
||||
name,
|
||||
script_type,
|
||||
target_user_id: target_user_id ?? null,
|
||||
target_backend_id: target_backend_id ?? null,
|
||||
script_code,
|
||||
is_active: is_active ?? true,
|
||||
name: data.name,
|
||||
script_type: data.script_type,
|
||||
target_user_id: data.target_user_id ?? null,
|
||||
target_backend_id: data.target_backend_id ?? null,
|
||||
script_code: data.script_code,
|
||||
is_active: data.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', zValidator('json', UpdateScriptInputSchema), (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
|
||||
if (!script) {
|
||||
res.status(404).json({ error: 'Script not found' });
|
||||
return;
|
||||
}
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const { name, script_type, target_user_id, target_backend_id, script_code, is_active } = req.body as UpdateScriptData;
|
||||
|
||||
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;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
// When script_type changes, the target fields must satisfy the discriminated
|
||||
// shape. The shared CreateScriptInputSchema enforces this on creation; the
|
||||
// update path mirrors the same checks because UpdateScriptInputSchema accepts
|
||||
// any combination by design.
|
||||
if (data.script_type === 'per-user-backend') {
|
||||
if (!data.target_user_id || !data.target_backend_id) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'target_user_id and target_backend_id are required for per-user-backend scripts',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else if (data.script_type === 'per-backend' && !data.target_backend_id) {
|
||||
return c.json(
|
||||
{ error: 'target_backend_id is required for per-backend scripts' },
|
||||
400,
|
||||
);
|
||||
} else if (data.script_type === 'per-user' && !data.target_user_id) {
|
||||
return c.json(
|
||||
{ error: 'target_user_id is required for per-user scripts' },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedScript = ScriptModel.update(id, {
|
||||
name,
|
||||
script_type,
|
||||
target_user_id,
|
||||
target_backend_id,
|
||||
script_code,
|
||||
is_active,
|
||||
});
|
||||
|
||||
res.json(updatedScript);
|
||||
const updatedScript = ScriptModel.update(id, data);
|
||||
return c.json(updatedScript);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const success = ScriptModel.delete(id);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({ error: 'Script not found' });
|
||||
return;
|
||||
router.delete('/:id', (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
if (!ScriptModel.delete(id)) {
|
||||
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;
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
if (!ScriptModel.activate(id)) {
|
||||
return c.json({ error: 'Failed to activate script' }, 500);
|
||||
}
|
||||
|
||||
const success = ScriptModel.activate(id);
|
||||
if (!success) {
|
||||
res.status(500).json({ error: 'Failed to activate script' });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
if (!ScriptModel.deactivate(id)) {
|
||||
return c.json({ error: 'Failed to deactivate script' }, 500);
|
||||
}
|
||||
|
||||
const success = ScriptModel.deactivate(id);
|
||||
if (!success) {
|
||||
res.status(500).json({ error: 'Failed to deactivate script' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ ...script, is_active: false });
|
||||
return c.json({ ...script, is_active: false });
|
||||
});
|
||||
|
||||
// ============ Script Testing ============
|
||||
router.post(
|
||||
'/:id/test',
|
||||
zValidator('json', ScriptTestInputSchema),
|
||||
async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const script = ScriptModel.findById(id);
|
||||
if (!script) return c.json({ error: 'Script not found' }, 404);
|
||||
|
||||
router.post('/:id/test', async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const script = ScriptModel.findById(id);
|
||||
const body = c.req.valid('json');
|
||||
const testContext: ScriptContextData = {
|
||||
user: body.user ?? null,
|
||||
backend: body.backend ?? null,
|
||||
request: body.request,
|
||||
};
|
||||
|
||||
if (!script) {
|
||||
res.status(404).json({ error: 'Script not found' });
|
||||
return;
|
||||
}
|
||||
let compiled: CompiledScript | null = null;
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
compiled = await CompiledScript.compile(script.script_code);
|
||||
|
||||
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 };
|
||||
};
|
||||
if (compiled.hasOnRequest) await compiled.callOnRequest(testContext);
|
||||
if (compiled.hasOnResponse) await compiled.callOnResponse(testContext);
|
||||
|
||||
if (!request) {
|
||||
res.status(400).json({ error: 'request is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const testContext: ScriptContextData = {
|
||||
user: user ?? null,
|
||||
backend: backend ?? null,
|
||||
request,
|
||||
};
|
||||
|
||||
let compiled: CompiledScript | null = null;
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
compiled = await CompiledScript.compile(script.script_code);
|
||||
|
||||
if (compiled.hasOnRequest) {
|
||||
await compiled.callOnRequest(testContext);
|
||||
return c.json({
|
||||
success: true,
|
||||
executionTime: Date.now() - startTime,
|
||||
hasOnRequest: compiled.hasOnRequest,
|
||||
hasOnResponse: compiled.hasOnResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
400,
|
||||
);
|
||||
} finally {
|
||||
compiled?.dispose();
|
||||
}
|
||||
if (compiled.hasOnResponse) {
|
||||
await compiled.callOnResponse(testContext);
|
||||
}
|
||||
|
||||
res.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),
|
||||
});
|
||||
} finally {
|
||||
compiled?.dispose();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
11
server/src/schemas/common.ts
Normal file
11
server/src/schemas/common.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { z } from '@hono/zod-openapi';
|
||||
import { ErrorResponseSchema } from '@kyush/shared';
|
||||
|
||||
export const ErrorResponse = ErrorResponseSchema.openapi('ErrorResponse', {
|
||||
example: { error: 'Something went wrong' },
|
||||
});
|
||||
|
||||
export type { ErrorResponse as ErrorResponseType } from '@kyush/shared';
|
||||
|
||||
// Keep `z` available for any future schema definitions in this file.
|
||||
export { z };
|
||||
32
server/src/schemas/v1.ts
Normal file
32
server/src/schemas/v1.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Importing `z` from `@hono/zod-openapi` patches zod's prototype with `.openapi()`,
|
||||
// which lets us decorate the shared schemas with OpenAPI metadata without
|
||||
// duplicating their shape on the server.
|
||||
import { z } from '@hono/zod-openapi';
|
||||
import {
|
||||
ChatCompletionRequestSchema,
|
||||
ChatCompletionResponseSchema,
|
||||
ChatMessageSchema,
|
||||
ModelEntrySchema,
|
||||
ModelListResponseSchema,
|
||||
ModelNotAvailableResponseSchema,
|
||||
} from '@kyush/shared';
|
||||
|
||||
// Re-export the shared schemas with OpenAPI annotations attached. The
|
||||
// underlying zod instances are shared with the client, so any schema change
|
||||
// applies to both sides simultaneously.
|
||||
export const ChatMessage = ChatMessageSchema.openapi('ChatMessage');
|
||||
export const ChatCompletionRequest = ChatCompletionRequestSchema.openapi(
|
||||
'ChatCompletionRequest',
|
||||
{ example: { model: 'gpt-4o-mini', messages: [] } },
|
||||
);
|
||||
export const ChatCompletionResponse = ChatCompletionResponseSchema.openapi(
|
||||
'ChatCompletionResponse',
|
||||
);
|
||||
export const ModelEntry = ModelEntrySchema.openapi('ModelEntry');
|
||||
export const ModelListResponse =
|
||||
ModelListResponseSchema.openapi('ModelListResponse');
|
||||
export const ModelNotAvailableResponse =
|
||||
ModelNotAvailableResponseSchema.openapi('ModelNotAvailableResponse');
|
||||
|
||||
// Re-export `z` so other server code can keep importing from a single place.
|
||||
export { z };
|
||||
|
|
@ -1,12 +1,27 @@
|
|||
import {
|
||||
type RequestLogInsert,
|
||||
type RequestLogQuery,
|
||||
RequestLogService,
|
||||
} from './RequestLogService';
|
||||
|
||||
import { ModelCatalogService } from './ModelCatalogService';
|
||||
|
||||
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 {
|
||||
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 {
|
||||
DashboardSummaryResponse,
|
||||
RequestLogPage,
|
||||
ScriptType,
|
||||
} from '../../../shared/types';
|
||||
|
||||
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,4 +1,11 @@
|
|||
import {
|
||||
import { env } from '../config/env';
|
||||
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';
|
||||
|
||||
import type {
|
||||
Backend,
|
||||
BackendModelCacheStatus,
|
||||
BackendModelCatalogEntry,
|
||||
|
|
@ -6,11 +13,6 @@ import {
|
|||
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';
|
||||
|
||||
interface BackendCacheEntry {
|
||||
backendId: number;
|
||||
|
|
@ -43,20 +45,21 @@ interface RewriteConfig {
|
|||
force: boolean;
|
||||
}
|
||||
|
||||
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 env.MODEL_CATALOG_REFRESH_MIN_MS;
|
||||
}
|
||||
|
||||
private static normalizeModelId(modelId: string): string {
|
||||
|
|
@ -76,7 +79,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 +108,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 +127,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,20 +142,31 @@ 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: unknown = await response.json().catch(() => ({}));
|
||||
const data: unknown[] =
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
'data' in payload &&
|
||||
Array.isArray(payload.data)
|
||||
? payload.data
|
||||
: [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const rawModels: Array<{ model_id: string; raw_json?: string }> = [];
|
||||
const models: string[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
if (!item || typeof item !== 'object' || typeof item.id !== 'string') {
|
||||
if (
|
||||
typeof item !== 'object' ||
|
||||
item === null ||
|
||||
!('id' in item) ||
|
||||
typeof item.id !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const modelId = this.normalizeModelId(item.id);
|
||||
|
|
@ -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';
|
||||
|
||||
import {
|
||||
getLocalDateKey,
|
||||
getLocalMonthKey,
|
||||
getMonthKeyFromDateString,
|
||||
getUtcTimestamp,
|
||||
} from '../utils/time';
|
||||
|
||||
import type { RequestLog, RequestLogPage } from '../../../shared/types';
|
||||
|
||||
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,224 +1,229 @@
|
|||
import { Backend } from '../../../shared/types';
|
||||
import { BackendModel } from '../models/Backend';
|
||||
|
||||
export class RouterService {
|
||||
private static prepareRequestBody(body?: unknown): string | Uint8Array | ArrayBuffer | undefined {
|
||||
if (body === undefined || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
import type { Backend } from '../../../shared/types';
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
interface BackendForwardError {
|
||||
error: string;
|
||||
cause?: string;
|
||||
backend: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
|
||||
return body;
|
||||
}
|
||||
interface ErrorCauseShape {
|
||||
code?: string;
|
||||
errno?: string;
|
||||
syscall?: string;
|
||||
address?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
/**
|
||||
* Coerces an unknown error.cause field into the small subset of fields we
|
||||
* actually look at when classifying upstream connection failures. Falls back
|
||||
* to an empty object so callers can read fields without nullish guards.
|
||||
*/
|
||||
const readErrorCause = (cause: unknown): ErrorCauseShape => {
|
||||
if (!isObject(cause)) return {};
|
||||
return {
|
||||
code: typeof cause.code === 'string' ? cause.code : undefined,
|
||||
errno: typeof cause.errno === 'string' ? cause.errno : undefined,
|
||||
syscall: typeof cause.syscall === 'string' ? cause.syscall : undefined,
|
||||
address:
|
||||
typeof cause.address === 'string'
|
||||
? cause.address
|
||||
: typeof cause.hostname === 'string'
|
||||
? cause.hostname
|
||||
: undefined,
|
||||
port: typeof cause.port === 'number' ? cause.port : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates an upstream fetch failure into a structured `{ error, cause }`
|
||||
* pair. Used by both the JSON and SSE forwarding paths so the message
|
||||
* surface stays consistent across them.
|
||||
*/
|
||||
const classifyForwardError = (
|
||||
error: unknown,
|
||||
): { error: string; cause?: string } => {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (error instanceof Error && error.cause !== undefined) {
|
||||
const cause = readErrorCause(error.cause);
|
||||
const code = cause.code ?? cause.errno;
|
||||
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return {
|
||||
error: 'Backend connection refused',
|
||||
cause:
|
||||
cause.address && cause.port
|
||||
? `Backend server at ${cause.address}:${cause.port} is not accepting connections`
|
||||
: 'Backend server is not accepting connections',
|
||||
};
|
||||
}
|
||||
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
|
||||
return {
|
||||
error: 'Backend request timeout',
|
||||
cause: 'Connection to backend timed out',
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND') {
|
||||
return {
|
||||
error: 'Backend unreachable',
|
||||
cause: cause.address
|
||||
? `Could not resolve hostname: ${cause.address}`
|
||||
: 'Could not resolve backend hostname',
|
||||
};
|
||||
}
|
||||
if (code === 'EPIPE' || cause.syscall === 'write') {
|
||||
return {
|
||||
error: 'Backend connection lost',
|
||||
cause: cause.syscall
|
||||
? `Connection broken during ${cause.syscall} operation`
|
||||
: 'Connection broken during operation',
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: 'Backend connection error',
|
||||
cause: `${code ?? 'Unknown error'} during ${cause.syscall ?? 'connection'}`,
|
||||
};
|
||||
}
|
||||
|
||||
static selectBackend(allowedBackendIds: number[]): Backend | null {
|
||||
if (allowedBackendIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (errorMsg.includes('ETIMEDOUT') || errorMsg.includes('ECONNABORTED')) {
|
||||
return {
|
||||
error: 'Backend request timeout',
|
||||
cause: 'Connection timed out after 30s',
|
||||
};
|
||||
}
|
||||
if (errorMsg.includes('aborted')) {
|
||||
return {
|
||||
error: 'Request aborted',
|
||||
cause: 'Request was aborted before completion',
|
||||
};
|
||||
}
|
||||
return { error: 'Failed to forward request to backend', cause: errorMsg };
|
||||
};
|
||||
|
||||
const allBackends = BackendModel.findAll();
|
||||
const backends = allBackends
|
||||
.filter(b => (b.is_active === true) && allowedBackendIds.includes(b.id));
|
||||
const buildForwardErrorPayload = (
|
||||
backend: Backend,
|
||||
path: string,
|
||||
error: unknown,
|
||||
): BackendForwardError => {
|
||||
const { error: errorType, cause } = classifyForwardError(error);
|
||||
return { error: errorType, cause, backend: backend.base_url, path };
|
||||
};
|
||||
|
||||
if (backends.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const prepareRequestBody = (
|
||||
body?: unknown,
|
||||
): string | Uint8Array | ArrayBuffer | undefined => {
|
||||
if (body === undefined || body === null) return undefined;
|
||||
if (typeof body === 'string') return body;
|
||||
if (body instanceof Uint8Array || body instanceof ArrayBuffer) return body;
|
||||
return JSON.stringify(body);
|
||||
};
|
||||
|
||||
const rawFetch = async (
|
||||
backend: Backend,
|
||||
path: string,
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
body?: unknown,
|
||||
): Promise<Response> => {
|
||||
const backendPath = backend.base_url.includes('/v1')
|
||||
? path.replace(/^\/v1/, '')
|
||||
: path;
|
||||
const backendUrl = backend.base_url.replace(/\/$/, '') + backendPath;
|
||||
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
const preparedBody = prepareRequestBody(body);
|
||||
|
||||
// Always let fetch/undici compute Content-Length from the final outgoing body.
|
||||
delete fetchHeaders['content-length'];
|
||||
delete fetchHeaders['Content-Length'];
|
||||
delete fetchHeaders.authorization;
|
||||
delete fetchHeaders.Authorization;
|
||||
delete fetchHeaders['content-type'];
|
||||
delete fetchHeaders['Content-Type'];
|
||||
|
||||
if (preparedBody !== undefined) {
|
||||
fetchHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (backend.api_key) {
|
||||
fetchHeaders.Authorization = `Bearer ${backend.api_key}`;
|
||||
}
|
||||
|
||||
return fetch(backendUrl, {
|
||||
method,
|
||||
headers: fetchHeaders,
|
||||
body: preparedBody,
|
||||
});
|
||||
};
|
||||
|
||||
export const RouterService = {
|
||||
selectBackend(allowedBackendIds: number[]): Backend | null {
|
||||
if (allowedBackendIds.length === 0) return null;
|
||||
|
||||
const backends = BackendModel.findAll().filter(
|
||||
(b) => b.is_active === true && allowedBackendIds.includes(b.id),
|
||||
);
|
||||
if (backends.length === 0) return null;
|
||||
|
||||
const roundRobinIndex = Math.floor(Math.random() * backends.length);
|
||||
return backends[roundRobinIndex];
|
||||
}
|
||||
},
|
||||
|
||||
private static async rawFetch(
|
||||
async forwardRequest(
|
||||
backend: Backend,
|
||||
path: string,
|
||||
method: string,
|
||||
headers: Record<string, string>,
|
||||
body?: unknown
|
||||
): Promise<Response> {
|
||||
let backendPath = path;
|
||||
if (backend.base_url.includes('/v1')) {
|
||||
backendPath = path.replace(/^\/v1/, '');
|
||||
}
|
||||
|
||||
const backendUrl = backend.base_url.replace(/\/$/, '') + backendPath;
|
||||
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
const preparedBody = this.prepareRequestBody(body);
|
||||
|
||||
// Always let fetch/undici compute Content-Length from the final outgoing body.
|
||||
delete fetchHeaders['content-length'];
|
||||
delete fetchHeaders['Content-Length'];
|
||||
delete fetchHeaders.authorization;
|
||||
delete fetchHeaders.Authorization;
|
||||
delete fetchHeaders['content-type'];
|
||||
delete fetchHeaders['Content-Type'];
|
||||
|
||||
if (preparedBody !== undefined) {
|
||||
fetchHeaders['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (backend.api_key) {
|
||||
fetchHeaders['Authorization'] = `Bearer ${backend.api_key}`;
|
||||
}
|
||||
|
||||
return fetch(backendUrl, {
|
||||
method,
|
||||
headers: fetchHeaders,
|
||||
body: preparedBody,
|
||||
});
|
||||
}
|
||||
|
||||
static async forwardRequest(
|
||||
backend: Backend,
|
||||
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 data = await response.json().catch(() => ({}));
|
||||
const response = await rawFetch(backend, path, method, headers, body);
|
||||
const data: unknown = await response.json().catch(() => ({}));
|
||||
const responseHeaders = Object.fromEntries(response.headers.entries());
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data,
|
||||
headers: responseHeaders,
|
||||
};
|
||||
return { status: response.status, data, headers: responseHeaders };
|
||||
} 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';
|
||||
} 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';
|
||||
} else if (causeCode === 'EPIPE' || causeSyscall === 'write') {
|
||||
errorType = 'Backend connection lost';
|
||||
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')) {
|
||||
errorType = 'Backend request timeout';
|
||||
cause = 'Connection timed out after 30s';
|
||||
} else if (errorMsg.includes('aborted')) {
|
||||
errorType = 'Request aborted';
|
||||
cause = 'Request was aborted before completion';
|
||||
} else {
|
||||
errorType = 'Failed to forward request to backend';
|
||||
cause = errorMsg;
|
||||
}
|
||||
|
||||
const detailedError = {
|
||||
error: errorType,
|
||||
cause: cause,
|
||||
backend: backend.base_url,
|
||||
path: path,
|
||||
};
|
||||
|
||||
return {
|
||||
status: 502,
|
||||
data: detailedError,
|
||||
data: buildForwardErrorPayload(backend, path, error),
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
static async forwardStreamRequest(
|
||||
async forwardStreamRequest(
|
||||
backend: Backend,
|
||||
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 rawFetch(backend, path, method, headers, body);
|
||||
return { response };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
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';
|
||||
} 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';
|
||||
} else if (causeCode === 'EPIPE' || causeSyscall === 'write') {
|
||||
errorType = 'Backend connection lost';
|
||||
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')) {
|
||||
errorType = 'Backend request timeout';
|
||||
cause = 'Connection timed out after 30s';
|
||||
} else if (errorMsg.includes('aborted')) {
|
||||
errorType = 'Request aborted';
|
||||
cause = 'Request was aborted before completion';
|
||||
} else {
|
||||
errorType = 'Failed to forward request to backend';
|
||||
cause = errorMsg;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 502,
|
||||
data: {
|
||||
error: errorType,
|
||||
cause: cause,
|
||||
backend: backend.base_url,
|
||||
path: path,
|
||||
},
|
||||
data: buildForwardErrorPayload(backend, path, error),
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { ScriptContextData } from '../../../shared/types';
|
||||
import { CompiledScript } from './ScriptExecutor';
|
||||
|
||||
import { ScriptModel } from '../models/Script';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
import type { ScriptContextData } from '../../../shared/types';
|
||||
|
||||
export interface ScriptChainResult {
|
||||
success: boolean;
|
||||
context: ScriptContextData;
|
||||
|
|
@ -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 type { Context, Isolate, Reference } from 'isolated-vm';
|
||||
import { ScriptContextData } from '../../../shared/types';
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
import type { Context, Isolate, Reference } from 'isolated-vm';
|
||||
import type { ScriptContextData } from '../../../shared/types';
|
||||
|
||||
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';
|
||||
|
||||
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,24 @@
|
|||
import { NextFunction, Request, Response } from 'express';
|
||||
import { AdminPrincipal } from '../../../shared/types';
|
||||
import { getSessionTokenFromContext, hashAdminToken } from './adminSecurity';
|
||||
|
||||
import { getTrustedProxyIps } from '../config/admin-auth';
|
||||
|
||||
import { AdminApiTokenModel } from '../models/AdminApiToken';
|
||||
import { AdminSessionModel } from '../models/AdminSession';
|
||||
import { getSessionTokenFromCookies, hashAdminToken } from './adminSecurity';
|
||||
|
||||
export interface AdminRequest extends Request {
|
||||
adminAuth?: {
|
||||
principal: AdminPrincipal;
|
||||
method: 'session' | 'token';
|
||||
csrfToken?: string;
|
||||
sessionId?: number;
|
||||
tokenId?: number;
|
||||
};
|
||||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
|
||||
import type { AdminPrincipal } from '../../../shared/types';
|
||||
import type { AdminAuthContext, AppEnv } from '../types/hono';
|
||||
|
||||
interface PrincipalRow {
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
function toPrincipal(data: { provider: 'env' | 'oidc'; subject: string; username?: string; email?: string; display_name: string }): AdminPrincipal {
|
||||
function toPrincipal(data: PrincipalRow): AdminPrincipal {
|
||||
return {
|
||||
provider: data.provider,
|
||||
subject: data.subject,
|
||||
|
|
@ -25,83 +28,119 @@ function toPrincipal(data: { provider: 'env' | 'oidc'; subject: string; username
|
|||
};
|
||||
}
|
||||
|
||||
function passesTrustedProxyGuard(req: Request): boolean {
|
||||
const allowedIps = getTrustedProxyIps();
|
||||
if (allowedIps.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const remoteIp = req.ip || req.socket.remoteAddress || '';
|
||||
return allowedIps.includes(remoteIp);
|
||||
/**
|
||||
* Type guard for `@hono/node-server`'s context env shape. The Node adapter
|
||||
* exposes the underlying `IncomingMessage` as `c.env.incoming`; we narrow it
|
||||
* here so the rest of the file never has to touch `as` casts.
|
||||
*/
|
||||
function getNodeIncomingSocket(
|
||||
env: unknown,
|
||||
): { remoteAddress?: string } | undefined {
|
||||
if (typeof env !== 'object' || env === null) return undefined;
|
||||
if (!('incoming' in env)) return undefined;
|
||||
const incoming = (env as { incoming: unknown }).incoming;
|
||||
if (typeof incoming !== 'object' || incoming === null) return undefined;
|
||||
if (!('socket' in incoming)) return undefined;
|
||||
const socket = (incoming as { socket: unknown }).socket;
|
||||
if (typeof socket !== 'object' || socket === null) return undefined;
|
||||
return socket as { remoteAddress?: string };
|
||||
}
|
||||
|
||||
export function resolveAdminAuth(req: AdminRequest): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
function getRemoteIp(c: Context): string {
|
||||
const forwarded = c.req.header('x-forwarded-for');
|
||||
if (forwarded) return forwarded.split(',')[0]?.trim() ?? '';
|
||||
return getNodeIncomingSocket(c.env)?.remoteAddress ?? '';
|
||||
}
|
||||
|
||||
function passesTrustedProxyGuard(c: Context): boolean {
|
||||
const allowedIps = getTrustedProxyIps();
|
||||
if (allowedIps.length === 0) return true;
|
||||
return allowedIps.includes(getRemoteIp(c));
|
||||
}
|
||||
|
||||
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);
|
||||
if (!sessionToken) {
|
||||
return;
|
||||
}
|
||||
// Cookie reads now go through hono/cookie's helper inside getSessionTokenFromContext.
|
||||
const sessionToken = getSessionTokenFromContext(c);
|
||||
if (!sessionToken) return undefined;
|
||||
|
||||
const session = AdminSessionModel.findByTokenHash(hashAdminToken(sessionToken));
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const session = AdminSessionModel.findByTokenHash(
|
||||
hashAdminToken(sessionToken),
|
||||
);
|
||||
if (!session) 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' });
|
||||
export const requireAdminAccess: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next,
|
||||
) => {
|
||||
if (!passesTrustedProxyGuard(c)) {
|
||||
return c.json(
|
||||
{ error: 'Admin access is restricted to trusted proxy IPs' },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const auth = resolveAdminAuth(c);
|
||||
if (!auth) {
|
||||
return c.json({ error: 'Admin authentication required' }, 401);
|
||||
}
|
||||
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export const requireSessionCsrf: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next,
|
||||
) => {
|
||||
if (SAFE_METHODS.has(c.req.method.toUpperCase())) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
resolveAdminAuth(req);
|
||||
if (!req.adminAuth) {
|
||||
res.status(401).json({ error: 'Admin authentication required' });
|
||||
const adminAuth = c.get('adminAuth');
|
||||
if (adminAuth?.method !== 'session') {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireSessionCsrf(req: AdminRequest, res: Response, next: NextFunction): void {
|
||||
const unsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes(req.method.toUpperCase());
|
||||
if (!unsafeMethod) {
|
||||
next();
|
||||
return;
|
||||
const csrfHeader = c.req.header('X-CSRF-Token');
|
||||
if (!csrfHeader || csrfHeader !== adminAuth.csrfToken) {
|
||||
return c.json({ error: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
|
||||
if (req.adminAuth?.method !== 'session') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfHeader = req.get('X-CSRF-Token');
|
||||
if (!csrfHeader || csrfHeader !== req.adminAuth.csrfToken) {
|
||||
res.status(403).json({ error: 'Invalid CSRF token' });
|
||||
return;
|
||||
}
|
||||
|
||||
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 { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
||||
|
||||
import {
|
||||
getAdminPasswordHash,
|
||||
getAdminSessionTtlHours,
|
||||
getCookieSecure,
|
||||
hashOpaqueToken,
|
||||
} from '../config/admin-auth';
|
||||
|
||||
import type { Context } from 'hono';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'kyush_admin_session';
|
||||
|
||||
|
|
@ -24,65 +38,44 @@ 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('; '));
|
||||
/**
|
||||
* Cookie writes go through hono's `setCookie`/`deleteCookie` helpers — Hono
|
||||
* handles the Set-Cookie serialisation, multiple-header concatenation, and
|
||||
* SameSite/Secure flag bookkeeping for us. We never touch the raw header.
|
||||
*/
|
||||
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> {
|
||||
if (!cookieHeader) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cookieHeader.split(';').reduce<Record<string, string>>((acc, pair) => {
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
if (separatorIndex === -1) return acc;
|
||||
const key = pair.slice(0, separatorIndex).trim();
|
||||
const value = pair.slice(separatorIndex + 1).trim();
|
||||
if (key) {
|
||||
acc[key] = decodeURIComponent(value);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function getSessionTokenFromCookies(cookieHeader?: string): string | null {
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
return cookies[SESSION_COOKIE_NAME] || null;
|
||||
/**
|
||||
* Reads the admin session cookie via Hono's `getCookie` helper. Returns null
|
||||
* if the cookie is absent or empty so the auth middleware can short-circuit.
|
||||
*/
|
||||
export function getSessionTokenFromContext(c: Context): string | null {
|
||||
const value = getCookie(c, SESSION_COOKIE_NAME);
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function verifyAdminPassword(password: string): boolean {
|
||||
const storedHash = getAdminPasswordHash();
|
||||
if (!storedHash) {
|
||||
return false;
|
||||
}
|
||||
if (!storedHash) return false;
|
||||
|
||||
if (storedHash.startsWith('sha256$')) {
|
||||
const expected = storedHash.slice('sha256$'.length);
|
||||
|
|
@ -92,25 +85,27 @@ export function verifyAdminPassword(password: string): boolean {
|
|||
|
||||
if (storedHash.startsWith('scrypt$')) {
|
||||
const [, saltHex, expectedHex] = storedHash.split('$');
|
||||
if (!saltHex || !expectedHex) {
|
||||
return false;
|
||||
}
|
||||
const derived = scryptSync(password, Buffer.from(saltHex, 'hex'), expectedHex.length / 2);
|
||||
if (!saltHex || !expectedHex) return false;
|
||||
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();
|
||||
}
|
||||
|
||||
function safeStringEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
|
|
|||
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