Compare commits
42 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28049bce2c | |||
| e8276cde3f | |||
| cb55e2d24a | |||
| 0f64a4cd85 | |||
| 227e5b12da | |||
| 4cae96500e | |||
| 1f1514b5da | |||
| fd37fd276a | |||
| 43664819d4 | |||
| 96c9b963b4 | |||
| 472e289198 | |||
| f6a032f81c | |||
| 3bcac29fa1 | |||
| 5b8b91d942 | |||
| c3b743ccbd | |||
| dee98a88b4 | |||
| 7d42d208b5 | |||
| 308ed58467 | |||
| fd67e481ec | |||
| 6b0e37cff7 | |||
| 3fcc017c0c | |||
| aa40e0236c | |||
| 7f574a2f22 | |||
| 7d44a70498 | |||
| fcc4fe22cc | |||
| 7cef8635bd | |||
| 2bac7ad6a4 | |||
| d8e0fda594 | |||
| df8293494f | |||
| ebeeb17170 | |||
| 8021297e8b | |||
| eacf024057 | |||
| bed925ef4c | |||
| 48455d94e8 | |||
| a1c3de04d5 | |||
| dfafd9a826 | |||
| 3c6f836a7e | |||
| b1780667f0 | |||
| 68d1635289 | |||
| f8c603fafb | |||
| 6d78e5198c | |||
| 451e87a826 |
146 changed files with 8449 additions and 12036 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 동작
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
# Kyush LLM Router
|
||||
|
||||
다중 사용자 LLM 라우팅 프록시
|
||||
API 키 인증, 백엔드 라우팅, 스크립트 기반 요청/응답 조작, 사용량 모니터링.
|
||||
다중 사용자 LLM 라우팅 프록시 — API 키 인증, 백엔드 라우팅, 스크립트 기반 요청/응답 조작, 사용량 모니터링.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1219
client/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { render } from 'solid-js/web';
|
||||
|
||||
import App from './App';
|
||||
import './ui/styles.css';
|
||||
|
||||
|
|
|
|||
5
client/src/reset.d.ts
vendored
5
client/src/reset.d.ts
vendored
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
21
client/src/ui/lib/format.ts
Normal file
21
client/src/ui/lib/format.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<
|
||||
</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"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)} />,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
client/src/vite-env.d.ts
vendored
9
client/src/vite-env.d.ts
vendored
|
|
@ -1,9 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,4 +27,5 @@
|
|||
|
||||
- 모델 추이의 모델 키는 `response_model -> routed_model -> request_model -> unknown` 순서로 결정한다.
|
||||
- response length 계열 시각화는 `completion_tokens` 값이 있는 요청만 집계한다.
|
||||
- response length 계열 시각화는 긴 꼬리 분포를 읽기 쉽도록 로그 계열 스케일을 사용한다.
|
||||
- 상세 요청 단위의 latency/body 확인은 계속 `DetailLogs` 화면에서 담당한다.
|
||||
|
|
|
|||
17
docs/api.md
17
docs/api.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 클라이언트 호환성에 맞춰 독립적으로 켜거나 끌 수 있다
|
||||
|
|
|
|||
|
|
@ -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` 를 저장하지 않는다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ isolated-vm 기반 JavaScript 샌드박스에서 요청/응답을 조작하는
|
|||
|
||||
백엔드 응답 수신 후 실행. 현재 구현에서는 응답 컨텍스트를 검사하거나 로그/부가 처리를 수행하는 용도로 실행되며, 훅이 반환한 변경 내용이 최종 HTTP 응답에 다시 반영되지는 않는다.
|
||||
|
||||
참고: `reasoning` 을 `reasoning_content` 로 복제하는 OpenAI-compatible 호환 처리는 script hook이 아니라 사용자별 라우터 내장 옵션 `copy_reasoning_to_reasoning_content` 로 수행한다.
|
||||
|
||||
## Script Context
|
||||
|
||||
스크립트에서 접근 가능한 데이터:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
);
|
||||
23
package.json
23
package.json
|
|
@ -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
3579
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,12 @@
|
|||
packages:
|
||||
- shared
|
||||
- server
|
||||
- client
|
||||
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
isolated-vm: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
16
server/scripts/copy-schemas.mjs
Normal file
16
server/scripts/copy-schemas.mjs
Normal 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));
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
4
server/src/config/model-list-metadata.ts
Normal file
4
server/src/config/model-list-metadata.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
11
server/src/config/stream-logging.ts
Normal file
11
server/src/config/stream-logging.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
},
|
||||
);
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue