Compare commits

..

42 commits

Author SHA1 Message Date
28049bce2c chore(package): bump version to 1.0.10-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 2m57s
2026-05-26 16:48:23 +09:00
e8276cde3f fix: analytics overload problem with group and indexing 2026-05-26 16:47:48 +09:00
cb55e2d24a chore(package): bump version to 1.0.9-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 2m11s
2026-05-13 21:55:05 +09:00
0f64a4cd85 feat(users): add 'copy_reasoning_to_reasoning_content' option for user creation and updates 2026-05-13 21:54:44 +09:00
227e5b12da chore(package): bump version to 1.0.8-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 1m20s
2026-05-12 18:24:46 +09:00
4cae96500e feat(chart): update symlog tick generation to allow custom intervals 2026-05-12 18:24:01 +09:00
1f1514b5da feat(dashboard): add traffic volume scale selector for linear and logarithmic views 2026-05-12 18:20:41 +09:00
fd37fd276a feat(chart): filter out non-positive ticks from symlog scale in TimeSeriesChart 2026-05-12 18:20:33 +09:00
43664819d4 feat(analytics): add logarithmic scale option for daily volume chart 2026-05-12 18:20:25 +09:00
96c9b963b4 chore(package): bump version to 1.0.7-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 2m10s
2026-05-12 16:20:01 +09:00
472e289198 feat(routes): implement memoization for resource states in Backends, Models, Scripts, and Users components 2026-05-12 16:17:32 +09:00
f6a032f81c feat(analytics): add auto-refresh functionality with customizable interval and refresh button 2026-05-12 16:14:51 +09:00
3bcac29fa1 feat(detail-logs): enhance search functionality with debounce and improve refresh button accessibility 2026-05-12 16:13:12 +09:00
5b8b91d942 feat(dashboard): implement auto-refresh feature with customizable interval and loading state for refresh button 2026-05-12 16:09:58 +09:00
c3b743ccbd feat(dashboard): add auto-refresh functionality and refresh interval selection 2026-05-12 15:56:10 +09:00
dee98a88b4 feat(workspace): add allowBuilds section for better dependency management 2026-05-12 15:55:06 +09:00
7d42d208b5 fix(workflow): add progress output to Docker build for better visibility 2026-04-27 18:19:57 +09:00
308ed58467 fix(conversation): update response creation key for clarity and remove stream creation key 2026-04-23 18:41:38 +09:00
fd67e481ec fix(conversation): tool call normalization for message handling 2026-04-23 18:31:30 +09:00
6b0e37cff7 release(package): update version to 1.0.6-express in package.json
All checks were successful
Publish Container Images / publish-images (push) Successful in 56s
2026-04-23 18:13:08 +09:00
3fcc017c0c feat(routing): add MODEL_LIST_INCLUDE_ROUTING_METADATA for enhanced model metadata in responses 2026-04-23 18:12:51 +09:00
aa40e0236c fix(package): update version to 1.0.5-express in package.json
All checks were successful
Publish Container Images / publish-images (push) Successful in 58s
2026-04-23 17:53:54 +09:00
7f574a2f22 fix(streaming.test): verify response status and content type for stream requests 2026-04-23 17:53:39 +09:00
7d44a70498 fix(scripts.test): enhance script API tests with mock backend and request validation 2026-04-23 17:51:30 +09:00
fcc4fe22cc fix(api.test): update logging to use dynamic date keys and UTC timestamps 2026-04-23 17:50:57 +09:00
7cef8635bd feat(rewrite): enhance model rewrite logic with cycle detection and chain evaluation 2026-04-23 17:39:58 +09:00
2bac7ad6a4 fix(package): update version to 1.0.4-express in package.json
All checks were successful
Publish Container Images / publish-images (push) Successful in 59s
2026-04-23 13:20:31 +09:00
d8e0fda594 feat(stream-logging): implement detailed stream logging modes and update related documentation 2026-04-23 13:19:47 +09:00
df8293494f feat(conversation): ConversationTimeline with stream structure and styling 2026-04-23 13:13:40 +09:00
ebeeb17170 fix(package): update version to 1.0.3-express in package.json
All checks were successful
Publish Container Images / publish-images (push) Successful in 1m15s
2026-04-23 02:38:17 +09:00
8021297e8b feat(chart-interaction): enhance HistogramChart and BoxPlotChart with hover functionality and tooltips 2026-04-23 02:37:43 +09:00
eacf024057 feat(analytics): enhance histogram and box plot charts with log scaling and unit formatting 2026-04-23 02:36:22 +09:00
bed925ef4c feat(analytics): implement logarithmic scale for response length histogram 2026-04-23 02:19:05 +09:00
48455d94e8 feat(analytics): add formatDurationMs function for improved duration formatting 2026-04-23 02:04:49 +09:00
a1c3de04d5 fix(package): update version to 1.0.2-express in package.json
All checks were successful
Publish Container Images / publish-images (push) Successful in 57s
2026-04-23 01:53:20 +09:00
dfafd9a826 feat(chart-interaction): add getSvgPointerX function for improved pointer handling in charts 2026-04-23 01:52:42 +09:00
3c6f836a7e fix(analytics): update tokens hint to reflect selected day window total 2026-04-23 01:49:31 +09:00
b1780667f0 fix(package): update version to 1.0.1-express for express branch
All checks were successful
Publish Container Images / publish-images (push) Successful in 1m0s
2026-04-23 01:39:31 +09:00
68d1635289 fix(package): update package name and version for express mainline 1.0.1 2026-04-23 01:36:52 +09:00
f8c603fafb feat(request-body): implement custom JSON body parser and error handler 2026-04-23 01:32:58 +09:00
6d78e5198c feat(tests): add test for multimodal image messages and update JSON payload limit to 30mb 2026-04-23 01:30:01 +09:00
451e87a826 fix(docs): clarify requirement to read documentation in UTF-8 before modifying features 2026-04-23 01:29:09 +09:00
146 changed files with 8449 additions and 12036 deletions

View file

@ -17,6 +17,9 @@ ADMIN_API_TOKEN_TTL_DAYS=30
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
ADMIN_TRUSTED_PROXY_IPS=
# Model routing
MODEL_LIST_INCLUDE_ROUTING_METADATA=false
# OpenID Connect
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=

View file

@ -80,11 +80,12 @@ jobs:
docker_args=(
build
.
--file Dockerfile
--progress=plain
--target app
--label "org.opencontainers.image.source=${REPOSITORY_URL}"
--label "org.opencontainers.image.revision=${GITHUB_SHA}"
.
)
for tag in "${tags[@]}"; do
@ -96,5 +97,3 @@ jobs:
for tag in "${tags[@]}"; do
docker push "${tag}"
done
# The single OCI image contains the public API and the admin dashboard runtime.

View file

@ -78,9 +78,11 @@ pnpm run bench # 벤치마크 실행
| `OIDC_ALLOWED_EMAILS` | empty | 관리자 접근을 허용할 이메일 목록 |
| `OIDC_SCOPES` | `openid profile email` | OIDC authorization scope |
| `MODEL_CATALOG_REFRESH_MIN_MS` | `300000` 예시 | 모델 카탈로그 refresh 최소 간격(ms) |
| `MODEL_LIST_INCLUDE_ROUTING_METADATA` | `false` | `true`이면 `/v1/models` model object에 비표준 `kyush_router` routing metadata 추가 |
| `DETAIL_STREAM_LOG_MODE` | `compact` | 상세 로그에서 stream response body 저장 방식 (`compact`, `raw`, `both`, `off`) |
## Detailed Docs
관련 기능을 수정하기 전에 해당 문서를 반드시 먼저 읽으세요.
관련 기능을 수정하기 전에 해당 문서를 반드시 UTF-8로 먼저 읽으세요.
클라이언트 중심
- [docs/client.md](docs/client.md) — 클라이언트 구조, `/dashboard` 라우팅, 관리자 UI 동작

View file

@ -13,7 +13,6 @@ 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
@ -40,9 +39,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/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /app/server ./server
COPY --from=build /app/shared ./shared
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/client/dist ./client/dist
COPY --from=build /app/database ./database
@ -50,4 +49,4 @@ RUN mkdir -p /data
EXPOSE 3000
CMD ["pnpm", "--filter", "server", "start"]
CMD ["node", "server/dist/server/src/index.js"]

View file

@ -1,7 +1,6 @@
# Kyush LLM Router
다중 사용자 LLM 라우팅 프록시
API 키 인증, 백엔드 라우팅, 스크립트 기반 요청/응답 조작, 사용량 모니터링.
다중 사용자 LLM 라우팅 프록시 — API 키 인증, 백엔드 라우팅, 스크립트 기반 요청/응답 조작, 사용량 모니터링.
## Quick Start

View file

@ -16,31 +16,23 @@
"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",
"zod": "^3.23.8"
"solid-monaco": "^0.3.0"
},
"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 Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,21 @@
import { Route, Router } from '@solidjs/router';
import { Router, Route } from '@solidjs/router';
import { lazy, Show, Suspense } from 'solid-js';
import { AuthProvider, useAuth } from './auth';
import LoginGate from './components/login-gate';
import { LoginGate } from './components/LoginGate';
import { Panel } from './ui';
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'));
const Dashboard = lazy(() => import('./routes/Dashboard').then((module) => ({ default: module.Dashboard })));
const Users = lazy(() => import('./routes/Users').then((module) => ({ default: module.Users })));
const Backends = lazy(() => import('./routes/Backends').then((module) => ({ default: module.Backends })));
const Analytics = lazy(() => import('./routes/Analytics').then((module) => ({ default: module.Analytics })));
const DetailLogs = lazy(() => import('./routes/DetailLogs').then((module) => ({ default: module.DetailLogs })));
const Models = lazy(() => import('./routes/Models').then((module) => ({ default: module.Models })));
const Scripts = lazy(() => import('./routes/Scripts').then((module) => ({ default: module.Scripts })));
function FullScreenPanel(props: { title: string; description: string }) {
function RouteLoadingFallback() {
return (
<div class="auth-screen">
<Panel
class="auth-screen__panel"
description={props.description}
title={props.title}
/>
<Panel class="auth-screen__panel" title="Loading Admin Page" description="Preparing the selected dashboard view." />
</div>
);
}
@ -30,31 +25,23 @@ function AuthenticatedApp() {
return (
<Show
fallback={
<FullScreenPanel
description="Restoring the current administrator session."
title="Loading Admin Session"
/>
}
when={!auth.loading()}
fallback={
<div class="auth-screen">
<Panel class="auth-screen__panel" title="Loading Admin Session" description="Restoring the current administrator session." />
</div>
}
>
<Show fallback={<LoginGate />} when={auth.session()?.authenticated}>
<Suspense
fallback={
<FullScreenPanel
description="Preparing the selected dashboard view."
title="Loading Admin Page"
/>
}
>
<Show when={auth.session()?.authenticated} fallback={<LoginGate />}>
<Suspense fallback={<RouteLoadingFallback />}>
<Router base="/dashboard">
<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" />
<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} />
</Router>
</Suspense>
</Show>

View file

@ -1,52 +1,27 @@
import { omitBy } from 'es-toolkit';
import ky, {
HTTPError,
type KyInstance,
type Options as KyOptions,
type ResponsePromise,
} from 'ky';
import type {
AdminApiTokenSummary,
AdminSessionResponse,
AnalyticsBackendQualityPoint,
AnalyticsBoxPlotPoint,
AnalyticsDailyTotalsPoint,
AnalyticsHistogramBin,
AnalyticsModelTrendPoint,
User,
Backend,
BackendMetrics,
BackendModelsResponse,
CreateBackendInput,
CreateModelRewriteInput,
CreatePermissionInput,
CreateScriptInput,
CreateUserInput,
DashboardSummaryResponse,
ModelCacheOverview,
ModelRewriteRule,
Permission,
RequestLogPage,
UpdateBackendInput,
UpdateModelRewriteInput,
UpdateScriptInput,
UpdateUserInput,
UsageStats,
User,
BackendMetrics,
AnalyticsDailyTotalsPoint,
AnalyticsBackendQualityPoint,
AnalyticsModelTrendPoint,
AnalyticsHistogramBin,
AnalyticsBoxPlotPoint,
DashboardSummaryResponse,
UserScript,
} 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) ?? '/';
CreateScriptData,
UpdateScriptData,
AdminApiTokenSummary,
AdminSessionResponse,
} from '../types';
const API_BASE = '';
let csrfToken: string | null = null;
let unauthorizedHandler: (() => void) | null = null;
@ -68,322 +43,195 @@ export function setUnauthorizedHandler(handler: (() => void) | null) {
unauthorizedHandler = handler;
}
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
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 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) {
nextHeaders['Content-Type'] = nextHeaders['Content-Type'] ?? 'application/json';
}
const url = new URL(request.url);
if (
response.status === 401 &&
!url.pathname.endsWith('/admin/auth/session')
) {
unauthorizedHandler?.();
if (isUnsafeMethod && url.startsWith('/admin') && csrfToken) {
nextHeaders['X-CSRF-Token'] = csrfToken;
}
const apiError = new ApiError(response.status, message);
apiError.stack = error.stack;
return apiError;
}
const response = await fetch(url, {
...options,
credentials: 'include',
headers: {
...nextHeaders,
},
});
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;
if (response.status === 204) {
return {} as T;
}
}
function getJson<T>(path: string, searchParams?: SearchParamsInit): Promise<T> {
return unwrap<T>(
httpClient.get(path, searchParams ? { searchParams } : undefined),
);
}
const payload = await response.json().catch(() => ({ error: 'Request failed' }));
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);
if (!response.ok) {
if (response.status === 401 && !url.endsWith('/admin/auth/session')) {
unauthorizedHandler?.();
}
throw new ApiError(response.status, payload.error || `HTTP ${response.status}`);
}
return url.toString();
}
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;
return payload;
}
export const api = {
auth: {
getSession: () => getJson<AdminSessionResponse>('admin/auth/session'),
login: (username: string, password: string) =>
postJson<AdminSessionResponse>('admin/auth/login', {
username,
password,
}),
logout: () => postJson<void>('admin/auth/logout'),
getSession: (): Promise<AdminSessionResponse> => fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/session`),
login: (username: string, password: string): Promise<AdminSessionResponse> =>
fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/login`, { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: (): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/logout`, { method: 'POST' }),
beginOidc: (next: string = window.location.pathname) => {
window.location.href = buildUrl('admin/auth/oidc/start', { next });
const search = new URLSearchParams({ next });
window.location.href = `${API_BASE}/admin/auth/oidc/start?${search.toString()}`;
},
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}`),
getTokens: (): Promise<AdminApiTokenSummary[]> => fetchJson<AdminApiTokenSummary[]>(`${API_BASE}/admin/auth/tokens`),
createToken: (name: string, expiresInDays?: number): Promise<{ token: string; record: AdminApiTokenSummary }> =>
fetchJson(`${API_BASE}/admin/auth/tokens`, { method: 'POST', body: JSON.stringify({ name, expiresInDays }) }),
deleteToken: (id: number): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/tokens/${id}`, { method: 'DELETE' }),
},
users: {
getAll: () => 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`),
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; copy_reasoning_to_reasoning_content?: 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' }),
},
backends: {
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}`),
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' }),
},
permissions: {
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),
}),
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' }),
},
modelRewrites: {
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}`),
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' }),
},
modelCache: {
getOverview: () => getJson<ModelCacheOverview>('admin/models/cache'),
getOverview: (): Promise<ModelCacheOverview> => fetchJson<ModelCacheOverview>(`${API_BASE}/admin/models/cache`),
},
scripts: {
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,
),
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) }),
},
dashboard: {
getSummary: (days: number = 30) =>
getJson<DashboardSummaryResponse>('admin/dashboard/summary', { days }),
getSummary: (days: number = 30): Promise<DashboardSummaryResponse> => {
const params = new URLSearchParams();
params.append('days', String(days));
return fetchJson<DashboardSummaryResponse>(`${API_BASE}/admin/dashboard/summary?${params}`);
},
},
analytics: {
getUsage: (userId?: number, backendId?: number, days: number = 30) =>
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 }),
),
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}`);
},
},
};

View file

@ -1,57 +0,0 @@
/**
* 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;

View file

@ -1,84 +1,10 @@
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)
* */
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';
interface AuthContextValue {
session: () => AdminSessionResponse | null;
loading: () => boolean;
session: Accessor<AdminSessionResponse | null>;
loading: Accessor<boolean>;
refreshSession: () => Promise<AdminSessionResponse>;
login: (username: string, password: string) => Promise<AdminSessionResponse>;
logout: () => Promise<void>;
@ -86,141 +12,63 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue>();
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,
}));
function unauthenticatedState(previous: AdminSessionResponse | null): AdminSessionResponse {
return {
authenticated: false,
authMode: previous?.authMode ?? 'both',
csrfToken: null,
principal: null,
};
}
/**
* 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';
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (props) => {
const [session, setSession] = createSignal<AdminSessionResponse | null>(null);
const [loading, setLoading] = createSignal(true);
/**
* 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),
});
}
/**
* 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),
});
}
function AuthContextProvider(props: { children: JSX.Element }) {
const queryClient = useQueryClient();
const sessionQuery = useSessionQuery();
// 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(),
const refreshSession = async () => {
const nextSession = await api.auth.getSession();
setSession(nextSession);
setAdminCsrfToken(nextSession.csrfToken);
setLoading(false);
return nextSession;
};
return (
<AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
);
}
const login = async (username: string, password: string) => {
const nextSession = await api.auth.login(username, password);
setSession(nextSession);
setAdminCsrfToken(nextSession.csrfToken);
return nextSession;
};
const logout = async () => {
await api.auth.logout();
setSession((previous) => unauthenticatedState(previous));
setAdminCsrfToken(null);
};
onMount(() => {
setUnauthorizedHandler(() => {
setSession((previous) => unauthenticatedState(previous));
setAdminCsrfToken(null);
});
void refreshSession().catch(() => {
setSession({ authenticated: false, authMode: 'both', csrfToken: null, principal: null });
setLoading(false);
});
});
export const AuthProvider: ParentComponent = (props) => {
const queryClient = makeQueryClient();
return (
<QueryClientProvider client={queryClient}>
<AuthContextProvider>{props.children}</AuthContextProvider>
</QueryClientProvider>
<AuthContext.Provider value={{ session, loading, refreshSession, login, logout }}>
{props.children}
</AuthContext.Provider>
);
};
export function useAuth(): AuthContextValue {
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside <AuthProvider>');
throw new Error('Auth context is not available');
}
return context;
}

View file

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

View file

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

View file

@ -1,21 +1,16 @@
import { createSignal, Show, type Component } from 'solid-js';
import { ApiError, api } from '../api/client';
import { useAuth } from '../auth';
import { Alert, Button, Panel, TextField } from '../ui';
import { useAuth } from '../auth';
import { api, ApiError } from '../api/client';
const LoginGate: Component = () => {
export 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 authMode = () => auth.session()?.authMode ?? 'both';
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
const handleSubmit = async (event: SubmitEvent) => {
const handleSubmit = async (event: Event) => {
event.preventDefault();
setSubmitting(true);
setErrorMessage(null);
@ -23,43 +18,29 @@ 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"
description="Sign in through the internal admin gateway before accessing router operations."
title="Admin Authentication"
>
<Panel class="auth-screen__panel" title="Admin Authentication" description="Sign in through the internal admin gateway before accessing router operations.">
<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"
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">
<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()}>
{submitting() ? 'Signing In...' : 'Sign In'}
</Button>
</form>
@ -67,14 +48,8 @@ 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
disabled={submitting()}
onClick={() => api.auth.beginOidc()}
>
<p class="ui-subtitle">Single sign-on is available through the configured OpenID provider.</p>
<Button onClick={() => api.auth.beginOidc()} disabled={submitting()}>
Continue With OpenID
</Button>
</div>
@ -84,5 +59,3 @@ const LoginGate: Component = () => {
</div>
);
};
export default LoginGate;

View file

@ -1,12 +1,8 @@
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
const THEME_STORAGE_KEY = 'kyush-theme';
const MonacoEditor = lazy(async () => {
const module = await import('solid-monaco');
return { default: module.MonacoEditor };
});
const MonacoEditor = lazy(() => import('solid-monaco').then((module) => ({ default: module.MonacoEditor })));
interface ScriptEditorProps {
value: string;
@ -15,7 +11,8 @@ interface ScriptEditorProps {
path?: string;
}
const DEFAULT_CODE = `// User-defined middleware script
export function ScriptEditor(props: ScriptEditorProps) {
const defaultCode = `// User-defined middleware script
// Available functions: onRequest, onResponse
/**
@ -29,12 +26,13 @@ export async function onRequest(ctx) {
// Example: Edit body
// if (typeof ctx.request.body === 'object') {
// ctx.request.body['chat_template_kwargs'] ??= {};
// }
// if (typeof ctx.request.body['chat_template_kwargs'] !== 'object') {
// ctx.request.body['chat_template_kwargs'] = {};
// }
// Example: Log request
// console.log('Request:', ctx.request.method, ctx.request.path);
return ctx;
}
@ -46,72 +44,51 @@ 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;
}
`;
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');
const [editorTheme, setEditorTheme] = createSignal<'vs' | 'vs-dark'>('vs-dark');
onMount(() => {
const root = document.documentElement;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setTheme(readThemePreference());
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 observer = new MutationObserver(sync);
observer.observe(root, {
attributes: true,
attributeFilter: ['data-theme'],
});
mediaQuery.addEventListener('change', sync);
sync();
const observer = new MutationObserver(syncTheme);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
mediaQuery.addEventListener('change', syncTheme);
syncTheme();
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', sync);
mediaQuery.removeEventListener('change', syncTheme);
});
});
return theme;
}
function ScriptEditor(props: ScriptEditorProps) {
const editorTheme = createEditorThemeSignal();
return (
<div class="script-editor">
<Suspense
fallback={
<div aria-live="polite" class="script-editor__loading" role="status">
<div class="script-editor__loading" role="status" aria-live="polite">
<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" />
@ -124,7 +101,10 @@ function ScriptEditor(props: ScriptEditorProps) {
<Dynamic
component={MonacoEditor}
language="typescript"
onChange={props.onChange}
value={props.value || defaultCode}
path={props.path}
onChange={(value: string) => props.onChange(value)}
theme={editorTheme()}
options={{
minimap: { enabled: false },
fontSize: 14,
@ -134,13 +114,8 @@ function ScriptEditor(props: ScriptEditorProps) {
scrollBeyondLastLine: false,
padding: { top: 16, bottom: 16 },
}}
path={props.path}
theme={editorTheme()}
value={props.value || DEFAULT_CODE}
/>
</Suspense>
</div>
);
}
export default ScriptEditor;

View file

@ -4,7 +4,7 @@ declare global {
interface Window {
Snakeground?: new (
canvas: HTMLCanvasElement,
opts?: Record<string, unknown>,
opts?: Record<string, unknown>
) => {
stop?: () => void;
setPageHeight?: (height: number) => void;
@ -16,21 +16,16 @@ 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;
@ -60,9 +55,7 @@ export default function SnakegroundBg(props: {
};
onMount(() => {
const existingScript = document.getElementById(
SCRIPT_ID,
) as HTMLScriptElement | null;
const existingScript = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if (window.Snakeground) {
mountSnakeground();
@ -87,8 +80,8 @@ export default function SnakegroundBg(props: {
});
return (
<div class="pub-bg-canvas-wrap" ref={wrapRef}>
<canvas class="pub-bg-canvas" ref={canvasRef} />
<div ref={wrapRef} class="pub-bg-canvas-wrap">
<canvas ref={canvasRef} class="pub-bg-canvas" />
</div>
);
}

View file

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

View file

@ -1,5 +0,0 @@
// 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';

View file

@ -1,13 +1,8 @@
import {
Show,
createMemo,
createResource,
createSignal,
type Component,
} from 'solid-js';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from 'solid-js';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import { formatDurationMs } from '../ui/lib/format';
import {
BoxPlotChart,
ChartLegend,
@ -15,11 +10,11 @@ import {
CommandBar,
CommandBarGroup,
HistogramChart,
MetaCluster,
PageHeader,
Panel,
Select,
SummaryStrip,
Switch,
TimeSeriesChart,
} from '../ui';
@ -29,71 +24,53 @@ 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');
const Analytics: Component = () => {
export const Analytics: Component = () => {
const [days, setDays] = createSignal('30');
const [backendFilter, setBackendFilter] = createSignal('all');
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(
new Set(),
);
const [hiddenResponseSeries, setHiddenResponseSeries] = createSignal<
Set<string>
>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(
new Set(),
);
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(new Set());
const [hiddenResponseSeries, setHiddenResponseSeries] = createSignal<Set<string>>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
const [isAutoRefresh, setIsAutoRefresh] = createSignal(false);
const [refreshInterval, setRefreshInterval] = createSignal('10');
const [refreshKey, setRefreshKey] = createSignal(0);
const [dailyVolumeScale, setDailyVolumeScale] = createSignal<'linear' | 'log'>('linear');
const filters = createMemo(() => ({
days: Number(days()),
backendId: backendFilter() === 'all' ? undefined : Number(backendFilter()),
key: refreshKey(),
}));
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 currentDailyTotals = createMemo(() => dailyTotals.latest ?? dailyTotals());
const currentBackendQuality = createMemo(() => backendQuality.latest ?? backendQuality());
const currentModelTrends = createMemo(() => modelTrends.latest ?? modelTrends());
const currentHistogram = createMemo(() => histogram.latest ?? histogram());
const currentBoxPlot = createMemo(() => boxPlot.latest ?? boxPlot());
const analyticsLoading = createMemo(() => dailyTotals.loading || backendQuality.loading || modelTrends.loading || histogram.loading || boxPlot.loading);
createEffect(() => {
if (!isAutoRefresh()) return;
const ms = Number(refreshInterval()) * 1000;
const id = setInterval(() => setRefreshKey((key) => key + 1), ms);
onCleanup(() => clearInterval(id));
});
const backendOptions = createMemo(() => [
{ value: 'all', label: 'All Backends' },
...(backends() ?? []).map((backend) => ({
...((backends() ?? []).map((backend) => ({
value: String(backend.id),
label: backend.name,
})),
}))),
]);
const backendNameById = createMemo(() => {
@ -105,31 +82,25 @@ const Analytics: Component = () => {
});
const dailyVolumeRows = createMemo(() =>
(dailyTotals() ?? []).map((row) => ({
(currentDailyTotals() ?? []).map((row) => ({
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,
};
for (const row of currentBackendQuality() ?? []) {
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((currentBackendQuality() ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
return ids.map((backendId, index) => ({
key: `backend_${backendId}`,
label: backendNameById().get(backendId) ?? `Backend ${backendId}`,
@ -139,7 +110,7 @@ const Analytics: Component = () => {
const reliabilityRows = createMemo(() => {
const grouped = new Map<string, { requests: number; errors: number }>();
for (const row of backendQuality() ?? []) {
for (const row of currentBackendQuality() ?? []) {
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
entry.requests += row.total_requests;
entry.errors += row.error_count;
@ -150,32 +121,23 @@ 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,
}));
});
const modelTrendRows = createMemo(() => {
const grouped = new Map<string, AnalyticsChartRow>();
for (const row of modelTrends() ?? []) {
const entry: AnalyticsChartRow = grouped.get(row.date) ?? {
date: row.date,
};
for (const row of currentModelTrends() ?? []) {
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((currentModelTrends() ?? []).map((row) => row.model)));
return models.map((model, index) => ({
key: `model_${model}`,
label: model,
@ -184,53 +146,29 @@ const Analytics: Component = () => {
});
const summaryItems = createMemo(() => {
const totals = (dailyTotals() ?? []).reduce(
const totals = (currentDailyTotals() ?? []).reduce(
(acc, row) => {
acc.requests += row.total_requests;
acc.tokens += row.total_tokens;
return acc;
},
{ requests: 0, tokens: 0 },
{ requests: 0, tokens: 0 }
);
const qualityRows = backendQuality() ?? [];
const qualityRows = currentBackendQuality() ?? [];
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: `Selected ${days()}-day window total` },
{ label: 'Avg Response', value: formatDurationMs(avgLatency), 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) => {
@ -248,24 +186,45 @@ const Analytics: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
description="Operational analytics with D3-driven time series, reliability, and response-length distributions."
title="Analytics"
description="Operational analytics with D3-driven time series, reliability, and response-length distributions."
/>
<CommandBar class="analytics__filters">
<CommandBarGroup>
<Select
label="Window"
onChange={setDays}
options={dayOptions}
value={days()}
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
<Select label="Backend" value={backendFilter()} options={backendOptions()} onChange={setBackendFilter} />
</CommandBarGroup>
<CommandBarGroup>
<Switch
label="Auto refresh"
checked={isAutoRefresh()}
onChange={setIsAutoRefresh}
/>
<Select
label="Backend"
onChange={setBackendFilter}
options={backendOptions()}
value={backendFilter()}
label="Refresh Interval"
value={refreshInterval()}
options={[
{ value: '5', label: 'Every 5s' },
{ value: '10', label: 'Every 10s' },
{ value: '30', label: 'Every 30s' },
{ value: '60', label: 'Every 60s' },
{ value: '600', label: 'Every 10m' },
]}
onChange={setRefreshInterval}
/>
<div class="ui-divider--vertical" />
<button
class="ui-button analytics__refresh-button"
classList={{ 'ui-button--loading': analyticsLoading() }}
type="button"
onClick={() => setRefreshKey((key) => key + 1)}
disabled={analyticsLoading()}
aria-busy={analyticsLoading()}
>
<RefreshCw />
{analyticsLoading() ? 'Refreshing' : 'Refresh'}
</button>
</CommandBarGroup>
</CommandBar>
@ -273,51 +232,51 @@ const Analytics: Component = () => {
<div class="ui-section-grid">
<Panel
actions={
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenDailySeries()}
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
}
description="Daily request and token totals on shared time axis."
title="Daily Volume"
description="Daily request and token totals on shared time axis."
actions={
<div style="display: flex; align-items: center; gap: 16px;">
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenDailySeries()}
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
<Select
label="Scale"
value={dailyVolumeScale()}
options={[
{ value: 'linear', label: 'Linear' },
{ value: 'log', label: 'Log' },
]}
onChange={setDailyVolumeScale}
/>
</div>
}
>
<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}
tooltipTitle="Daily request and token totals"
hiddenKeys={hiddenDailySeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
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"
yScaleType={dailyVolumeScale()}
/>
</Panel>
<Panel
title="Backend Reliability"
description="Success rate and absolute error count per day."
actions={
<ChartLegend
items={[
@ -326,15 +285,13 @@ const Analytics: Component = () => {
]}
/>
}
description="Success rate and absolute error count per day."
title="Backend Reliability"
>
<ComboChart
barColor="#b42318"
barLabel="Errors"
data={reliabilityRows()}
lineColor="#2357d8"
lineLabel="Success Rate"
barLabel="Errors"
lineColor="#2357d8"
barColor="#b42318"
showLegend={false}
/>
</Panel>
@ -342,33 +299,31 @@ 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="Response time"
formatLeftValue={formatDurationMs}
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()}
@ -376,48 +331,34 @@ 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}
tooltipTitle="Model request trend"
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
yLeftLabel="Requests"
formatLeftValue={(value) => `${Math.round(value)}`}
tooltipTitle="Model request trend"
/>
</Panel>
</div>
<div class="ui-section-grid analytics__grid--spread-wide">
<Panel
description="Histogram of completion token lengths across the selected window."
title="Response Length Distribution"
>
<MetaCluster
items={[{ key: 'Metric', value: 'completion_tokens' }]}
<Panel title="Response Length Distribution" description="Log-scaled completion_tokens histogram across the selected window.">
<HistogramChart
data={currentHistogram() ?? []}
xTickUnit="tok"
yTickUnit="req"
/>
<HistogramChart data={histogram() ?? []} />
</Panel>
<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' }]}
/>
<BoxPlotChart data={boxPlot() ?? []} />
<Panel title="Daily Response Length Spread" description="Log-scaled daily completion_tokens spread; outliers are hidden.">
<BoxPlotChart data={currentBoxPlot() ?? []} />
</Panel>
</div>
</div>
</Layout>
);
};
export default Analytics;

View file

@ -1,18 +1,11 @@
import {
For,
createResource,
createSignal,
Show,
type Component,
} from 'solid-js';
import { For, createMemo, 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,
@ -28,8 +21,6 @@ import {
TextField,
} from '../ui';
import type { Backend, BackendModelsResponse } from '../types';
interface BackendFormState {
name: string;
base_url: string;
@ -46,31 +37,20 @@ const emptyForm = (): BackendFormState => ({
detail_logging: false,
});
const Backends: Component = () => {
export const Backends: Component = () => {
const [backends, { refetch }] = createResource(() => api.backends.getAll());
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
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';
@ -148,11 +128,7 @@ 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);
}
@ -175,11 +151,7 @@ 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);
}
@ -196,13 +168,7 @@ 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.' });
}
};
@ -213,17 +179,10 @@ 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);
}
@ -233,192 +192,99 @@ const Backends: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
actions={
<IconButton
icon={<Plus />}
label="Add Backend"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Register upstream LLM targets, connection URLs, and activation state for routing."
title="Backends"
description="Register upstream LLM targets, connection URLs, and activation state for routing."
actions={<IconButton variant="primary" icon={<Plus />} label="Add Backend" onClick={openCreateDialog} />}
/>
<Show when={notice()}>
{(currentNotice) => (
<Alert tone={currentNotice().tone}>{currentNotice().message}</Alert>
)}
{(currentNotice) => <Alert tone={currentNotice().tone}>{currentNotice().message}</Alert>}
</Show>
<Panel
description="Operational list with overflow-safe URL presentation and compact actions."
title="Backend catalog"
>
<Panel title="Backend catalog" description="Operational list with overflow-safe URL presentation and compact actions.">
<Show
fallback={
<EmptyState
description="Reading upstream routing targets from the admin API."
title="Loading backends"
/>
}
when={!backends.loading || (backends()?.length ?? 0) > 0}
when={!backends.loading || (currentBackends()?.length ?? 0) > 0}
fallback={<EmptyState title="Loading backends" description="Reading upstream routing targets from the admin API." />}
>
<Show
when={(currentBackends()?.length ?? 0) > 0}
fallback={
<EmptyState
action={
<IconButton
icon={<Plus />}
label="Add Backend"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Add a backend before granting permissions or routing requests."
title="No backends yet"
description="Add a backend before granting permissions or routing requests."
action={<IconButton variant="primary" icon={<Plus />} label="Add Backend" onClick={openCreateDialog} />}
/>
}
when={(backends()?.length ?? 0) > 0}
>
<DataGrid
rows={currentBackends() ?? []}
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}
loading={backends.loading}
loading={backends.loading && (currentBackends()?.length ?? 0) === 0}
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
icon={<Trash2 />}
label="Delete"
onClick={() => requestDelete(backend)}
variant="danger"
/>
<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)} />
</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
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'}`}
<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'}`}
</Alert>
<Show
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}
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." />}
>
<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>
@ -432,100 +298,61 @@ 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
disabled={submitting()}
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button
disabled={submitting()}
form="backend-form"
type="submit"
variant="primary"
>
<Button onClick={() => setDialogOpen(false)} disabled={submitting()}>Cancel</Button>
<Button type="submit" form="backend-form" variant="primary" disabled={submitting()}>
{editingBackend() ? 'Save Changes' : 'Create Backend'}
</Button>
</>
}
onOpenChange={setDialogOpen}
open={dialogOpen()}
title={editingBackend() ? 'Edit Backend' : 'Add Backend'}
>
<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}
/>
<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 }))} />
<TextField
label="Base URL"
onInput={(event) =>
setForm((current) => ({
...current,
base_url: event.currentTarget.value,
}))
}
placeholder="https://api.openai.com/v1"
value={form().base_url}
placeholder="https://api.openai.com/v1"
onInput={(event) => setForm((current) => ({ ...current, base_url: event.currentTarget.value }))}
/>
<TextField
label="API Key"
onInput={(event) =>
setForm((current) => ({
...current,
api_key: event.currentTarget.value,
}))
}
placeholder="Optional upstream API key"
value={form().api_key}
placeholder="Optional upstream API key"
onInput={(event) => setForm((current) => ({ ...current, api_key: event.currentTarget.value }))}
/>
<Show when={editingBackend()}>
<Checkbox
checked={form().is_active}
description="Inactive backends stay configured but are not selected for routing."
label="Backend is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
description="Inactive backends stay configured but are not selected for routing."
checked={form().is_active}
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
/>
</Show>
<Checkbox
checked={form().detail_logging}
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 }))
}
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 }))}
/>
</form>
</FormDialog>
<ConfirmDialog
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()}
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()}
onConfirm={() => void deleteBackend()}
/>
</div>
</Layout>
);
};
export default Backends;

View file

@ -1,12 +1,9 @@
import { useQuery } from '@tanstack/solid-query';
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
import { Show, createSignal, type Component, For } from 'solid-js';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from 'solid-js';
import { api } from '../api/client';
import { queryKeys } from '../api/query-keys';
import { Layout } from '../components/Layout';
import { formatDurationMs } from '../ui/lib/format';
import {
Button,
ChartLegend,
ComboChart,
CommandBar,
@ -17,6 +14,7 @@ import {
Panel,
Select,
SummaryStrip,
Switch,
TimeSeriesChart,
} from '../ui';
@ -26,70 +24,53 @@ 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>;
const Dashboard: Component = () => {
export const Dashboard: Component = () => {
const [days, setDays] = createSignal('30');
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<
Set<string>
>(new Set());
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<
Set<string>
>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(
new Set(),
);
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<Set<string>>(new Set());
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<Set<string>>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
const [isAutoRefresh, setIsAutoRefresh] = createSignal(false);
const [refreshInterval, setRefreshInterval] = createSignal('10');
const [refreshKey, setRefreshKey] = createSignal(0);
const [trafficVolumeScale, setTrafficVolumeScale] = createSignal<'linear' | 'log'>('linear');
// 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 windowDays = createMemo(() => Number(days()));
const summarySource = createMemo(() => ({ days: windowDays(), key: refreshKey() }));
const [summary] = createResource(summarySource, (value) => api.dashboard.getSummary(value.days));
const [backends] = createResource(() => api.backends.getAll());
const currentSummary = createMemo(() => summary.latest ?? summary());
const summary = () => summaryQuery.data;
const backends = () => backendsQuery.data;
const refetch = () => summaryQuery.refetch();
createEffect(() => {
if (!isAutoRefresh()) return;
const ms = Number(refreshInterval()) * 1000;
const id = setInterval(() => setRefreshKey((k) => k + 1), ms);
onCleanup(() => clearInterval(id));
});
// 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 backendNameById = createMemo(() => {
const entries = new Map<number, string>();
for (const backend of backends() ?? []) {
entries.set(backend.id, backend.name);
}
return entries;
};
});
const trafficRows = () =>
(summary()?.series.daily_totals ?? []).map((row) => ({
const trafficRows = createMemo(() =>
(currentSummary()?.series.daily_totals ?? []).map((row) => ({
date: row.date,
requests: row.total_requests,
tokens: row.total_tokens,
}));
}))
);
const reliabilityRows = () => {
const reliabilityRows = createMemo(() => {
const grouped = new Map<string, { requests: number; errors: number }>();
for (const row of summary()?.series.backend_quality ?? []) {
for (const row of currentSummary()?.series.backend_quality ?? []) {
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
entry.requests += row.total_requests;
entry.errors += row.error_count;
@ -100,101 +81,63 @@ 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 = (): DashboardChartRow[] => {
const latencyRows = createMemo(() => {
const grouped = new Map<string, DashboardChartRow>();
for (const row of summary()?.series.backend_quality ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? {
date: row.date,
};
for (const row of currentSummary()?.series.backend_quality ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
entry[`backend_${row.backend_id}`] = row.avg_response_time_ms;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
};
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
const latencySeries = () => {
const ids = Array.from(
new Set(
(summary()?.series.backend_quality ?? []).map((row) => row.backend_id),
),
).sort((left, right) => left - right);
const latencySeries = createMemo(() => {
const ids = Array.from(new Set((currentSummary()?.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 = (): DashboardChartRow[] => {
const modelRows = createMemo(() => {
const grouped = new Map<string, DashboardChartRow>();
for (const row of summary()?.series.model_trends ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? {
date: row.date,
};
for (const row of currentSummary()?.series.model_trends ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
entry[`model_${row.model}`] = row.request_count;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
};
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
const modelSeries = () => {
const models = Array.from(
new Set((summary()?.series.model_trends ?? []).map((row) => row.model)),
);
const modelSeries = createMemo(() => {
const models = Array.from(new Set((currentSummary()?.series.model_trends ?? []).map((row) => row.model)));
return models.map((model, index) => ({
key: `model_${model}`,
label: model,
color: palette[index % palette.length],
}));
};
});
const summaryItems = () => {
const payload = summary();
const latestTraffic =
payload?.series.daily_totals[payload.series.daily_totals.length - 1];
const summaryItems = createMemo(() => {
const payload = currentSummary();
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 = () => {
const counts = summary()?.health.cache_state_counts;
const cacheStateItems = createMemo(() => {
const counts = currentSummary()?.health.cache_state_counts;
if (!counts) return [];
return [
{ key: 'Ready', value: String(counts.ready) },
@ -202,56 +145,33 @@ const Dashboard: Component = () => {
{ key: 'Error', value: String(counts.error) },
{ key: 'Inactive', value: String(counts.inactive) },
];
};
});
const scriptItems = () => {
const payload = summary();
const scriptItems = createMemo(() => {
const payload = currentSummary();
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 = () => {
const payload = summary();
const accessItems = createMemo(() => {
const payload = currentSummary();
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) => {
@ -269,113 +189,105 @@ const Dashboard: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
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."
title="Dashboard"
description="Operations cockpit for router health, traffic shape, and the configuration context behind current behavior."
/>
<CommandBar class="analytics__filters">
<CommandBarGroup>
<Select
label="Window"
onChange={setDays}
options={dayOptions}
value={days()}
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
</CommandBarGroup>
<CommandBarGroup>
<Switch
label="Auto refresh"
checked={isAutoRefresh()}
onChange={setIsAutoRefresh}
/>
<Select
label="Refresh Interval"
value={refreshInterval()}
options={[
{ value: '5', label: 'Every 5s' },
{ value: '10', label: 'Every 10s' },
{ value: '30', label: 'Every 30s' },
{ value: '60', label: 'Every 60s' },
{ value: '600', label: 'Every 10m' },
]}
onChange={setRefreshInterval}
/>
<div class="ui-divider--vertical" />
<button
class="ui-button dashboard__refresh-button"
classList={{ 'ui-button--loading': summary.loading }}
type="button"
onClick={() => setRefreshKey((k) => k + 1)}
disabled={summary.loading}
aria-busy={summary.loading}
>
<RefreshCw />
{summary.loading ? 'Refreshing' : 'Refresh'}
</button>
</CommandBarGroup>
</CommandBar>
<SummaryStrip items={summaryItems()} />
<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}
>
<Show when={!summary.error} fallback={<Panel title="Dashboard unavailable" description={summary.error instanceof Error ? summary.error.message : 'Failed to load dashboard summary.'}><EmptyState title="Failed to load summary" description="Refresh the page or verify the admin API is available." /></Panel>}>
<div class="ui-section-grid">
<Panel
actions={
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenTrafficSeries()}
onToggle={(key) =>
toggleHiddenKey(setHiddenTrafficSeries, key)
}
/>
}
description="Daily request and token totals for the selected window."
title="Traffic Volume"
description="Daily request and token totals for the selected window."
actions={
<div style="display: flex; align-items: center; gap: 16px;">
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenTrafficSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
/>
<Select
label="Scale"
value={trafficVolumeScale()}
options={[
{ value: 'linear', label: 'Linear' },
{ value: 'log', label: 'Log' },
]}
onChange={setTrafficVolumeScale}
/>
</div>
}
>
<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}
tooltipTitle="Traffic volume"
hiddenKeys={hiddenTrafficSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
yLeftLabel="Requests"
yRightLabel="Tokens"
formatLeftValue={(value) => formatInteger.format(Math.round(value))}
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
tooltipTitle="Traffic volume"
yScaleType={trafficVolumeScale()}
/>
</Panel>
<Panel
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."
title="Reliability Snapshot"
description="Success rate and absolute error count across all visible traffic."
actions={<ChartLegend items={[{ key: 'line', label: 'Success Rate', color: '#2357d8' }, { key: 'bar', label: 'Errors', color: '#b42318' }]} />}
>
<ComboChart
barColor="#b42318"
barLabel="Errors"
data={reliabilityRows()}
lineColor="#2357d8"
lineLabel="Success Rate"
barLabel="Errors"
lineColor="#2357d8"
barColor="#b42318"
showLegend={false}
/>
</Panel>
@ -383,113 +295,69 @@ const Dashboard: Component = () => {
<div class="ui-section-grid">
<Panel
actions={
<ChartLegend
items={latencySeries()}
mutedKeys={hiddenLatencySeries()}
onToggle={(key) =>
toggleHiddenKey(setHiddenLatencySeries, key)
}
/>
}
description="Average response time by backend with per-series toggles."
title="Backend Latency"
description="Average response time by backend with per-series toggles."
actions={<ChartLegend items={latencySeries()} mutedKeys={hiddenLatencySeries()} onToggle={(key) => toggleHiddenKey(setHiddenLatencySeries, key)} />}
>
<TimeSeriesChart
data={latencyRows()}
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="Latency"
formatLeftValue={formatDurationMs}
tooltipTitle="Backend latency"
yLeftLabel="Milliseconds"
/>
</Panel>
<Panel
actions={
<ChartLegend
items={modelSeries()}
mutedKeys={hiddenModelSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
/>
}
description="Top models by request volume across the current window."
title="Model Activity"
description="Top models by request volume across the current window."
actions={<ChartLegend items={modelSeries()} mutedKeys={hiddenModelSeries()} onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)} />}
>
<TimeSeriesChart
data={modelRows()}
formatLeftValue={(value) => `${Math.round(value)}`}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenModelSeries, key)
}
series={modelSeries()}
showLegend={false}
tooltipTitle="Model activity"
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
yLeftLabel="Requests"
formatLeftValue={(value) => `${Math.round(value)}`}
tooltipTitle="Model activity"
/>
</Panel>
</div>
<div class="ui-section-grid dashboard__context-grid">
<Panel
description="Cache readiness, liveness, and sync drift indicators for current backends."
title="Backend Health"
>
<Panel title="Backend Health" description="Cache readiness, liveness, and sync drift indicators for current backends.">
<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}
when={(currentSummary()?.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">
{
<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>
}
{currentSummary()?.health.stale_backends.map((backend) => (
<div class="dashboard__status-item">
<div>
<strong>{backend.name}</strong>
<p>Last sync: {backend.last_synced_at ? new Date(backend.last_synced_at).toLocaleString() : 'Never'}</p>
</div>
<span>{backend.state}</span>
</div>
))}
</div>
</Show>
</Panel>
<Panel
description="Active middleware footprint and target distribution."
title="Script Runtime"
>
<Panel title="Script Runtime" description="Active middleware footprint and target distribution.">
<MetaCluster items={scriptItems()} />
<div class="dashboard__note">
Active scripts shape request and response behavior before
traffic reaches the upstream backend.
Active scripts shape request and response behavior before traffic reaches the upstream backend.
</div>
</Panel>
<Panel
description="Identity and logging posture behind current routing activity."
title="Access Context"
>
<Panel title="Access Context" description="Identity and logging posture behind current routing activity.">
<MetaCluster items={accessItems()} />
</Panel>
</div>
@ -498,5 +366,3 @@ const Dashboard: Component = () => {
</Layout>
);
};
export default Dashboard;

View file

@ -1,34 +1,9 @@
import {
createMemo,
createResource,
createSignal,
Show,
type Component,
} from 'solid-js';
import { createEffect, createMemo, createResource, createSignal, onCleanup, 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, extractAssistantConversationPreview, hasRenderableConversation } from '../ui';
interface FilterState {
month: string;
@ -40,6 +15,7 @@ interface FilterState {
}
const PAGE_SIZE_OPTIONS = [25, 50, 100];
const SEARCH_DEBOUNCE_MS = 350;
const emptyFilters = (): FilterState => ({
month: '',
@ -50,31 +26,6 @@ const emptyFilters = (): FilterState => ({
endpoint: '',
});
function extractAssistantPreview(responseBody?: string): string {
if (!responseBody) return '-';
try {
const parsed = JSON.parse(responseBody) as {
choices?: Array<{
message?: {
content?: unknown;
};
}>;
};
const content = parsed.choices?.[0]?.message?.content;
if (typeof content !== 'string') return '-';
const normalized = content.replace(/\r/g, '').replace(/\n+/g, ' ').trim();
if (!normalized) return '-';
return normalized.length > 50
? `${normalized.slice(0, 50)}...`
: normalized;
} catch {
return '-';
}
}
function prettyPrint(value?: string): string {
if (!value) return '';
@ -85,8 +36,9 @@ function prettyPrint(value?: string): string {
}
}
const DetailLogs: Component = () => {
export const DetailLogs: Component = () => {
const [filters, setFilters] = createSignal<FilterState>(emptyFilters());
const [searchDraft, setSearchDraft] = createSignal('');
const [page, setPage] = createSignal(1);
const [pageSize, setPageSize] = createSignal(25);
const [selectedLogId, setSelectedLogId] = createSignal<number | null>(null);
@ -109,18 +61,37 @@ 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 updateFilter = (key: keyof FilterState, value: string) => {
let changed = false;
setFilters((current) => {
if (current[key] === value) return current;
changed = true;
return { ...current, [key]: value };
});
if (changed) {
setPage(1);
}
};
createEffect(() => {
const nextQuery = searchDraft();
const id = window.setTimeout(() => updateFilter('q', nextQuery), SEARCH_DEBOUNCE_MS);
onCleanup(() => window.clearTimeout(id));
});
const requestPage = createMemo(() => (logs.state === 'ready' || logs.state === 'refreshing' ? logs.latest : undefined));
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 logsError = createMemo(() => {
if (!logs.error) return null;
return logs.error instanceof Error ? logs.error.message : 'Failed to load detailed logs.';
});
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();
@ -145,12 +116,7 @@ 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();
@ -161,9 +127,7 @@ 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) {
@ -175,36 +139,23 @@ const DetailLogs: Component = () => {
const assistantPreviewById = createMemo(() => {
const previews = new Map<number, string>();
for (const row of requestRows()) {
previews.set(row.id, extractAssistantPreview(row.response_body));
previews.set(row.id, extractAssistantConversationPreview(row.response_body));
}
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 = [
@ -213,46 +164,36 @@ const DetailLogs: Component = () => {
];
const resetFilters = () => {
setSearchDraft('');
setFilters(emptyFilters());
setPage(1);
};
const updateFilter = (key: keyof FilterState, value: string) => {
setFilters((current) => ({ ...current, [key]: value }));
setPage(1);
};
return (
<Layout>
<div class="ui-app-page">
<PageHeader
title="Detail Logs"
description="Inspect verbose request logs with monthly filters, text search, and full request/response payload views."
actions={
<Button onClick={() => void refetch()}>
<Button
class="detail-logs__refresh-button"
classList={{ 'ui-button--loading': logs.loading }}
onClick={() => void refetch()}
disabled={logs.loading}
aria-busy={logs.loading}
>
<RefreshCcw />
Refresh
{logs.loading ? 'Refreshing' : 'Refresh'}
</Button>
}
description="Inspect verbose request logs with monthly filters, text search, and full request/response payload views."
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` },
]}
/>
@ -260,97 +201,42 @@ const DetailLogs: Component = () => {
<CommandBarGroup>
<TextField
label="Search"
onInput={(event) => updateFilter('q', event.currentTarget.value)}
value={searchDraft()}
placeholder="Search body, headers, models, errors"
value={filters().q}
onInput={(event) => setSearchDraft(event.currentTarget.value)}
/>
<TextField
label="Month"
onInput={(event) =>
updateFilter('month', event.currentTarget.value)
}
placeholder="YYYY-MM"
value={filters().month}
placeholder="YYYY-MM"
onInput={(event) => updateFilter('month', event.currentTarget.value)}
/>
<TextField
label="Date"
onInput={(event) =>
updateFilter('date', event.currentTarget.value)
}
placeholder="YYYY-MM-DD"
value={filters().date}
placeholder="YYYY-MM-DD"
onInput={(event) => updateFilter('date', event.currentTarget.value)}
/>
</CommandBarGroup>
<CommandBarGroup>
<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}
/>
<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)} />
<Button onClick={resetFilters}>Reset</Button>
</CommandBarGroup>
</CommandBar>
<div class="ui-section-grid">
<Panel
description="Monthly request log rows. Select one to inspect full payload snapshots."
title="Log Results"
>
<Panel title="Log Results" description="Monthly request log rows. Select one to inspect full payload snapshots.">
<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',
@ -364,30 +250,19 @@ 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}
loading={logs.loading && requestRows().length === 0}
error={logsError()}
emptyMessage="No detailed logs matched the current filters."
onRowClick={(row) => setSelectedLogId(row.id)}
pagination={{
page: page(),
@ -400,29 +275,16 @@ const DetailLogs: Component = () => {
},
pageSizeOptions: PAGE_SIZE_OPTIONS,
}}
rows={requestRows()}
tableLayout="fixed"
/>
{!logs.loading && requestRows().length === 0 && (
<EmptyState
description="Try a different month, date, or search term."
title="No logs found"
/>
{!logs.loading && !logsError() && requestRows().length === 0 && (
<EmptyState title="No logs found" description="Try a different month, date, or search term." />
)}
</Panel>
<Panel
description="Expanded metadata and serialized request/response snapshots for the active row."
title="Selected Log"
>
<Panel title="Selected Log" description="Expanded metadata and serialized request/response snapshots for the active row.">
<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">
@ -434,35 +296,19 @@ 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"
multiline
value={log().error_message ?? ''}
/>
<TextField label="Error" value={log().error_message ?? ''} multiline />
</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>
@ -480,31 +326,15 @@ const DetailLogs: Component = () => {
<Tabs.Content value="request">
<div class="ui-stack">
<TextField
label="Request Headers"
multiline
value={prettyPrint(log().request_headers)}
/>
<TextField
label="Request Body"
multiline
value={prettyPrint(log().request_body)}
/>
<TextField label="Request Headers" value={prettyPrint(log().request_headers)} multiline />
<TextField label="Request Body" value={prettyPrint(log().request_body)} multiline />
</div>
</Tabs.Content>
<Tabs.Content value="response">
<div class="ui-stack">
<TextField
label="Response Headers"
multiline
value={prettyPrint(log().response_headers)}
/>
<TextField
label="Response Body"
multiline
value={prettyPrint(log().response_body)}
/>
<TextField label="Response Headers" value={prettyPrint(log().response_headers)} multiline />
<TextField label="Response Body" value={prettyPrint(log().response_body)} multiline />
</div>
</Tabs.Content>
@ -512,8 +342,8 @@ const DetailLogs: Component = () => {
<div class="ui-stack">
<TextField
label="Raw Log JSON"
multiline
value={JSON.stringify(log(), null, 2)}
multiline
/>
</div>
</Tabs.Content>
@ -527,5 +357,3 @@ const DetailLogs: Component = () => {
</Layout>
);
};
export default DetailLogs;

View file

@ -1,17 +1,10 @@
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,
@ -28,8 +21,6 @@ import {
TextField,
} from '../ui';
import type { ModelRewriteRule } from '../types';
interface RewriteFormState {
source_model: string;
target_model: string;
@ -46,44 +37,34 @@ const emptyForm = (): RewriteFormState => ({
note: '',
});
const Models: Component = () => {
const [overview, { refetch: refetchOverview }] = createResource(() =>
api.modelCache.getOverview(),
);
export 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 currentOverview = createMemo(() => overview.state === 'ready' || overview.state === 'refreshing' ? overview.latest : undefined);
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
const currentRules = createMemo(() => rules.state === 'ready' || rules.state === 'refreshing' ? rules.latest : undefined);
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() ?? []) {
for (const backend of currentBackends() ?? []) {
names.set(backend.id, backend.name);
}
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) => ({
(currentOverview()?.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 = () => {
@ -108,10 +89,7 @@ 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;
}
@ -141,11 +119,7 @@ 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);
}
@ -158,21 +132,12 @@ 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);
}
@ -182,155 +147,68 @@ const Models: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
actions={
<Button
onClick={() =>
void Promise.all([refetchOverview(), refetchRules()])
}
>
Refresh
</Button>
}
description="Inspect cached backend model catalogs and manage global model rewrite rules."
title="Models"
description="Inspect cached backend model catalogs and manage chained global model rewrite rules."
actions={<Button onClick={() => void Promise.all([refetchOverview(), refetchRules()])}>Refresh</Button>}
/>
<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: currentOverview()?.models.length ?? 0, hint: 'Unique models across active backends' },
{ label: 'Tracked Backends', value: currentOverview()?.backends.length ?? 0, hint: 'Memory cache status by backend' },
{ label: 'Rewrite Rules', value: currentRules()?.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
description="Memory-backed backend cache state used by request routing and `/v1/models`."
title="Backend Cache Status"
>
<Panel title="Backend Cache Status" description="Memory-backed backend cache state used by request routing and `/v1/models`.">
<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}
when={(currentOverview()?.backends.length ?? 0) > 0 || overview.loading}
fallback={<EmptyState title="No backend cache yet" description="Backend model states appear here after the server has seen active backends." />}
>
<DataGrid
rows={currentOverview()?.backends ?? []}
columns={[
{
id: 'backend_id',
header: 'Backend',
class: 'models__catalog-column',
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>
),
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> },
]}
getRowKey={(item) => item.backend_id}
loading={overview.loading}
rows={overview()?.backends ?? []}
loading={overview.loading && (currentOverview()?.backends.length ?? 0) === 0}
/>
</Show>
</Panel>
<Panel
description="Unique models and the backend names currently advertising each one."
title="Model Catalog"
>
<Panel title="Model Catalog" description="Unique models and the backend names currently advertising each one.">
<Show
fallback={
<EmptyState
description="Model catalog entries appear here after backend model snapshots are available."
title="No cached models yet"
/>
}
when={modelCatalogRows().length > 0}
when={modelCatalogRows().length > 0 || overview.loading}
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',
@ -341,192 +219,96 @@ const Models: Component = () => {
},
]}
getRowKey={(item) => item.model_id}
loading={overview.loading}
rows={modelCatalogRows()}
loading={overview.loading && modelCatalogRows().length === 0}
/>
</Show>
</Panel>
</div>
<Panel
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."
title="Model Rewrite Rules"
description="Force rules always rewrite and continue through the chain. Fallback rules continue only when the current model has no usable backend."
actions={<IconButton variant="primary" icon={<Plus />} label="Add Rule" onClick={openCreateDialog} />}
>
<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}
when={(currentRules()?.length ?? 0) > 0 || rules.loading}
fallback={<EmptyState title="No rewrite rules" description="Requests currently route using the original model name." />}
>
<DataGrid
rows={currentRules() ?? []}
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}
loading={rules.loading && (currentRules()?.length ?? 0) === 0}
rowActions={(rule) => (
<div class="ui-row-actions">
<IconButton icon={<Pencil />} label="Edit" onClick={() => openEditDialog(rule)} />
<IconButton
icon={<Pencil />}
label="Edit"
onClick={() => openEditDialog(rule)}
/>
<IconButton
variant="danger"
icon={<Trash2 />}
label="Delete"
onClick={() => {
setPendingDeleteRule(rule);
setConfirmOpen(true);
}}
variant="danger"
/>
</div>
)}
rows={rules() ?? []}
/>
</Show>
</div>
</Panel>
<FormDialog
description="Choose whether the target model should always replace the source, or only act as a fallback when the source is unavailable."
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 continue the chain when the current model is unavailable."
footer={
<>
<Button
disabled={submitting()}
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button
disabled={submitting()}
form="model-rule-form"
type="submit"
variant="primary"
>
<Button onClick={() => setDialogOpen(false)} disabled={submitting()}>Cancel</Button>
<Button type="submit" form="model-rule-form" variant="primary" disabled={submitting()}>
{editingRule() ? 'Save Changes' : 'Create Rule'}
</Button>
</>
}
onOpenChange={setDialogOpen}
open={dialogOpen()}
title={editingRule() ? 'Edit Model Rule' : 'Add Model Rule'}
>
<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}
/>
<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
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 }))
}
description="When enabled, requests always continue to the target model. When disabled, the target is used only if the current model has no backend."
checked={form().force}
onChange={(checked) => setForm((current) => ({ ...current, force: checked }))}
/>
<Checkbox
checked={form().is_active}
description="Inactive rules stay stored but do not affect request routing."
label="Rule is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
description="Inactive rules stay stored but do not affect request routing."
checked={form().is_active}
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
/>
</form>
</FormDialog>
<ConfirmDialog
busy={submitting()}
confirmLabel="Delete Rule"
description="Removing the rule stops rewriting requests that target this source model."
onConfirm={() => void deleteRule()}
onOpenChange={setConfirmOpen}
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()}
onConfirm={() => void deleteRule()}
/>
</div>
</Layout>
);
};
export default Models;

View file

@ -1,13 +1,6 @@
import {
createMemo,
createResource,
createSignal,
lazy,
Show,
Suspense,
type Component,
} from 'solid-js';
import { createMemo, createResource, createSignal, lazy, Show, Suspense, type Component } from 'solid-js';
import { api } from '../api/client';
import { Layout } from '../components/Layout';
import Play from 'lucide-solid/icons/play';
import Plus from 'lucide-solid/icons/plus';
import Power from 'lucide-solid/icons/power';
@ -16,10 +9,7 @@ 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 { Layout } from '../components/Layout';
import { api } from '../api/client';
import type { ScriptType, UserScript } from '../types';
import {
Alert,
Button,
@ -39,15 +29,8 @@ import {
TextField,
} from '../ui';
import type {
CreateScriptInput,
ScriptType,
UpdateScriptInput,
UserScript,
} from '../types';
type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
const ScriptEditor = lazy(() => import('../components/script-editor'));
const ScriptEditor = lazy(() => import('../components/ScriptEditor').then((module) => ({ default: module.ScriptEditor })));
interface ScriptFormState {
id?: number;
@ -120,56 +103,27 @@ const scriptTypeLabels: Record<ScriptType, string> = {
'per-user': 'Per User',
};
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(),
);
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 currentScripts = createMemo(() => scripts.state === 'ready' || scripts.state === 'refreshing' ? scripts.latest : undefined);
const currentUsers = createMemo(() => users.state === 'ready' || users.state === 'refreshing' ? users.latest : undefined);
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
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(() => (currentUsers() ?? []).map((user) => ({ value: String(user.id), label: user.name })));
const backendOptions = createMemo(() => (currentBackends() ?? []).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(() => (currentScripts() ?? []).filter((script) => script.is_active).length);
const selectedScript = createMemo(() => (currentScripts() ?? []).find((script) => script.id === selectedScriptId()) ?? null);
const syncForm = (script?: UserScript | null) => {
if (!script) {
@ -184,30 +138,17 @@ 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 = (currentUsers() ?? []).find((item) => item.id === script.target_user_id);
const backend = (currentBackends() ?? []).find((item) => item.id === script.target_backend_id);
if (script.script_type === 'per-user-backend') {
return {
@ -233,10 +174,7 @@ 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) {
@ -248,51 +186,6 @@ 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) {
@ -301,18 +194,23 @@ 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,
buildUpdatePayload(current),
);
const updated = await api.scripts.update(current.id, payload);
setNotice({ tone: 'success', message: 'Script updated.' });
syncForm(updated);
} else {
const created = await api.scripts.create(buildCreatePayload(current));
const created = await api.scripts.create(payload);
setNotice({ tone: 'success', message: 'Script created.' });
syncForm(created);
}
@ -320,13 +218,7 @@ 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);
}
@ -339,20 +231,13 @@ 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.' });
}
};
@ -376,11 +261,7 @@ 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);
}
@ -389,10 +270,7 @@ 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;
}
@ -400,16 +278,13 @@ const Scripts: Component = () => {
setTestResult(null);
try {
const result = await api.scripts.test(current.id, {
user: users()?.[0] || undefined,
backend: backends()?.[0] || undefined,
user: currentUsers()?.[0] || undefined,
backend: currentBackends()?.[0] || undefined,
request: {
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,
},
});
@ -428,23 +303,13 @@ const Scripts: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
description="Create and maintain request and response middleware with compact editing, metadata, and test feedback."
title="Scripts"
description="Create and maintain request and response middleware with compact editing, metadata, and test feedback."
/>
<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>
)}
@ -452,53 +317,38 @@ 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}
when={!scripts.loading || (currentScripts()?.length ?? 0) > 0}
fallback={<EmptyState title="Loading scripts" description="Reading middleware definitions and target mappings." />}
>
<Show
when={(currentScripts()?.length ?? 0) > 0}
fallback={
<EmptyState
action={
<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"
description="Create your first middleware script to intercept requests or responses."
action={
<IconButton variant="primary" icon={<Plus />} label="Create Script" onClick={() => syncForm(null)} />
}
/>
}
when={(scripts()?.length ?? 0) > 0}
>
<DataGrid
rows={currentScripts() ?? []}
columns={[
{
id: 'name',
@ -508,11 +358,7 @@ 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',
@ -521,12 +367,8 @@ 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>
);
},
@ -534,17 +376,11 @@ 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}
loading={scripts.loading}
loading={scripts.loading && (currentScripts()?.length ?? 0) === 0}
onRowClick={(script) => syncForm(script)}
rowActions={(script) => (
<div class="ui-row-actions">
@ -553,139 +389,80 @@ const Scripts: Component = () => {
label={script.is_active ? 'Disable' : 'Enable'}
onClick={() => void toggleActive(script)}
/>
<IconButton
icon={<Trash2 />}
label="Delete"
onClick={() => requestDelete(script)}
variant="danger"
/>
<IconButton variant="danger" icon={<Trash2 />} label="Delete" onClick={() => requestDelete(script)} />
</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
disabled={submitting()}
variant="primary"
icon={<Save />}
label={form().id ? 'Save Script' : 'Create Script'}
onClick={() => void saveScript()}
variant="primary"
/>
<IconButton
icon={<Plus />}
label="New Script"
onClick={() => syncForm(null)}
/>
<IconButton
icon={<RotateCcw />}
label="Reset"
onClick={() => syncForm(selectedScript())}
disabled={submitting()}
/>
<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"
onInput={(event) =>
setForm((current) => ({
...current,
name: event.currentTarget.value,
}))
}
value={form().name}
/>
<TextField label="Script name" value={form().name} onInput={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))} />
<Select
label="Scope"
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', label: scriptTypeLabels['per-user'] },
{
value: 'per-backend',
label: scriptTypeLabels['per-backend'],
},
]}
value={form().script_type}
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', label: scriptTypeLabels['per-user'] },
{ value: 'per-backend', label: scriptTypeLabels['per-backend'] },
]}
/>
<Show when={form().script_type !== 'per-backend'}>
<Select
label="Target user"
onChange={(value) =>
setForm((current) => ({
...current,
target_user_id: value,
}))
}
value={form().target_user_id}
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"
onChange={(value) =>
setForm((current) => ({
...current,
target_backend_id: value,
}))
}
value={form().target_backend_id}
onChange={(value) => setForm((current) => ({ ...current, target_backend_id: value }))}
options={backendOptions()}
placeholder="Select backend"
value={form().target_backend_id}
/>
</Show>
<Checkbox
checked={form().is_active}
description="Inactive scripts remain editable but are skipped during routing."
label="Script is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
description="Inactive scripts remain editable but are skipped during routing."
checked={form().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' },
]}
/>
@ -695,59 +472,27 @@ const Scripts: Component = () => {
<Tabs.Trigger value="test">Test</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor">
<Suspense
fallback={
<Panel
class="script-editor__fallback-panel"
description="Preparing the Monaco runtime for this script."
title="Loading Editor"
/>
}
>
<Suspense fallback={<Panel title="Loading Editor" description="Preparing the Monaco runtime for this script." class="script-editor__fallback-panel" />}>
<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
disabled={testing()}
icon={<Play />}
label={testing() ? 'Running...' : 'Run Test'}
onClick={() => void runTest()}
variant="primary"
/>
<IconButton variant="primary" icon={<Play />} label={testing() ? 'Running...' : 'Run Test'} onClick={() => void runTest()} disabled={testing()} />
</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
title={result().success ? 'Test passed' : 'Test failed'}
tone={result().success ? 'success' : 'danger'}
>
{result().error ??
`Execution time: ${result().executionTime ?? 0}ms`}
<Alert tone={result().success ? 'success' : 'danger'} title={result().success ? 'Test passed' : 'Test failed'}>
{result().error ?? `Execution time: ${result().executionTime ?? 0}ms`}
</Alert>
)}
</Show>
@ -758,19 +503,20 @@ const Scripts: Component = () => {
</div>
<ConfirmDialog
busy={submitting()}
confirmLabel="Delete Script"
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()}
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 },
]}
/>
@ -778,14 +524,8 @@ const Scripts: Component = () => {
</Show>
}
onConfirm={() => void deleteScript()}
onOpenChange={setConfirmOpen}
open={confirmOpen()}
title="Delete script"
tone="danger"
/>
</div>
</Layout>
);
};
export default Scripts;

View file

@ -1,11 +1,4 @@
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';
@ -13,10 +6,9 @@ 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,
@ -38,8 +30,6 @@ import {
TextField,
} from '../ui';
import type { User } from '../types';
type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
interface UserFormState {
@ -48,6 +38,7 @@ interface UserFormState {
api_key: string;
is_active: boolean;
detail_logging: boolean;
copy_reasoning_to_reasoning_content: boolean;
}
const emptyForm = (): UserFormState => ({
@ -56,86 +47,65 @@ const emptyForm = (): UserFormState => ({
api_key: '',
is_active: true,
detail_logging: false,
copy_reasoning_to_reasoning_content: false,
});
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
const Users: Component = () => {
const [users, { refetch: refetchUsers }] = createResource(() =>
api.users.getAll(),
);
export 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 currentUsers = createMemo(() => users.state === 'ready' || users.state === 'refreshing' ? users.latest : undefined);
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
const currentPermissions = createMemo(() => permissions.state === 'ready' || permissions.state === 'refreshing' ? permissions.latest : undefined);
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(() => {
const value = query().trim().toLowerCase();
const list = users() ?? [];
const list = currentUsers() ?? [];
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(() => (currentUsers() ?? []).filter((user) => user.is_active).length);
const selectedUser = createMemo(() => (currentUsers() ?? []).find((user) => user.id === selectedUserId()) ?? null);
const permissionsForSelectedUser = createMemo(() => {
const currentUserId = selectedUserId();
if (!currentUserId) return [];
return (permissions() ?? []).filter(
(permission) => permission.user_id === currentUserId,
);
return (currentPermissions() ?? []).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() ?? [])
(currentBackends() ?? [])
.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>();
for (const backend of backends() ?? []) {
for (const backend of currentBackends() ?? []) {
names.set(backend.id, backend.name);
}
return names;
});
createEffect(() => {
const list = users() ?? [];
const list = currentUsers() ?? [];
const currentSelectedUserId = selectedUserId();
if (list.length === 0) {
@ -145,10 +115,7 @@ 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);
}
});
@ -167,6 +134,7 @@ const Users: Component = () => {
api_key: user.api_key,
is_active: user.is_active,
detail_logging: user.detail_logging,
copy_reasoning_to_reasoning_content: user.copy_reasoning_to_reasoning_content,
});
setDialogOpen(true);
};
@ -189,6 +157,7 @@ const Users: Component = () => {
api_key: current.api_key.trim() || undefined,
is_active: current.is_active,
detail_logging: current.detail_logging,
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
});
setNotice({ tone: 'success', message: 'User updated.' });
} else {
@ -197,6 +166,7 @@ const Users: Component = () => {
email: current.email.trim() || undefined,
api_key: current.api_key.trim() || undefined,
detail_logging: current.detail_logging,
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
});
setNotice({ tone: 'success', message: 'User created.' });
}
@ -206,10 +176,7 @@ 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);
}
@ -218,19 +185,10 @@ 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.' });
}
};
@ -239,11 +197,7 @@ 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.' });
}
};
@ -264,11 +218,7 @@ 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);
}
@ -284,40 +234,24 @@ 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);
}
@ -343,11 +277,7 @@ 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);
}
@ -357,31 +287,14 @@ const Users: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
actions={
<IconButton
icon={<Plus />}
label="Add User"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Manage API identities, lifecycle state, and operational access for the router."
title="Users"
description="Manage API identities, lifecycle state, and operational access for the router."
actions={<IconButton variant="primary" icon={<Plus />} label="Add User" onClick={openCreateDialog} />}
/>
<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>
)}
@ -389,11 +302,7 @@ const Users: Component = () => {
<CommandBar>
<CommandBarGroup>
<TextField
label="Search users"
onInput={(event) => setQuery(event.currentTarget.value)}
value={query()}
/>
<TextField label="Search users" value={query()} onInput={(event) => setQuery(event.currentTarget.value)} />
</CommandBarGroup>
<CommandBarGroup>
<StatusBadge tone="success">{`${activeCount()} active`}</StatusBadge>
@ -405,37 +314,26 @@ const Users: Component = () => {
<div class="ui-section-grid">
<Panel
bodyClass="ui-stack ui-stack--tight"
description="Dense operational view with API key overflow handling and row-level actions."
title="User registry"
description="Dense operational view with API key overflow handling and row-level actions."
bodyClass="ui-stack ui-stack--tight"
>
<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
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."
title="No users yet"
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} />}
/>
}
when={filteredUsers().length > 0 || users.loading}
>
<DataGrid
rows={filteredUsers()}
columns={[
{
id: 'id',
@ -452,11 +350,7 @@ 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',
@ -464,76 +358,50 @@ 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: 'reasoning_compat',
header: 'Reasoning Compat',
cell: (user) => <StatusBadge tone={user.copy_reasoning_to_reasoning_content ? 'success' : 'neutral'}>{user.copy_reasoning_to_reasoning_content ? '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}
loading={users.loading && filteredUsers().length === 0}
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
aria-label="More actions"
as={Button}
class="ui-button--icon"
>
<span aria-hidden="true" class="ui-button__icon">
<DropdownMenu.Trigger as={Button} class="ui-button--icon" aria-label="More actions">
<span class="ui-button__icon" aria-hidden="true">
<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>
@ -542,38 +410,20 @@ const Users: Component = () => {
</DropdownMenu.Root>
</div>
)}
rows={filteredUsers()}
/>
</Show>
</Show>
</Panel>
<Panel
actions={
<IconButton
disabled={
!selectedUser() || availableBackendOptions().length === 0
}
icon={<Plus />}
label="Grant Backend"
onClick={openPermissionDialog}
variant="primary"
/>
}
bodyClass="ui-stack ui-stack--tight"
title={selectedUser() ? `${selectedUser()!.name} Access` : 'User Access'}
description="Grant or revoke backend access for the currently selected user."
title={
selectedUser() ? `${selectedUser()!.name} Access` : 'User Access'
}
actions={<IconButton variant="primary" icon={<Plus />} label="Grant Backend" onClick={openPermissionDialog} disabled={!selectedUser() || availableBackendOptions().length === 0} />}
bodyClass="ui-stack ui-stack--tight"
>
<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) => (
<>
@ -581,106 +431,57 @@ 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
fallback={
<EmptyState
description="Reading backend assignments for the selected user."
title="Loading access"
/>
}
when={
!permissions.loading ||
permissionsForSelectedUser().length > 0
}
when={!permissions.loading || permissionsForSelectedUser().length > 0}
fallback={<EmptyState title="Loading access" description="Reading backend assignments for the selected user." />}
>
<Show
when={permissionsForSelectedUser().length > 0}
fallback={
<EmptyState
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."
title="No backend access yet"
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} />}
/>
}
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}`
}
loading={permissions.loading || backends.loading}
getRowKey={(permission) => `${permission.user_id}-${permission.backend_id}`}
loading={(permissions.loading || backends.loading) && permissionsForSelectedUser().length === 0}
rowActions={(permission) => (
<IconButton
variant="danger"
icon={<ShieldMinus />}
label="Revoke"
onClick={() =>
requestPermissionDelete(permission.backend_id)
}
variant="danger"
onClick={() => requestPermissionDelete(permission.backend_id)}
/>
)}
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>
</>
)}
@ -689,97 +490,72 @@ const Users: Component = () => {
</div>
<FormDialog
class="ui-dialog__content--compact"
open={dialogOpen()}
onOpenChange={setDialogOpen}
title={editingUser() ? 'Edit User' : 'Add User'}
description="Compact form dialog for user identity and lifecycle status."
footer={
<>
<Button
disabled={submitting()}
onClick={() => setDialogOpen(false)}
>
<Button onClick={() => setDialogOpen(false)} disabled={submitting()}>
Cancel
</Button>
<Button
disabled={submitting()}
form="user-form"
type="submit"
variant="primary"
>
<Button type="submit" variant="primary" form="user-form" disabled={submitting()}>
{editingUser() ? 'Save Changes' : 'Create User'}
</Button>
</>
}
onOpenChange={setDialogOpen}
open={dialogOpen()}
title={editingUser() ? 'Edit User' : 'Add User'}
class="ui-dialog__content--compact"
>
<form
class="ui-form"
id="user-form"
onSubmit={(event) => void handleSubmit(event)}
>
<TextField
label="Name"
onInput={(event) =>
setForm((current) => ({
...current,
name: event.currentTarget.value,
}))
}
value={form().name}
/>
<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 }))} />
<TextField
label="Email"
onInput={(event) =>
setForm((current) => ({
...current,
email: event.currentTarget.value,
}))
}
placeholder="ops@example.com"
value={form().email}
placeholder="ops@example.com"
onInput={(event) => setForm((current) => ({ ...current, email: event.currentTarget.value }))}
/>
<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.'
}
label="API Key"
onInput={(event) =>
setForm((current) => ({
...current,
api_key: event.currentTarget.value,
}))
}
placeholder="Leave blank to auto-generate"
value={form().api_key}
onInput={(event) => setForm((current) => ({ ...current, api_key: event.currentTarget.value }))}
/>
<Show when={editingUser()}>
<Checkbox
checked={form().is_active}
description="Inactive users keep their record but cannot route traffic."
label="User is active"
onChange={(checked) =>
setForm((current) => ({ ...current, is_active: checked }))
}
description="Inactive users keep their record but cannot route traffic."
checked={form().is_active}
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
/>
</Show>
<Checkbox
checked={form().detail_logging}
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 }))
}
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 }))}
/>
<Checkbox
label="Copy reasoning to reasoning_content"
description="Enable for clients that only display thinking from reasoning_content."
checked={form().copy_reasoning_to_reasoning_content}
onChange={(checked) => setForm((current) => ({ ...current, copy_reasoning_to_reasoning_content: checked }))}
/>
</form>
</FormDialog>
<ConfirmDialog
busy={submitting()}
confirmLabel="Delete User"
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()}
details={
<Show when={pendingDeleteUser()}>
{(user) => (
@ -793,109 +569,65 @@ const Users: Component = () => {
</Show>
}
onConfirm={() => void handleDelete()}
onOpenChange={setUserDeleteConfirmOpen}
open={userDeleteConfirmOpen()}
title="Delete user"
tone="danger"
/>
<FormDialog
class="ui-dialog__content--compact"
open={permissionDialogOpen()}
onOpenChange={setPermissionDialogOpen}
title={selectedUser() ? `Grant Backend to ${selectedUser()!.name}` : 'Grant Backend'}
description="Assign backend access for the selected user."
footer={
<>
<Button
disabled={submitting()}
onClick={() => setPermissionDialogOpen(false)}
>
<Button onClick={() => setPermissionDialogOpen(false)} disabled={submitting()}>
Cancel
</Button>
<Button
disabled={
submitting() ||
!selectedUser() ||
availableBackendOptions().length === 0
}
form="user-permission-form"
type="submit"
variant="primary"
>
<Button type="submit" form="user-permission-form" variant="primary" disabled={submitting() || !selectedUser() || availableBackendOptions().length === 0}>
Grant
</Button>
</>
}
onOpenChange={setPermissionDialogOpen}
open={permissionDialogOpen()}
title={
selectedUser()
? `Grant Backend to ${selectedUser()!.name}`
: 'Grant Backend'
}
class="ui-dialog__content--compact"
>
<form
class="ui-form"
id="user-permission-form"
onSubmit={(event) => void createPermission(event)}
>
<form id="user-permission-form" class="ui-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'
}
value={permissionBackendId()}
placeholder={availableBackendOptions().length > 0 ? 'Select backend' : 'No unassigned backends'}
/>
</form>
</FormDialog>
<ConfirmDialog
busy={submitting()}
confirmLabel="Revoke"
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()}
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;

View file

@ -1,4 +1,291 @@
// 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';
export type User = {
id: number;
api_key: string;
name: string;
email?: string;
is_active: boolean;
detail_logging: boolean;
copy_reasoning_to_reasoning_content: 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;
};

View file

@ -0,0 +1,21 @@
const durationFormatters = {
seconds: new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }),
minutes: new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }),
};
export function formatDurationMs(value: number): string {
if (!Number.isFinite(value)) {
return '0ms';
}
const absoluteValue = Math.abs(value);
if (absoluteValue < 1000) {
return `${Math.round(value)}ms`;
}
if (absoluteValue < 60_000) {
return `${durationFormatters.seconds.format(value / 1000)}s`;
}
return `${durationFormatters.minutes.format(value / 60_000)}m`;
}

View file

@ -9,21 +9,11 @@ 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 = [
@ -63,16 +53,12 @@ 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);
@ -112,17 +98,11 @@ export const AppShell: ParentComponent<AppShellProps> = (props) => {
</div>
</div>
<nav aria-label="Primary navigation" class="nav-rail__nav">
<nav class="nav-rail__nav" aria-label="Primary navigation">
<For each={navItems}>
{(item) => (
<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">
<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">
<item.icon />
</span>
<span>{item.label}</span>
@ -133,14 +113,8 @@ export const AppShell: ParentComponent<AppShellProps> = (props) => {
<div class="nav-rail__footer">
<div class="nav-rail__session">
<p class="nav-rail__session-name">
{auth.session()?.principal?.displayName ?? 'Admin'}
</p>
<p class="nav-rail__session-meta">
{auth.session()?.principal?.email ??
auth.session()?.principal?.subject ??
''}
</p>
<p class="nav-rail__session-name">{auth.session()?.principal?.displayName ?? 'Admin'}</p>
<p class="nav-rail__session-meta">{auth.session()?.principal?.email ?? auth.session()?.principal?.subject ?? ''}</p>
</div>
<IconButton
class="nav-rail__theme-toggle"

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,27 +1,17 @@
import {
LooseChatCompletionRequestSchema,
LooseChatCompletionResponseSchema,
} from '@kyush/shared';
import { For, Show, type Component } from 'solid-js';
import { For, Show, createMemo } 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;
}
const COMPACT_CHAT_STREAM_FORMAT = 'kyush.chat_stream.compact.v1';
const RAW_CHAT_STREAM_FORMAT = 'kyush.chat_stream.raw.v1';
interface ParsedMessage {
role: string;
content: string;
metadata?: MetaItem[];
reasoning?: string;
toolCalls?: string;
metadata?: Array<{ key: string; value: string }>;
}
interface ConversationTimelineProps {
@ -30,25 +20,41 @@ interface ConversationTimelineProps {
emptyMessage?: string;
}
/*
* Parsing helpers
* */
function parseJsonLike(value: unknown): unknown {
if (value == null) return null;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return null;
}
}
return value;
interface ParsedStreamResponse {
messages: ParsedMessage[];
model?: string;
created?: number;
usage?: Record<string, unknown>;
}
function stringifyContent(value: unknown): string {
if (value == null) return '';
interface StreamChoiceState {
index: number;
role?: string;
content: string[];
reasoning: string[];
toolCalls: Map<number, StreamToolCallState>;
finishReason?: string;
stopReason?: string;
}
interface StreamToolCallState {
index: number;
id?: string;
type?: string;
function?: {
name?: string;
arguments?: string;
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function stringifyValue(value: unknown): string {
if (typeof value === 'string') return value;
if (value === undefined || value === null) return '';
try {
return JSON.stringify(value);
} catch {
@ -56,165 +62,371 @@ function stringifyContent(value: unknown): string {
}
}
function parseRequest(body: unknown) {
const raw = parseJsonLike(body);
if (raw == null) return null;
const result = LooseChatCompletionRequestSchema.safeParse(raw);
return result.success ? result.data : null;
function prettyJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function parseResponse(body: unknown) {
const raw = parseJsonLike(body);
if (raw == null) return null;
const result = LooseChatCompletionResponseSchema.safeParse(raw);
return result.success ? result.data : null;
function hasMeaningfulToolCall(value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (Array.isArray(value)) return value.some((item) => hasMeaningfulToolCall(item));
if (isRecord(value)) {
return Object.entries(value).some(([key, item]) => key !== 'index' && hasMeaningfulToolCall(item));
}
return true;
}
/**
* 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),
}));
function normalizeToolCalls(value: unknown): string | undefined {
if (!hasMeaningfulToolCall(value)) return undefined;
const rendered = prettyJson(value).trim();
if (!rendered || rendered === '[]' || rendered === '{}') return undefined;
return rendered;
}
/**
* 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 [];
function normalizePayload(value: unknown): Record<string, unknown> | null {
if (!value) return null;
return response.choices
.map((choice): ParsedMessage | null => {
const message = choice.message;
if (!message) return null;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
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 typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
function normalizeCompactStreamResponse(payload: Record<string, unknown> | null): ParsedStreamResponse | null {
const compactPayload = payload?.format === RAW_CHAT_STREAM_FORMAT && isRecord(payload.compact)
? payload.compact
: payload;
if (compactPayload?.format !== COMPACT_CHAT_STREAM_FORMAT) return null;
const rawChoices = compactPayload.choices;
const messages = Array.isArray(rawChoices)
? rawChoices
.filter((choice): choice is Record<string, unknown> => isRecord(choice))
.map((choice) => {
const metadata = [
choice.finish_reason !== undefined && choice.finish_reason !== null
? { key: 'Finish', value: String(choice.finish_reason) }
: null,
choice.stop_reason !== undefined && choice.stop_reason !== null
? { key: 'Stop Reason', value: String(choice.stop_reason) }
: null,
choice.matched_stop !== undefined && choice.matched_stop !== null
? { key: 'Matched Stop', value: String(choice.matched_stop) }
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
return {
role: typeof choice.role === 'string' ? choice.role : 'assistant',
content: stringifyValue(choice.content),
reasoning: stringifyValue(choice.reasoning).trim() || undefined,
toolCalls: normalizeToolCalls(choice.tool_calls),
metadata,
};
})
.filter((message) => message.content || message.reasoning || message.toolCalls || (message.metadata?.length ?? 0) > 0)
: [];
return {
messages,
model: typeof compactPayload.model === 'string' ? compactPayload.model : undefined,
created: typeof compactPayload.created === 'number' ? compactPayload.created : undefined,
usage: isRecord(compactPayload.usage) ? compactPayload.usage : undefined,
};
}
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: stringifyValue(item.content),
}));
}
function normalizeAssistantMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
const choices = payload?.choices;
if (!Array.isArray(choices)) return [];
const messages: Array<ParsedMessage | null> = choices.map((choice) => {
if (!choice || typeof choice !== 'object') return null;
const message = (choice as Record<string, unknown>).message;
if (!message || typeof message !== 'object') return null;
const messageRecord = message as Record<string, unknown>;
const content = stringifyValue(messageRecord.content);
const reasoning = stringifyValue(messageRecord.reasoning_content ?? messageRecord.reasoning).trim();
const toolCalls = normalizeToolCalls(messageRecord.tool_calls);
const metadata = [
(choice as Record<string, unknown>).finish_reason !== undefined
? { key: 'Finish', value: String((choice as Record<string, unknown>).finish_reason) }
: null,
(choice as Record<string, unknown>).matched_stop !== undefined
? { key: 'Matched Stop', value: String((choice as Record<string, unknown>).matched_stop) }
: null,
(choice as Record<string, unknown>).logprobs !== undefined
? { key: 'Logprobs', value: JSON.stringify((choice as Record<string, unknown>).logprobs) }
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
return {
role: 'assistant',
content: stringifyContent(message.content),
metadata: metadata.length > 0 ? metadata : undefined,
role: 'assistant' as const,
content,
reasoning: reasoning || undefined,
toolCalls,
metadata,
};
});
return messages.filter((message): message is ParsedMessage => message !== null);
}
function extractSseJsonPayloads(value: string): Record<string, unknown>[] {
const payloads: Record<string, unknown>[] = [];
const lines = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
let dataLines: string[] = [];
const flush = () => {
if (dataLines.length === 0) return;
const data = dataLines.join('\n');
dataLines = [];
if (data.trim() === '[DONE]') return;
try {
const parsed = JSON.parse(data);
if (isRecord(parsed)) payloads.push(parsed);
} catch {
// Ignore non-JSON SSE data frames.
}
};
for (const line of lines) {
if (line === '') {
flush();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^ /, ''));
}
}
flush();
return payloads;
}
function mergeToolCall(target: StreamToolCallState, chunk: Record<string, unknown>): void {
if (typeof chunk.id === 'string') target.id = chunk.id;
if (typeof chunk.type === 'string') target.type = chunk.type;
const functionChunk = chunk.function;
if (!isRecord(functionChunk)) return;
target.function = target.function ?? {};
if (typeof functionChunk.name === 'string') {
target.function.name = `${target.function.name ?? ''}${functionChunk.name}`;
}
if (typeof functionChunk.arguments === 'string') {
target.function.arguments = `${target.function.arguments ?? ''}${functionChunk.arguments}`;
}
}
function parseStreamResponse(value: unknown): ParsedStreamResponse | null {
if (typeof value !== 'string' || !value.includes('data:')) return null;
const payloads = extractSseJsonPayloads(value);
if (payloads.length === 0) return null;
const choices = new Map<number, StreamChoiceState>();
let model: string | undefined;
let created: number | undefined;
let usage: Record<string, unknown> | undefined;
const getChoice = (index: number) => {
const existing = choices.get(index);
if (existing) return existing;
const createdChoice: StreamChoiceState = {
index,
content: [],
reasoning: [],
toolCalls: new Map(),
};
choices.set(index, createdChoice);
return createdChoice;
};
for (const payload of payloads) {
if (typeof payload.model === 'string' && !model) model = payload.model;
if (typeof payload.created === 'number' && created === undefined) created = payload.created;
if (isRecord(payload.usage)) usage = payload.usage;
if (!Array.isArray(payload.choices)) continue;
for (const rawChoice of payload.choices) {
if (!isRecord(rawChoice)) continue;
const index = typeof rawChoice.index === 'number' ? rawChoice.index : 0;
const choice = getChoice(index);
const delta = rawChoice.delta;
if (isRecord(delta)) {
if (typeof delta.role === 'string') choice.role = delta.role;
if (typeof delta.content === 'string') choice.content.push(delta.content);
const reasoning = typeof delta.reasoning_content === 'string'
? delta.reasoning_content
: typeof delta.reasoning === 'string'
? delta.reasoning
: undefined;
if (reasoning) choice.reasoning.push(reasoning);
if (Array.isArray(delta.tool_calls)) {
for (const rawToolCall of delta.tool_calls) {
if (!isRecord(rawToolCall)) continue;
const toolIndex = typeof rawToolCall.index === 'number' ? rawToolCall.index : choice.toolCalls.size;
const existing = choice.toolCalls.get(toolIndex) ?? { index: toolIndex };
mergeToolCall(existing, rawToolCall);
choice.toolCalls.set(toolIndex, existing);
}
}
}
if (rawChoice.finish_reason !== undefined && rawChoice.finish_reason !== null) {
choice.finishReason = String(rawChoice.finish_reason);
}
if (rawChoice.stop_reason !== undefined && rawChoice.stop_reason !== null) {
choice.stopReason = String(rawChoice.stop_reason);
}
}
}
const messages = [...choices.values()]
.sort((left, right) => left.index - right.index)
.map((choice) => {
const toolCalls = [...choice.toolCalls.values()].sort((left, right) => left.index - right.index);
const metadata = [
choice.finishReason ? { key: 'Finish', value: choice.finishReason } : null,
choice.stopReason ? { key: 'Stop Reason', value: choice.stopReason } : null,
].filter((item): item is { key: string; value: string } => Boolean(item));
return {
role: choice.role ?? 'assistant',
content: choice.content.join(''),
reasoning: choice.reasoning.join('') || undefined,
toolCalls: normalizeToolCalls(toolCalls),
metadata,
};
})
.filter((message): message is ParsedMessage => message !== null);
.filter((message) => message.content || message.reasoning || message.toolCalls || (message.metadata?.length ?? 0) > 0);
return {
messages,
model,
created,
usage,
};
}
function buildSummaryItems(
request: ReturnType<typeof parseRequest>,
response: ReturnType<typeof parseResponse>,
): MetaItem[] {
const items: MetaItem[] = [];
function getAssistantMessages(responseBody?: unknown): ParsedMessage[] {
const payload = normalizePayload(responseBody);
const compactStream = normalizeCompactStreamResponse(payload);
if (compactStream) return compactStream.messages;
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 stream = parseStreamResponse(responseBody);
if (stream) return stream.messages;
return normalizeAssistantMessages(payload);
}
/*
* Public helpers + component
* */
export function extractAssistantConversationPreview(responseBody?: unknown): string {
const assistantMessage = getAssistantMessages(responseBody).find((message) => (
message.content.trim() || message.reasoning?.trim() || message.toolCalls?.trim()
));
const ROLE_TONES: Record<KnownChatRole, StatusTone> = {
if (!assistantMessage) return '-';
const source = assistantMessage.content.trim()
? assistantMessage.content
: assistantMessage.reasoning
? `Thinking: ${assistantMessage.reasoning}`
: `Tool Calls: ${assistantMessage.toolCalls ?? ''}`;
const normalized = source
.replace(/\r/g, '')
.replace(/\n+/g, ' ')
.trim();
if (!normalized) return '-';
return normalized.length > 50 ? `${normalized.slice(0, 50)}...` : normalized;
}
export function hasRenderableConversation(requestBody?: unknown, responseBody?: unknown): boolean {
const requestMessages = normalizeMessages(normalizePayload(requestBody));
const responseMessages = getAssistantMessages(responseBody);
return requestMessages.length > 0 || responseMessages.length > 0;
}
const roleTone: Record<KnownChatRole, StatusTone> = {
system: 'info',
user: 'warning',
assistant: 'success',
};
function isKnownRole(role: string): role is KnownChatRole {
return role === 'system' || role === 'user' || role === 'assistant';
function getRoleTone(role: string): StatusTone {
if (role === 'system' || role === 'user' || role === 'assistant') {
return roleTone[role];
}
return 'neutral';
}
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;
function getRoleClass(role: string): string {
if (role === 'system' || role === 'user' || role === 'assistant') {
return `ui-conversation__turn--${role}`;
}
return 'ui-conversation__turn--unknown';
}
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());
export function ConversationTimeline(props: ConversationTimelineProps) {
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
const parsedCompactStreamResponse = createMemo(() => normalizeCompactStreamResponse(parsedResponse()));
const parsedStreamResponse = createMemo(() => parseStreamResponse(props.responseBody));
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
const responseMessages = createMemo(() => parsedCompactStreamResponse()?.messages ?? parsedStreamResponse()?.messages ?? normalizeAssistantMessages(parsedResponse()));
const messages = createMemo(() => [...requestMessages(), ...responseMessages()]);
const summaryItems = createMemo(() => {
const request = parsedRequest();
const response = parsedResponse();
const stream = parsedCompactStreamResponse() ?? parsedStreamResponse();
const usage = response?.usage && typeof response.usage === 'object' ? response.usage as Record<string, unknown> : null;
const responseUsage = usage ?? stream?.usage ?? null;
return [
typeof request?.model === 'string' ? { key: 'Model', value: request.model } : null,
request?.temperature !== undefined ? { key: 'Temp', value: String(request.temperature) } : null,
typeof stream?.model === 'string' && stream.model !== request?.model ? { key: 'Response Model', value: stream.model } : null,
typeof response?.created === 'number' ? { key: 'Res. Created', value: String(response.created) } : null,
// typeof stream?.created === 'number' ? { key: 'Stream Created', value: String(stream.created) } : null,
responseUsage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(responseUsage.prompt_tokens) } : null,
responseUsage?.completion_tokens !== undefined ? { key: 'Completion', value: String(responseUsage.completion_tokens) } : null,
responseUsage?.total_tokens !== undefined ? { key: 'Total', value: String(responseUsage.total_tokens) } : null,
].filter((item): item is { key: string; value: string } => Boolean(item));
});
return (
<div class="ui-conversation">
@ -223,30 +435,38 @@ export const ConversationTimeline: Component<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>
<Show when={message.reasoning}>
<section class="ui-conversation__block ui-conversation__block--reasoning">
<div class="ui-conversation__block-label">Thinking</div>
<pre class="ui-conversation__content">{message.reasoning}</pre>
</section>
</Show>
<Show when={message.content.trim().length > 0 || (!message.reasoning && !message.toolCalls)}>
<section class="ui-conversation__block">
<Show when={message.reasoning}>
<div class="ui-conversation__block-label">Response</div>
</Show>
<pre class="ui-conversation__content">{message.content}</pre>
</section>
</Show>
<Show when={message.toolCalls}>
<section class="ui-conversation__block ui-conversation__block--tool-calls">
<div class="ui-conversation__block-label">Tool Calls</div>
<pre class="ui-conversation__content">{message.toolCalls}</pre>
</section>
</Show>
<Show when={message.metadata && message.metadata.length > 0}>
<div class="ui-conversation__meta">
<MetaCluster items={message.metadata!} />
@ -260,4 +480,4 @@ export const ConversationTimeline: Component<ConversationTimelineProps> = (
</Show>
</div>
);
};
}

View file

@ -1,8 +1,6 @@
import { For, Match, Show, Switch } from 'solid-js';
import { cn } from '../lib/cn';
import type { JSX } from 'solid-js';
import { cn } from '../lib/cn';
export type DataMode = 'paged' | 'infinite';
export type DataDensity = 'dense' | 'regular';
@ -48,10 +46,7 @@ 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);
}
@ -94,10 +89,7 @@ function buildPaginationTokens(
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[];
@ -105,12 +97,7 @@ 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"
@ -126,10 +113,9 @@ 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}
@ -145,42 +131,21 @@ 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>
@ -189,30 +154,21 @@ 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
checked={isSelected()}
onChange={(event) =>
props.onToggleRowSelection?.(
row,
event.currentTarget.checked,
)
}
onClick={(event) => event.stopPropagation()}
type="checkbox"
checked={isSelected()}
onClick={(event) => event.stopPropagation()}
onChange={(event) => props.onToggleRowSelection?.(row, event.currentTarget.checked)}
/>
</td>
</Show>
@ -225,11 +181,7 @@ 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>
@ -241,13 +193,7 @@ 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>
@ -269,27 +215,17 @@ 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>}
@ -298,49 +234,35 @@ 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),
)
}
onClick={() => props.pagination!.onPageChange(Math.max(1, props.pagination!.page - 1))}
aria-label="Previous page"
>
&lt;
</button>
<For each={paginationTokens()}>
{(token) =>
{(token) => (
token === 'ellipsis' ? (
<span aria-hidden="true" class="ui-pagination__ellipsis">
<span class="ui-pagination__ellipsis" aria-hidden="true">
...
</span>
) : (
<button
aria-current={
props.pagination!.page === token ? 'page' : undefined
}
class={cn(
'ui-pagination__button',
props.pagination!.page === token &&
'ui-pagination__button--active',
)}
class={cn('ui-pagination__button', props.pagination!.page === token && 'ui-pagination__button--active')}
aria-current={props.pagination!.page === token ? 'page' : undefined}
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),
)
}
onClick={() => props.pagination!.onPageChange(Math.min(pageCount(), props.pagination!.page + 1))}
aria-label="Next page"
>
&gt;
</button>

View file

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

View file

@ -1,8 +1,7 @@
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;
@ -14,16 +13,14 @@ interface FormDialogProps extends ParentProps {
export function FormDialog(props: FormDialogProps) {
return (
<Dialog.Root onOpenChange={props.onOpenChange} open={props.open}>
<Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content class={cn('ui-dialog__content', props.class)}>
<div class="ui-dialog__header">
<div>
<Dialog.Title>{props.title}</Dialog.Title>
{props.description && (
<Dialog.Description>{props.description}</Dialog.Description>
)}
{props.description && <Dialog.Description>{props.description}</Dialog.Description>}
</div>
</div>
<div class="ui-dialog__body">{props.children}</div>

View file

@ -1,8 +1,6 @@
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { StatusBadge, type StatusTone } from './StatusBadge';
import { Button } from '../primitives/Button';
import { StatusBadge, type StatusTone } from './StatusBadge';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
@ -42,9 +40,7 @@ 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());
@ -54,11 +50,7 @@ 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);
@ -83,10 +75,7 @@ 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>
)}
@ -99,11 +88,7 @@ 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
@ -118,40 +103,22 @@ export function LogConsole(props: LogConsoleProps) {
<Show when={!props.loading && props.error}>
<div>{props.error}</div>
</Show>
<Show
when={!props.loading && !props.error && visibleEntries().length === 0}
>
<Show when={!props.loading && !props.error && visibleEntries().length === 0}>
<div>{props.emptyMessage ?? 'No log entries.'}</div>
</Show>
<For each={visibleEntries()}>
{(entry, index) => (
<div class="ui-log-console__line" tabindex={0}>
<span class="ui-log-console__line-number">
{String(index() + 1).padStart(3, '0')}
</span>
<span class="ui-log-console__line-number">{String(index() + 1).padStart(3, '0')}</span>
<Show when={props.showTimestamp !== false}>
<span class="ui-log-console__timestamp">
{entry.timestamp ?? '--:--:--'}
</span>
<span class="ui-log-console__timestamp">{entry.timestamp ?? '--:--:--'}</span>
</Show>
<Show when={props.showLevel !== false}>
<StatusBadge
tone={entry.level ? levelTones[entry.level] : 'neutral'}
>
{entry.level ?? 'info'}
</StatusBadge>
<StatusBadge tone={entry.level ? levelTones[entry.level] : 'neutral'}>{entry.level ?? 'info'}</StatusBadge>
</Show>
<div
class={
props.wrapLines === false
? 'ui-log-console__message ui-log-console__message--nowrap'
: 'ui-log-console__message'
}
>
<div class={props.wrapLines === false ? 'ui-log-console__message ui-log-console__message--nowrap' : 'ui-log-console__message'}>
<Show when={entry.context}>
<span style={{ color: 'var(--color-text-soft)' }}>
{entry.context}{' '}
</span>
<span style={{ color: 'var(--color-text-soft)' }}>{entry.context} </span>
</Show>
{entry.message}
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,9 @@
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;
@ -18,18 +12,12 @@ interface ButtonProps
}
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',
@ -38,7 +26,6 @@ export function Button(props: ButtonProps) {
)}
disabled={local.disabled}
onClick={local.onClick}
type={local.type ?? 'button'}
>
{local.children}
</button>

View file

@ -1,7 +1,4 @@
import * as CheckboxPrimitive from '@kobalte/core/checkbox';
import Check from 'lucide-solid/icons/check';
import { Show, type Component } from 'solid-js';
import * as KCheckbox from '@kobalte/core/checkbox';
import { cn } from '../lib/cn';
interface CheckboxProps {
@ -14,29 +11,23 @@ interface CheckboxProps {
onChange?: (checked: boolean) => void;
}
export const Checkbox: Component<CheckboxProps> = (props) => {
export function Checkbox(props: CheckboxProps) {
return (
<CheckboxPrimitive.Root
checked={props.checked}
<KCheckbox.Root
class={cn('ui-checkbox', props.class)}
checked={props.checked}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
onChange={props.onChange}
>
<CheckboxPrimitive.Input />
<CheckboxPrimitive.Control class="ui-checkbox__control">
<CheckboxPrimitive.Indicator class="ui-checkbox__indicator">
<Check aria-hidden="true" size={14} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Control>
<KCheckbox.Input />
<KCheckbox.Control class="ui-checkbox__control">
<KCheckbox.Indicator class="ui-checkbox__indicator"></KCheckbox.Indicator>
</KCheckbox.Control>
<span>
<CheckboxPrimitive.Label>{props.label}</CheckboxPrimitive.Label>
<Show when={props.description}>
<CheckboxPrimitive.Description class="ui-field__description">
{props.description}
</CheckboxPrimitive.Description>
</Show>
<KCheckbox.Label>{props.label}</KCheckbox.Label>
{props.description && <KCheckbox.Description class="ui-field__description">{props.description}</KCheckbox.Description>}
</span>
</CheckboxPrimitive.Root>
</KCheckbox.Root>
);
};
}

View file

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

View file

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

View file

@ -1,13 +1,7 @@
import type { JSX, ParentProps } from 'solid-js';
import { Button } from './Button';
import type { JSX, ParentProps } from 'solid-js';
interface IconButtonProps
extends ParentProps,
Omit<
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
'children' | 'class' | 'type' | 'onClick'
> {
interface IconButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'class' | 'type' | 'onClick'> {
icon: JSX.Element;
label: JSX.Element;
class?: string;
@ -21,12 +15,10 @@ 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 aria-hidden="true" class="ui-button__icon">
<span class="ui-button__icon" aria-hidden="true">
{props.icon}
</span>
<span class="ui-button__label">{props.label}</span>

View file

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

View file

@ -1,8 +1,5 @@
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 * as KSelect from '@kobalte/core/select';
import { Show, createMemo } from 'solid-js';
import { cn } from '../lib/cn';
export interface SelectOption {
@ -20,53 +17,39 @@ interface SelectProps {
onChange?: (value: string) => void;
}
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);
export function Select(props: SelectProps) {
const selected = createMemo(() => props.options.find((option) => option.value === props.value));
return (
<SelectPrimitive.Root<SelectOption>
<KSelect.Root<SelectOption>
class={cn('ui-select', props.class)}
itemComponent={(itemProps) => (
<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'}
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>
)}
>
<Show when={props.label}>
<SelectPrimitive.Label class="ui-field__label">
{props.label}
</SelectPrimitive.Label>
<KSelect.Label class="ui-field__label">{props.label}</KSelect.Label>
</Show>
<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>
<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>
);
};
}

View file

@ -1,5 +1,4 @@
import * as SwitchPrimitive from '@kobalte/core/switch';
import * as KSwitch from '@kobalte/core/switch';
import { cn } from '../lib/cn';
interface SwitchProps {
@ -14,25 +13,21 @@ interface SwitchProps {
export function Switch(props: SwitchProps) {
return (
<SwitchPrimitive.Root
checked={props.checked}
<KSwitch.Root
class={cn('ui-switch', props.class)}
checked={props.checked}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
onChange={props.onChange}
>
<SwitchPrimitive.Input />
<SwitchPrimitive.Control class="ui-switch__control">
<SwitchPrimitive.Thumb class="ui-switch__thumb" />
</SwitchPrimitive.Control>
<KSwitch.Input />
<KSwitch.Control class="ui-switch__control">
<KSwitch.Thumb class="ui-switch__thumb" />
</KSwitch.Control>
<span>
<SwitchPrimitive.Label>{props.label}</SwitchPrimitive.Label>
{props.description && (
<SwitchPrimitive.Description class="ui-field__description">
{props.description}
</SwitchPrimitive.Description>
)}
<KSwitch.Label>{props.label}</KSwitch.Label>
{props.description && <KSwitch.Description class="ui-field__description">{props.description}</KSwitch.Description>}
</span>
</SwitchPrimitive.Root>
</KSwitch.Root>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import { createSignal } from 'solid-js';
import {
Alert,
AppShell,
@ -47,12 +46,7 @@ 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',
@ -69,11 +63,7 @@ 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>,
},
];
@ -86,11 +76,7 @@ export const PageShell = {
render: () => (
<AppShell>
<div class="ui-app-page">
<PageHeader
actions={<Button variant="primary">Add User</Button>}
description="Shared page shell with command header and compact panel structure."
title="Users"
/>
<PageHeader title="Users" description="Shared page shell with command header and compact panel structure." actions={<Button variant="primary">Add User</Button>} />
<SummaryStrip
items={[
{ label: 'Users', value: 24, hint: 'Provisioned identities' },
@ -98,14 +84,8 @@ export const PageShell = {
{ label: 'Backends', value: 6, hint: 'Permission targets' },
]}
/>
<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 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>
</div>
</AppShell>
@ -115,11 +95,7 @@ export const PageShell = {
export const UsersTable = {
render: () => (
<div class="ui-workbench ui-stack">
<PageHeader
actions={<Button variant="primary">Add User</Button>}
description="Dense table pattern with overflow-safe API key handling."
title="Users"
/>
<PageHeader title="Users" description="Dense table pattern with overflow-safe API key handling." actions={<Button variant="primary">Add User</Button>} />
<CommandBar>
<CommandBarGroup>
<TextField label="Search users" value="ops" />
@ -128,12 +104,8 @@ export const UsersTable = {
<StatusBadge tone="success">18 active</StatusBadge>
</CommandBarGroup>
</CommandBar>
<Panel description="Route-ready table composition." title="User registry">
<DataGrid
columns={userColumns}
getRowKey={(row) => row.id}
rows={userRows}
/>
<Panel title="User registry" description="Route-ready table composition.">
<DataGrid rows={userRows} columns={userColumns} getRowKey={(row) => row.id} />
</Panel>
</div>
),
@ -145,64 +117,34 @@ export const ScriptsWorkspace = {
return (
<div class="ui-workbench ui-stack">
<PageHeader
actions={<Button variant="primary">Create Script</Button>}
description="Split workspace pattern with dense form controls and a test tab."
title="Scripts"
/>
<PageHeader title="Scripts" description="Split workspace pattern with dense form controls and a test tab." actions={<Button variant="primary">Create Script</Button>} />
<div class="ui-split-panel">
<Panel
description="Left-side selection list."
title="Script registry"
>
<Panel title="Script registry" description="Left-side selection list.">
<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
description="Right-side editor panel."
title="Editing OpenAI request guard"
>
<Panel title="Editing OpenAI request guard" description="Right-side editor panel.">
<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={[
@ -217,19 +159,12 @@ export const ScriptsWorkspace = {
<Tabs.Trigger value="test">Test</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor">
<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 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>
</Tabs.Content>
<Tabs.Content value="test">
<Alert title="Test passed" tone="success">
<Alert tone="success" title="Test passed">
Execution time: 12ms
</Alert>
</Tabs.Content>
@ -246,18 +181,14 @@ export const States = {
render: () => (
<div class="ui-workbench ui-stack">
<Panel title="Empty State">
<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"
/>
<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>} />
</Panel>
<Panel title="Loading and Error">
<div class="ui-stack">
<Alert title="Loading" tone="info">
<Alert tone="info" title="Loading">
Fetching identities and access state from the admin API.
</Alert>
<Alert title="Failed to load" tone="danger">
<Alert tone="danger" title="Failed to load">
Request failed while refreshing analytics snapshots.
</Alert>
</div>

View file

@ -1,14 +1,5 @@
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;
@ -56,11 +47,7 @@ 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',
@ -79,9 +66,7 @@ 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();
@ -92,10 +77,7 @@ 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>
@ -111,14 +93,23 @@ 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(),
@ -130,15 +121,6 @@ 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>
);
@ -150,25 +132,9 @@ export const States = {
<div class="ui-workbench ui-stack">
<div class="ui-stack">
<h1 class="ui-title">Grid States</h1>
<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={[]}
/>
<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." />
</div>
</div>
),

View file

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

View file

@ -1,5 +1,4 @@
import { createSignal } from 'solid-js';
import {
Alert,
Button,
@ -33,9 +32,7 @@ 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>
@ -55,9 +52,7 @@ 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>
@ -72,38 +67,32 @@ export const Default = {
</DropdownMenu.Root>
</div>
<div class="ui-panel__body ui-stack">
<TextField
description="Shown in routing and analytics views."
label="Backend Name"
value="OpenAI Primary"
>
<TextField label="Backend Name" value="OpenAI Primary" description="Shown in routing and analytics views.">
<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 defaultChecked label="Active only" />
<Switch defaultChecked label="Auto refresh" />
<Checkbox label="Active only" defaultChecked />
<Switch label="Auto refresh" defaultChecked />
</div>
<Tabs.Root defaultValue="request">
@ -112,15 +101,9 @@ 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">
@ -129,10 +112,7 @@ 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>
@ -142,21 +122,18 @@ export const Default = {
</div>
</div>
<Alert title="Migration note" tone="warning">
Wrapper components should replace direct primitive usage before
route-level refactors begin.
<Alert tone="warning" title="Migration note">
Wrapper components should replace direct primitive usage before route-level refactors begin.
</Alert>
<Dialog.Root onOpenChange={setDialogOpen} open={dialogOpen()}>
<Dialog.Root open={dialogOpen()} onOpenChange={setDialogOpen}>
<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">
@ -169,7 +146,7 @@ export const Default = {
</div>
<div class="ui-dialog__footer">
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={() => setDialogOpen(false)} variant="primary">
<Button variant="primary" onClick={() => setDialogOpen(false)}>
Save
</Button>
</div>

View file

@ -1,32 +1,6 @@
@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);
}

View file

@ -86,3 +86,10 @@ select {
height: 1px;
background: var(--color-border);
}
.ui-divider--vertical {
width: 1px;
height: 1.5em;
background: var(--color-border);
align-self: center;
}

View file

@ -464,6 +464,8 @@
}
.ui-conversation__bubble {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
@ -498,8 +500,29 @@
color: var(--color-text);
}
.ui-conversation__block {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.ui-conversation__block--reasoning,
.ui-conversation__block--tool-calls {
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-bg-inset);
}
.ui-conversation__block-label {
color: var(--color-text-soft);
font-size: var(--text-1);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.ui-conversation__meta {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
@ -623,6 +646,18 @@
min-width: 180px;
}
.dashboard__refresh-button {
min-width: 116px;
}
.detail-logs__refresh-button {
min-width: 116px;
}
.analytics__refresh-button {
min-width: 116px;
}
.analytics__grid--wide {
grid-template-columns: 1.5fr minmax(340px, 1fr);
}

View file

@ -66,6 +66,10 @@
cursor: not-allowed;
}
.ui-button--loading svg {
animation: ui-button-spin 0.8s linear infinite;
}
.ui-field {
display: grid;
gap: var(--space-2);
@ -445,3 +449,15 @@
padding: var(--space-4);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-button--loading svg {
animation: none;
}
}
@keyframes ui-button-spin {
to {
transform: rotate(360deg);
}
}

View file

@ -1,9 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -1,4 +1,3 @@
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
@ -6,7 +5,6 @@ export default defineConfig({
base: '/dashboard/',
plugins: [
solidPlugin(),
tailwindcss(),
{
name: 'dashboard-trailing-slash-redirect',
configureServer(server) {

View file

@ -28,5 +28,8 @@ CREATE TABLE IF NOT EXISTS backend_metrics (
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_usage_stats_user ON usage_stats(user_id);
CREATE INDEX IF NOT EXISTS idx_usage_stats_date ON usage_stats(date);
CREATE INDEX IF NOT EXISTS idx_usage_stats_backend_date ON usage_stats(backend_id, date);
CREATE INDEX IF NOT EXISTS idx_usage_stats_user_backend_date ON usage_stats(user_id, backend_id, date);
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend ON backend_metrics(backend_id);
CREATE INDEX IF NOT EXISTS idx_backend_metrics_date ON backend_metrics(date);
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend_date ON backend_metrics(backend_id, date);

View file

@ -23,7 +23,9 @@ CREATE TABLE IF NOT EXISTS request_logs (
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date ON request_logs(local_date);
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date_backend ON request_logs(local_date, backend_id);
CREATE INDEX IF NOT EXISTS idx_request_logs_user ON request_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_request_logs_backend ON request_logs(backend_id);
CREATE INDEX IF NOT EXISTS idx_request_logs_endpoint ON request_logs(endpoint);
CREATE INDEX IF NOT EXISTS idx_request_logs_detail_logged ON request_logs(detail_logged);
CREATE INDEX IF NOT EXISTS idx_request_logs_completion_tokens ON request_logs(completion_tokens);

View file

@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT,
is_active BOOLEAN DEFAULT 1,
detail_logging INTEGER NOT NULL DEFAULT 0,
copy_reasoning_to_reasoning_content INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View file

@ -27,4 +27,5 @@
- 모델 추이의 모델 키는 `response_model -> routed_model -> request_model -> unknown` 순서로 결정한다.
- response length 계열 시각화는 `completion_tokens` 값이 있는 요청만 집계한다.
- response length 계열 시각화는 긴 꼬리 분포를 읽기 쉽도록 로그 계열 스케일을 사용한다.
- 상세 요청 단위의 latency/body 확인은 계속 `DetailLogs` 화면에서 담당한다.

View file

@ -23,9 +23,12 @@
`/v1/**`는 기존 사용자 API 키 인증을 유지하며 관리자 인증과 분리된다.
추가 동작:
- `/v1/chat/completions` 는 요청 모델명을 먼저 전역 rewrite 규칙으로 해석한 뒤, 최종 모델을 서빙하는 허용 가능한 활성 백엔드만 후보로 사용한다
- `force=true` rewrite 는 항상 적용된다
- `force=false` rewrite 는 원본 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용된다
- `/v1/chat/completions` 는 요청 모델명을 먼저 전역 rewrite 체인으로 해석한 뒤, 최종 모델을 서빙하는 허용 가능한 활성 백엔드만 후보로 사용한다
- 사용자 옵션 `copy_reasoning_to_reasoning_content` 가 켜져 있으면 chat completion 응답의 `reasoning` 필드를 `reasoning_content` 로 추가 복제한다. streaming/non-stream 모두 적용되며 기존 `reasoning_content` 는 덮어쓰지 않는다
- `force=true` rewrite 는 항상 적용되고 target 모델의 다음 규칙까지 계속 평가한다
- `force=false` rewrite 는 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용되고 target 모델의 다음 규칙까지 계속 평가한다
- `/v1/models` 는 native backend 모델뿐 아니라 현재 사용자 권한에서 최종 후보가 있는 rewrite source alias도 함께 반환한다
- `MODEL_LIST_INCLUDE_ROUTING_METADATA=1|true|yes|on` 이면 `/v1/models` 의 각 model object에 비표준 `kyush_router` metadata를 추가한다. 이 metadata는 `requested_model`, `routed_model`, `was_rewritten`, `rule_type`, `rewrite_path` 를 포함한다
- 최종 후보가 없으면 모델 미지원 오류를 반환하고 `request_model`, `routed_model` 을 함께 내려준다
## Admin API
@ -52,9 +55,9 @@
| Method | Path | Description |
|--------|------|-------------|
| GET | `/admin/users` | 전체 사용자 목록 |
| POST | `/admin/users` | 사용자 생성 (`api_key` 생략 시 자동 발급, 지정 시 수동 등록) |
| POST | `/admin/users` | 사용자 생성 (`api_key` 생략 시 자동 발급, 지정 시 수동 등록, copy_reasoning_to_reasoning_content 선택 가능) |
| GET | `/admin/users/:id` | 사용자 조회 |
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, api_key, is_active, detail_logging) |
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, api_key, is_active, detail_logging, copy_reasoning_to_reasoning_content) |
| DELETE | `/admin/users/:id` | 사용자 삭제 |
| POST | `/admin/users/:id/regenerate-api-key` | API 키 재발급 |
@ -80,6 +83,8 @@
| PUT | `/admin/model-rewrites/:id` | 전역 모델 rewrite 규칙 수정 |
| DELETE | `/admin/model-rewrites/:id` | 전역 모델 rewrite 규칙 삭제 |
활성 rewrite 그래프에 cycle을 만드는 생성/수정 요청은 `409 { error, cycle }` 로 거부된다. 비활성 규칙끼리의 cycle은 저장할 수 있지만 활성화 시점에는 같은 검사를 통과해야 한다.
`GET /admin/backends/:id/models` 응답에는 아래가 함께 포함된다.
- `backend`: 백엔드 기본 정보 + 캐시 요약
- `cache`: 메모리 캐시 상태 (`ready`, `uninitialized`, `error`, `inactive`)
@ -128,6 +133,8 @@
- `model-trends``response_model -> routed_model -> request_model -> unknown` 순서로 모델 키를 결정한다.
- response length 계열 endpoint는 `completion_tokens` 가 있는 요청만 집계한다.
- `response-length-histogram` 은 긴 꼬리 분포를 읽기 쉽도록 로그 간격 bin을 반환한다.
- stream response body 저장 방식은 `DETAIL_STREAM_LOG_MODE=compact|raw|both|off` 로 제어한다. 기본값 `compact` 는 raw SSE를 저장하지 않고 누적된 thinking/content/tool call/usage JSON을 저장하며, 기존 raw SSE 로그는 관리자 UI에서 계속 파싱된다.
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
### Dashboard Summary

View file

@ -70,6 +70,7 @@ SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `
- 공통 필터는 기간(`7`, `30`, `90`일)과 backend 선택이다.
- 상단 summary strip 뒤에 일별 volume, reliability, response time, model trends, response length 분포 패널이 배치된다.
- 상세 raw request 확인은 계속 `DetailLogs` 화면이 담당한다.
- `DetailLogs` 의 Conversation 탭은 non-stream completion JSON, 기존 raw SSE stream 문자열, 신규 compact stream JSON(`kyush.chat_stream.compact.v1`)을 모두 파싱한다.
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
## Model Management UI
@ -77,7 +78,13 @@ SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `
- `Backends` 화면은 백엔드별 모델 캐시 상태, 모델 수, 마지막 sync 상태를 표시한다
- `Backends` 화면에서 활성 백엔드는 수동 refresh 와 캐시된 모델 목록 확인이 가능하다
- 비활성 백엔드는 모델 조회를 시도하지 않으며 UI에서도 `Skipped` 상태로 표시된다
- `Models` 화면은 전체 메모리 모델 카탈로그와 전역 모델 rewrite 규칙을 관리한다
- `Models` 화면은 전체 메모리 모델 카탈로그와 전역 모델 rewrite 체인을 관리한다
- rewrite 규칙은 2가지 모드를 가진다
- `Force`: 원본 모델 사용 가능 여부와 관계없이 항상 target model 로 rewrite
- `Fallback`: 원본 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 target model 로 rewrite
- `Force`: 현재 모델 사용 가능 여부와 관계없이 항상 target model 로 이동하고 다음 규칙을 계속 평가
- `Fallback`: 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 target model 로 이동하고 다음 규칙을 계속 평가
- 활성 rewrite cycle은 저장 시점에 거부되며, `/v1/models` 는 실제 요청 가능한 rewrite alias를 함께 반환한다
## User Reasoning Compatibility
- `Users` 화면은 API 키별 `Copy reasoning to reasoning_content` 옵션을 표시하고 편집한다
- 이 옵션은 같은 백엔드를 공유하는 사용자라도 downstream 클라이언트 호환성에 맞춰 독립적으로 켜거나 끌 수 있다

View file

@ -18,6 +18,7 @@ DB는 `DB_DIR` 하위에 분리 저장된다.
| email | TEXT | |
| is_active | BOOLEAN | DEFAULT 1 |
| detail_logging | INTEGER | NOT NULL DEFAULT 0 |
| copy_reasoning_to_reasoning_content | INTEGER | NOT NULL DEFAULT 0 |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
@ -215,7 +216,16 @@ Indexes: `idx_backend_metrics_backend`, `idx_backend_metrics_date`
| request_headers | TEXT | JSON 문자열 |
| request_body | TEXT | JSON 또는 raw 문자열 |
| response_headers | TEXT | JSON 문자열 |
| response_body | TEXT | JSON 또는 raw 문자열 |
| response_body | TEXT | JSON 또는 raw 문자열. stream 상세 로그는 기본적으로 `kyush.chat_stream.compact.v1` JSON으로 저장되며, `DETAIL_STREAM_LOG_MODE=raw` 인 경우 기존 raw SSE 문자열로 저장된다 |
| created_at | TEXT | UTC ISO timestamp |
Indexes: `idx_request_logs_created_at`, `idx_request_logs_local_date`, `idx_request_logs_user`, `idx_request_logs_backend`, `idx_request_logs_endpoint`, `idx_request_logs_detail_logged`
### Stream response body formats
기존 월별 DB의 raw SSE 문자열은 계속 유효하다. 새 stream 로그는 `DETAIL_STREAM_LOG_MODE` 값에 따라 아래 형식 중 하나로 저장된다.
- `compact`(기본): `response_body``{"format":"kyush.chat_stream.compact.v1", ...}` JSON 문자열이다. 반복되는 chunk 공통 필드(`id`, `object`, `created`, `model`)는 한 번만 저장하고, `choices[].reasoning`, `choices[].content`, `choices[].tool_calls[].function.arguments` 는 누적 문자열로 저장한다.
- `raw`: `response_body` 가 기존처럼 `data: ...\n\n` 형태의 raw SSE 문자열이다.
- `both`: `response_body``{"format":"kyush.chat_stream.raw.v1","compact":...,"raw_sse":"..."}` JSON 문자열이다.
- `off`: stream `response_body` 를 저장하지 않는다.

View file

@ -9,7 +9,7 @@
1. 사용자 API 키 인증
2. 사용자가 접근 가능한 backend id 목록 로드
3. 접근 가능한 활성 백엔드 중 아직 메모리 카탈로그가 초기화되지 않은 백엔드만 `/v1/models` 로 lazy fetch
4. 요청 `model` 에 대해 전역 `model_rewrites` 규칙 평가
4. 요청 `model` 에 대해 전역 `model_rewrites` 체인을 끝까지 평가
5. 최종 모델을 서빙하는 허용 가능한 활성 백엔드만 후보로 선택
6. 후보 중 1개를 랜덤 선택 후 업스트림으로 포워딩
@ -17,7 +17,9 @@
1. 사용자 API 키 인증
2. 접근 가능한 활성 백엔드의 메모리 카탈로그를 확인
3. 모델 ID 합집합을 반환
3. native backend 모델과 rewrite `source_model` alias를 같은 체인 해석기로 평가
4. 최종 모델 후보가 있는 requestable 모델 ID 합집합을 반환
5. `MODEL_LIST_INCLUDE_ROUTING_METADATA` 가 켜져 있으면 각 model object에 비표준 `kyush_router` routing metadata를 추가한다
## Caching Rules
@ -37,13 +39,24 @@
| Mode | Condition | Result |
|------|-----------|--------|
| `force=true` | 항상 | `source_model` 즉시 `target_model` 로 치환 |
| `force=false` | 원본 모델 후보가 없을 때만 | `target_model` 을 fallback 으로 사용 |
| `force=true` | 항상 | `source_model``target_model` 로 치환하고 다음 규칙을 계속 평가 |
| `force=false` | 현재 모델 후보가 없을 때만 | `target_model` 을 fallback 으로 사용하고 다음 규칙을 계속 평가 |
해석 기준:
- “원본 모델 후보가 있다”는 것은 사용자가 접근 가능하고 활성 상태이며, 메모리 카탈로그상 해당 모델을 서빙하는 백엔드가 하나 이상 있다는 뜻이다
- 원본 모델 후보가 있으면 fallback 규칙은 무시된다
- “현재 모델 후보가 있다”는 것은 사용자가 접근 가능하고 활성 상태이며, 메모리 카탈로그상 해당 모델을 서빙하는 백엔드가 하나 이상 있다는 뜻이다
- 현재 모델 후보가 있으면 fallback 규칙은 무시되고 체인 평가가 멈춘다
- force 규칙은 현재 모델 후보 존재 여부와 관계없이 target으로 이동한다
- 최종 모델 후보가 없으면 라우터는 포워딩하지 않고 모델 미지원 오류를 반환한다
- 활성 rewrite 그래프에 cycle이 생기는 관리자 생성/수정은 거부된다
- 직접 DB 조작 등으로 runtime cycle이 발견되면 라우터는 설정 오류를 반환한다
- 체인 평가는 요청별 allowed backend set과 candidate memo를 사용해 반복 DB 조회를 피한다
- `/v1/models``kyush_router` metadata는 적용된 rewrite hop만 `rewrite_path` 에 담는다. 후보가 있어 fallback이 중단된 규칙은 path에 포함하지 않는다
- `kyush_router` 는 public routing 설명용이며 backend id/name 같은 내부 라우팅 대상 정보는 포함하지 않는다
예시:
`AutoModelTranslate -(Force)-> Qwen3.5 -(Force)-> Qwen/Qwen3.5-397B-A17B-FP8 -(Fallback)-> Gemma4 -(Force)-> cyankiwi/gemma-4-26B-A4B-it-AWQ-4bit`
위 예시에서 `Qwen/Qwen3.5-397B-A17B-FP8` 후보가 있으면 fallback이 적용되지 않고 그 모델로 라우팅된다. 후보가 없으면 `Gemma4`로 이동한 뒤 force 규칙을 이어서 적용한다.
## Admin Surface

View file

@ -20,6 +20,8 @@ isolated-vm 기반 JavaScript 샌드박스에서 요청/응답을 조작하는
백엔드 응답 수신 후 실행. 현재 구현에서는 응답 컨텍스트를 검사하거나 로그/부가 처리를 수행하는 용도로 실행되며, 훅이 반환한 변경 내용이 최종 HTTP 응답에 다시 반영되지는 않는다.
참고: `reasoning``reasoning_content` 로 복제하는 OpenAI-compatible 호환 처리는 script hook이 아니라 사용자별 라우터 내장 옵션 `copy_reasoning_to_reasoning_content` 로 수행한다.
## Script Context
스크립트에서 접근 가능한 데이터:

View file

@ -54,14 +54,28 @@ server/src/
- `core.db` 에는 `admin_sessions`, `admin_api_tokens` 도 함께 저장된다
- `core.db` 에는 `backend_models`, `model_rewrites` 도 저장된다
- 시간 경계 계산은 `TZ` 기준이다
- 상세 로그가 켜진 stream 응답은 `DETAIL_STREAM_LOG_MODE` 에 따라 저장된다
- `compact`(기본): SSE chunk를 전달하면서 동시에 누적 파싱해 반복 필드를 제거한 `kyush.chat_stream.compact.v1` JSON을 `response_body`에 저장한다
- `raw`: 기존 동작처럼 raw SSE 문자열 전체를 저장한다
- `both`: compact JSON과 raw SSE를 함께 담은 `kyush.chat_stream.raw.v1` JSON을 저장한다
- `off`: stream `response_body` 저장을 생략한다. request/response headers와 request body는 detail logging 설정에 따라 계속 저장된다
## Model Routing
- 요청 모델명은 먼저 전역 `model_rewrites` 규칙을 확인한다
- `force=1` 규칙은 항상 `source_model -> target_model` 로 변환한다
- `force=0` 규칙은 원본 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용한다
- 요청 모델명은 먼저 전역 `model_rewrites` 체인을 확인한다
- `force=1` 규칙은 항상 `source_model -> target_model` 로 변환하고 다음 규칙을 계속 확인한다
- `force=0` 규칙은 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용하고 다음 규칙을 계속 확인한다
- 활성 rewrite cycle은 관리자 생성/수정 시 거부하고, runtime에서도 방어한다
- 최종 모델을 서빙하는 허용 가능한 활성 백엔드가 없으면 `/v1/chat/completions` 는 모델 미지원 오류를 반환한다
- `/v1/models` 는 허용 가능한 활성 백엔드들의 캐시된 모델 목록 합집합을 반환한다
- `/v1/models` 는 허용 가능한 활성 백엔드들의 native 모델과 실제 요청 가능한 rewrite alias 합집합을 반환한다
- `MODEL_LIST_INCLUDE_ROUTING_METADATA` 가 켜져 있으면 `/v1/models` 는 비표준 `kyush_router` metadata를 추가해 요청 모델, 최종 라우팅 모델, 적용된 rewrite path를 노출한다.
## Reasoning Compatibility
- 사용자별 `copy_reasoning_to_reasoning_content` 옵션이 켜져 있으면 `/v1/chat/completions` 응답에서 `reasoning``reasoning_content` 로 추가 복제한다
- 같은 백엔드라도 API 키별로 옵션을 다르게 둘 수 있다
- streaming 응답은 옵션이 켜진 경우에만 SSE JSON frame을 변환하고, 옵션이 꺼진 경우 기존처럼 원본 바이트를 전달한다
- 이미 `reasoning_content` 가 있으면 덮어쓰지 않고 `reasoning` 원본도 유지한다
참고:
- 세부 라우팅 규칙과 캐시 트리거는 [docs/model-routing.md](./model-routing.md) 참고
@ -73,6 +87,7 @@ server/src/
- `AnalyticsService``analytics.db` 의 일별 집계와 `request_logs_YYYY-MM.db` 의 범위 조회를 함께 사용해 시계열/분포 데이터를 만든다.
- 모델 추이 키는 `response_model -> routed_model -> request_model -> unknown` 순서로 결정한다.
- response length 계열 집계는 `completion_tokens` 가 있는 요청만 포함한다.
- response length histogram은 긴 꼬리 분포를 위해 로그 간격 bin을 사용한다.
- 자세한 화면/API 설명은 [docs/analytics.md](./analytics.md) 참고.
## Deployment Notes

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "kyush-llm-router",
"version": "1.0",
"name": "kyush-llm-router-express",
"version": "1.0.10-express",
"description": "LLM routing server with multi-user API key management",
"scripts": {
"dev": "pnpm --parallel dev",
@ -8,9 +8,7 @@
"start": "pnpm --parallel start",
"test": "pnpm -r --filter server test",
"bench": "pnpm -r --filter server run bench",
"storybook": "pnpm -r --filter client storybook",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"storybook": "pnpm -r --filter client storybook"
},
"keywords": [
"llm",
@ -26,20 +24,5 @@
],
"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"
}
}

3579
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,12 @@
packages:
- shared
- server
- client
allowBuilds:
better-sqlite3: true
esbuild: true
isolated-vm: true
onlyBuiltDependencies:
- better-sqlite3
- esbuild

View file

@ -1,15 +1,9 @@
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import {
type BenchmarkConfig,
type BenchmarkEnv,
setupBenchmark,
runBenchmark,
} from './runner';
import { BenchmarkConfig, BenchmarkEnv, setupBenchmark, runBenchmark } from './runner';
import { Scenarios, createRealBackendPayload } from './scenarios';
import { calculateStats, type BenchmarkResult } from './stats';
import { calculateStats, BenchmarkResult } from './stats';
import { printReport, exportToJSON } from './report';
// Utility: Normalize backend URL (remove trailing slash and /v1 prefix)
@ -27,7 +21,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}`;
@ -41,18 +35,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}`
};
}
}
@ -63,14 +57,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);
@ -79,7 +73,7 @@ async function runScenarioBenchmark(
const benchmarkConfig = {
concurrent: config.concurrentRequests,
total: config.totalRequests,
warmup: config.warmupRequests,
warmup: config.warmupRequests
};
if (backendType === 'real') {
@ -92,7 +86,7 @@ async function runScenarioBenchmark(
scenario.method,
scenario.payload,
directHeaders,
benchmarkConfig,
benchmarkConfig
);
console.log(chalk.yellow(' Running routed requests...'));
@ -101,7 +95,7 @@ async function runScenarioBenchmark(
scenario.method,
scenario.payload,
routeHeaders,
benchmarkConfig,
benchmarkConfig
);
return { directResults: directRaw, routeResults: routeRaw };
@ -117,10 +111,7 @@ 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);
@ -129,21 +120,19 @@ 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);
}
@ -159,7 +148,7 @@ async function main() {
const scenarios = [
Scenarios.smallPayload(),
Scenarios.largePayload(),
Scenarios.modelsEndpoint(),
Scenarios.modelsEndpoint()
];
if (config.backendType === 'real') {
@ -176,17 +165,13 @@ 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);
}
@ -195,19 +180,16 @@ async function main() {
concurrent: config.concurrentRequests,
total: config.totalRequests,
warmup: config.warmupRequests,
backend: config.backendType,
backend: config.backendType
});
// Export to JSON if requested
if (options.output) {
exportToJSON(allResults, options.output);
}
} catch (error) {
console.error(
chalk.red(
`Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
),
);
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`));
process.exit(1);
} finally {
// Cleanup

View file

@ -1,31 +1,28 @@
import fs from 'node:fs';
import Table from 'cli-table3';
import chalk from 'chalk';
import { type BenchmarkResult, calculateOverhead } from './stats';
import { 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'),
@ -37,22 +34,13 @@ 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'),
@ -64,15 +52,14 @@ 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}`,
@ -83,25 +70,26 @@ export function printReport(results: BenchmarkResult[], config: any) {
route.p95ResponseTime.toFixed(2),
route.p99ResponseTime.toFixed(2),
route.errors,
route.throughput.toFixed(2),
route.throughput.toFixed(2)
]);
console.log(table.toString());
console.log(`\n${overheadColor(` Overhead: ${overhead.toFixed(2)}%`)}`);
} else {
console.log(table.toString());
}
}
console.log('\n' + '='.repeat(80));
console.log(chalk.bold.green(' Benchmark completed!'));
console.log('='.repeat(80) + '\n');
}
export function exportToJSON(results: BenchmarkResult[], outputPath: string) {
const fs = require('fs');
const data = {
timestamp: new Date().toISOString(),
results,
results
};
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2));
console.log(chalk.green(`Results exported to ${outputPath}`));

View file

@ -1,12 +1,10 @@
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 { createApp } from '../src/index';
import type { RawResult } from './stats';
import { createServer } from '../src/index';
import { RawResult } from './stats';
export interface BenchmarkConfig {
concurrentRequests: number;
@ -25,36 +23,26 @@ 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: ServerType | null = null;
let routerServer: ServerType | null = null;
let mockServer: any = null;
let routerServer: any = 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);
}
@ -62,13 +50,11 @@ export async function setupBenchmark(
mockServer = server;
mockBackendPort = port;
console.log(`Mock backend started on port ${port}`);
// Check if benchmark backend already exists
const backend = BackendModel.findAll().find(
(b) => b.name === 'benchmark-backend',
);
let 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)
@ -77,105 +63,97 @@ export async function setupBenchmark(
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 = createApp();
routerServer = serve({ fetch: app.fetch, port: routerPort }, () => {
const app = createServer();
routerServer = app.listen(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 = createApp();
routerServer = serve({ fetch: app.fetch, port: routerPort }, () => {
const app = createServer();
routerServer = app.listen(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
const backend = BackendModel.findAll().find(
(b) => b.name === 'real-backend',
);
let 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 });
@ -190,7 +168,7 @@ export async function setupBenchmark(
cleanup: () => {
if (mockServer) mockServer.close();
if (routerServer) routerServer.close();
},
}
};
}
@ -199,37 +177,31 @@ 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;
}
@ -237,40 +209,40 @@ async function makeRequest(
url: string,
method: string,
payload: any,
headers: Record<string, string>,
headers: Record<string, string>
): Promise<RawResult> {
const startTime = Date.now();
try {
const options: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
signal: AbortSignal.timeout(30000)
};
if (method !== 'GET' && payload && Object.keys(payload).length > 0) {
options.body = JSON.stringify(payload);
}
const response = await fetch(url, options);
const responseTime = Date.now() - startTime;
if (response.ok) {
return { responseTime, success: true };
} else {
const errorText = await response.text().catch(() => 'Unknown error');
return {
responseTime,
success: false,
error: `HTTP ${response.status}: ${errorText.substring(0, 100)}`,
return {
responseTime,
success: false,
error: `HTTP ${response.status}: ${errorText.substring(0, 100)}`
};
}
} catch (error) {
const responseTime = Date.now() - startTime;
return {
responseTime,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
return {
responseTime,
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}

View file

@ -26,10 +26,12 @@ 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 => ({
@ -40,30 +42,14 @@ 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 => ({
@ -71,8 +57,8 @@ export const Scenarios = {
description: 'GET /models request',
endpoint: '/v1/models',
method: 'GET',
payload: {} as ChatCompletionPayload,
}),
payload: {} as ChatCompletionPayload
})
};
export function createRealBackendPayload(): Scenario {
@ -83,9 +69,11 @@ export function createRealBackendPayload(): Scenario {
method: 'POST',
payload: {
model: process.env.REAL_MODEL || 'default-model',
messages: [{ role: 'user', content: 'Hello, this is a benchmark test.' }],
messages: [
{ role: 'user', content: 'Hello, this is a benchmark test.' }
],
temperature: 0.7,
max_tokens: 100,
},
max_tokens: 100
}
};
}

View file

@ -20,47 +20,31 @@ 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,
@ -78,14 +62,7 @@ export function calculateStats(
};
}
export function calculateOverhead(
direct: BenchmarkResult,
route: BenchmarkResult,
): number {
export function calculateOverhead(direct: BenchmarkResult, route: BenchmarkResult): number {
if (direct.avgResponseTime === 0) return 0;
return (
((route.avgResponseTime - direct.avgResponseTime) /
direct.avgResponseTime) *
100
);
return ((route.avgResponseTime - direct.avgResponseTime) / direct.avgResponseTime) * 100;
}

View file

@ -2,12 +2,11 @@
"name": "server",
"version": "1.0.0",
"description": "LLM Router Server",
"main": "src/main.ts",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc --noEmit",
"start": "tsx src/main.ts",
"dev": "tsx watch src/main.ts",
"build": "tsc && node scripts/copy-schemas.mjs",
"start": "node dist/server/src/index.js",
"dev": "tsx watch src",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
@ -18,27 +17,25 @@
"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",
"es-toolkit": "^1.32.0",
"hono": "^4.6.14",
"express": "^5.2.1",
"isolated-vm": "^6.0.2",
"node-fetch": "^3.3.2",
"tsx": "^4.21.0",
"zod": "^3.23.8"
"zod": "^4.3.6"
},
"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"
}

View file

@ -0,0 +1,16 @@
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));
}

View file

@ -1,68 +1,81 @@
import { createHash } from 'node:crypto';
import { createHash } from 'crypto';
import { AdminAuthMode } from '../../../shared/types';
import { env } from './env';
function normalizeAuthMode(value?: string): AdminAuthMode {
if (value === 'env' || value === 'oidc' || value === 'both') {
return value;
}
return 'both';
}
import type { AdminAuthMode } from '../../../shared/types';
function parseList(value?: string): string[] {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
export function getAdminAuthMode(): AdminAuthMode {
return env.ADMIN_AUTH_MODE;
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
}
export function isEnvAdminEnabled(): boolean {
const mode = env.ADMIN_AUTH_MODE;
const mode = getAdminAuthMode();
return mode === 'env' || mode === 'both';
}
export function isOidcEnabled(): boolean {
const mode = env.ADMIN_AUTH_MODE;
const mode = getAdminAuthMode();
return mode === 'oidc' || mode === 'both';
}
export function getAdminUsername(): string | null {
return env.ADMIN_USERNAME;
return process.env.ADMIN_USERNAME?.trim() || null;
}
export function getAdminPasswordHash(): string | null {
return env.ADMIN_PASSWORD_HASH;
return process.env.ADMIN_PASSWORD_HASH?.trim() || null;
}
export function getAdminSessionSecret(): string {
return env.ADMIN_SESSION_SECRET;
return process.env.ADMIN_SESSION_SECRET?.trim() || 'development-admin-session-secret';
}
export function getAdminSessionTtlHours(): number {
return env.ADMIN_SESSION_TTL_HOURS;
const parsed = Number(process.env.ADMIN_SESSION_TTL_HOURS ?? 12);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 12;
}
export function getAdminApiTokenTtlDays(): number {
return env.ADMIN_API_TOKEN_TTL_DAYS;
const parsed = Number(process.env.ADMIN_API_TOKEN_TTL_DAYS ?? 30);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
}
export function getCookieSecure(): boolean {
return env.ADMIN_COOKIE_SECURE;
return process.env.NODE_ENV === 'production' && process.env.ADMIN_COOKIE_SECURE !== 'false';
}
export function getAllowedOidcEmails(): string[] {
return env.OIDC_ALLOWED_EMAILS;
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) => entry.toLowerCase());
}
export function getTrustedProxyIps(): string[] {
return env.ADMIN_TRUSTED_PROXY_IPS;
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
}
export function getOidcConfig() {
return {
issuerUrl: env.OIDC_ISSUER_URL,
clientId: env.OIDC_CLIENT_ID,
clientSecret: env.OIDC_CLIENT_SECRET,
redirectUri: env.OIDC_REDIRECT_URI,
scopes: env.OIDC_SCOPES,
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',
};
}
export function hashOpaqueToken(token: string): string {
return createHash('sha256')
.update(env.ADMIN_SESSION_SECRET)
.update(getAdminSessionSecret())
.update(':')
.update(token)
.digest('hex');

View file

@ -1,41 +1,67 @@
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, getAnalyticsDbPath } from './db-paths';
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 hasIndex(database: Database.Database, tableName: string, indexName: string): boolean {
const result = database.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name = ?`).get(tableName, indexName) as { name: string } | undefined;
return Boolean(result);
}
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;
function ensureAnalyticsIndexes(db: Database.Database): void {
const indexes: Array<{ name: string; table: string; sql: string }> = [
{ name: 'idx_usage_stats_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_backend_date ON usage_stats(backend_id, date)' },
{ name: 'idx_usage_stats_user_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_user_backend_date ON usage_stats(user_id, backend_id, date)' },
{ name: 'idx_backend_metrics_backend_date', table: 'backend_metrics', sql: 'CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend_date ON backend_metrics(backend_id, date)' },
];
for (const { name, table, sql } of indexes) {
if (!hasIndex(db, table, name)) {
db.exec(sql);
}
}
}
let db: Database.Database;
export function getAnalyticsDb(): Database.Database {
db ??= openDb();
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);
ensureAnalyticsIndexes(db);
}
return db;
}
export function initAnalyticsDb(): Database.Database {
db?.close();
db = openDb();
// 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);
return db;
}
export function closeAnalyticsDb(): void {
db?.close();
db = undefined;
if (db) {
db.close();
db = undefined as unknown as Database.Database;
}
}

View file

@ -1,70 +1,63 @@
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';
import { ensureDir, getCoreDbPath, getSchemaPath } from './db-paths';
let db: Database.Database;
// 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 hasColumn(database: Database.Database, tableName: string, columnName: string): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
}
function runCoreMigrations(database: Database.Database): void {
if (hasColumn(database, 'model_rewrites', 'force') === false) {
database.exec(
'ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0',
);
database.exec('ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0');
}
if (hasColumn(database, 'users', 'copy_reasoning_to_reasoning_content') === false) {
database.exec('ALTER TABLE users ADD COLUMN copy_reasoning_to_reasoning_content INTEGER NOT NULL 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 {
db ??= openDb();
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);
}
return db;
}
export function initDb(): Database.Database {
db?.close();
db = openDb();
// 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);
return db;
}
export function closeDb(): void {
db?.close();
db = undefined;
if (db) {
db.close();
db = undefined as unknown as Database.Database;
}
}

View file

@ -1,31 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'fs';
import path from 'path';
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)
* */
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
export function getDbRootDir(): string {
return env.DB_DIR;
return process.env.DB_DIR || process.env.DB_PATH || DEFAULT_DB_DIR;
}
export function ensureDir(dirPath: string): void {
@ -49,3 +28,4 @@ export function getRequestLogsDir(): string {
export function getRequestLogsDbPath(monthKey: string): string {
return path.join(getRequestLogsDir(), `request_logs_${monthKey}.db`);
}

View file

@ -1,165 +0,0 @@
/**
* 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,
);
},
};

View file

@ -0,0 +1,4 @@
export function shouldIncludeModelListRoutingMetadata(): boolean {
const value = process.env.MODEL_LIST_INCLUDE_ROUTING_METADATA?.trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
}

View file

@ -1,83 +1,75 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import {
ensureDir,
getRequestLogsDbPath,
getRequestLogsDir,
getSchemaPath,
} from './db-paths';
import fs from 'fs';
import path from 'path';
import { ensureDir, getRequestLogsDbPath, getRequestLogsDir } from './db-paths';
import { getLocalMonthKey } from '../utils/time';
const connections = new Map<string, Database.Database>();
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 hasColumn(database: Database.Database, tableName: string, columnName: string): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
}
function initRequestLogsSchema(db: Database.Database): void {
const schema = fs.readFileSync(
getSchemaPath('request-logs-schema.sql'),
'utf-8',
);
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'request-logs-schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
if (!hasColumn(db, 'request_logs', 'routed_model')) {
if (hasColumn(db, 'request_logs', 'routed_model') === false) {
db.exec('ALTER TABLE request_logs ADD COLUMN routed_model TEXT');
}
}
export function getRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
function ensureRequestLogsIndexes(db: Database.Database): void {
const existingIndexes = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'request_logs'").all() as Array<{ name: string }>;
const indexNames = new Set(existingIndexes.map((idx) => idx.name));
const indexes = [
['idx_request_logs_local_date_backend', 'CREATE INDEX IF NOT EXISTS idx_request_logs_local_date_backend ON request_logs(local_date, backend_id)'],
['idx_request_logs_completion_tokens', 'CREATE INDEX IF NOT EXISTS idx_request_logs_completion_tokens ON request_logs(completion_tokens)'],
];
for (const [name, sql] of indexes) {
if (!indexNames.has(name)) {
db.exec(sql);
}
}
}
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));
const db = new Database(dbPath);
initRequestLogsSchema(db);
ensureRequestLogsIndexes(db);
connections.set(monthKey, db);
return db;
}
export function initRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
connections.get(monthKey)?.close();
connections.delete(monthKey);
export function initRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
existing.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_LOG_FILENAME_PATTERN.exec(entry)?.[1])
.filter((value): value is string => value !== undefined)
.map((entry) => /^request_logs_(\d{4}-\d{2})\.db$/.exec(entry)?.[1])
.filter((value): value is string => Boolean(value))
.sort((a, b) => b.localeCompare(a));
}

View file

@ -0,0 +1,11 @@
export type DetailStreamLogMode = 'compact' | 'raw' | 'both' | 'off';
export function getDetailStreamLogMode(): DetailStreamLogMode {
const value = process.env.DETAIL_STREAM_LOG_MODE?.trim().toLowerCase();
if (value === 'compact' || value === 'raw' || value === 'both' || value === 'off') {
return value;
}
return 'compact';
}

View file

@ -1,136 +1,91 @@
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 express, { Application } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
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;
import { createJsonBodyParser, JSON_BODY_LIMIT, requestBodyErrorHandler } from './utils/requestBody';
const envPathCandidates = [
path.resolve(moduleDir, '..', '..', '.env'),
path.resolve(moduleDir, '..', '..', '..', '..', '.env'),
path.resolve(__dirname, '..', '..', '.env'),
path.resolve(__dirname, '..', '..', '..', '..', '.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,
});
const MAX_BODY_SIZE = 30 * 1024 * 1024; // 30mb
export function createApp(): OpenAPIHono<AppEnv> {
export function createServer(): Application {
void ModelCatalogService.initialize();
const app = new OpenAPIHono<AppEnv>();
const app = express();
const adminDistCandidates = [
path.resolve(moduleDir, '..', '..', 'client', 'dist'),
path.resolve(moduleDir, '..', '..', '..', 'client', 'dist'),
path.resolve(moduleDir, '..', '..', '..', '..', 'client', 'dist'),
path.resolve(__dirname, '..', '..', '..', 'client', 'dist'),
path.resolve(__dirname, '..', '..', '..', '..', 'client', 'dist'),
];
const adminDistPath = adminDistCandidates.find((candidate) =>
fs.existsSync(candidate),
);
const adminDistPath = adminDistCandidates.find((candidate) => fs.existsSync(candidate));
app.use(
'*',
cors({
origin: env.CORS_ORIGINS,
credentials: true,
}),
);
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(
'*',
bodyLimit({
maxSize: MAX_BODY_SIZE,
}),
);
app.use(cors({
origin: corsOrigins,
credentials: true,
}));
app.use(createJsonBodyParser());
app.use(requestBodyErrorHandler);
// Public admin auth endpoints
app.route('/admin/auth', adminAuthRoutes);
app.use('/admin/auth', adminAuthRoutes);
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
app.use('/admin', requireAdminAccess, requireSessionCsrf, adminRoutes);
app.use('/v1', apiRoutes);
// 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',
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
});
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: '/' }],
});
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();
app.use('/dashboard', express.static(adminDistPath, { index: false, fallthrough: true }));
app.get(/^\/dashboard(?:\/.*)?$/, (req, res, next) => {
if (path.extname(req.path)) {
next();
return;
}
return c.html(indexHtml());
res.sendFile(path.join(adminDistPath, 'index.html'));
});
}
app.notFound((c) => c.json({ error: 'Not found' }, 404));
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
return app;
}
const app = createApp();
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`);
});
}
export default app;

View file

@ -1,19 +0,0 @@
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`);
},
);

View file

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

View file

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

View file

@ -1,13 +1,7 @@
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;
@ -21,39 +15,24 @@ 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 (?, ?, ?, ?, ?, ?)',
);
const result = stmt.run(
data.name,
data.base_url,
data.api_key || null,
detailLogging ? 1 : 0,
timestamp,
timestamp,
'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);
return {
id: result.lastInsertRowid as number,
@ -100,9 +79,7 @@ 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);
}
@ -112,9 +89,7 @@ export class BackendModel {
}
static deactivate(id: number): boolean {
const result = getDb()
.prepare('UPDATE backends SET is_active = 0, updated_at = ? WHERE id = ?')
.run(getUtcTimestamp(), id);
const result = getDb().prepare('UPDATE backends SET is_active = 0, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
return result.changes > 0;
}
}

View file

@ -1,8 +1,7 @@
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;
}
@ -10,24 +9,16 @@ 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)
@ -41,7 +32,7 @@ export class BackendModelSnapshotModel {
model.raw_json || null,
fetchedAt,
timestamp,
timestamp,
timestamp
);
}
});

View file

@ -1,11 +1,10 @@
import { getDb } from '../config/database';
import { getUtcTimestamp } from '../utils/time';
import type {
import {
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;
@ -22,21 +21,17 @@ 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,
@ -44,16 +39,13 @@ 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[] = [];
@ -83,16 +75,12 @@ export class ModelRewriteModel {
updates.push('updated_at = ?');
values.push(getUtcTimestamp(), id);
getDb()
.prepare(`UPDATE model_rewrites SET ${updates.join(', ')} WHERE id = ?`)
.run(...values);
getDb().prepare(`UPDATE model_rewrites SET ${updates.join(', ')} WHERE id = ?`).run(...values);
return this.findById(id);
}
static delete(id: number): boolean {
const result = getDb()
.prepare('DELETE FROM model_rewrites WHERE id = ?')
.run(id);
const result = getDb().prepare('DELETE FROM model_rewrites WHERE id = ?').run(id);
return result.changes > 0;
}
}

View file

@ -1,46 +1,29 @@
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);
@ -51,10 +34,7 @@ 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;
@ -62,30 +42,22 @@ export class PermissionModel {
}
static delete(user_id: number, backend_id: number): boolean {
const result = getDb()
.prepare('DELETE FROM permissions WHERE user_id = ? AND backend_id = ?')
.run(user_id, backend_id);
const result = getDb().prepare('DELETE FROM permissions WHERE user_id = ? AND backend_id = ?').run(user_id, backend_id);
return result.changes > 0;
}
static deleteByUserId(userId: number): number {
const result = getDb()
.prepare('DELETE FROM permissions WHERE user_id = ?')
.run(userId);
const result = getDb().prepare('DELETE FROM permissions WHERE user_id = ?').run(userId);
return result.changes;
}
static deleteByBackendId(backendId: number): number {
const result = getDb()
.prepare('DELETE FROM permissions WHERE backend_id = ?')
.run(backendId);
const result = getDb().prepare('DELETE FROM permissions WHERE backend_id = ?').run(backendId);
return result.changes;
}
static getUserBackendIds(userId: number): number[] {
const rows = getDb()
.prepare('SELECT backend_id FROM permissions WHERE user_id = ?')
.all(userId) as { backend_id: number }[];
return rows.map((row) => row.backend_id);
const rows = getDb().prepare('SELECT backend_id FROM permissions WHERE user_id = ?').all(userId) as { backend_id: number }[];
return rows.map(row => row.backend_id);
}
}

View file

@ -1,13 +1,7 @@
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;
@ -20,47 +14,30 @@ 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(
@ -71,7 +48,7 @@ export class ScriptModel {
data.script_code,
isActive ? 1 : 0,
timestamp,
timestamp,
timestamp
);
return {
@ -86,10 +63,7 @@ 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;
@ -133,51 +107,33 @@ 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') {
@ -189,13 +145,10 @@ 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') {
@ -207,13 +160,10 @@ 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') {

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