Compare commits

...

18 commits

Author SHA1 Message Date
ReturnToFirst
df262e0bbe
Merge pull request #4 from organization/fix/permissive-chat-schema
fix(shared): accept tool role and multimodal content in chat schemas
2026-04-12 02:48:14 +09:00
JellyBrick
db58054fdb fix(shared): accept tool role and multimodal content in chat schemas
The v1 chat completion proxy rejected requests with `role: "tool"` or
array-typed `content` (multimodal image/video payloads) because the
shared zod schemas were too restrictive:

- `ChatRoleSchema` was `z.enum(['system','user','assistant'])` — now
  `z.string()` so any role the backend supports passes through. The
  router is a proxy and has no reason to constrain which roles are
  valid; the upstream provider decides that.

- `ChatMessageSchema.content` was `z.string()` — now
  `z.union([z.string(), z.array(z.any()), z.null()]).optional()` to
  accept the three shapes the OpenAI spec defines: plain text, an
  array of content-part objects (images, video frames, etc.), or null
  (e.g. assistant messages that only carry tool_calls). `.passthrough()`
  on the message object ensures extra fields like `tool_call_id`,
  `name`, `tool_calls`, etc. are forwarded untouched.

- `ChatCompletionChoiceSchema.finish_reason` was `z.string()` — now
  `z.string().nullable().optional()` since some providers return null
  for streaming chunks or incomplete generations.

Fixes #2, Fixes #3

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-11 18:36:27 +09:00
f97e67382b fix: add missing adminDist candidate path for client distribution 2026-04-08 12:30:09 +09:00
f72de32d29 fix: update command to start server using pnpm 2026-04-08 12:19:51 +09:00
12ca255867 fix: update server file structure and command in Dockerfile 2026-04-08 12:14:03 +09:00
59bf6e6df6 fix: add missing shared package.json copy in Dockerfile 2026-04-08 12:10:35 +09:00
ba36b58641 chore: add tsx dependency to pnpm-lock.yaml 2026-04-08 12:07:17 +09:00
ce38ca0f71
Merge pull request #1 from organization/feat/hono-migration-eslint
feat: migrate Express to Hono + OpenAPI/Swagger + root ESLint
2026-04-08 11:54:42 +09:00
JellyBrick
18edad1085
refactor(server): centralise schema-file resolution + drop dead copy step
The three DB modules each carried their own copy-pasted
`path.join(moduleDir, '..', '..', '..', 'database', '<name>.sql')` to
locate the SQL schemas. That worked, but only by coincidence: the same
`../../../` count happened to land on `dist/` under the old compiled
runtime AND on the repo root under the new tsx runtime, because the file
lives at the same depth in both layouts. Move the file (or change the
run mode) and it silently breaks in three places at once.

Replace the per-file path math with a single `getSchemaPath(filename)`
helper in `db-paths.ts` that resolves against `import.meta.url`:

  const SCHEMA_DIR_URL = new URL('../../../database/', import.meta.url);
  export const getSchemaPath = (filename) =>
    fileURLToPath(new URL(filename, SCHEMA_DIR_URL));

- One place for the relative-path arithmetic (locally verifiable, no
  parallel copies to keep in sync).
- Anchored on the module's own URL, so the resolution is correct
  regardless of whether tsx, a future bundler, or `node` runs it.
- `database.ts` / `analytics-db.ts` / `request-logs-db.ts` all become
  one-liners: `fs.readFileSync(getSchemaPath('schema.sql'), 'utf-8')`.

Drop `server/scripts/copy-schemas.mjs` entirely — it was the build step
that used to populate `dist/database/` for the compiled runtime, but
since the previous commit switched `start` to `tsx src/main.ts` we read
the SQL directly from the source `database/` dir. The script has been
producing dead copies on every build. The empty `server/scripts/`
directory goes too, and `package.json`'s `build` script collapses to
just `tsc --noEmit`.

Verified by booting via tsx (all schemas load) and by running the
integration test suite that exercises every DB module — 59/59 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 02:37:20 +09:00
JellyBrick
0f4950739a
fix(client/auth): invalidate/remove cached queries on auth-state changes
Every `setQueryData(authKeys.session, ...)` call left the rest of the
TanStack Query cache untouched, so:

- After login the dashboard kept showing data fetched as a different (or
  no) user until each individual query happened to refetch on its own.
- After logout / 401 the cache held onto the previous user's
  responses indefinitely; mount the dashboard again and the same data
  flashed back into view before the auth gate kicked in.

Add a small helper layer:

- `isAuthQuery(queryKey)` — anchors on the `'auth'` first segment so we
  can keep auth-namespace queries while wiping everything else.
- `clearAuthenticatedState(queryClient)` — collapses the session cache to
  the unauthenticated fallback (preserving the configured auth mode for
  the login gate), nulls the CSRF token, and `removeQueries({ predicate
  })` for every non-auth key. Used by both the logout success path and
  the api client's 401 handler.
- `refreshAfterLogin(queryClient)` — after a successful login,
  `invalidateQueries({ predicate })` every non-auth query so mounted
  views refetch under the new session. We *invalidate* (instead of
  removing) here on purpose: removing would leave the just-logged-in
  dashboard staring at empty fallbacks until each query mounted.

The unauthorizedHandler / logout / login mutation success paths now all
flow through these helpers; the duplicate `setQueryData` blocks that
used to live in two places are gone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 02:19:48 +09:00
JellyBrick
9ecdd41a33
refactor: extensionless server imports + auth.tsx onMount fix
Server: drop NodeNext + .js extensions, run on tsx
- server/tsconfig.json switches to `module: Preserve` + `moduleResolution:
  Bundler` so the type checker is happy with extensionless imports the way
  the rest of the monorepo (client + shared) already is. tsc no longer
  emits anything (`noEmit: true`); typechecking still runs in CI via the
  `typecheck` script.
- The build pipeline now skips the JS emit step entirely. `pnpm --filter
  server build` is just `tsc --noEmit && copy-schemas.mjs`, and `start`
  runs the source directly via `tsx src/main.ts`. Single source of truth
  for code, no `dist/` to keep in sync, and the runtime matches the
  authoring style exactly.
- `tsx` moves from devDependencies to dependencies (it's a runtime
  prerequisite now, not just a dev tool).
- Strip every relative `.js` import specifier in `server/src/**`,
  `server/tests/**`, `server/benchmarks/**`, and `shared/**`. ~33 files
  touched, ~80 import sites cleaned up — `import { foo } from './bar'`
  everywhere instead of `'./bar.js'`.
- shared/package.json gains a tiny note about the resolution model so
  the next reader knows why the relative imports inside it look the way
  they do.

auth.tsx: createEffect → onMount where there are no reactive reads
- The block that wires the api client's 401 handler doesn't read any
  signal (it only registers a callback closure), so it ran exactly once
  inside its `createEffect`. That's the textbook case for `onMount`.
- Switched to `onMount(() => { setUnauthorizedHandler(...); onCleanup(
  () => setUnauthorizedHandler(null)); })`. No behaviour change, the
  intent is now obvious from the call site.
- The other createEffect in this file (the CSRF token mirror) genuinely
  reads `sessionQuery.data` so it stays as-is.
- Type-only import of `JSX` from solid-js so the component prop type is
  spelled `JSX.Element` instead of the inline `import('solid-js').JSX`
  resolution.

Verification
- `pnpm --filter server typecheck` 
- `pnpm --filter client typecheck` 
- `pnpm --filter server test`  (81/88 — same scripts.test.ts isolated-vm
  worker crash on Windows; everything else green)
- `pnpm -w lint`  0 errors, 47 pre-existing warnings
- `tsx src/main.ts` boot-tests the server end-to-end (only fails on
  port-permission, which proves all imports resolved cleanly)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 02:15:46 +09:00
JellyBrick
43f4eb1531
refactor(client): use inline type imports in auth.tsx
- Add `type JSX` to the solid-js import alongside `type ParentComponent`
- Replace the inline `import('solid-js').JSX.Element` with `JSX.Element`
  so the JSX namespace is imported once at the top of the file rather
  than re-resolved at every prop type position

Note: every other binding in this file (`QueryClient`, `useQuery`,
`createContext`, `ApiError`, `api`, etc.) is referenced as a value at
runtime, so they have to stay as plain value imports — `QueryClient` is
constructed via `new`, `ApiError` is used in `instanceof` checks, the
hooks/effects are all called at runtime, etc.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 02:10:13 +09:00
JellyBrick
58959fdf74
fix(client): make script-editor theme observer cleanup obviously safe
The previous file did call `observer.disconnect()` (and removed the
matchMedia listener) inside an `onCleanup` registered within `onMount`,
so the teardown was already wired up correctly — but the registration
was buried at the bottom of an inline closure inside `onMount`, which
makes it easy to miss in review and easy to break by adding any nested
helper that returns early before the cleanup is registered.

This commit lifts the whole thing into a `createEditorThemeSignal`
helper that:
- creates the signal up front
- registers MutationObserver + matchMedia listener once on mount
- pairs each subscription with its teardown in the same closure, so
  it's structurally impossible to add one without removing the other
- returns the signal accessor to the component, which now just calls
  `editorTheme()`

No behaviour change beyond clarity, but the dispose path is now
self-evident from the call site.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 02:06:50 +09:00
JellyBrick
988b351419
refactor(client): Dashboard route on TanStack Query + shared query keys
- New client/src/api/query-keys.ts: centralised TanStack Query key factory
  with `as const` literal tuples for type-safe invalidation patterns. Each
  scope (auth/users/backends/permissions/scripts/dashboard/analytics) lives
  under a typed namespace.

- Dashboard route now uses `useQuery` from @tanstack/solid-query instead of
  `createResource`:
  * `summaryQuery` keyed off the time-window selector — switching windows
    triggers an automatic refetch via the reactive query key
  * `backendsQuery` shares cache with the rest of the dashboard
  * Refresh button calls `summaryQuery.refetch()`
  * Error fallback reads `summaryQuery.error` / `summaryQuery.isError`
- All cheap createMemo wrappers in Dashboard collapsed to inline arrow
  derivations (`backendNameById`, `trafficRows`, `latencyRows`, `modelRows`,
  `summaryItems`, `cacheStateItems`, `scriptItems`, `accessItems`). The
  expensive grouping/aggregation work runs the same number of times either
  way; Solid's reactive prop reads make the explicit memo wrapping moot.
- Replace the raw `<button class="ui-button">` refresh control with the
  shared `Button` primitive + a sized lucide-solid `RefreshCcw` icon.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 02:03:31 +09:00
JellyBrick
6b6b92ca45
refactor(server): Hono cookie helpers + import.meta.dirname + type-guard cleanup
Hono session/cookie delegation
- adminSecurity.ts now reads cookies via hono/cookie's getCookie() helper
  through a new getSessionTokenFromContext(c) instead of parsing the raw
  Cookie header by hand
- The legacy parseCookies/getSessionTokenFromCookies pair is gone — we never
  touch the raw header anymore, hono handles serialisation, multi-cookie
  concat, and Secure/SameSite plumbing on both reads and writes
- adminAuth.resolveAdminAuth() consumes the new helper

import.meta.dirname (Node 20.11+)
- Replace `path.dirname(fileURLToPath(import.meta.url))` boilerplate with
  the native `import.meta.dirname` across:
    server/src/index.ts
    server/src/config/database.ts
    server/src/config/analytics-db.ts
    server/src/config/request-logs-db.ts
    server/tests/setup.ts
    server/vitest.config.ts
- All four DB modules now expose `moduleDir` once instead of recomputing it

Type-guard cleanup pass (no more `as any` / `as unknown as` hacks)
- New `isObject(value): value is Record<string, unknown>` guard +
  `isPragmaColumnRow` for SQLite PRAGMA rows replace the
  `as Array<{ name: string }>` casts in database.ts and request-logs-db.ts
- Lazy DB singletons rewritten as `let db: Database | undefined` +
  `db ??= openDb()`; `closeDb` no longer needs the `undefined as unknown as
  Database` reset
- ModelCatalogService.fetchBackendModels narrows the upstream payload via
  `'data' in payload` instead of `(payload as any).data`
- RouterService rewritten as a single object with shared
  `classifyForwardError` / `buildForwardErrorPayload` helpers, eliminating
  the duplicated error analysis blocks AND both `error.cause as any` casts.
  `readErrorCause` is the only place that touches the unknown cause object.
- adminAuth.getNodeIncomingSocket guards `c.env` instead of casting to
  `{ incoming?: { socket?: { remoteAddress?: string } } }`

API route streaming endpoint
- Drop the `(async (c: any) => {...}) as any` wrapper around
  router.openapi(chatCompletionsRoute, …) — the route is now registered via
  plain `router.post('/chat/completions', zValidator('json',
  ChatCompletionRequestSchema), …)` and documented via
  `router.openAPIRegistry.registerPath()`. `c.req.valid('json')` returns the
  shared `ChatCompletionRequest` type without any `as` cast.
- New `extractCompletionMetadata(data)` helper builds analytics fields by
  walking unknown shapes through type guards (no more
  `(data as { usage?: { prompt_tokens?: number } }).usage?.prompt_tokens`
  chains)
- New `extractErrorDetails(data)` helper for upstream error formatting
- Forwarded JSON responses use `new Response(JSON.stringify(...), {...})`
  instead of casting `c.json(data, status as 502)` to a literal status
- SSE branch uses `c.status(200)` (every code-path that reaches the streaming
  branch is already a successful upstream)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 02:00:22 +09:00
JellyBrick
435ffdce42
refactor: shared zod, tanstack-query auth, env module, primitives, lucide icons
@kyush/shared workspace package
- Add shared workspace with package.json + tsconfig + index/schemas/types
- Schemas cover the major input shapes (CreateUser, UpdateBackend,
  CreateScriptInput as a discriminated union, AdminLoginInput, ScriptTestInput,
  v1 chat completion request/response, etc.)
- Loose chat completion schemas for the conversation timeline
- Both server and client now depend on the workspace package; client/src/types
  is now a thin re-export of @kyush/shared

Server route validation
- All admin/auth + admin + scripts mutating routes now use @hono/zod-validator
  with the shared input schemas, eliminating dozens of manual `if (!field)` checks
- server/src/schemas/{common,v1}.ts re-export the shared schemas with .openapi()
  metadata so the OpenAPI doc still describes everything

Single-source process.env
- New server/src/config/env.ts is the only file that touches process.env
- admin-auth, db-paths, time, ModelCatalogService, index, main all read from env
- Each value is parsed/normalised exactly once with sensible fallbacks

Production-ready auth (TanStack Query)
- @tanstack/solid-query is the new data layer for the session
- AuthProvider wraps QueryClientProvider, exposes session/loading/login/logout
  via a useAuth() context that mirrors the previous shape
- 401 responses collapse the cached session via setQueryData; CSRF is mirrored
  into the api client through a tracked createEffect

API client (ky + es-toolkit + shared types)
- client/src/api/client.ts now imports CreateUserInput, CreateScriptInput, etc.
  from @kyush/shared
- compactSearchParams() uses es-toolkit's omitBy to drop undefined query params
- buildUrl() retained for OIDC redirects through window.location.href

Lazy imports + default exports
- Every lazy-loaded route file (Dashboard, Users, Backends, Analytics,
  DetailLogs, Models, Scripts) now has a default export
- App.tsx + Scripts.tsx use the cleaner `lazy(() => import('./...'))` pattern
  with no `.then((m) => ({ default: m.X }))` indirection
- ScriptEditor moved to client/src/components/script-editor.tsx (kebab-case)
  with default export; LoginGate similarly moved to login-gate.tsx

UI primitive cleanup
- KCheckbox/KSelect/KDialog/KDropdownMenu/KPopover/KSwitch/KTabs/KTextField/
  KToast/KTooltip → CheckboxPrimitive/SelectPrimitive/DialogPrimitive/...
- Select.tsx and Checkbox.tsx now use lucide-solid icons (Check, ChevronDown)
  instead of '✓' / '▾' literals
- Select drops its createMemo over a tiny option list — inline derivation is
  cheaper than the memo bookkeeping

ConversationTimeline refactor (the prior hacky parser)
- Drives entirely off the new LooseChatCompletionRequestSchema /
  LooseChatCompletionResponseSchema
- All the inlined `(message as Record<string, unknown>).reasoning_content`
  type-narrowing chains are gone; helpers extract messages cleanly
- createMemo wrappers replaced by inline reactive derivations

ts-reset + Tailwind v4 + es-toolkit + Node 24 cleanups
- Add @total-typescript/ts-reset to client and server (reset.d.ts in both)
- @tailwindcss/vite plugin wired into client/vite.config.ts; styles.css now
  imports tailwindcss and bridges existing tokens via @theme inline
- es-toolkit added to client + server
- Replace `path.dirname(fileURLToPath(import.meta.url))` with the native
  `import.meta.dirname` (Node 20.11+) across server config files + tests

Type-guard / no-`as` cleanup pass
- isPragmaColumnRow type guard replaces `as Array<{ name: string }>` casts in
  database.ts and request-logs-db.ts
- Lazy DB singletons rewrite: `let db: Database | undefined` + `db ??= openDb()`
  removes the `undefined as unknown as Database` reset hack
- Scripts.tsx splits the save payload through buildCreatePayload /
  buildUpdatePayload helpers that use a switch on script_type so the
  discriminated union picks the correct variant — no payload casts

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 01:52:10 +09:00
JellyBrick
17c4286b6a
refactor(client): replace fetch wrapper with ky and centralise base URL
- Drop the hand-rolled fetchJson helper and the API_BASE constant
- Use ky.extend() with prefixUrl so every endpoint is a relative path
- New VITE_API_BASE_URL env var (defaults to '/') feeds prefixUrl, so the
  dashboard can target a separate API origin without touching call sites
- Translate ky's HTTPError to ApiError in a shared unwrap() helper, preserving
  the existing 401 -> unauthorizedHandler behaviour
- Attach the X-CSRF-Token header for unsafe /admin/* requests via beforeRequest
- Replace ad-hoc URLSearchParams construction with ky's searchParams option
- buildUrl() retained for OIDC redirects that go through window.location.href
- Add client/src/vite-env.d.ts to type import.meta.env

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 01:12:48 +09:00
JellyBrick
66261474d2
feat: migrate Express to Hono with OpenAPI/Swagger and add root ESLint
Server changes:
- Migrate from Express 5 to Hono with @hono/node-server adapter
- Convert server package to ESM (NodeNext) with explicit .js import extensions
- Split entry points: index.ts exports createApp(), main.ts starts the server
- Add @hono/zod-openapi for /v1/* routes with zod-validated request/response
- Add @hono/swagger-ui at /admin/docs (admin-auth gated) and OpenAPI 3.1 spec
  at /admin/openapi.json
- Rewrite SSE streaming using hono/streaming with onAbort cancellation
- Replace AuthenticatedRequest/AdminRequest with Hono Variables generics
- Switch cookie handling to hono/cookie helpers
- Drop express, cors, supertest and their type packages

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-08 01:08:23 +09:00
122 changed files with 11790 additions and 5719 deletions

View file

@ -13,6 +13,7 @@ RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY server/package.json ./server/package.json
COPY client/package.json ./client/package.json
COPY shared/package.json ./shared/package.json
RUN pnpm install --frozen-lockfile
@ -39,9 +40,9 @@ ENV DB_DIR=/data
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/server/package.json ./server/package.json
COPY --from=build /app/server/node_modules ./server/node_modules
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /app/server ./server
COPY --from=build /app/shared ./shared
COPY --from=build /app/client/dist ./client/dist
COPY --from=build /app/database ./database
@ -49,4 +50,4 @@ RUN mkdir -p /data
EXPOSE 3000
CMD ["node", "server/dist/server/src/index.js"]
CMD ["pnpm", "--filter", "server", "start"]

View file

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

View file

@ -16,23 +16,31 @@
"license": "MIT",
"dependencies": {
"@kobalte/core": "^0.13.11",
"@kyush/shared": "workspace:*",
"@solidjs/router": "^0.15.4",
"@tanstack/solid-query": "^5.62.7",
"d3": "^7.9.0",
"es-toolkit": "^1.32.0",
"ky": "^1.7.5",
"lucide-solid": "^1.1.0",
"solid-js": "^1.9.11",
"solid-monaco": "^0.3.0"
"solid-monaco": "^0.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.3.3",
"@storybook/addon-docs": "^10.3.3",
"@storybook/addon-links": "^10.3.3",
"@storybook/addon-vitest": "^10.3.3",
"@tailwindcss/vite": "^4.0.0",
"@total-typescript/ts-reset": "^0.6.1",
"@types/node": "^25.3.3",
"@vitest/browser": "^4.1.1",
"@vitest/coverage-v8": "^4.1.1",
"playwright": "^1.58.2",
"storybook": "^10.3.3",
"storybook-solidjs-vite": "^10.0.11",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10",

1219
client/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,27 +1,52 @@
import { omitBy } from 'es-toolkit';
import ky, {
HTTPError,
type KyInstance,
type Options as KyOptions,
type ResponsePromise,
} from 'ky';
import type {
User,
AdminApiTokenSummary,
AdminSessionResponse,
AnalyticsBackendQualityPoint,
AnalyticsBoxPlotPoint,
AnalyticsDailyTotalsPoint,
AnalyticsHistogramBin,
AnalyticsModelTrendPoint,
Backend,
BackendMetrics,
BackendModelsResponse,
CreateBackendInput,
CreateModelRewriteInput,
CreatePermissionInput,
CreateScriptInput,
CreateUserInput,
DashboardSummaryResponse,
ModelCacheOverview,
ModelRewriteRule,
Permission,
RequestLogPage,
UpdateBackendInput,
UpdateModelRewriteInput,
UpdateScriptInput,
UpdateUserInput,
UsageStats,
BackendMetrics,
AnalyticsDailyTotalsPoint,
AnalyticsBackendQualityPoint,
AnalyticsModelTrendPoint,
AnalyticsHistogramBin,
AnalyticsBoxPlotPoint,
DashboardSummaryResponse,
User,
UserScript,
CreateScriptData,
UpdateScriptData,
AdminApiTokenSummary,
AdminSessionResponse,
} from '../types';
} from '@kyush/shared';
/**
* Base URL prepended by ky to every request.
*
* - In dev, the Vite proxy forwards `/admin/*` to the backend, so `'/'` is the right default.
* - In prod (single-binary deploy), the dashboard is served from the same origin as the API.
* - Override at build time with `VITE_API_BASE_URL` (e.g. `https://router.example.com/`)
* when the dashboard and API live on different origins.
*/
const API_BASE_URL: string =
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/';
const API_BASE = '';
let csrfToken: string | null = null;
let unauthorizedHandler: (() => void) | null = null;
@ -43,195 +68,322 @@ export function setUnauthorizedHandler(handler: (() => void) | null) {
unauthorizedHandler = handler;
}
async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const isUnsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes((options.method ?? 'GET').toUpperCase());
const nextHeaders: Record<string, string> = {
...(options.headers as Record<string, string> | undefined),
};
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
if (isUnsafeMethod) {
nextHeaders['Content-Type'] = nextHeaders['Content-Type'] ?? 'application/json';
const httpClient: KyInstance = ky.extend({
prefixUrl: API_BASE_URL,
credentials: 'include',
hooks: {
beforeRequest: [
(request) => {
if (!UNSAFE_METHODS.has(request.method.toUpperCase())) return;
if (!csrfToken) return;
const url = new URL(request.url);
if (url.pathname.startsWith('/admin')) {
request.headers.set('X-CSRF-Token', csrfToken);
}
},
],
},
});
type SearchParamsInit = Exclude<KyOptions['searchParams'], undefined>;
type Primitive = string | number | boolean | undefined | null;
/**
* Drop `undefined`/`null` keys so callers can pass `{ userId: maybeUndefined }`
* without polluting the query string with empty values. Returns `undefined`
* when nothing is left so ky skips the search parameter step entirely.
*/
function compactSearchParams(
params: Record<string, Primitive>,
): SearchParamsInit | undefined {
const cleaned = omitBy(
params,
(value) => value === undefined || value === null,
);
const entries = Object.entries(cleaned);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries.map(([k, v]) => [k, String(v)]));
}
async function toApiError(error: HTTPError): Promise<ApiError> {
const { response, request } = error;
let message = `HTTP ${response.status}`;
try {
const payload = (await response.clone().json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
// body wasn't JSON; keep the default message
}
if (isUnsafeMethod && url.startsWith('/admin') && csrfToken) {
nextHeaders['X-CSRF-Token'] = csrfToken;
const url = new URL(request.url);
if (
response.status === 401 &&
!url.pathname.endsWith('/admin/auth/session')
) {
unauthorizedHandler?.();
}
const response = await fetch(url, {
...options,
credentials: 'include',
headers: {
...nextHeaders,
},
});
const apiError = new ApiError(response.status, message);
apiError.stack = error.stack;
return apiError;
}
if (response.status === 204) {
return {} as T;
async function unwrap<T>(promise: ResponsePromise): Promise<T> {
try {
const response = await promise;
if (response.status === 204) return {} as T;
return (await response.json()) as T;
} catch (error) {
if (error instanceof HTTPError) throw await toApiError(error);
throw error;
}
}
const payload = await response.json().catch(() => ({ error: 'Request failed' }));
function getJson<T>(path: string, searchParams?: SearchParamsInit): Promise<T> {
return unwrap<T>(
httpClient.get(path, searchParams ? { searchParams } : undefined),
);
}
if (!response.ok) {
if (response.status === 401 && !url.endsWith('/admin/auth/session')) {
unauthorizedHandler?.();
function postJson<T>(path: string, body?: unknown): Promise<T> {
return unwrap<T>(
httpClient.post(path, body !== undefined ? { json: body } : undefined),
);
}
function putJson<T>(path: string, body?: unknown): Promise<T> {
return unwrap<T>(
httpClient.put(path, body !== undefined ? { json: body } : undefined),
);
}
function deleteJson<T>(
path: string,
searchParams?: SearchParamsInit,
): Promise<T> {
return unwrap<T>(
httpClient.delete(path, searchParams ? { searchParams } : undefined),
);
}
/**
* Build a fully-qualified URL using the same prefix as the API client.
* Used for window.location-style redirects (OIDC) where ky can't be invoked.
*/
function buildUrl(path: string, searchParams?: Record<string, string>): string {
const base =
API_BASE_URL.startsWith('http://') || API_BASE_URL.startsWith('https://')
? API_BASE_URL
: new URL(API_BASE_URL, window.location.origin).toString();
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
url.searchParams.set(key, value);
}
throw new ApiError(response.status, payload.error || `HTTP ${response.status}`);
}
return url.toString();
}
return payload;
interface AnalyticsRequestParams {
limit?: number;
offset?: number;
month?: string;
date?: string;
q?: string;
userId?: number;
backendId?: number;
endpoint?: string;
detailLogged?: boolean;
}
interface ModelTrendsParams {
backendId?: number;
days?: number;
limit?: number;
}
interface HistogramParams {
backendId?: number;
days?: number;
bins?: number;
}
export const api = {
auth: {
getSession: (): Promise<AdminSessionResponse> => fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/session`),
login: (username: string, password: string): Promise<AdminSessionResponse> =>
fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/login`, { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: (): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/logout`, { method: 'POST' }),
getSession: () => getJson<AdminSessionResponse>('admin/auth/session'),
login: (username: string, password: string) =>
postJson<AdminSessionResponse>('admin/auth/login', {
username,
password,
}),
logout: () => postJson<void>('admin/auth/logout'),
beginOidc: (next: string = window.location.pathname) => {
const search = new URLSearchParams({ next });
window.location.href = `${API_BASE}/admin/auth/oidc/start?${search.toString()}`;
window.location.href = buildUrl('admin/auth/oidc/start', { next });
},
getTokens: (): Promise<AdminApiTokenSummary[]> => fetchJson<AdminApiTokenSummary[]>(`${API_BASE}/admin/auth/tokens`),
createToken: (name: string, expiresInDays?: number): Promise<{ token: string; record: AdminApiTokenSummary }> =>
fetchJson(`${API_BASE}/admin/auth/tokens`, { method: 'POST', body: JSON.stringify({ name, expiresInDays }) }),
deleteToken: (id: number): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/tokens/${id}`, { method: 'DELETE' }),
getTokens: () => getJson<AdminApiTokenSummary[]>('admin/auth/tokens'),
createToken: (name: string, expiresInDays?: number) =>
postJson<{ token: string; record: AdminApiTokenSummary }>(
'admin/auth/tokens',
{ name, expiresInDays },
),
deleteToken: (id: number) => deleteJson<void>(`admin/auth/tokens/${id}`),
},
users: {
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
create: (data: { name: string; email?: string; api_key?: string; detail_logging?: boolean }): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<User>): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/users/${id}`, { method: 'DELETE' }),
regenerateApiKey: (id: number): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}/regenerate-api-key`, { method: 'POST' }),
getAll: () => getJson<User[]>('admin/users'),
getById: (id: number) => getJson<User>(`admin/users/${id}`),
create: (data: CreateUserInput) => postJson<User>('admin/users', data),
update: (id: number, data: UpdateUserInput) =>
putJson<User>(`admin/users/${id}`, data),
delete: (id: number) => deleteJson<void>(`admin/users/${id}`),
regenerateApiKey: (id: number) =>
postJson<User>(`admin/users/${id}/regenerate-api-key`),
},
backends: {
getAll: (): Promise<Backend[]> => fetchJson<Backend[]>(`${API_BASE}/admin/backends`),
getById: (id: number): Promise<Backend> => fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`),
getModels: (id: number): Promise<BackendModelsResponse> => fetchJson<BackendModelsResponse>(`${API_BASE}/admin/backends/${id}/models`),
refreshModels: (id: number): Promise<BackendModelsResponse> =>
fetchJson<BackendModelsResponse>(`${API_BASE}/admin/backends/${id}/models/refresh`, { method: 'POST' }),
create: (data: { name: string; base_url: string; api_key?: string; detail_logging?: boolean }): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<Backend>): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/backends/${id}`, { method: 'DELETE' }),
getAll: () => getJson<Backend[]>('admin/backends'),
getById: (id: number) => getJson<Backend>(`admin/backends/${id}`),
getModels: (id: number) =>
getJson<BackendModelsResponse>(`admin/backends/${id}/models`),
refreshModels: (id: number) =>
postJson<BackendModelsResponse>(`admin/backends/${id}/models/refresh`),
create: (data: CreateBackendInput) =>
postJson<Backend>('admin/backends', data),
update: (id: number, data: UpdateBackendInput) =>
putJson<Backend>(`admin/backends/${id}`, data),
delete: (id: number) => deleteJson<void>(`admin/backends/${id}`),
},
permissions: {
getAll: (): Promise<Permission[]> => fetchJson<Permission[]>(`${API_BASE}/admin/permissions`),
getByUser: (userId: number): Promise<Permission[]> =>
fetchJson<Permission[]>(`${API_BASE}/admin/permissions/user/${userId}`),
getByBackend: (backendId: number): Promise<Permission[]> =>
fetchJson<Permission[]>(`${API_BASE}/admin/permissions/backend/${backendId}`),
create: (data: { user_id: number; backend_id: number }): Promise<Permission> =>
fetchJson<Permission>(`${API_BASE}/admin/permissions`, { method: 'POST', body: JSON.stringify(data) }),
delete: (userId: number, backendId: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/permissions?user_id=${userId}&backend_id=${backendId}`, { method: 'DELETE' }),
getAll: () => getJson<Permission[]>('admin/permissions'),
getByUser: (userId: number) =>
getJson<Permission[]>(`admin/permissions/user/${userId}`),
getByBackend: (backendId: number) =>
getJson<Permission[]>(`admin/permissions/backend/${backendId}`),
create: (data: CreatePermissionInput) =>
postJson<Permission>('admin/permissions', data),
delete: (userId: number, backendId: number) =>
deleteJson<void>('admin/permissions', {
user_id: String(userId),
backend_id: String(backendId),
}),
},
modelRewrites: {
getAll: (): Promise<ModelRewriteRule[]> => fetchJson<ModelRewriteRule[]>(`${API_BASE}/admin/model-rewrites`),
create: (data: { source_model: string; target_model: string; is_active?: boolean; force?: boolean; note?: string }): Promise<ModelRewriteRule> =>
fetchJson<ModelRewriteRule>(`${API_BASE}/admin/model-rewrites`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<ModelRewriteRule>): Promise<ModelRewriteRule> =>
fetchJson<ModelRewriteRule>(`${API_BASE}/admin/model-rewrites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/model-rewrites/${id}`, { method: 'DELETE' }),
getAll: () => getJson<ModelRewriteRule[]>('admin/model-rewrites'),
create: (data: CreateModelRewriteInput) =>
postJson<ModelRewriteRule>('admin/model-rewrites', data),
update: (id: number, data: UpdateModelRewriteInput) =>
putJson<ModelRewriteRule>(`admin/model-rewrites/${id}`, data),
delete: (id: number) => deleteJson<void>(`admin/model-rewrites/${id}`),
},
modelCache: {
getOverview: (): Promise<ModelCacheOverview> => fetchJson<ModelCacheOverview>(`${API_BASE}/admin/models/cache`),
getOverview: () => getJson<ModelCacheOverview>('admin/models/cache'),
},
scripts: {
getAll: (): Promise<UserScript[]> => fetchJson<UserScript[]>(`${API_BASE}/admin/scripts`),
getById: (id: number): Promise<UserScript> => fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`),
create: (data: CreateScriptData): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: UpdateScriptData): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/scripts/${id}`, { method: 'DELETE' }),
activate: (id: number): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/activate`, { method: 'POST' }),
deactivate: (id: number): Promise<UserScript> =>
fetchJson<UserScript>(`${API_BASE}/admin/scripts/${id}/deactivate`, { method: 'POST' }),
test: (id: number, context: { user?: User; backend?: Backend; request: { method: string; path: string; headers: Record<string, string>; body: unknown; isStream: boolean } }): Promise<{ success: boolean; error?: string; executionTime?: number }> =>
fetchJson(`${API_BASE}/admin/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(context) }),
getAll: () => getJson<UserScript[]>('admin/scripts'),
getById: (id: number) => getJson<UserScript>(`admin/scripts/${id}`),
create: (data: CreateScriptInput) =>
postJson<UserScript>('admin/scripts', data),
update: (id: number, data: UpdateScriptInput) =>
putJson<UserScript>(`admin/scripts/${id}`, data),
delete: (id: number) => deleteJson<void>(`admin/scripts/${id}`),
activate: (id: number) =>
postJson<UserScript>(`admin/scripts/${id}/activate`),
deactivate: (id: number) =>
postJson<UserScript>(`admin/scripts/${id}/deactivate`),
test: (
id: number,
context: {
user?: { id: number; name: string; email?: string };
backend?: { id: number; name: string; base_url: string };
request: {
method: string;
path: string;
headers: Record<string, string>;
body: unknown;
isStream: boolean;
};
},
) =>
postJson<{ success: boolean; error?: string; executionTime?: number }>(
`admin/scripts/${id}/test`,
context,
),
},
dashboard: {
getSummary: (days: number = 30): Promise<DashboardSummaryResponse> => {
const params = new URLSearchParams();
params.append('days', String(days));
return fetchJson<DashboardSummaryResponse>(`${API_BASE}/admin/dashboard/summary?${params}`);
},
getSummary: (days: number = 30) =>
getJson<DashboardSummaryResponse>('admin/dashboard/summary', { days }),
},
analytics: {
getUsage: (userId?: number, backendId?: number, days: number = 30): Promise<UsageStats[]> => {
const params = new URLSearchParams();
if (userId) params.append('userId', String(userId));
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<UsageStats[]>(`${API_BASE}/admin/analytics/usage?${params}`);
},
getRequests: (params: { limit?: number; offset?: number; month?: string; date?: string; q?: string; userId?: number; backendId?: number; endpoint?: string; detailLogged?: boolean } = {}): Promise<RequestLogPage> => {
const search = new URLSearchParams();
search.set('limit', String(params.limit ?? 100));
search.set('offset', String(params.offset ?? 0));
if (params.month) search.set('month', params.month);
if (params.date) search.set('date', params.date);
if (params.q) search.set('q', params.q);
if (params.userId) search.set('userId', String(params.userId));
if (params.backendId) search.set('backendId', String(params.backendId));
if (params.endpoint) search.set('endpoint', params.endpoint);
if (params.detailLogged !== undefined) search.set('detailLogged', params.detailLogged ? '1' : '0');
return fetchJson<RequestLogPage>(`${API_BASE}/admin/analytics/requests?${search}`);
},
getMetrics: (backendId?: number, days: number = 30): Promise<BackendMetrics[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<BackendMetrics[]>(`${API_BASE}/admin/analytics/metrics?${params}`);
},
getDailyTotals: (backendId?: number, days: number = 30): Promise<AnalyticsDailyTotalsPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsDailyTotalsPoint[]>(`${API_BASE}/admin/analytics/daily-totals?${params}`);
},
getBackendQuality: (backendId?: number, days: number = 30): Promise<AnalyticsBackendQualityPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsBackendQualityPoint[]>(`${API_BASE}/admin/analytics/backend-quality?${params}`);
},
getModelTrends: (params: { backendId?: number; days?: number; limit?: number } = {}): Promise<AnalyticsModelTrendPoint[]> => {
const search = new URLSearchParams();
if (params.backendId) search.set('backendId', String(params.backendId));
search.set('days', String(params.days ?? 30));
search.set('limit', String(params.limit ?? 8));
return fetchJson<AnalyticsModelTrendPoint[]>(`${API_BASE}/admin/analytics/model-trends?${search}`);
},
getResponseLengthHistogram: (params: { backendId?: number; days?: number; bins?: number } = {}): Promise<AnalyticsHistogramBin[]> => {
const search = new URLSearchParams();
if (params.backendId) search.set('backendId', String(params.backendId));
search.set('days', String(params.days ?? 30));
search.set('bins', String(params.bins ?? 20));
return fetchJson<AnalyticsHistogramBin[]>(`${API_BASE}/admin/analytics/response-length-histogram?${search}`);
},
getResponseLengthBoxPlot: (backendId?: number, days: number = 30): Promise<AnalyticsBoxPlotPoint[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<AnalyticsBoxPlotPoint[]>(`${API_BASE}/admin/analytics/response-length-box-plot?${params}`);
},
getUsage: (userId?: number, backendId?: number, days: number = 30) =>
getJson<UsageStats[]>(
'admin/analytics/usage',
compactSearchParams({ userId, backendId, days }),
),
getRequests: (params: AnalyticsRequestParams = {}) =>
getJson<RequestLogPage>(
'admin/analytics/requests',
compactSearchParams({
limit: params.limit ?? 100,
offset: params.offset ?? 0,
month: params.month,
date: params.date,
q: params.q,
userId: params.userId,
backendId: params.backendId,
endpoint: params.endpoint,
detailLogged:
params.detailLogged === undefined
? undefined
: params.detailLogged
? '1'
: '0',
}),
),
getMetrics: (backendId?: number, days: number = 30) =>
getJson<BackendMetrics[]>(
'admin/analytics/metrics',
compactSearchParams({ backendId, days }),
),
getDailyTotals: (backendId?: number, days: number = 30) =>
getJson<AnalyticsDailyTotalsPoint[]>(
'admin/analytics/daily-totals',
compactSearchParams({ backendId, days }),
),
getBackendQuality: (backendId?: number, days: number = 30) =>
getJson<AnalyticsBackendQualityPoint[]>(
'admin/analytics/backend-quality',
compactSearchParams({ backendId, days }),
),
getModelTrends: (params: ModelTrendsParams = {}) =>
getJson<AnalyticsModelTrendPoint[]>(
'admin/analytics/model-trends',
compactSearchParams({
backendId: params.backendId,
days: params.days ?? 30,
limit: params.limit ?? 8,
}),
),
getResponseLengthHistogram: (params: HistogramParams = {}) =>
getJson<AnalyticsHistogramBin[]>(
'admin/analytics/response-length-histogram',
compactSearchParams({
backendId: params.backendId,
days: params.days ?? 30,
bins: params.bins ?? 20,
}),
),
getResponseLengthBoxPlot: (backendId?: number, days: number = 30) =>
getJson<AnalyticsBoxPlotPoint[]>(
'admin/analytics/response-length-box-plot',
compactSearchParams({ backendId, days }),
),
},
};

View file

@ -0,0 +1,57 @@
/**
* Centralised TanStack Query keys.
*
* Each scope returns a literal `as const` tuple so query invalidation
* patterns like `queryClient.invalidateQueries({ queryKey: queryKeys.users.all() })`
* stay type-safe.
*/
export const queryKeys = {
auth: {
session: () => ['auth', 'session'] as const,
},
users: {
all: () => ['users'] as const,
detail: (id: number) => ['users', id] as const,
},
backends: {
all: () => ['backends'] as const,
detail: (id: number) => ['backends', id] as const,
models: (id: number) => ['backends', id, 'models'] as const,
},
permissions: {
all: () => ['permissions'] as const,
byUser: (userId: number) => ['permissions', 'user', userId] as const,
byBackend: (backendId: number) =>
['permissions', 'backend', backendId] as const,
},
modelRewrites: {
all: () => ['model-rewrites'] as const,
},
modelCache: {
overview: () => ['models', 'cache'] as const,
},
scripts: {
all: () => ['scripts'] as const,
detail: (id: number) => ['scripts', id] as const,
},
dashboard: {
summary: (days: number) => ['dashboard', 'summary', days] as const,
},
analytics: {
requests: (params: Record<string, unknown>) =>
['analytics', 'requests', params] as const,
dailyTotals: (backendId: number | undefined, days: number) =>
['analytics', 'daily-totals', backendId, days] as const,
backendQuality: (backendId: number | undefined, days: number) =>
['analytics', 'backend-quality', backendId, days] as const,
modelTrends: (params: {
backendId?: number;
days?: number;
limit?: number;
}) => ['analytics', 'model-trends', params] as const,
histogram: (params: { backendId?: number; days?: number; bins?: number }) =>
['analytics', 'response-length-histogram', params] as const,
boxPlot: (backendId: number | undefined, days: number) =>
['analytics', 'response-length-box-plot', backendId, days] as const,
},
} as const;

View file

@ -1,10 +1,84 @@
import { createContext, createSignal, onMount, useContext, type Accessor, type JSX, type ParentComponent } from 'solid-js';
import type { AdminSessionResponse } from './types';
import { api, setAdminCsrfToken, setUnauthorizedHandler } from './api/client';
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/solid-query';
import {
createContext,
createEffect,
onCleanup,
onMount,
useContext,
type JSX,
type ParentComponent,
} from 'solid-js';
import {
ApiError,
api,
setAdminCsrfToken,
setUnauthorizedHandler,
} from './api/client';
import type { AdminSessionResponse } from '@kyush/shared';
/*
* QueryClient
* */
function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
// Treat 401 specially via a global onError pipeline; never retry auth
// failures, since the unauthorizedHandler will reset the session.
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 401) return false;
if (
error instanceof ApiError &&
error.status >= 400 &&
error.status < 500
) {
return false;
}
return failureCount < 2;
},
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
throwOnError: false,
},
mutations: {
retry: false,
},
},
});
}
/*
* Query keys
* */
export const authKeys = {
session: ['auth', 'session'] as const,
} as const;
const UNAUTHENTICATED_FALLBACK: AdminSessionResponse = {
authenticated: false,
authMode: 'both',
csrfToken: null,
principal: null,
};
/*
* Auth context (thin wrapper around the session query + mutations)
* */
interface AuthContextValue {
session: Accessor<AdminSessionResponse | null>;
loading: Accessor<boolean>;
session: () => AdminSessionResponse | null;
loading: () => boolean;
refreshSession: () => Promise<AdminSessionResponse>;
login: (username: string, password: string) => Promise<AdminSessionResponse>;
logout: () => Promise<void>;
@ -12,63 +86,141 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue>();
function unauthenticatedState(previous: AdminSessionResponse | null): AdminSessionResponse {
return {
authenticated: false,
authMode: previous?.authMode ?? 'both',
csrfToken: null,
principal: null,
};
function useSessionQuery() {
return useQuery(() => ({
queryKey: authKeys.session,
queryFn: () => api.auth.getSession(),
// The session is already authoritative for the dashboard's lifecycle,
// so cache it forever and let mutations invalidate it explicitly.
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
refetchOnMount: true,
refetchOnWindowFocus: false,
retry: false,
}));
}
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (props) => {
const [session, setSession] = createSignal<AdminSessionResponse | null>(null);
const [loading, setLoading] = createSignal(true);
/**
* Tag a query as belonging to the auth namespace so we can keep it across
* sign-in/sign-out transitions while wiping every other cached query.
*
* `removeQueries`/`invalidateQueries` accept a `predicate` that runs against
* each Query in the cache anchoring on the first key segment is the
* cheapest stable identifier we have.
*/
const isAuthQuery = (queryKey: readonly unknown[]) => queryKey[0] === 'auth';
const refreshSession = async () => {
const nextSession = await api.auth.getSession();
setSession(nextSession);
setAdminCsrfToken(nextSession.csrfToken);
setLoading(false);
return nextSession;
};
/**
* Replace the cached session with the unauthenticated fallback (preserving
* the configured auth mode so the login gate keeps showing the right form),
* then evict every non-auth query so stale user-scoped data doesn't leak
* across sign-out/401 boundaries.
*/
function clearAuthenticatedState(queryClient: QueryClient): void {
queryClient.setQueryData<AdminSessionResponse>(
authKeys.session,
(previous) => ({
...UNAUTHENTICATED_FALLBACK,
authMode: previous?.authMode ?? UNAUTHENTICATED_FALLBACK.authMode,
}),
);
setAdminCsrfToken(null);
queryClient.removeQueries({
predicate: (query) => !isAuthQuery(query.queryKey),
});
}
const login = async (username: string, password: string) => {
const nextSession = await api.auth.login(username, password);
setSession(nextSession);
setAdminCsrfToken(nextSession.csrfToken);
return nextSession;
};
/**
* After a successful login the cached results from the previous (anonymous
* or different-user) session are stale. Mark every non-auth query stale so
* mounted components refetch under the new session.
*/
function refreshAfterLogin(queryClient: QueryClient): Promise<void> {
return queryClient.invalidateQueries({
predicate: (query) => !isAuthQuery(query.queryKey),
});
}
const logout = async () => {
await api.auth.logout();
setSession((previous) => unauthenticatedState(previous));
setAdminCsrfToken(null);
};
function AuthContextProvider(props: { children: JSX.Element }) {
const queryClient = useQueryClient();
const sessionQuery = useSessionQuery();
onMount(() => {
setUnauthorizedHandler(() => {
setSession((previous) => unauthenticatedState(previous));
setAdminCsrfToken(null);
});
void refreshSession().catch(() => {
setSession({ authenticated: false, authMode: 'both', csrfToken: null, principal: null });
setLoading(false);
});
// Mirror the CSRF token into the api client whenever the session updates.
createEffect(() => {
const data = sessionQuery.data;
setAdminCsrfToken(data?.csrfToken ?? null);
});
// Wire the api client's 401 handler once at mount — when an unauthorized
// response surfaces, immediately collapse the cached session to the
// unauthenticated fallback AND wipe every other cached query so we never
// render data that was fetched under a now-revoked session. There are no
// reactive reads in this block, so `onMount` (one-shot) is a more honest
// fit than `createEffect`.
onMount(() => {
setUnauthorizedHandler(() => clearAuthenticatedState(queryClient));
onCleanup(() => setUnauthorizedHandler(null));
});
const loginMutation = useMutation(() => ({
mutationFn: ({
username,
password,
}: {
username: string;
password: string;
}) => api.auth.login(username, password),
onSuccess: async (next) => {
queryClient.setQueryData(authKeys.session, next);
// Invalidate (don't remove) so any currently-mounted view kicks off
// a refetch under the new session — `removeQueries` here would leave
// the dashboard staring at empty fallbacks until each query mounted.
await refreshAfterLogin(queryClient);
},
}));
const logoutMutation = useMutation(() => ({
mutationFn: () => api.auth.logout(),
onSuccess: () => clearAuthenticatedState(queryClient),
}));
const value: AuthContextValue = {
session: () => sessionQuery.data ?? null,
// Only treat the very first fetch as "loading" — once we have any data
// (or an error), the gate should resolve to login or to the dashboard.
loading: () =>
sessionQuery.isPending && sessionQuery.fetchStatus !== 'idle',
refreshSession: async () => {
const next = await queryClient.fetchQuery({
queryKey: authKeys.session,
queryFn: () => api.auth.getSession(),
staleTime: 0,
});
return next;
},
login: (username, password) =>
loginMutation.mutateAsync({ username, password }),
logout: () => logoutMutation.mutateAsync(),
};
return (
<AuthContext.Provider value={{ session, loading, refreshSession, login, logout }}>
{props.children}
</AuthContext.Provider>
<AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
);
}
export const AuthProvider: ParentComponent = (props) => {
const queryClient = makeQueryClient();
return (
<QueryClientProvider client={queryClient}>
<AuthContextProvider>{props.children}</AuthContextProvider>
</QueryClientProvider>
);
};
export function useAuth() {
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('Auth context is not available');
throw new Error('useAuth must be used inside <AuthProvider>');
}
return context;
}

View file

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

View file

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

View file

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

View file

@ -1,16 +1,21 @@
import { createSignal, Show, type Component } from 'solid-js';
import { Alert, Button, Panel, TextField } from '../ui';
import { useAuth } from '../auth';
import { api, ApiError } from '../api/client';
export const LoginGate: Component = () => {
import { ApiError, api } from '../api/client';
import { useAuth } from '../auth';
import { Alert, Button, Panel, TextField } from '../ui';
const LoginGate: Component = () => {
const auth = useAuth();
const [username, setUsername] = createSignal('');
const [password, setPassword] = createSignal('');
const [submitting, setSubmitting] = createSignal(false);
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
const handleSubmit = async (event: Event) => {
const authMode = () => auth.session()?.authMode ?? 'both';
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
setSubmitting(true);
setErrorMessage(null);
@ -18,29 +23,43 @@ export const LoginGate: Component = () => {
await auth.login(username().trim(), password());
setPassword('');
} catch (error) {
setErrorMessage(error instanceof ApiError ? error.message : 'Admin login failed.');
setErrorMessage(
error instanceof ApiError ? error.message : 'Admin login failed.',
);
} finally {
setSubmitting(false);
}
};
const authMode = () => auth.session()?.authMode ?? 'both';
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
return (
<div class="auth-screen">
<Panel class="auth-screen__panel" title="Admin Authentication" description="Sign in through the internal admin gateway before accessing router operations.">
<Panel
class="auth-screen__panel"
description="Sign in through the internal admin gateway before accessing router operations."
title="Admin Authentication"
>
<div class="ui-stack">
<Show when={errorMessage()}>
{(message) => <Alert tone="danger">{message()}</Alert>}
</Show>
<Show when={envEnabled()}>
<form class="ui-form" onSubmit={(event) => void handleSubmit(event)}>
<TextField label="Username" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} />
<TextField type="password" label="Password" value={password()} onInput={(event) => setPassword(event.currentTarget.value)} />
<Button type="submit" variant="primary" disabled={submitting()}>
<form
class="ui-form"
onSubmit={(event) => void handleSubmit(event)}
>
<TextField
label="Username"
onInput={(event) => setUsername(event.currentTarget.value)}
value={username()}
/>
<TextField
label="Password"
onInput={(event) => setPassword(event.currentTarget.value)}
type="password"
value={password()}
/>
<Button disabled={submitting()} type="submit" variant="primary">
{submitting() ? 'Signing In...' : 'Sign In'}
</Button>
</form>
@ -48,8 +67,14 @@ export const LoginGate: Component = () => {
<Show when={oidcEnabled()}>
<div class="ui-stack ui-stack--tight">
<p class="ui-subtitle">Single sign-on is available through the configured OpenID provider.</p>
<Button onClick={() => api.auth.beginOidc()} disabled={submitting()}>
<p class="ui-subtitle">
Single sign-on is available through the configured OpenID
provider.
</p>
<Button
disabled={submitting()}
onClick={() => api.auth.beginOidc()}
>
Continue With OpenID
</Button>
</div>
@ -59,3 +84,5 @@ export const LoginGate: Component = () => {
</div>
);
};
export default LoginGate;

View file

@ -1,8 +1,12 @@
import { Dynamic } from 'solid-js/web';
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
import { Dynamic } from 'solid-js/web';
const THEME_STORAGE_KEY = 'kyush-theme';
const MonacoEditor = lazy(() => import('solid-monaco').then((module) => ({ default: module.MonacoEditor })));
const MonacoEditor = lazy(async () => {
const module = await import('solid-monaco');
return { default: module.MonacoEditor };
});
interface ScriptEditorProps {
value: string;
@ -11,8 +15,7 @@ interface ScriptEditorProps {
path?: string;
}
export function ScriptEditor(props: ScriptEditorProps) {
const defaultCode = `// User-defined middleware script
const DEFAULT_CODE = `// User-defined middleware script
// Available functions: onRequest, onResponse
/**
@ -26,13 +29,12 @@ export async function onRequest(ctx) {
// Example: Edit body
// if (typeof ctx.request.body === 'object') {
// if (typeof ctx.request.body['chat_template_kwargs'] !== 'object') {
// ctx.request.body['chat_template_kwargs'] = {};
// }
// ctx.request.body['chat_template_kwargs'] ??= {};
// }
// Example: Log request
// console.log('Request:', ctx.request.method, ctx.request.path);
return ctx;
}
@ -44,51 +46,72 @@ export async function onRequest(ctx) {
export async function onResponse(ctx) {
// Example: Log response
// console.log('Response status:', ctx.response?.status);
// Example: Handle streaming responses
// if (ctx.response?.isStream && ctx.onChunk) {
// const originalOnChunk = ctx.onChunk;
// ctx.onChunk = (chunk) => {
// console.log('Stream chunk:', chunk);
// originalOnChunk(chunk);
// };
// }
return ctx;
}
`;
const [editorTheme, setEditorTheme] = createSignal<'vs' | 'vs-dark'>('vs-dark');
type EditorTheme = 'vs' | 'vs-dark';
function readThemePreference(): EditorTheme {
const root = document.documentElement;
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
const explicit = root.dataset.theme;
const preferred = stored === 'light' || stored === 'dark' ? stored : explicit;
const isDark = preferred
? preferred === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
return isDark ? 'vs-dark' : 'vs';
}
/**
* Tracks the user's preferred Monaco theme. Watches three sources of truth:
* 1. `localStorage[kyush-theme]` (explicit user choice)
* 2. `<html data-theme="…">` (set by the app shell)
* 3. `prefers-color-scheme` media query (system fallback)
*
* Both the MutationObserver and the media-query listener are wired up in
* `onMount` (so they're guaranteed to run only on the client) and torn down
* via the matching `onCleanup` registered in the same effect that
* registration is owned by the surrounding component scope, so it always
* fires on unmount.
*/
function createEditorThemeSignal() {
const [theme, setTheme] = createSignal<EditorTheme>('vs-dark');
onMount(() => {
const root = document.documentElement;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const syncTheme = () => {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const explicitTheme = root.dataset.theme;
const preferredTheme = storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : explicitTheme;
const isDark = preferredTheme ? preferredTheme === 'dark' : mediaQuery.matches;
setEditorTheme(isDark ? 'vs-dark' : 'vs');
};
const sync = () => setTheme(readThemePreference());
const observer = new MutationObserver(syncTheme);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
mediaQuery.addEventListener('change', syncTheme);
syncTheme();
const observer = new MutationObserver(sync);
observer.observe(root, {
attributes: true,
attributeFilter: ['data-theme'],
});
mediaQuery.addEventListener('change', sync);
sync();
onCleanup(() => {
// Both subscriptions are paired with their teardown in the same closure
// so it's impossible to add one without removing the other.
observer.disconnect();
mediaQuery.removeEventListener('change', syncTheme);
mediaQuery.removeEventListener('change', sync);
});
});
return theme;
}
function ScriptEditor(props: ScriptEditorProps) {
const editorTheme = createEditorThemeSignal();
return (
<div class="script-editor">
<Suspense
fallback={
<div class="script-editor__loading" role="status" aria-live="polite">
<div aria-live="polite" class="script-editor__loading" role="status">
<div class="script-editor__skeleton script-editor__skeleton--toolbar" />
<div class="script-editor__skeleton script-editor__skeleton--line" />
<div class="script-editor__skeleton script-editor__skeleton--line script-editor__skeleton--short" />
@ -101,10 +124,7 @@ export async function onResponse(ctx) {
<Dynamic
component={MonacoEditor}
language="typescript"
value={props.value || defaultCode}
path={props.path}
onChange={(value: string) => props.onChange(value)}
theme={editorTheme()}
onChange={props.onChange}
options={{
minimap: { enabled: false },
fontSize: 14,
@ -114,8 +134,13 @@ export async function onResponse(ctx) {
scrollBeyondLastLine: false,
padding: { top: 16, bottom: 16 },
}}
path={props.path}
theme={editorTheme()}
value={props.value || DEFAULT_CODE}
/>
</Suspense>
</div>
);
}
export default ScriptEditor;

View file

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

5
client/src/reset.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
// Activate ts-reset's improved built-in types globally for the client.
// See https://www.totaltypescript.com/ts-reset for the rules this enables —
// it sharpens types like `JSON.parse`, `Array.prototype.filter`, `fetch`,
// `Object.entries`, etc., so we don't need to widen them with manual casts.
import '@total-typescript/ts-reset';

View file

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

View file

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

View file

@ -1,8 +1,12 @@
import { useQuery } from '@tanstack/solid-query';
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
import { Show, createSignal, type Component, For } from 'solid-js';
import { api } from '../api/client';
import { queryKeys } from '../api/query-keys';
import { Layout } from '../components/Layout';
import {
Button,
ChartLegend,
ComboChart,
CommandBar,
@ -22,38 +26,68 @@ const dayOptions = [
{ value: '90', label: 'Last 90 days' },
];
const palette = ['#2357d8', '#1f7a45', '#c05621', '#8b5cf6', '#0f766e', '#b42318'];
const palette = [
'#2357d8',
'#1f7a45',
'#c05621',
'#8b5cf6',
'#0f766e',
'#b42318',
];
const formatInteger = new Intl.NumberFormat('en-US');
type DashboardChartRow = { date: string } & Record<string, string | number | null>;
type DashboardChartRow = { date: string } & Record<
string,
string | number | null
>;
export const Dashboard: Component = () => {
const Dashboard: Component = () => {
const [days, setDays] = createSignal('30');
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<Set<string>>(new Set());
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<Set<string>>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<
Set<string>
>(new Set());
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<
Set<string>
>(new Set());
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(
new Set(),
);
const windowDays = createMemo(() => Number(days()));
const [summary, { refetch }] = createResource(windowDays, (value) => api.dashboard.getSummary(value));
const [backends] = createResource(() => api.backends.getAll());
// Reactive data: TanStack Query handles fetching, caching, and refetch.
// The query key tracks `windowDays()` so changing the time window kicks off
// a fresh fetch automatically.
const windowDays = () => Number(days());
const summaryQuery = useQuery(() => ({
queryKey: queryKeys.dashboard.summary(windowDays()),
queryFn: () => api.dashboard.getSummary(windowDays()),
}));
const backendsQuery = useQuery(() => ({
queryKey: queryKeys.backends.all(),
queryFn: () => api.backends.getAll(),
}));
const backendNameById = createMemo(() => {
const summary = () => summaryQuery.data;
const backends = () => backendsQuery.data;
const refetch = () => summaryQuery.refetch();
// Inline derivations — Solid's reactive prop reads keep these cheap, no
// need to wrap in createMemo for what amounts to a single Map build.
const backendNameById = (): ReadonlyMap<number, string> => {
const entries = new Map<number, string>();
for (const backend of backends() ?? []) {
entries.set(backend.id, backend.name);
}
return entries;
});
};
const trafficRows = createMemo(() =>
const trafficRows = () =>
(summary()?.series.daily_totals ?? []).map((row) => ({
date: row.date,
requests: row.total_requests,
tokens: row.total_tokens,
}))
);
}));
const reliabilityRows = createMemo(() => {
const reliabilityRows = () => {
const grouped = new Map<string, { requests: number; errors: number }>();
for (const row of summary()?.series.backend_quality ?? []) {
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
@ -66,62 +100,100 @@ export const Dashboard: Component = () => {
.sort((left, right) => left[0].localeCompare(right[0]))
.map(([date, value]) => ({
date,
lineValue: value.requests === 0 ? 0 : ((value.requests - value.errors) / value.requests) * 100,
lineValue:
value.requests === 0
? 0
: ((value.requests - value.errors) / value.requests) * 100,
barValue: value.errors,
}));
});
};
const latencyRows = createMemo(() => {
const latencyRows = (): DashboardChartRow[] => {
const grouped = new Map<string, DashboardChartRow>();
for (const row of summary()?.series.backend_quality ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
const entry: DashboardChartRow = grouped.get(row.date) ?? {
date: row.date,
};
entry[`backend_${row.backend_id}`] = row.avg_response_time_ms;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
};
const latencySeries = createMemo(() => {
const ids = Array.from(new Set((summary()?.series.backend_quality ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
const latencySeries = () => {
const ids = Array.from(
new Set(
(summary()?.series.backend_quality ?? []).map((row) => row.backend_id),
),
).sort((left, right) => left - right);
return ids.map((backendId, index) => ({
key: `backend_${backendId}`,
label: backendNameById().get(backendId) ?? `Backend ${backendId}`,
color: palette[index % palette.length],
}));
});
};
const modelRows = createMemo(() => {
const modelRows = (): DashboardChartRow[] => {
const grouped = new Map<string, DashboardChartRow>();
for (const row of summary()?.series.model_trends ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
const entry: DashboardChartRow = grouped.get(row.date) ?? {
date: row.date,
};
entry[`model_${row.model}`] = row.request_count;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
};
const modelSeries = createMemo(() => {
const models = Array.from(new Set((summary()?.series.model_trends ?? []).map((row) => row.model)));
const modelSeries = () => {
const models = Array.from(
new Set((summary()?.series.model_trends ?? []).map((row) => row.model)),
);
return models.map((model, index) => ({
key: `model_${model}`,
label: model,
color: palette[index % palette.length],
}));
});
};
const summaryItems = createMemo(() => {
const summaryItems = () => {
const payload = summary();
const latestTraffic = payload?.series.daily_totals[payload.series.daily_totals.length - 1];
const latestTraffic =
payload?.series.daily_totals[payload.series.daily_totals.length - 1];
return [
{ label: 'Active Users', value: payload?.overview.active_users ?? 0, hint: `${payload?.overview.total_users ?? 0} total identities` },
{ label: 'Active Backends', value: payload?.overview.active_backends ?? 0, hint: `${payload?.overview.total_backends ?? 0} configured upstreams` },
{ label: 'Live Scripts', value: payload?.overview.active_scripts ?? 0, hint: `${payload?.overview.total_scripts ?? 0} total middleware rules` },
{ label: 'Latest Volume', value: latestTraffic ? formatInteger.format(latestTraffic.total_requests) : '0', hint: latestTraffic ? `${latestTraffic.date} request count` : 'No traffic in window' },
{
label: 'Active Users',
value: payload?.overview.active_users ?? 0,
hint: `${payload?.overview.total_users ?? 0} total identities`,
},
{
label: 'Active Backends',
value: payload?.overview.active_backends ?? 0,
hint: `${payload?.overview.total_backends ?? 0} configured upstreams`,
},
{
label: 'Live Scripts',
value: payload?.overview.active_scripts ?? 0,
hint: `${payload?.overview.total_scripts ?? 0} total middleware rules`,
},
{
label: 'Latest Volume',
value: latestTraffic
? formatInteger.format(latestTraffic.total_requests)
: '0',
hint: latestTraffic
? `${latestTraffic.date} request count`
: 'No traffic in window',
},
];
});
};
const cacheStateItems = createMemo(() => {
const cacheStateItems = () => {
const counts = summary()?.health.cache_state_counts;
if (!counts) return [];
return [
@ -130,33 +202,56 @@ export const Dashboard: Component = () => {
{ key: 'Error', value: String(counts.error) },
{ key: 'Inactive', value: String(counts.inactive) },
];
});
};
const scriptItems = createMemo(() => {
const scriptItems = () => {
const payload = summary();
if (!payload) return [];
return [
{ key: 'Per User', value: `${payload.scripts.active_by_type['per-user']} active / ${payload.scripts.total_by_type['per-user']} total` },
{ key: 'Per Backend', value: `${payload.scripts.active_by_type['per-backend']} active / ${payload.scripts.total_by_type['per-backend']} total` },
{ key: 'Scoped', value: `${payload.scripts.active_by_type['per-user-backend']} active / ${payload.scripts.total_by_type['per-user-backend']} total` },
{
key: 'Per User',
value: `${payload.scripts.active_by_type['per-user']} active / ${payload.scripts.total_by_type['per-user']} total`,
},
{
key: 'Per Backend',
value: `${payload.scripts.active_by_type['per-backend']} active / ${payload.scripts.total_by_type['per-backend']} total`,
},
{
key: 'Scoped',
value: `${payload.scripts.active_by_type['per-user-backend']} active / ${payload.scripts.total_by_type['per-user-backend']} total`,
},
];
});
};
const accessItems = createMemo(() => {
const accessItems = () => {
const payload = summary();
if (!payload) return [];
return [
{ key: 'Assignments', value: formatInteger.format(payload.access.permission_assignments) },
{ key: 'No Backend Access', value: String(payload.access.users_without_permissions) },
{ key: 'User Detail Logs', value: String(payload.logging.users_with_detail_logging) },
{ key: 'Backend Detail Logs', value: String(payload.logging.backends_with_detail_logging) },
{
key: 'Assignments',
value: formatInteger.format(payload.access.permission_assignments),
},
{
key: 'No Backend Access',
value: String(payload.access.users_without_permissions),
},
{
key: 'User Detail Logs',
value: String(payload.logging.users_with_detail_logging),
},
{
key: 'Backend Detail Logs',
value: String(payload.logging.backends_with_detail_logging),
},
];
});
};
const toggleHiddenKey = (
setter: (value: Set<string> | ((current: Set<string>) => Set<string>)) => void,
setter: (
value: Set<string> | ((current: Set<string>) => Set<string>),
) => void,
key: string,
) => {
setter((current) => {
@ -174,24 +269,49 @@ export const Dashboard: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
title="Dashboard"
actions={
<Button onClick={() => void refetch()} type="button">
<RefreshCcw aria-hidden="true" size={14} />
Refresh
</Button>
}
description="Operations cockpit for router health, traffic shape, and the configuration context behind current behavior."
actions={<button class="ui-button" type="button" onClick={() => void refetch()}><RefreshCcw />Refresh</button>}
title="Dashboard"
/>
<CommandBar class="analytics__filters">
<CommandBarGroup>
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
<Select
label="Window"
onChange={setDays}
options={dayOptions}
value={days()}
/>
</CommandBarGroup>
</CommandBar>
<SummaryStrip items={summaryItems()} />
<Show when={!summary.error} fallback={<Panel title="Dashboard unavailable" description={summary.error instanceof Error ? summary.error.message : 'Failed to load dashboard summary.'}><EmptyState title="Failed to load summary" description="Refresh the page or verify the admin API is available." /></Panel>}>
<Show
fallback={
<Panel
description={
summaryQuery.error instanceof Error
? summaryQuery.error.message
: 'Failed to load dashboard summary.'
}
title="Dashboard unavailable"
>
<EmptyState
description="Refresh the page or verify the admin API is available."
title="Failed to load summary"
/>
</Panel>
}
when={!summaryQuery.isError}
>
<div class="ui-section-grid">
<Panel
title="Traffic Volume"
description="Daily request and token totals for the selected window."
actions={
<ChartLegend
items={[
@ -199,38 +319,63 @@ export const Dashboard: Component = () => {
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenTrafficSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
onToggle={(key) =>
toggleHiddenKey(setHiddenTrafficSeries, key)
}
/>
}
description="Daily request and token totals for the selected window."
title="Traffic Volume"
>
<TimeSeriesChart
data={trafficRows()}
formatLeftValue={(value) =>
formatInteger.format(Math.round(value))
}
formatRightValue={(value) =>
new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value)
}
hiddenKeys={hiddenTrafficSeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenTrafficSeries, key)
}
series={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45', axis: 'right' },
{
key: 'tokens',
label: 'Tokens',
color: '#1f7a45',
axis: 'right',
},
]}
showLegend={false}
hiddenKeys={hiddenTrafficSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
tooltipTitle="Traffic volume"
yLeftLabel="Requests"
yRightLabel="Tokens"
formatLeftValue={(value) => formatInteger.format(Math.round(value))}
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
tooltipTitle="Traffic volume"
/>
</Panel>
<Panel
title="Reliability Snapshot"
actions={
<ChartLegend
items={[
{ key: 'line', label: 'Success Rate', color: '#2357d8' },
{ key: 'bar', label: 'Errors', color: '#b42318' },
]}
/>
}
description="Success rate and absolute error count across all visible traffic."
actions={<ChartLegend items={[{ key: 'line', label: 'Success Rate', color: '#2357d8' }, { key: 'bar', label: 'Errors', color: '#b42318' }]} />}
title="Reliability Snapshot"
>
<ComboChart
data={reliabilityRows()}
lineLabel="Success Rate"
barLabel="Errors"
lineColor="#2357d8"
barColor="#b42318"
barLabel="Errors"
data={reliabilityRows()}
lineColor="#2357d8"
lineLabel="Success Rate"
showLegend={false}
/>
</Panel>
@ -238,69 +383,113 @@ export const Dashboard: Component = () => {
<div class="ui-section-grid">
<Panel
title="Backend Latency"
actions={
<ChartLegend
items={latencySeries()}
mutedKeys={hiddenLatencySeries()}
onToggle={(key) =>
toggleHiddenKey(setHiddenLatencySeries, key)
}
/>
}
description="Average response time by backend with per-series toggles."
actions={<ChartLegend items={latencySeries()} mutedKeys={hiddenLatencySeries()} onToggle={(key) => toggleHiddenKey(setHiddenLatencySeries, key)} />}
title="Backend Latency"
>
<TimeSeriesChart
data={latencyRows()}
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
hiddenKeys={hiddenLatencySeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenLatencySeries, key)
}
series={latencySeries()}
showLegend={false}
hiddenKeys={hiddenLatencySeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenLatencySeries, key)}
yLeftLabel="Milliseconds"
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
tooltipTitle="Backend latency"
yLeftLabel="Milliseconds"
/>
</Panel>
<Panel
title="Model Activity"
actions={
<ChartLegend
items={modelSeries()}
mutedKeys={hiddenModelSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
/>
}
description="Top models by request volume across the current window."
actions={<ChartLegend items={modelSeries()} mutedKeys={hiddenModelSeries()} onToggle={(key) => toggleHiddenKey(setHiddenModelSeries, key)} />}
title="Model Activity"
>
<TimeSeriesChart
data={modelRows()}
formatLeftValue={(value) => `${Math.round(value)}`}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) =>
toggleHiddenKey(setHiddenModelSeries, key)
}
series={modelSeries()}
showLegend={false}
hiddenKeys={hiddenModelSeries()}
onToggleLegend={(key) => toggleHiddenKey(setHiddenModelSeries, key)}
yLeftLabel="Requests"
formatLeftValue={(value) => `${Math.round(value)}`}
tooltipTitle="Model activity"
yLeftLabel="Requests"
/>
</Panel>
</div>
<div class="ui-section-grid dashboard__context-grid">
<Panel title="Backend Health" description="Cache readiness, liveness, and sync drift indicators for current backends.">
<Panel
description="Cache readiness, liveness, and sync drift indicators for current backends."
title="Backend Health"
>
<MetaCluster items={cacheStateItems()} />
<Show
fallback={
<EmptyState
description="All active backends synced within the freshness window."
title="No stale backend syncs"
/>
}
when={(summary()?.health.stale_backends.length ?? 0) > 0}
fallback={<EmptyState title="No stale backend syncs" description="All active backends synced within the freshness window." />}
>
<div class="dashboard__status-list">
{summary()?.health.stale_backends.map((backend) => (
<div class="dashboard__status-item">
<div>
<strong>{backend.name}</strong>
<p>Last sync: {backend.last_synced_at ? new Date(backend.last_synced_at).toLocaleString() : 'Never'}</p>
</div>
<span>{backend.state}</span>
</div>
))}
{
<For each={summary()?.health.stale_backends}>
{(backend) => (
<div class="dashboard__status-item">
<div>
<strong>{backend.name}</strong>
<p>
Last sync:{' '}
{backend.last_synced_at
? new Date(
backend.last_synced_at,
).toLocaleString()
: 'Never'}
</p>
</div>
<span>{backend.state}</span>
</div>
)}
</For>
}
</div>
</Show>
</Panel>
<Panel title="Script Runtime" description="Active middleware footprint and target distribution.">
<Panel
description="Active middleware footprint and target distribution."
title="Script Runtime"
>
<MetaCluster items={scriptItems()} />
<div class="dashboard__note">
Active scripts shape request and response behavior before traffic reaches the upstream backend.
Active scripts shape request and response behavior before
traffic reaches the upstream backend.
</div>
</Panel>
<Panel title="Access Context" description="Identity and logging posture behind current routing activity.">
<Panel
description="Identity and logging posture behind current routing activity."
title="Access Context"
>
<MetaCluster items={accessItems()} />
</Panel>
</div>
@ -309,3 +498,5 @@ export const Dashboard: Component = () => {
</Layout>
);
};
export default Dashboard;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,290 +1,4 @@
export type User = {
id: number;
api_key: string;
name: string;
email?: string;
is_active: boolean;
detail_logging: boolean;
created_at: string;
updated_at: string;
};
export type Backend = {
id: number;
name: string;
base_url: string;
api_key?: string;
is_active: boolean;
detail_logging: boolean;
created_at: string;
updated_at: string;
cached_model_count?: number;
last_model_sync_at?: string;
model_cache_initialized?: boolean;
model_cache_state?: 'ready' | 'uninitialized' | 'error' | 'inactive';
};
export type BackendModelSnapshot = {
id: number;
backend_id: number;
model_id: string;
raw_json?: string;
fetched_at: string;
created_at: string;
updated_at: string;
};
export type BackendModelCacheStatus = {
backend_id: number;
initialized: boolean;
state: 'ready' | 'uninitialized' | 'error' | 'inactive';
model_count: number;
last_synced_at?: string;
last_attempted_at?: string;
last_error?: string;
};
export type BackendModelsResponse = {
backend: Backend;
cache: BackendModelCacheStatus;
snapshots: BackendModelSnapshot[];
models: string[];
};
export type BackendModelCatalogEntry = {
model_id: string;
backend_ids: number[];
};
export type ModelCacheOverview = {
backends: BackendModelCacheStatus[];
models: BackendModelCatalogEntry[];
};
export type ModelRewriteRule = {
id: number;
source_model: string;
target_model: string;
is_active: boolean;
force: boolean;
note?: string;
created_at: string;
updated_at: string;
};
export type Permission = {
id: number;
user_id: number;
backend_id: number;
created_at: string;
};
export type RequestLog = {
id: number;
user_id: number;
backend_id: number;
endpoint: string;
request_model?: string;
routed_model?: string;
response_model?: string;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
status_code: number;
response_time_ms?: number;
error_message?: string;
detail_logged: boolean;
local_date: string;
request_headers?: string;
request_body?: string;
response_headers?: string;
response_body?: string;
created_at: string;
};
export type RequestLogPage = {
rows: RequestLog[];
total: number;
limit: number;
offset: number;
};
export type UsageStats = {
id: number;
user_id: number;
backend_id: number;
date: string;
total_requests: number;
total_tokens: number;
};
export type BackendMetrics = {
id: number;
backend_id: number;
date: string;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
};
export type AnalyticsDailyTotalsPoint = {
date: string;
total_requests: number;
total_tokens: number;
};
export type AnalyticsBackendQualityPoint = {
date: string;
backend_id: number;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
};
export type AnalyticsModelTrendPoint = {
date: string;
model: string;
request_count: number;
};
export type AnalyticsHistogramBin = {
bin_start: number;
bin_end: number;
count: number;
};
export type AnalyticsBoxPlotPoint = {
date: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
count: number;
};
export type DashboardHealthStatus = {
status: 'ok';
timestamp: string;
};
export type DashboardOverviewSummary = {
total_users: number;
active_users: number;
total_backends: number;
active_backends: number;
total_permissions: number;
total_scripts: number;
active_scripts: number;
};
export type DashboardHealthSummary = {
cache_state_counts: Record<Backend['model_cache_state'] extends infer T ? Extract<T, string> : never, number>;
stale_backends: Array<{
id: number;
name: string;
state: NonNullable<Backend['model_cache_state']>;
last_synced_at?: string;
}>;
public_health: DashboardHealthStatus;
admin_health: DashboardHealthStatus;
};
export type DashboardLoggingSummary = {
users_with_detail_logging: number;
backends_with_detail_logging: number;
};
export type DashboardScriptSummary = {
active_by_type: Record<ScriptType, number>;
total_by_type: Record<ScriptType, number>;
};
export type DashboardAccessSummary = {
permission_assignments: number;
users_without_permissions: number;
};
export type DashboardSummaryResponse = {
window_days: number;
generated_at: string;
overview: DashboardOverviewSummary;
health: DashboardHealthSummary;
logging: DashboardLoggingSummary;
scripts: DashboardScriptSummary;
access: DashboardAccessSummary;
series: {
daily_totals: AnalyticsDailyTotalsPoint[];
backend_quality: AnalyticsBackendQualityPoint[];
model_trends: AnalyticsModelTrendPoint[];
};
};
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
export type UserScript = {
id: number;
name: string;
script_type: ScriptType;
target_user_id: number | null;
target_backend_id: number | null;
script_code: string;
is_active: boolean;
created_at: string;
updated_at: string;
};
export type CreateScriptData = {
name: string;
script_type: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code: string;
is_active?: boolean;
};
export type UpdateScriptData = {
name?: string;
script_type?: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code?: string;
is_active?: boolean;
};
export type AdminAuthMode = 'env' | 'oidc' | 'both';
export type AdminPrincipal = {
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
displayName: string;
};
export type AdminSessionResponse = {
authenticated: boolean;
authMode: AdminAuthMode;
csrfToken: string | null;
principal: AdminPrincipal | null;
};
export type AdminApiTokenSummary = {
id: number;
name: string;
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
display_name: string;
token_prefix: string;
expires_at: string;
last_used_at?: string;
revoked_at?: string;
created_at: string;
updated_at: string;
};
// Re-export every shared domain type so existing client code can keep
// importing from `../types`. The shared package is the single source of
// truth for the schemas and types that travel across the wire.
export * from '@kyush/shared';

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,13 +1,27 @@
import { For, Show, createMemo } from 'solid-js';
import {
LooseChatCompletionRequestSchema,
LooseChatCompletionResponseSchema,
} from '@kyush/shared';
import { For, Show, type Component } from 'solid-js';
import { MetaCluster } from './MetaCluster';
import { StatusBadge, type StatusTone } from './StatusBadge';
/*
* Types
* */
type KnownChatRole = 'system' | 'user' | 'assistant';
interface MetaItem {
key: string;
value: string;
}
interface ParsedMessage {
role: string;
content: string;
metadata?: Array<{ key: string; value: string }>;
metadata?: MetaItem[];
}
interface ConversationTimelineProps {
@ -16,130 +30,191 @@ interface ConversationTimelineProps {
emptyMessage?: string;
}
function normalizePayload(value: unknown): Record<string, unknown> | null {
if (!value) return null;
/*
* Parsing helpers
* */
function parseJsonLike(value: unknown): unknown {
if (value == null) return null;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
return JSON.parse(value);
} catch {
return null;
}
}
return typeof value === 'object' ? (value as Record<string, unknown>) : null;
return value;
}
function normalizeMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
const messages = payload?.messages;
if (!Array.isArray(messages)) return [];
return messages
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item) => ({
role: typeof item.role === 'string' ? item.role : 'unknown',
content: typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? ''),
}));
function stringifyContent(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function normalizeAssistantMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
const choices = payload?.choices;
if (!Array.isArray(choices)) return [];
function parseRequest(body: unknown) {
const raw = parseJsonLike(body);
if (raw == null) return null;
const result = LooseChatCompletionRequestSchema.safeParse(raw);
return result.success ? result.data : null;
}
const messages: Array<ParsedMessage | null> = choices.map((choice) => {
if (!choice || typeof choice !== 'object') return null;
const message = (choice as Record<string, unknown>).message;
if (!message || typeof message !== 'object') return null;
function parseResponse(body: unknown) {
const raw = parseJsonLike(body);
if (raw == null) return null;
const result = LooseChatCompletionResponseSchema.safeParse(raw);
return result.success ? result.data : null;
}
const content = typeof (message as Record<string, unknown>).content === 'string'
? String((message as Record<string, unknown>).content)
: JSON.stringify((message as Record<string, unknown>).content ?? '');
const metadata = [
(message as Record<string, unknown>).reasoning_content !== undefined
? {
key: 'Reasoning',
value:
typeof (message as Record<string, unknown>).reasoning_content === 'string'
? String((message as Record<string, unknown>).reasoning_content)
: JSON.stringify((message as Record<string, unknown>).reasoning_content),
}
: null,
(message as Record<string, unknown>).tool_calls !== undefined
? {
key: 'Tool Calls',
value: JSON.stringify((message as Record<string, unknown>).tool_calls),
}
: null,
(choice as Record<string, unknown>).finish_reason !== undefined
? { key: 'Finish', value: String((choice as Record<string, unknown>).finish_reason) }
: null,
(choice as Record<string, unknown>).matched_stop !== undefined
? { key: 'Matched Stop', value: String((choice as Record<string, unknown>).matched_stop) }
: null,
(choice as Record<string, unknown>).logprobs !== undefined
? { key: 'Logprobs', value: JSON.stringify((choice as Record<string, unknown>).logprobs) }
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
/**
* Lift the user/system/assistant messages out of an OpenAI-shaped request body.
* Returns an empty array when nothing parsable is present.
*/
function extractRequestMessages(
request: ReturnType<typeof parseRequest>,
): ParsedMessage[] {
if (!request?.messages) return [];
return request.messages.map((message) => ({
role: typeof message.role === 'string' ? message.role : 'unknown',
content: stringifyContent(message.content),
}));
}
/**
* Lift the assistant turns out of an OpenAI-shaped response body, including the
* extra metadata fields (reasoning, tool calls, finish_reason, etc.) that show
* up in chat completion choices.
*/
function extractResponseMessages(
response: ReturnType<typeof parseResponse>,
): ParsedMessage[] {
if (!response?.choices) return [];
return response.choices
.map((choice): ParsedMessage | null => {
const message = choice.message;
if (!message) return null;
const metadata: MetaItem[] = [];
if (message.reasoning_content !== undefined) {
metadata.push({
key: 'Reasoning',
value: stringifyContent(message.reasoning_content),
});
}
if (message.tool_calls !== undefined) {
metadata.push({
key: 'Tool Calls',
value: stringifyContent(message.tool_calls),
});
}
if (choice.finish_reason !== undefined) {
metadata.push({
key: 'Finish',
value: String(choice.finish_reason),
});
}
if (choice.matched_stop !== undefined) {
metadata.push({
key: 'Matched Stop',
value: String(choice.matched_stop),
});
}
if (choice.logprobs !== undefined) {
metadata.push({
key: 'Logprobs',
value: stringifyContent(choice.logprobs),
});
}
return {
role: 'assistant' as const,
content,
metadata,
role: 'assistant',
content: stringifyContent(message.content),
metadata: metadata.length > 0 ? metadata : undefined,
};
});
return messages.filter((message): message is ParsedMessage => message !== null);
})
.filter((message): message is ParsedMessage => message !== null);
}
export function hasRenderableConversation(requestBody?: unknown, responseBody?: unknown): boolean {
const requestMessages = normalizeMessages(normalizePayload(requestBody));
const responseMessages = normalizeAssistantMessages(normalizePayload(responseBody));
return requestMessages.length > 0 || responseMessages.length > 0;
function buildSummaryItems(
request: ReturnType<typeof parseRequest>,
response: ReturnType<typeof parseResponse>,
): MetaItem[] {
const items: MetaItem[] = [];
if (typeof request?.model === 'string') {
items.push({ key: 'Model', value: request.model });
}
if (request?.temperature !== undefined) {
items.push({ key: 'Temp', value: String(request.temperature) });
}
if (typeof response?.created === 'number') {
items.push({ key: 'Created', value: String(response.created) });
}
const usage = response?.usage;
if (usage) {
if (usage.prompt_tokens !== undefined) {
items.push({ key: 'Prompt', value: String(usage.prompt_tokens) });
}
if (usage.completion_tokens !== undefined) {
items.push({ key: 'Completion', value: String(usage.completion_tokens) });
}
if (usage.total_tokens !== undefined) {
items.push({ key: 'Total', value: String(usage.total_tokens) });
}
}
return items;
}
const roleTone: Record<KnownChatRole, StatusTone> = {
/*
* Public helpers + component
* */
const ROLE_TONES: Record<KnownChatRole, StatusTone> = {
system: 'info',
user: 'warning',
assistant: 'success',
};
function getRoleTone(role: string): StatusTone {
if (role === 'system' || role === 'user' || role === 'assistant') {
return roleTone[role];
}
return 'neutral';
function isKnownRole(role: string): role is KnownChatRole {
return role === 'system' || role === 'user' || role === 'assistant';
}
function getRoleClass(role: string): string {
if (role === 'system' || role === 'user' || role === 'assistant') {
return `ui-conversation__turn--${role}`;
}
return 'ui-conversation__turn--unknown';
const getRoleTone = (role: string): StatusTone =>
isKnownRole(role) ? ROLE_TONES[role] : 'neutral';
const getRoleClass = (role: string): string =>
isKnownRole(role)
? `ui-conversation__turn--${role}`
: 'ui-conversation__turn--unknown';
export function hasRenderableConversation(
requestBody?: unknown,
responseBody?: unknown,
): boolean {
const requestMessages = extractRequestMessages(parseRequest(requestBody));
const responseMessages = extractResponseMessages(parseResponse(responseBody));
return requestMessages.length > 0 || responseMessages.length > 0;
}
export function ConversationTimeline(props: ConversationTimelineProps) {
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
const responseMessages = createMemo(() => normalizeAssistantMessages(parsedResponse()));
const messages = createMemo(() => [...requestMessages(), ...responseMessages()]);
const summaryItems = createMemo(() => {
const request = parsedRequest();
const response = parsedResponse();
const usage = response?.usage && typeof response.usage === 'object' ? response.usage as Record<string, unknown> : null;
return [
typeof request?.model === 'string' ? { key: 'Model', value: request.model } : null,
request?.temperature !== undefined ? { key: 'Temp', value: String(request.temperature) } : null,
typeof response?.created === 'number' ? { key: 'Created', value: String(response.created) } : null,
usage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(usage.prompt_tokens) } : null,
usage?.completion_tokens !== undefined ? { key: 'Completion', value: String(usage.completion_tokens) } : null,
usage?.total_tokens !== undefined ? { key: 'Total', value: String(usage.total_tokens) } : null,
].filter((item): item is { key: string; value: string } => Boolean(item));
});
export const ConversationTimeline: Component<ConversationTimelineProps> = (
props,
) => {
// Inline derivations — Solid's reactive prop reads make extra createMemo
// wrappers unnecessary for cheap shape transforms like these.
const request = () => parseRequest(props.requestBody);
const response = () => parseResponse(props.responseBody);
const messages = () => [
...extractRequestMessages(request()),
...extractResponseMessages(response()),
];
const summaryItems = () => buildSummaryItems(request(), response());
return (
<div class="ui-conversation">
@ -148,16 +223,27 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
</Show>
<Show
fallback={
<div class="ui-conversation__empty">
{props.emptyMessage ??
'No parsed conversation available for this log.'}
</div>
}
when={messages().length > 0}
fallback={<div class="ui-conversation__empty">{props.emptyMessage ?? 'No parsed conversation available for this log.'}</div>}
>
<div class="ui-conversation__list">
<For each={messages()}>
{(message, index) => (
<article class={`ui-conversation__turn ${getRoleClass(message.role)}`}>
<article
class={`ui-conversation__turn ${getRoleClass(message.role)}`}
>
<header class="ui-conversation__turn-header">
<StatusBadge tone={getRoleTone(message.role)}>{message.role}</StatusBadge>
<span class="ui-conversation__turn-index">Turn {index() + 1}</span>
<StatusBadge tone={getRoleTone(message.role)}>
{message.role}
</StatusBadge>
<span class="ui-conversation__turn-index">
Turn {index() + 1}
</span>
</header>
<div class="ui-conversation__bubble">
<pre class="ui-conversation__content">{message.content}</pre>
@ -174,4 +260,4 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
</Show>
</div>
);
}
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,8 @@
import * as KSelect from '@kobalte/core/select';
import { Show, createMemo } from 'solid-js';
import * as SelectPrimitive from '@kobalte/core/select';
import Check from 'lucide-solid/icons/check';
import ChevronDown from 'lucide-solid/icons/chevron-down';
import { Show, type Component } from 'solid-js';
import { cn } from '../lib/cn';
export interface SelectOption {
@ -17,39 +20,53 @@ interface SelectProps {
onChange?: (value: string) => void;
}
export function Select(props: SelectProps) {
const selected = createMemo(() => props.options.find((option) => option.value === props.value));
export const Select: Component<SelectProps> = (props) => {
// Note: derive `selected` inline (no createMemo) — Solid's reactive primitives
// already cache prop reads, and `find` over a typically tiny option list is
// cheaper than the memo bookkeeping.
const selected = () =>
props.options.find((option) => option.value === props.value);
return (
<KSelect.Root<SelectOption>
<SelectPrimitive.Root<SelectOption>
class={cn('ui-select', props.class)}
options={props.options}
optionValue="value"
optionTextValue="label"
value={selected()}
placeholder={props.placeholder ?? 'Select'}
onChange={(option) => props.onChange?.(option?.value ?? '')}
itemComponent={(itemProps) => (
<KSelect.Item item={itemProps.item} class="ui-select__item">
<KSelect.ItemLabel>{itemProps.item.rawValue.label}</KSelect.ItemLabel>
<KSelect.ItemIndicator></KSelect.ItemIndicator>
</KSelect.Item>
<SelectPrimitive.Item class="ui-select__item" item={itemProps.item}>
<SelectPrimitive.ItemLabel>
{itemProps.item.rawValue.label}
</SelectPrimitive.ItemLabel>
<SelectPrimitive.ItemIndicator class="ui-select__item-indicator">
<Check aria-hidden="true" size={14} />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)}
onChange={(option) => props.onChange?.(option?.value ?? '')}
optionTextValue="label"
optionValue="value"
options={props.options}
placeholder={props.placeholder ?? 'Select'}
value={selected()}
>
<Show when={props.label}>
<KSelect.Label class="ui-field__label">{props.label}</KSelect.Label>
<SelectPrimitive.Label class="ui-field__label">
{props.label}
</SelectPrimitive.Label>
</Show>
<KSelect.Trigger class="ui-select__trigger">
<KSelect.Value<SelectOption> class="ui-select__value">
{(state) => state.selectedOption()?.label ?? props.placeholder ?? 'Select'}
</KSelect.Value>
<KSelect.Icon></KSelect.Icon>
</KSelect.Trigger>
<KSelect.Portal>
<KSelect.Content class="ui-select__content">
<KSelect.Listbox />
</KSelect.Content>
</KSelect.Portal>
</KSelect.Root>
<SelectPrimitive.Trigger class="ui-select__trigger">
<SelectPrimitive.Value<SelectOption> class="ui-select__value">
{(state) =>
state.selectedOption()?.label ?? props.placeholder ?? 'Select'
}
</SelectPrimitive.Value>
<SelectPrimitive.Icon class="ui-select__icon">
<ChevronDown aria-hidden="true" size={14} />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content class="ui-select__content">
<SelectPrimitive.Listbox />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
}
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,32 @@
@import 'tailwindcss';
@import './styles/tokens.css';
@import './styles/base.css';
@import './styles/primitives.css';
@import './styles/patterns.css';
@import './styles/layout.css';
@import './styles/pages.css';
/*
* Tailwind v4 theme bridging expose existing CSS custom properties as theme tokens
* so utility classes (e.g. `text-(--color-text)`, `bg-(--color-surface)`) and the
* `theme()` function resolve to the same design tokens that the legacy stylesheets use.
*/
@theme inline {
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
--color-text: var(--color-text);
--color-text-muted: var(--color-text-muted);
--color-accent: var(--color-accent);
--color-success: var(--color-success);
--color-warning: var(--color-warning);
--color-danger: var(--color-danger);
--color-border: var(--color-border);
--radius-1: var(--radius-1);
--radius-2: var(--radius-2);
--radius-3: var(--radius-3);
--font-sans: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
--font-mono: var(--font-mono, ui-monospace, monospace);
}

9
client/src/vite-env.d.ts vendored Normal file
View file

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

View file

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

204
eslint.config.mjs Normal file
View file

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

View file

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

3577
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
packages:
- shared
- server
- client

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const serverRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(serverRoot, '..');
const sourceDir = path.join(repoRoot, 'database');
const targetDir = path.join(serverRoot, 'dist', 'database');
fs.mkdirSync(targetDir, { recursive: true });
for (const fileName of ['schema.sql', 'analytics-schema.sql', 'request-logs-schema.sql']) {
fs.copyFileSync(path.join(sourceDir, fileName), path.join(targetDir, fileName));
}

View file

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

View file

@ -1,47 +1,41 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { ensureDir, getAnalyticsDbPath } from './db-paths';
import fs from 'node:fs';
import path from 'node:path';
let db: Database.Database;
import Database from 'better-sqlite3';
import { ensureDir, getAnalyticsDbPath, getSchemaPath } from './db-paths';
let db: Database.Database | undefined;
function loadSchema(database: Database.Database): void {
const schema = fs.readFileSync(
getSchemaPath('analytics-schema.sql'),
'utf-8',
);
database.exec(schema);
}
function openDb(): Database.Database {
const analyticsDbPath = getAnalyticsDbPath();
ensureDir(path.dirname(analyticsDbPath));
const handle = new Database(analyticsDbPath);
handle.pragma('foreign_keys = ON');
loadSchema(handle);
return handle;
}
export function getAnalyticsDb(): Database.Database {
if (!db) {
const analyticsDbPath = getAnalyticsDbPath();
ensureDir(path.dirname(analyticsDbPath));
db = new Database(analyticsDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
}
db ??= openDb();
return db;
}
export function initAnalyticsDb(): Database.Database {
// Close existing connection if any
if (db) {
db.close();
}
const analyticsDbPath = getAnalyticsDbPath();
ensureDir(path.dirname(analyticsDbPath));
db = new Database(analyticsDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
db?.close();
db = openDb();
return db;
}
export function closeAnalyticsDb(): void {
if (db) {
db.close();
db = undefined as unknown as Database.Database;
}
db?.close();
db = undefined;
}

View file

@ -1,60 +1,70 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { ensureDir, getCoreDbPath } from './db-paths';
let db: Database.Database;
import { ensureDir, getCoreDbPath, getSchemaPath } from './db-paths';
function hasColumn(database: Database.Database, tableName: string, columnName: string): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
// Lazy singleton — `getDb()` instantiates on first access, `closeDb()` resets
// it back to `undefined` so the next `getDb()` reopens a fresh handle.
let db: Database.Database | undefined;
function isPragmaColumnRow(value: unknown): value is { name: string } {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string'
);
}
function hasColumn(
database: Database.Database,
tableName: string,
columnName: string,
): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
return columns.some(
(column) => isPragmaColumnRow(column) && column.name === columnName,
);
}
function runCoreMigrations(database: Database.Database): void {
if (hasColumn(database, 'model_rewrites', 'force') === false) {
database.exec('ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0');
database.exec(
'ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0',
);
}
}
function loadSchema(database: Database.Database): void {
const schema = fs.readFileSync(getSchemaPath('schema.sql'), 'utf-8');
database.exec(schema);
}
function openDb(): Database.Database {
const coreDbPath = getCoreDbPath();
ensureDir(path.dirname(coreDbPath));
const handle = new Database(coreDbPath);
handle.pragma('foreign_keys = ON');
loadSchema(handle);
runCoreMigrations(handle);
return handle;
}
export function getDb(): Database.Database {
if (!db) {
const coreDbPath = getCoreDbPath();
ensureDir(path.dirname(coreDbPath));
db = new Database(coreDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
runCoreMigrations(db);
}
db ??= openDb();
return db;
}
export function initDb(): Database.Database {
// Close existing connection if any
if (db) {
db.close();
}
const coreDbPath = getCoreDbPath();
ensureDir(path.dirname(coreDbPath));
db = new Database(coreDbPath);
db.pragma('foreign_keys = ON');
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
runCoreMigrations(db);
db?.close();
db = openDb();
return db;
}
export function closeDb(): void {
if (db) {
db.close();
db = undefined as unknown as Database.Database;
}
db?.close();
db = undefined;
}

View file

@ -1,10 +1,31 @@
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
import { env } from './env';
/*
* Schema files (the .sql sources baked into the repo)
*
* Resolved relative to *this module's URL* counted in one place so the
* other DB modules don't each carry their own `'..'/'..'/'..'/'database'`
* path math. Anchoring on `import.meta.url` also means it doesn't care
* whether we're running from src (tsx) or some future bundler output, as
* long as this file's relative position to `<repo>/database/` is preserved.
* */
const SCHEMA_DIR_URL = new URL('../../../database/', import.meta.url);
export function getSchemaPath(filename: string): string {
return fileURLToPath(new URL(filename, SCHEMA_DIR_URL));
}
/*
* Runtime data directories (configurable via env.DB_DIR)
* */
export function getDbRootDir(): string {
return process.env.DB_DIR || process.env.DB_PATH || DEFAULT_DB_DIR;
return env.DB_DIR;
}
export function ensureDir(dirPath: string): void {
@ -28,4 +49,3 @@ export function getRequestLogsDir(): string {
export function getRequestLogsDbPath(monthKey: string): string {
return path.join(getRequestLogsDir(), `request_logs_${monthKey}.db`);
}

165
server/src/config/env.ts Normal file
View file

@ -0,0 +1,165 @@
/**
* Single source of truth for every environment variable the server reads.
*
* - All `process.env` access is centralised here.
* - Each value is parsed/validated/normalised once at module load and exposed
* via `env`, an immutable object.
* - Importers should grab a typed value (`env.SERVER_PORT`) instead of touching
* `process.env` directly. This makes mistakes loud (typos surface as TS
* errors) and concentrates default values in one place.
*
* Tests can mutate the underlying `process.env` before importing this module
* (see tests/setup.ts) values that may legitimately change at runtime are
* exposed as functions instead of frozen primitives.
*/
import path from 'node:path';
import type { AdminAuthMode } from '../../../shared/types';
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
const DEFAULT_TIME_ZONE = 'UTC';
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
const DEFAULT_CORS_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:3002',
'http://127.0.0.1:3002',
];
function trimmed(value: string | undefined): string | undefined {
const result = value?.trim();
return result && result.length > 0 ? result : undefined;
}
function parseList(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function parsePositiveNumber(
value: string | undefined,
fallback: number,
): number {
if (!value) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parseNonNegativeNumber(
value: string | undefined,
fallback: number,
): number {
if (!value) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function normalizeAuthMode(value: string | undefined): AdminAuthMode {
return value === 'env' || value === 'oidc' || value === 'both'
? value
: 'both';
}
/*
* Eagerly-resolved values (read once at boot)
* */
export const env = {
// Server runtime
get SERVER_PORT(): number {
return parsePositiveNumber(process.env.SERVER_PORT, 3000);
},
get NODE_ENV(): string | undefined {
return process.env.NODE_ENV;
},
get IS_PRODUCTION(): boolean {
return process.env.NODE_ENV === 'production';
},
// Storage paths
get DB_DIR(): string {
return (
trimmed(process.env.DB_DIR) ??
trimmed(process.env.DB_PATH) ??
DEFAULT_DB_DIR
);
},
// Time zone (used for daily/monthly bucket math)
get TIME_ZONE(): string {
return trimmed(process.env.TZ) ?? DEFAULT_TIME_ZONE;
},
// CORS
get CORS_ORIGINS(): string[] {
const raw = trimmed(process.env.CORS_ORIGINS);
return raw
? raw.split(',').map((origin) => origin.trim())
: DEFAULT_CORS_ORIGINS;
},
// Admin auth mode + ENV credentials
get ADMIN_AUTH_MODE(): AdminAuthMode {
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
},
get ADMIN_USERNAME(): string | null {
return trimmed(process.env.ADMIN_USERNAME) ?? null;
},
get ADMIN_PASSWORD_HASH(): string | null {
return trimmed(process.env.ADMIN_PASSWORD_HASH) ?? null;
},
get ADMIN_SESSION_SECRET(): string {
return (
trimmed(process.env.ADMIN_SESSION_SECRET) ??
'development-admin-session-secret'
);
},
get ADMIN_SESSION_TTL_HOURS(): number {
return parsePositiveNumber(process.env.ADMIN_SESSION_TTL_HOURS, 12);
},
get ADMIN_API_TOKEN_TTL_DAYS(): number {
return parsePositiveNumber(process.env.ADMIN_API_TOKEN_TTL_DAYS, 30);
},
get ADMIN_COOKIE_SECURE(): boolean {
return (
process.env.NODE_ENV === 'production' &&
process.env.ADMIN_COOKIE_SECURE !== 'false'
);
},
get ADMIN_TRUSTED_PROXY_IPS(): string[] {
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
},
// OIDC
get OIDC_ISSUER_URL(): string {
return trimmed(process.env.OIDC_ISSUER_URL) ?? '';
},
get OIDC_CLIENT_ID(): string {
return trimmed(process.env.OIDC_CLIENT_ID) ?? '';
},
get OIDC_CLIENT_SECRET(): string {
return trimmed(process.env.OIDC_CLIENT_SECRET) ?? '';
},
get OIDC_REDIRECT_URI(): string {
return trimmed(process.env.OIDC_REDIRECT_URI) ?? '';
},
get OIDC_SCOPES(): string {
return trimmed(process.env.OIDC_SCOPES) ?? 'openid profile email';
},
get OIDC_ALLOWED_EMAILS(): string[] {
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) =>
entry.toLowerCase(),
);
},
// Catalog refresh
get MODEL_CATALOG_REFRESH_MIN_MS(): number {
return parseNonNegativeNumber(
process.env.MODEL_CATALOG_REFRESH_MIN_MS,
DEFAULT_REFRESH_MIN_MS,
);
},
};

View file

@ -1,30 +1,55 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { ensureDir, getRequestLogsDbPath, getRequestLogsDir } from './db-paths';
import {
ensureDir,
getRequestLogsDbPath,
getRequestLogsDir,
getSchemaPath,
} from './db-paths';
import { getLocalMonthKey } from '../utils/time';
const connections = new Map<string, Database.Database>();
function hasColumn(database: Database.Database, tableName: string, columnName: string): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
function isPragmaColumnRow(value: unknown): value is { name: string } {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string'
);
}
function hasColumn(
database: Database.Database,
tableName: string,
columnName: string,
): boolean {
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
return columns.some(
(column) => isPragmaColumnRow(column) && column.name === columnName,
);
}
function initRequestLogsSchema(db: Database.Database): void {
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'request-logs-schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
const schema = fs.readFileSync(
getSchemaPath('request-logs-schema.sql'),
'utf-8',
);
db.exec(schema);
if (hasColumn(db, 'request_logs', 'routed_model') === false) {
if (!hasColumn(db, 'request_logs', 'routed_model')) {
db.exec('ALTER TABLE request_logs ADD COLUMN routed_model TEXT');
}
}
export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
export function getRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
return existing;
}
if (existing) return existing;
const dbPath = getRequestLogsDbPath(monthKey);
ensureDir(path.dirname(dbPath));
@ -35,24 +60,24 @@ export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Databas
return db;
}
export function initRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
existing.close();
connections.delete(monthKey);
}
export function initRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
connections.get(monthKey)?.close();
connections.delete(monthKey);
return getRequestLogsDb(monthKey);
}
const REQUEST_LOG_FILENAME_PATTERN = /^request_logs_(\d{4}-\d{2})\.db$/;
export function listRequestLogMonths(): string[] {
const requestLogsDir = getRequestLogsDir();
ensureDir(requestLogsDir);
return fs
.readdirSync(requestLogsDir)
.map((entry) => /^request_logs_(\d{4}-\d{2})\.db$/.exec(entry)?.[1])
.filter((value): value is string => Boolean(value))
.map((entry) => REQUEST_LOG_FILENAME_PATTERN.exec(entry)?.[1])
.filter((value): value is string => value !== undefined)
.sort((a, b) => b.localeCompare(a));
}

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

3
server/src/reset.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
// Activate ts-reset's improved built-in types globally for the server.
// See https://www.totaltypescript.com/ts-reset
import '@total-typescript/ts-reset';

View file

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

View file

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

View file

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

View file

@ -1,5 +1,14 @@
import { Router, Request, Response } from 'express';
import { authenticate, AuthenticatedRequest } from './auth';
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
import { zValidator } from '@hono/zod-validator';
import { stream } from 'hono/streaming';
import {
ChatCompletionRequestSchema,
type ChatCompletionRequest as ChatCompletionRequestType,
} from '@kyush/shared';
import { authenticate } from './auth';
import { BackendModel } from '../models/Backend';
import { RouterService } from '../services/RouterService';
import { AnalyticsService } from '../services/AnalyticsService';
@ -7,334 +16,587 @@ import { ScriptEngine } from '../services/ScriptEngine';
import { logger } from '../utils/logger';
import { ModelCatalogService } from '../services/ModelCatalogService';
const router: Router = Router();
import {
ChatCompletionRequest,
ChatCompletionResponse,
ModelListResponse,
ModelNotAvailableResponse,
} from '../schemas/v1';
import { ErrorResponse } from '../schemas/common';
router.use(authenticate);
import type { AppEnv } from '../types/hono';
function normalizeHeaders(headers: Request['headers']): Record<string, string> {
return Object.entries(headers).reduce<Record<string, string>>((acc, [key, value]) => {
if (Array.isArray(value)) {
acc[key] = value.join(', ');
} else if (typeof value === 'string') {
acc[key] = value;
}
return acc;
}, {});
const router = new OpenAPIHono<AppEnv>();
router.use('*', authenticate);
function normalizeHeaders(
headers: Record<string, string | string[] | undefined>,
): Record<string, string> {
return Object.entries(headers).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (Array.isArray(value)) {
acc[key] = value.join(', ');
} else if (typeof value === 'string') {
acc[key] = value;
}
return acc;
},
{},
);
}
router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response) => {
const startTime = Date.now();
const user = req.user!;
const allowedBackendIds = req.allowedBackendIds!;
function getRequestHeaders(c: {
req: { raw: Request };
}): Record<string, string> {
const out: Record<string, string> = {};
c.req.raw.headers.forEach((value, key) => {
out[key] = value;
});
return out;
}
if (allowedBackendIds.length === 0) {
res.status(403).json({ error: 'No backends available for your account' });
return;
interface CompletionUsageShape {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
}
interface CompletionMetadata {
model?: string;
usage?: CompletionUsageShape;
}
/**
* Pull `model` and `usage` out of an upstream chat-completion JSON body
* without trusting the shape. Type guards keep this honest no `as` cast
* fallbacks for the structurally narrow lookups we need for analytics logging.
*/
interface ErrorDetails {
error: string;
cause: string;
backend: string;
}
function extractErrorDetails(data: unknown): ErrorDetails {
const result: ErrorDetails = {
error: 'Unknown error',
cause: '',
backend: '',
};
if (!isObject(data)) return result;
if (typeof data.error === 'string') {
result.error = data.error;
}
const requestedModel = typeof req.body?.model === 'string' ? req.body.model : '';
await ModelCatalogService.ensureInitializedForBackends(allowedBackendIds);
const resolution = ModelCatalogService.resolveRequestedModel(requestedModel, allowedBackendIds);
const activeAllowedBackendIds = BackendModel.findActive()
.map((item) => item.id)
.filter((backendId) => allowedBackendIds.includes(backendId));
if (activeAllowedBackendIds.length === 0) {
AnalyticsService.logRequest({
user_id: user.id,
backend_id: 0,
endpoint: '/v1/chat/completions',
request_model: requestedModel,
routed_model: resolution.routedModel,
status_code: 403,
error_message: 'No active backends available',
detail_logged: user.detail_logging,
request_headers: user.detail_logging ? normalizeHeaders(req.headers) : undefined,
request_body: user.detail_logging ? req.body : undefined,
});
res.status(403).json({ error: 'No active backends available' });
return;
if (typeof data.cause === 'string') {
result.cause = ` (Cause: ${data.cause})`;
}
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(resolution.routedModel, allowedBackendIds);
const backend = RouterService.selectBackend(candidateBackendIds);
if (!backend) {
AnalyticsService.logRequest({
user_id: user.id,
backend_id: 0,
endpoint: '/v1/chat/completions',
request_model: resolution.requestedModel,
routed_model: resolution.routedModel,
status_code: 404,
error_message: 'Requested model is not available for your account',
detail_logged: user.detail_logging,
request_headers: user.detail_logging ? normalizeHeaders(req.headers) : undefined,
request_body: user.detail_logging ? req.body : undefined,
});
res.status(404).json({
error: 'Requested model is not available for your account',
request_model: resolution.requestedModel,
routed_model: resolution.routedModel,
});
return;
if (typeof data.backend === 'string') {
result.backend = ` [Backend: ${data.backend}]`;
}
return result;
}
try {
const { model, messages, ...rest } = req.body;
const detailLoggingEnabled = user.detail_logging || backend.detail_logging;
const rewrittenBody = { model: resolution.routedModel, messages, ...rest };
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
const execContext = {
user: { id: user.id, name: user.name, email: user.email },
backend: { id: backend.id, name: backend.name, base_url: backend.base_url },
request: {
method: 'POST',
path: req.path,
headers: {
...normalizeHeaders(req.headers),
'content-type': req.get('content-type') || 'application/json',
},
body: rewrittenBody,
isStream: req.body.stream === true,
function extractCompletionMetadata(data: unknown): CompletionMetadata {
if (!isObject(data)) return {};
const meta: CompletionMetadata = {};
if (typeof data.model === 'string') {
meta.model = data.model;
}
if (isObject(data.usage)) {
const usage = data.usage;
const result: CompletionUsageShape = {};
if (typeof usage.prompt_tokens === 'number') {
result.prompt_tokens = usage.prompt_tokens;
}
if (typeof usage.completion_tokens === 'number') {
result.completion_tokens = usage.completion_tokens;
}
if (typeof usage.total_tokens === 'number') {
result.total_tokens = usage.total_tokens;
}
meta.usage = result;
}
return meta;
}
const chatCompletionsRoute = createRoute({
method: 'post',
path: '/chat/completions',
tags: ['v1'],
security: [{ bearerAuth: [] }],
request: {
body: {
content: {
'application/json': { schema: ChatCompletionRequest },
},
};
required: true,
},
},
responses: {
200: {
description:
'Chat completion (JSON for non-stream, text/event-stream for stream:true)',
content: {
'application/json': { schema: ChatCompletionResponse },
},
},
403: {
description: 'Forbidden',
content: { 'application/json': { schema: ErrorResponse } },
},
404: {
description: 'Requested model not available',
content: { 'application/json': { schema: ModelNotAvailableResponse } },
},
502: {
description: 'Backend error',
content: { 'application/json': { schema: ErrorResponse } },
},
},
});
const { context: modifiedContext, errors: requestErrors } = await ScriptEngine.applyOnRequestScripts(
execContext,
user.id,
backend.id
);
// SSE branch returns a stream Response that doesn't fit the typed-response
// generic `router.openapi()` enforces. We document the route through the
// openapi registry, then register the actual handler via plain `router.post`
// with `zValidator` so the streaming branch is type-checked normally and
// `c.req.valid('json')` is fully typed off the shared schema.
router.openAPIRegistry.registerPath({
...chatCompletionsRoute,
responses: chatCompletionsRoute.responses,
});
if (requestErrors.length > 0) {
logger.warn(`Script warnings for user ${user.id}: ${requestErrors.join('; ')}`);
router.post(
'/chat/completions',
zValidator('json', ChatCompletionRequestSchema),
async (c) => {
const startTime = Date.now();
const user = c.get('user')!;
const allowedBackendIds = c.get('allowedBackendIds')!;
const reqBody: ChatCompletionRequestType = c.req.valid('json');
const requestHeaders = getRequestHeaders(c);
if (allowedBackendIds.length === 0) {
return c.json({ error: 'No backends available for your account' }, 403);
}
// Stream path: pipe SSE response directly to client
if (modifiedContext.request.body && typeof modifiedContext.request.body === 'object' && 'stream' in modifiedContext.request.body && modifiedContext.request.body.stream === true) {
const streamResult = await RouterService.forwardStreamRequest(
const requestedModel =
typeof reqBody.model === 'string' ? reqBody.model : '';
await ModelCatalogService.ensureInitializedForBackends(allowedBackendIds);
const resolution = ModelCatalogService.resolveRequestedModel(
requestedModel,
allowedBackendIds,
);
const activeAllowedBackendIds = BackendModel.findActive()
.map((item) => item.id)
.filter((backendId) => allowedBackendIds.includes(backendId));
if (activeAllowedBackendIds.length === 0) {
AnalyticsService.logRequest({
user_id: user.id,
backend_id: 0,
endpoint: '/v1/chat/completions',
request_model: requestedModel,
routed_model: resolution.routedModel,
status_code: 403,
error_message: 'No active backends available',
detail_logged: user.detail_logging,
request_headers: user.detail_logging
? normalizeHeaders(requestHeaders)
: undefined,
request_body: user.detail_logging ? reqBody : undefined,
});
return c.json({ error: 'No active backends available' }, 403);
}
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(
resolution.routedModel,
allowedBackendIds,
);
const backend = RouterService.selectBackend(candidateBackendIds);
if (!backend) {
AnalyticsService.logRequest({
user_id: user.id,
backend_id: 0,
endpoint: '/v1/chat/completions',
request_model: resolution.requestedModel,
routed_model: resolution.routedModel,
status_code: 404,
error_message: 'Requested model is not available for your account',
detail_logged: user.detail_logging,
request_headers: user.detail_logging
? normalizeHeaders(requestHeaders)
: undefined,
request_body: user.detail_logging ? reqBody : undefined,
});
return c.json(
{
error: 'Requested model is not available for your account',
request_model: resolution.requestedModel,
routed_model: resolution.routedModel,
},
404,
);
}
try {
const { model, messages, ...rest } = reqBody;
const detailLoggingEnabled =
user.detail_logging || backend.detail_logging;
const rewrittenBody = {
model: resolution.routedModel,
messages,
...rest,
};
const execContext = {
user: { id: user.id, name: user.name, email: user.email },
backend: {
id: backend.id,
name: backend.name,
base_url: backend.base_url,
},
request: {
method: 'POST',
path: c.req.path,
headers: {
...normalizeHeaders(requestHeaders),
'content-type': c.req.header('content-type') || 'application/json',
},
body: rewrittenBody,
isStream: reqBody.stream === true,
},
};
const { context: modifiedContext, errors: requestErrors } =
await ScriptEngine.applyOnRequestScripts(
execContext,
user.id,
backend.id,
);
if (requestErrors.length > 0) {
logger.warn(
`Script warnings for user ${user.id}: ${requestErrors.join('; ')}`,
);
}
const isStreamRequest =
modifiedContext.request.body &&
typeof modifiedContext.request.body === 'object' &&
'stream' in modifiedContext.request.body &&
(modifiedContext.request.body as { stream?: boolean }).stream === true;
if (isStreamRequest) {
const streamResult = await RouterService.forwardStreamRequest(
backend,
'/v1/chat/completions',
'POST',
modifiedContext.request.headers,
modifiedContext.request.body,
);
if (!('response' in streamResult)) {
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
status_code: streamResult.status,
response_time_ms: responseTime,
error_message: JSON.stringify(streamResult.data),
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
local_date: undefined,
});
logger.error(
`Backend error for user ${user.id} (stream): ${JSON.stringify(streamResult.data)}`,
);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
return new Response(JSON.stringify(streamResult.data ?? {}), {
status: streamResult.status,
headers: { 'Content-Type': 'application/json' },
});
}
const backendResponse = streamResult.response;
const backendResponseHeaders = Object.fromEntries(
backendResponse.headers.entries(),
);
if (
!backendResponse.headers
.get('content-type')
?.includes('text/event-stream')
) {
const data: unknown = await backendResponse.json().catch(() => ({}));
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
status_code: backendResponse.status,
response_time_ms: responseTime,
error_message:
backendResponse.status >= 400 ? JSON.stringify(data) : undefined,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
response_headers: detailLoggingEnabled
? backendResponseHeaders
: undefined,
response_body: detailLoggingEnabled ? data : undefined,
local_date: undefined,
});
if (backendResponse.status >= 400) {
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
return new Response(JSON.stringify(data ?? {}), {
status: backendResponse.status,
headers: { 'Content-Type': 'application/json' },
});
}
await ScriptEngine.applyOnResponseScripts(
execContext,
{
status: backendResponse.status,
headers: backendResponseHeaders,
body: null,
isStream: true,
},
user.id,
backend.id,
);
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
c.header('Connection', 'keep-alive');
// We've already short-circuited every non-SSE upstream above, so the
// streaming branch is always a successful 200 by the time we get here.
c.status(200);
let responseModel: string | undefined;
let usage:
| {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
}
| undefined;
const collectedChunks: string[] = [];
return stream(c, async (s) => {
const reader = backendResponse.body!.getReader();
const decoder = new TextDecoder();
s.onAbort(() => {
void reader.cancel();
});
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
await s.write(value);
const text = decoder.decode(value, { stream: true });
if (detailLoggingEnabled) collectedChunks.push(text);
for (const line of text.split('\n')) {
if (!line.startsWith('data: ') || line === 'data: [DONE]')
continue;
try {
const parsed = JSON.parse(line.slice(6)) as {
model?: string;
usage?: typeof usage;
};
if (parsed.model && !responseModel)
responseModel = parsed.model;
if (parsed.usage) usage = parsed.usage;
} catch {
/* non-JSON data line, skip */
}
}
}
} catch (err) {
logger.error(
`Stream interrupted for user ${user.id}: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
response_model: responseModel,
prompt_tokens: usage?.prompt_tokens,
completion_tokens: usage?.completion_tokens,
total_tokens: usage?.total_tokens,
status_code: backendResponse.status,
response_time_ms: responseTime,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
response_headers: detailLoggingEnabled
? backendResponseHeaders
: undefined,
response_body: detailLoggingEnabled
? collectedChunks.join('')
: undefined,
local_date: undefined,
});
if (backendResponse.status >= 400) {
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
}
});
}
const response = await RouterService.forwardRequest(
backend,
'/v1/chat/completions',
'POST',
modifiedContext.request.headers,
modifiedContext.request.body
modifiedContext.request.body,
);
// Network error — return JSON error
if (!('response' in streamResult)) {
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
status_code: streamResult.status,
response_time_ms: responseTime,
error_message: JSON.stringify(streamResult.data),
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
local_date: undefined,
});
logger.error(`Backend error for user ${user.id} (stream): ${JSON.stringify(streamResult.data)}`);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
res.status(streamResult.status).json(streamResult.data);
return;
}
const backendResponse = streamResult.response;
const backendResponseHeaders = Object.fromEntries(backendResponse.headers.entries());
// Backend returned non-SSE response (e.g. JSON error)
if (!backendResponse.headers.get('content-type')?.includes('text/event-stream')) {
const data = await backendResponse.json().catch(() => ({}));
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
status_code: backendResponse.status,
response_time_ms: responseTime,
error_message: backendResponse.status >= 400 ? JSON.stringify(data) : undefined,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
response_headers: detailLoggingEnabled ? backendResponseHeaders : undefined,
response_body: detailLoggingEnabled ? data : undefined,
local_date: undefined,
});
if (backendResponse.status >= 400) {
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
res.status(backendResponse.status).json(data);
return;
}
// onResponse scripts (body not available for streams)
await ScriptEngine.applyOnResponseScripts(
execContext,
{ status: backendResponse.status, headers: backendResponseHeaders, body: null, isStream: true },
user.id,
backend.id
);
// Set SSE headers and start piping
res.status(backendResponse.status);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const reader = backendResponse.body!.getReader();
const decoder = new TextDecoder();
req.on('close', () => reader.cancel());
let responseModel: string | undefined;
let usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | undefined;
const collectedChunks: string[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
// Parse SSE chunks for model and usage metadata
const text = decoder.decode(value, { stream: true });
if (detailLoggingEnabled) collectedChunks.push(text);
for (const line of text.split('\n')) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.model && !responseModel) responseModel = parsed.model;
if (parsed.usage) usage = parsed.usage;
} catch { /* non-JSON data line, skip */ }
}
}
} catch (err) {
logger.error(`Stream interrupted for user ${user.id}: ${err instanceof Error ? err.message : String(err)}`);
} finally {
res.end();
}
const responseTime = Date.now() - startTime;
const responseContext = {
status: response.status,
headers: response.headers,
body: response.data,
isStream: false,
};
await ScriptEngine.applyOnResponseScripts(
execContext,
responseContext,
user.id,
backend.id,
);
const completionMeta = extractCompletionMetadata(response.data);
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
response_model: responseModel,
prompt_tokens: usage?.prompt_tokens,
completion_tokens: usage?.completion_tokens,
total_tokens: usage?.total_tokens,
status_code: backendResponse.status,
response_model: completionMeta.model,
prompt_tokens: completionMeta.usage?.prompt_tokens,
completion_tokens: completionMeta.usage?.completion_tokens,
total_tokens: completionMeta.usage?.total_tokens,
status_code: response.status,
response_time_ms: responseTime,
error_message:
response.status >= 400 ? JSON.stringify(response.data) : undefined,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
response_headers: detailLoggingEnabled ? backendResponseHeaders : undefined,
response_body: detailLoggingEnabled ? collectedChunks.join('') : undefined,
request_headers: detailLoggingEnabled
? modifiedContext.request.headers
: undefined,
request_body: detailLoggingEnabled
? modifiedContext.request.body
: undefined,
response_headers: detailLoggingEnabled ? response.headers : undefined,
response_body: detailLoggingEnabled ? response.data : undefined,
local_date: undefined,
});
if (backendResponse.status >= 400) {
if (response.status >= 400) {
const details = extractErrorDetails(response.data);
logger.error(
`Backend error for user ${user.id}: ${details.error}${details.cause}${details.backend}`,
);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
}
return;
}
// Non-stream path: buffer and return JSON (unchanged)
const response = await RouterService.forwardRequest(
backend,
'/v1/chat/completions',
'POST',
modifiedContext.request.headers,
modifiedContext.request.body
);
// The upstream JSON body comes back as `unknown`; build a Response by
// hand so we don't lean on `c.json`'s typed status union.
return new Response(JSON.stringify(response.data ?? {}), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
const responseTime = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model:
typeof reqBody.model === 'string' ? reqBody.model : undefined,
routed_model: resolution.routedModel,
status_code: 502,
response_time_ms: responseTime,
error_message: errorMsg,
detail_logged: user.detail_logging || backend.detail_logging,
request_headers:
user.detail_logging || backend.detail_logging
? normalizeHeaders(requestHeaders)
: undefined,
request_body:
user.detail_logging || backend.detail_logging ? reqBody : undefined,
response_headers: undefined,
response_body: undefined,
local_date: undefined,
});
const responseContext = {
status: response.status,
headers: response.headers,
body: response.data,
isStream: false,
};
await ScriptEngine.applyOnResponseScripts(
execContext,
responseContext,
user.id,
backend.id
);
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
routed_model: resolution.routedModel,
response_model: response.data && typeof response.data === 'object' && 'model' in response.data ? String(response.data.model) : undefined,
prompt_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { prompt_tokens?: number } }).usage === 'object' ? (response.data as { usage: { prompt_tokens: number } }).usage?.prompt_tokens : undefined,
completion_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { completion_tokens?: number } }).usage === 'object' ? (response.data as { usage: { completion_tokens: number } }).usage?.completion_tokens : undefined,
total_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { total_tokens?: number } }).usage === 'object' ? (response.data as { usage: { total_tokens: number } }).usage?.total_tokens : undefined,
status_code: response.status,
response_time_ms: responseTime,
error_message: response.status >= 400 ? JSON.stringify(response.data) : undefined,
detail_logged: detailLoggingEnabled,
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
response_headers: detailLoggingEnabled ? response.headers : undefined,
response_body: detailLoggingEnabled ? response.data : undefined,
local_date: undefined,
});
if (response.status >= 400) {
const errorDetails = response.data as any;
const errorInfo = errorDetails.error || 'Unknown error';
const causeInfo = errorDetails.cause ? ` (Cause: ${errorDetails.cause})` : '';
const backendInfo = errorDetails.backend ? ` [Backend: ${errorDetails.backend}]` : '';
logger.error(`Backend error for user ${user.id}: ${errorInfo}${causeInfo}${backendInfo}`);
logger.error(`Request failed for user ${user.id}: ${errorMsg}`);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
return c.json({ error: 'Backend request failed', cause: errorMsg }, 502);
}
},
);
res.status(response.status).json(response.data);
} catch (error) {
const responseTime = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: req.body.model,
routed_model: resolution.routedModel,
status_code: 502,
response_time_ms: responseTime,
error_message: errorMsg,
detail_logged: user.detail_logging || backend.detail_logging,
request_headers: user.detail_logging || backend.detail_logging ? normalizeHeaders(req.headers) : undefined,
request_body: user.detail_logging || backend.detail_logging ? req.body : undefined,
response_headers: undefined,
response_body: undefined,
local_date: undefined,
});
logger.error(`Request failed for user ${user.id}: ${errorMsg}`);
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
res.status(502).json({ error: 'Backend request failed', details: errorMsg });
}
const modelsRoute = createRoute({
method: 'get',
path: '/models',
tags: ['v1'],
security: [{ bearerAuth: [] }],
responses: {
200: {
description: 'Models accessible to the authenticated user',
content: { 'application/json': { schema: ModelListResponse } },
},
403: {
description: 'No backends available',
content: { 'application/json': { schema: ErrorResponse } },
},
},
});
router.get('/models', async (req: AuthenticatedRequest, res: Response) => {
const allowedBackendIds = req.allowedBackendIds!;
router.openapi(modelsRoute, async (c) => {
const allowedBackendIds = c.get('allowedBackendIds')!;
if (allowedBackendIds.length === 0) {
res.status(403).json({ error: 'No backends available for your account' });
return;
return c.json({ error: 'No backends available for your account' }, 403);
}
await ModelCatalogService.ensureInitializedForBackends(allowedBackendIds);
@ -342,14 +604,18 @@ router.get('/models', async (req: AuthenticatedRequest, res: Response) => {
.map((item) => item.id)
.filter((backendId) => allowedBackendIds.includes(backendId));
if (activeAllowedBackendIds.length === 0) {
res.status(403).json({ error: 'No active backends available' });
return;
return c.json({ error: 'No active backends available' }, 403);
}
const models = ModelCatalogService.getModelsForAllowedBackends(activeAllowedBackendIds).map((entry) => ({
const models = ModelCatalogService.getModelsForAllowedBackends(
activeAllowedBackendIds,
).map((entry) => ({
id: entry.model_id,
object: 'model',
object: 'model' as const,
}));
res.json({ object: 'list', data: models });
return c.json({ object: 'list' as const, data: models }, 200);
});
// Re-export z for completeness (used by other modules importing from this route file).
export { z };
export default router;

View file

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

View file

@ -1,232 +1,162 @@
import { Router, Request, Response } from 'express';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import {
CreateScriptInputSchema,
ScriptTestInputSchema,
UpdateScriptInputSchema,
} from '@kyush/shared';
import { ScriptModel } from '../models/Script';
import { UserModel } from '../models/User';
import { BackendModel } from '../models/Backend';
import { CompiledScript } from '../services/ScriptExecutor';
import { CreateScriptData, UpdateScriptData, ScriptContextData } from '../../../shared/types';
const router: Router = Router();
import type { ScriptContextData } from '../../../shared/types';
import type { AppEnv } from '../types/hono';
// ============ Script Management ============
const router = new Hono<AppEnv>();
router.get('/', (req: Request, res: Response) => {
const scripts = ScriptModel.findAll();
res.json(scripts);
router.get('/', (c) => c.json(ScriptModel.findAll()));
router.get('/active', (c) => c.json(ScriptModel.findActive()));
router.get('/type/:type', (c) => {
const scriptType = c.req.param('type');
return c.json(ScriptModel.findByScriptType(scriptType));
});
router.get('/active', (req: Request, res: Response) => {
const scripts = ScriptModel.findActive();
res.json(scripts);
});
router.get('/type/:type', (req: Request, res: Response) => {
const scriptType = String(req.params.type);
const scripts = ScriptModel.findByScriptType(scriptType);
res.json(scripts);
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.get('/:id', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
res.json(script);
if (!script) return c.json({ error: 'Script not found' }, 404);
return c.json(script);
});
router.post('/', (req: Request, res: Response) => {
const { name, script_type, target_user_id, target_backend_id, script_code, is_active } = req.body as CreateScriptData;
if (!name || !script_type || !script_code) {
res.status(400).json({ error: 'name, script_type, and script_code are required' });
return;
}
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
res.status(400).json({ error: 'target_user_id and target_backend_id are required for per-user-backend scripts' });
return;
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
res.status(400).json({ error: 'target_backend_id is required for per-backend scripts' });
return;
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
res.status(400).json({ error: 'target_user_id is required for per-user scripts' });
return;
}
}
router.post('/', zValidator('json', CreateScriptInputSchema), (c) => {
const data = c.req.valid('json');
try {
const script = ScriptModel.create({
name,
script_type,
target_user_id: target_user_id ?? null,
target_backend_id: target_backend_id ?? null,
script_code,
is_active: is_active ?? true,
name: data.name,
script_type: data.script_type,
target_user_id: data.target_user_id ?? null,
target_backend_id: data.target_backend_id ?? null,
script_code: data.script_code,
is_active: data.is_active ?? true,
});
res.status(201).json(script);
return c.json(script, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
return;
} else {
console.error('Unexpected error creating script:', error);
res.status(500).json({ error: 'Failed to create script' });
return c.json({ error: error.message }, 409);
}
console.error('Unexpected error creating script:', error);
return c.json({ error: 'Failed to create script' }, 500);
}
});
router.put('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.put('/:id', zValidator('json', UpdateScriptInputSchema), (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) return c.json({ error: 'Script not found' }, 404);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
const data = c.req.valid('json');
const { name, script_type, target_user_id, target_backend_id, script_code, is_active } = req.body as UpdateScriptData;
if (script_type) {
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
res.status(400).json({ error: 'target_user_id and target_backend_id are required for per-user-backend scripts' });
return;
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
res.status(400).json({ error: 'target_backend_id is required for per-backend scripts' });
return;
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
res.status(400).json({ error: 'target_user_id is required for per-user scripts' });
return;
}
// When script_type changes, the target fields must satisfy the discriminated
// shape. The shared CreateScriptInputSchema enforces this on creation; the
// update path mirrors the same checks because UpdateScriptInputSchema accepts
// any combination by design.
if (data.script_type === 'per-user-backend') {
if (!data.target_user_id || !data.target_backend_id) {
return c.json(
{
error:
'target_user_id and target_backend_id are required for per-user-backend scripts',
},
400,
);
}
} else if (data.script_type === 'per-backend' && !data.target_backend_id) {
return c.json(
{ error: 'target_backend_id is required for per-backend scripts' },
400,
);
} else if (data.script_type === 'per-user' && !data.target_user_id) {
return c.json(
{ error: 'target_user_id is required for per-user scripts' },
400,
);
}
const updatedScript = ScriptModel.update(id, {
name,
script_type,
target_user_id,
target_backend_id,
script_code,
is_active,
});
res.json(updatedScript);
const updatedScript = ScriptModel.update(id, data);
return c.json(updatedScript);
});
router.delete('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const success = ScriptModel.delete(id);
if (!success) {
res.status(404).json({ error: 'Script not found' });
return;
router.delete('/:id', (c) => {
const id = Number(c.req.param('id'));
if (!ScriptModel.delete(id)) {
return c.json({ error: 'Script not found' }, 404);
}
res.status(204).send();
return c.body(null, 204);
});
router.post('/:id/activate', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.post('/:id/activate', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
if (!script) return c.json({ error: 'Script not found' }, 404);
if (!ScriptModel.activate(id)) {
return c.json({ error: 'Failed to activate script' }, 500);
}
const success = ScriptModel.activate(id);
if (!success) {
res.status(500).json({ error: 'Failed to activate script' });
return;
}
res.json({ ...script, is_active: true });
return c.json({ ...script, is_active: true });
});
router.post('/:id/deactivate', (req: Request, res: Response) => {
const id = Number(req.params.id);
router.post('/:id/deactivate', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
if (!script) return c.json({ error: 'Script not found' }, 404);
if (!ScriptModel.deactivate(id)) {
return c.json({ error: 'Failed to deactivate script' }, 500);
}
const success = ScriptModel.deactivate(id);
if (!success) {
res.status(500).json({ error: 'Failed to deactivate script' });
return;
}
res.json({ ...script, is_active: false });
return c.json({ ...script, is_active: false });
});
// ============ Script Testing ============
router.post(
'/:id/test',
zValidator('json', ScriptTestInputSchema),
async (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) return c.json({ error: 'Script not found' }, 404);
router.post('/:id/test', async (req: Request, res: Response) => {
const id = Number(req.params.id);
const script = ScriptModel.findById(id);
const body = c.req.valid('json');
const testContext: ScriptContextData = {
user: body.user ?? null,
backend: body.backend ?? null,
request: body.request,
};
if (!script) {
res.status(404).json({ error: 'Script not found' });
return;
}
let compiled: CompiledScript | null = null;
try {
const startTime = Date.now();
compiled = await CompiledScript.compile(script.script_code);
const { user, backend, request } = req.body as {
user?: { id: number; name: string; email?: string };
backend?: { id: number; name: string; base_url: string };
request: { method: string; path: string; headers: Record<string, string>; body: unknown; isStream: boolean };
};
if (compiled.hasOnRequest) await compiled.callOnRequest(testContext);
if (compiled.hasOnResponse) await compiled.callOnResponse(testContext);
if (!request) {
res.status(400).json({ error: 'request is required' });
return;
}
const testContext: ScriptContextData = {
user: user ?? null,
backend: backend ?? null,
request,
};
let compiled: CompiledScript | null = null;
try {
const startTime = Date.now();
compiled = await CompiledScript.compile(script.script_code);
if (compiled.hasOnRequest) {
await compiled.callOnRequest(testContext);
return c.json({
success: true,
executionTime: Date.now() - startTime,
hasOnRequest: compiled.hasOnRequest,
hasOnResponse: compiled.hasOnResponse,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
},
400,
);
} finally {
compiled?.dispose();
}
if (compiled.hasOnResponse) {
await compiled.callOnResponse(testContext);
}
res.json({
success: true,
executionTime: Date.now() - startTime,
hasOnRequest: compiled.hasOnRequest,
hasOnResponse: compiled.hasOnResponse,
});
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : String(error),
});
} finally {
compiled?.dispose();
}
});
},
);
export default router;

View file

@ -0,0 +1,11 @@
import { z } from '@hono/zod-openapi';
import { ErrorResponseSchema } from '@kyush/shared';
export const ErrorResponse = ErrorResponseSchema.openapi('ErrorResponse', {
example: { error: 'Something went wrong' },
});
export type { ErrorResponse as ErrorResponseType } from '@kyush/shared';
// Keep `z` available for any future schema definitions in this file.
export { z };

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

@ -0,0 +1,32 @@
// Importing `z` from `@hono/zod-openapi` patches zod's prototype with `.openapi()`,
// which lets us decorate the shared schemas with OpenAPI metadata without
// duplicating their shape on the server.
import { z } from '@hono/zod-openapi';
import {
ChatCompletionRequestSchema,
ChatCompletionResponseSchema,
ChatMessageSchema,
ModelEntrySchema,
ModelListResponseSchema,
ModelNotAvailableResponseSchema,
} from '@kyush/shared';
// Re-export the shared schemas with OpenAPI annotations attached. The
// underlying zod instances are shared with the client, so any schema change
// applies to both sides simultaneously.
export const ChatMessage = ChatMessageSchema.openapi('ChatMessage');
export const ChatCompletionRequest = ChatCompletionRequestSchema.openapi(
'ChatCompletionRequest',
{ example: { model: 'gpt-4o-mini', messages: [] } },
);
export const ChatCompletionResponse = ChatCompletionResponseSchema.openapi(
'ChatCompletionResponse',
);
export const ModelEntry = ModelEntrySchema.openapi('ModelEntry');
export const ModelListResponse =
ModelListResponseSchema.openapi('ModelListResponse');
export const ModelNotAvailableResponse =
ModelNotAvailableResponseSchema.openapi('ModelNotAvailableResponse');
// Re-export `z` so other server code can keep importing from a single place.
export { z };

View file

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

View file

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

View file

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

View file

@ -1,224 +1,229 @@
import { Backend } from '../../../shared/types';
import { BackendModel } from '../models/Backend';
export class RouterService {
private static prepareRequestBody(body?: unknown): string | Uint8Array | ArrayBuffer | undefined {
if (body === undefined || body === null) {
return undefined;
}
import type { Backend } from '../../../shared/types';
if (typeof body === 'string') {
return body;
}
interface BackendForwardError {
error: string;
cause?: string;
backend: string;
path: string;
}
if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
return body;
}
interface ErrorCauseShape {
code?: string;
errno?: string;
syscall?: string;
address?: string;
hostname?: string;
port?: number;
}
return JSON.stringify(body);
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
/**
* Coerces an unknown error.cause field into the small subset of fields we
* actually look at when classifying upstream connection failures. Falls back
* to an empty object so callers can read fields without nullish guards.
*/
const readErrorCause = (cause: unknown): ErrorCauseShape => {
if (!isObject(cause)) return {};
return {
code: typeof cause.code === 'string' ? cause.code : undefined,
errno: typeof cause.errno === 'string' ? cause.errno : undefined,
syscall: typeof cause.syscall === 'string' ? cause.syscall : undefined,
address:
typeof cause.address === 'string'
? cause.address
: typeof cause.hostname === 'string'
? cause.hostname
: undefined,
port: typeof cause.port === 'number' ? cause.port : undefined,
};
};
/**
* Translates an upstream fetch failure into a structured `{ error, cause }`
* pair. Used by both the JSON and SSE forwarding paths so the message
* surface stays consistent across them.
*/
const classifyForwardError = (
error: unknown,
): { error: string; cause?: string } => {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
if (error instanceof Error && error.cause !== undefined) {
const cause = readErrorCause(error.cause);
const code = cause.code ?? cause.errno;
if (code === 'ECONNREFUSED') {
return {
error: 'Backend connection refused',
cause:
cause.address && cause.port
? `Backend server at ${cause.address}:${cause.port} is not accepting connections`
: 'Backend server is not accepting connections',
};
}
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
return {
error: 'Backend request timeout',
cause: 'Connection to backend timed out',
};
}
if (code === 'ENOTFOUND') {
return {
error: 'Backend unreachable',
cause: cause.address
? `Could not resolve hostname: ${cause.address}`
: 'Could not resolve backend hostname',
};
}
if (code === 'EPIPE' || cause.syscall === 'write') {
return {
error: 'Backend connection lost',
cause: cause.syscall
? `Connection broken during ${cause.syscall} operation`
: 'Connection broken during operation',
};
}
return {
error: 'Backend connection error',
cause: `${code ?? 'Unknown error'} during ${cause.syscall ?? 'connection'}`,
};
}
static selectBackend(allowedBackendIds: number[]): Backend | null {
if (allowedBackendIds.length === 0) {
return null;
}
if (errorMsg.includes('ETIMEDOUT') || errorMsg.includes('ECONNABORTED')) {
return {
error: 'Backend request timeout',
cause: 'Connection timed out after 30s',
};
}
if (errorMsg.includes('aborted')) {
return {
error: 'Request aborted',
cause: 'Request was aborted before completion',
};
}
return { error: 'Failed to forward request to backend', cause: errorMsg };
};
const allBackends = BackendModel.findAll();
const backends = allBackends
.filter(b => (b.is_active === true) && allowedBackendIds.includes(b.id));
const buildForwardErrorPayload = (
backend: Backend,
path: string,
error: unknown,
): BackendForwardError => {
const { error: errorType, cause } = classifyForwardError(error);
return { error: errorType, cause, backend: backend.base_url, path };
};
if (backends.length === 0) {
return null;
}
const prepareRequestBody = (
body?: unknown,
): string | Uint8Array | ArrayBuffer | undefined => {
if (body === undefined || body === null) return undefined;
if (typeof body === 'string') return body;
if (body instanceof Uint8Array || body instanceof ArrayBuffer) return body;
return JSON.stringify(body);
};
const rawFetch = async (
backend: Backend,
path: string,
method: string,
headers: Record<string, string>,
body?: unknown,
): Promise<Response> => {
const backendPath = backend.base_url.includes('/v1')
? path.replace(/^\/v1/, '')
: path;
const backendUrl = backend.base_url.replace(/\/$/, '') + backendPath;
const fetchHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
const preparedBody = prepareRequestBody(body);
// Always let fetch/undici compute Content-Length from the final outgoing body.
delete fetchHeaders['content-length'];
delete fetchHeaders['Content-Length'];
delete fetchHeaders.authorization;
delete fetchHeaders.Authorization;
delete fetchHeaders['content-type'];
delete fetchHeaders['Content-Type'];
if (preparedBody !== undefined) {
fetchHeaders['Content-Type'] = 'application/json';
}
if (backend.api_key) {
fetchHeaders.Authorization = `Bearer ${backend.api_key}`;
}
return fetch(backendUrl, {
method,
headers: fetchHeaders,
body: preparedBody,
});
};
export const RouterService = {
selectBackend(allowedBackendIds: number[]): Backend | null {
if (allowedBackendIds.length === 0) return null;
const backends = BackendModel.findAll().filter(
(b) => b.is_active === true && allowedBackendIds.includes(b.id),
);
if (backends.length === 0) return null;
const roundRobinIndex = Math.floor(Math.random() * backends.length);
return backends[roundRobinIndex];
}
},
private static async rawFetch(
async forwardRequest(
backend: Backend,
path: string,
method: string,
headers: Record<string, string>,
body?: unknown
): Promise<Response> {
let backendPath = path;
if (backend.base_url.includes('/v1')) {
backendPath = path.replace(/^\/v1/, '');
}
const backendUrl = backend.base_url.replace(/\/$/, '') + backendPath;
const fetchHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
const preparedBody = this.prepareRequestBody(body);
// Always let fetch/undici compute Content-Length from the final outgoing body.
delete fetchHeaders['content-length'];
delete fetchHeaders['Content-Length'];
delete fetchHeaders.authorization;
delete fetchHeaders.Authorization;
delete fetchHeaders['content-type'];
delete fetchHeaders['Content-Type'];
if (preparedBody !== undefined) {
fetchHeaders['Content-Type'] = 'application/json';
}
if (backend.api_key) {
fetchHeaders['Authorization'] = `Bearer ${backend.api_key}`;
}
return fetch(backendUrl, {
method,
headers: fetchHeaders,
body: preparedBody,
});
}
static async forwardRequest(
backend: Backend,
path: string,
method: string,
headers: Record<string, string>,
body?: unknown
): Promise<{ status: number; data: unknown; headers: Record<string, string> }> {
body?: unknown,
): Promise<{
status: number;
data: unknown;
headers: Record<string, string>;
}> {
try {
const response = await this.rawFetch(backend, path, method, headers, body);
const data = await response.json().catch(() => ({}));
const response = await rawFetch(backend, path, method, headers, body);
const data: unknown = await response.json().catch(() => ({}));
const responseHeaders = Object.fromEntries(response.headers.entries());
return {
status: response.status,
data,
headers: responseHeaders,
};
return { status: response.status, data, headers: responseHeaders };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
// Extract detailed error information from error cause
let cause: string | undefined;
let errorType: string;
if (error instanceof Error && error.cause) {
const causeError = error.cause as any;
const causeCode = causeError.code || causeError.errno;
const causeSyscall = causeError.syscall;
const causeAddress = causeError.address || causeError.hostname;
const causePort = causeError.port;
if (causeCode === 'ECONNREFUSED') {
errorType = 'Backend connection refused';
cause = causeAddress && causePort
? `Backend server at ${causeAddress}:${causePort} is not accepting connections`
: 'Backend server is not accepting connections';
} else if (causeCode === 'ETIMEDOUT' || causeCode === 'ECONNABORTED') {
errorType = 'Backend request timeout';
cause = 'Connection to backend timed out';
} else if (causeCode === 'ENOTFOUND') {
errorType = 'Backend unreachable';
cause = causeAddress ? `Could not resolve hostname: ${causeAddress}` : 'Could not resolve backend hostname';
} else if (causeCode === 'EPIPE' || causeSyscall === 'write') {
errorType = 'Backend connection lost';
cause = causeSyscall ? `Connection broken during ${causeSyscall} operation` : 'Connection broken during operation';
} else {
errorType = 'Backend connection error';
cause = `${causeCode || 'Unknown error'} during ${causeSyscall || 'connection'}`;
}
} else if (errorMsg.includes('ETIMEDOUT') || errorMsg.includes('ECONNABORTED')) {
errorType = 'Backend request timeout';
cause = 'Connection timed out after 30s';
} else if (errorMsg.includes('aborted')) {
errorType = 'Request aborted';
cause = 'Request was aborted before completion';
} else {
errorType = 'Failed to forward request to backend';
cause = errorMsg;
}
const detailedError = {
error: errorType,
cause: cause,
backend: backend.base_url,
path: path,
};
return {
status: 502,
data: detailedError,
data: buildForwardErrorPayload(backend, path, error),
headers: {},
};
}
}
},
static async forwardStreamRequest(
async forwardStreamRequest(
backend: Backend,
path: string,
method: string,
headers: Record<string, string>,
body?: unknown
body?: unknown,
): Promise<
| { response: Response }
| { status: number; data: unknown; headers: Record<string, string> }
> {
try {
const response = await this.rawFetch(backend, path, method, headers, body);
const response = await rawFetch(backend, path, method, headers, body);
return { response };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
let cause: string | undefined;
let errorType: string;
if (error instanceof Error && error.cause) {
const causeError = error.cause as any;
const causeCode = causeError.code || causeError.errno;
const causeSyscall = causeError.syscall;
const causeAddress = causeError.address || causeError.hostname;
const causePort = causeError.port;
if (causeCode === 'ECONNREFUSED') {
errorType = 'Backend connection refused';
cause = causeAddress && causePort
? `Backend server at ${causeAddress}:${causePort} is not accepting connections`
: 'Backend server is not accepting connections';
} else if (causeCode === 'ETIMEDOUT' || causeCode === 'ECONNABORTED') {
errorType = 'Backend request timeout';
cause = 'Connection to backend timed out';
} else if (causeCode === 'ENOTFOUND') {
errorType = 'Backend unreachable';
cause = causeAddress ? `Could not resolve hostname: ${causeAddress}` : 'Could not resolve backend hostname';
} else if (causeCode === 'EPIPE' || causeSyscall === 'write') {
errorType = 'Backend connection lost';
cause = causeSyscall ? `Connection broken during ${causeSyscall} operation` : 'Connection broken during operation';
} else {
errorType = 'Backend connection error';
cause = `${causeCode || 'Unknown error'} during ${causeSyscall || 'connection'}`;
}
} else if (errorMsg.includes('ETIMEDOUT') || errorMsg.includes('ECONNABORTED')) {
errorType = 'Backend request timeout';
cause = 'Connection timed out after 30s';
} else if (errorMsg.includes('aborted')) {
errorType = 'Request aborted';
cause = 'Request was aborted before completion';
} else {
errorType = 'Failed to forward request to backend';
cause = errorMsg;
}
return {
status: 502,
data: {
error: errorType,
cause: cause,
backend: backend.base_url,
path: path,
},
data: buildForwardErrorPayload(backend, path, error),
headers: {},
};
}
}
}
},
};

View file

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

View file

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

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

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

View file

@ -1,21 +1,24 @@
import { NextFunction, Request, Response } from 'express';
import { AdminPrincipal } from '../../../shared/types';
import { getSessionTokenFromContext, hashAdminToken } from './adminSecurity';
import { getTrustedProxyIps } from '../config/admin-auth';
import { AdminApiTokenModel } from '../models/AdminApiToken';
import { AdminSessionModel } from '../models/AdminSession';
import { getSessionTokenFromCookies, hashAdminToken } from './adminSecurity';
export interface AdminRequest extends Request {
adminAuth?: {
principal: AdminPrincipal;
method: 'session' | 'token';
csrfToken?: string;
sessionId?: number;
tokenId?: number;
};
import type { Context, MiddlewareHandler } from 'hono';
import type { AdminPrincipal } from '../../../shared/types';
import type { AdminAuthContext, AppEnv } from '../types/hono';
interface PrincipalRow {
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
display_name: string;
}
function toPrincipal(data: { provider: 'env' | 'oidc'; subject: string; username?: string; email?: string; display_name: string }): AdminPrincipal {
function toPrincipal(data: PrincipalRow): AdminPrincipal {
return {
provider: data.provider,
subject: data.subject,
@ -25,83 +28,119 @@ function toPrincipal(data: { provider: 'env' | 'oidc'; subject: string; username
};
}
function passesTrustedProxyGuard(req: Request): boolean {
const allowedIps = getTrustedProxyIps();
if (allowedIps.length === 0) {
return true;
}
const remoteIp = req.ip || req.socket.remoteAddress || '';
return allowedIps.includes(remoteIp);
/**
* Type guard for `@hono/node-server`'s context env shape. The Node adapter
* exposes the underlying `IncomingMessage` as `c.env.incoming`; we narrow it
* here so the rest of the file never has to touch `as` casts.
*/
function getNodeIncomingSocket(
env: unknown,
): { remoteAddress?: string } | undefined {
if (typeof env !== 'object' || env === null) return undefined;
if (!('incoming' in env)) return undefined;
const incoming = (env as { incoming: unknown }).incoming;
if (typeof incoming !== 'object' || incoming === null) return undefined;
if (!('socket' in incoming)) return undefined;
const socket = (incoming as { socket: unknown }).socket;
if (typeof socket !== 'object' || socket === null) return undefined;
return socket as { remoteAddress?: string };
}
export function resolveAdminAuth(req: AdminRequest): void {
const authHeader = req.headers.authorization;
function getRemoteIp(c: Context): string {
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0]?.trim() ?? '';
return getNodeIncomingSocket(c.env)?.remoteAddress ?? '';
}
function passesTrustedProxyGuard(c: Context): boolean {
const allowedIps = getTrustedProxyIps();
if (allowedIps.length === 0) return true;
return allowedIps.includes(getRemoteIp(c));
}
export function resolveAdminAuth(
c: Context<AppEnv>,
): AdminAuthContext | undefined {
const authHeader = c.req.header('authorization');
if (authHeader?.startsWith('Bearer ')) {
const bearerToken = authHeader.slice('Bearer '.length).trim();
const adminToken = AdminApiTokenModel.findByTokenHash(hashAdminToken(bearerToken));
const adminToken = AdminApiTokenModel.findByTokenHash(
hashAdminToken(bearerToken),
);
if (adminToken) {
AdminApiTokenModel.touch(adminToken.id);
req.adminAuth = {
const ctx: AdminAuthContext = {
principal: toPrincipal(adminToken),
method: 'token',
tokenId: adminToken.id,
};
return;
c.set('adminAuth', ctx);
return ctx;
}
}
const sessionToken = getSessionTokenFromCookies(req.headers.cookie);
if (!sessionToken) {
return;
}
// Cookie reads now go through hono/cookie's helper inside getSessionTokenFromContext.
const sessionToken = getSessionTokenFromContext(c);
if (!sessionToken) return undefined;
const session = AdminSessionModel.findByTokenHash(hashAdminToken(sessionToken));
if (!session) {
return;
}
const session = AdminSessionModel.findByTokenHash(
hashAdminToken(sessionToken),
);
if (!session) return undefined;
AdminSessionModel.touch(session.id);
req.adminAuth = {
const ctx: AdminAuthContext = {
principal: toPrincipal(session),
method: 'session',
csrfToken: session.csrf_token,
sessionId: session.id,
};
c.set('adminAuth', ctx);
return ctx;
}
export function requireAdminAccess(req: AdminRequest, res: Response, next: NextFunction): void {
if (!passesTrustedProxyGuard(req)) {
res.status(403).json({ error: 'Admin access is restricted to trusted proxy IPs' });
export const requireAdminAccess: MiddlewareHandler<AppEnv> = async (
c,
next,
) => {
if (!passesTrustedProxyGuard(c)) {
return c.json(
{ error: 'Admin access is restricted to trusted proxy IPs' },
403,
);
}
const auth = resolveAdminAuth(c);
if (!auth) {
return c.json({ error: 'Admin authentication required' }, 401);
}
await next();
return;
};
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export const requireSessionCsrf: MiddlewareHandler<AppEnv> = async (
c,
next,
) => {
if (SAFE_METHODS.has(c.req.method.toUpperCase())) {
await next();
return;
}
resolveAdminAuth(req);
if (!req.adminAuth) {
res.status(401).json({ error: 'Admin authentication required' });
const adminAuth = c.get('adminAuth');
if (adminAuth?.method !== 'session') {
await next();
return;
}
next();
}
export function requireSessionCsrf(req: AdminRequest, res: Response, next: NextFunction): void {
const unsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes(req.method.toUpperCase());
if (!unsafeMethod) {
next();
return;
const csrfHeader = c.req.header('X-CSRF-Token');
if (!csrfHeader || csrfHeader !== adminAuth.csrfToken) {
return c.json({ error: 'Invalid CSRF token' }, 403);
}
if (req.adminAuth?.method !== 'session') {
next();
return;
}
const csrfHeader = req.get('X-CSRF-Token');
if (!csrfHeader || csrfHeader !== req.adminAuth.csrfToken) {
res.status(403).json({ error: 'Invalid CSRF token' });
return;
}
next();
}
await next();
return;
};

View file

@ -1,6 +1,20 @@
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
import { Response } from 'express';
import { getCookieSecure, getAdminPasswordHash, getAdminSessionTtlHours, hashOpaqueToken } from '../config/admin-auth';
import {
createHash,
randomBytes,
scryptSync,
timingSafeEqual,
} from 'node:crypto';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import {
getAdminPasswordHash,
getAdminSessionTtlHours,
getCookieSecure,
hashOpaqueToken,
} from '../config/admin-auth';
import type { Context } from 'hono';
const SESSION_COOKIE_NAME = 'kyush_admin_session';
@ -24,65 +38,44 @@ export function tokenPrefix(token: string): string {
return token.slice(0, 12);
}
export function issueAdminSessionCookie(res: Response, sessionToken: string, maxAgeMs: number): void {
const parts = [
`${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionToken)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Max-Age=${Math.max(1, Math.floor(maxAgeMs / 1000))}`,
];
if (getCookieSecure()) {
parts.push('Secure');
}
res.append('Set-Cookie', parts.join('; '));
/**
* Cookie writes go through hono's `setCookie`/`deleteCookie` helpers Hono
* handles the Set-Cookie serialisation, multiple-header concatenation, and
* SameSite/Secure flag bookkeeping for us. We never touch the raw header.
*/
export function issueAdminSessionCookie(
c: Context,
sessionToken: string,
maxAgeMs: number,
): void {
setCookie(c, SESSION_COOKIE_NAME, sessionToken, {
path: '/',
httpOnly: true,
sameSite: 'Lax',
secure: getCookieSecure(),
maxAge: Math.max(1, Math.floor(maxAgeMs / 1000)),
});
}
export function clearAdminSessionCookie(res: Response): void {
const parts = [
`${SESSION_COOKIE_NAME}=`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=0',
];
if (getCookieSecure()) {
parts.push('Secure');
}
res.append('Set-Cookie', parts.join('; '));
export function clearAdminSessionCookie(c: Context): void {
deleteCookie(c, SESSION_COOKIE_NAME, {
path: '/',
secure: getCookieSecure(),
});
}
export function parseCookies(cookieHeader?: string): Record<string, string> {
if (!cookieHeader) {
return {};
}
return cookieHeader.split(';').reduce<Record<string, string>>((acc, pair) => {
const separatorIndex = pair.indexOf('=');
if (separatorIndex === -1) return acc;
const key = pair.slice(0, separatorIndex).trim();
const value = pair.slice(separatorIndex + 1).trim();
if (key) {
acc[key] = decodeURIComponent(value);
}
return acc;
}, {});
}
export function getSessionTokenFromCookies(cookieHeader?: string): string | null {
const cookies = parseCookies(cookieHeader);
return cookies[SESSION_COOKIE_NAME] || null;
/**
* Reads the admin session cookie via Hono's `getCookie` helper. Returns null
* if the cookie is absent or empty so the auth middleware can short-circuit.
*/
export function getSessionTokenFromContext(c: Context): string | null {
const value = getCookie(c, SESSION_COOKIE_NAME);
return value && value.length > 0 ? value : null;
}
export function verifyAdminPassword(password: string): boolean {
const storedHash = getAdminPasswordHash();
if (!storedHash) {
return false;
}
if (!storedHash) return false;
if (storedHash.startsWith('sha256$')) {
const expected = storedHash.slice('sha256$'.length);
@ -92,25 +85,27 @@ export function verifyAdminPassword(password: string): boolean {
if (storedHash.startsWith('scrypt$')) {
const [, saltHex, expectedHex] = storedHash.split('$');
if (!saltHex || !expectedHex) {
return false;
}
const derived = scryptSync(password, Buffer.from(saltHex, 'hex'), expectedHex.length / 2);
if (!saltHex || !expectedHex) return false;
const derived = scryptSync(
password,
Buffer.from(saltHex, 'hex'),
expectedHex.length / 2,
);
return timingSafeEqual(derived, Buffer.from(expectedHex, 'hex'));
}
return false;
}
export function computeExpiry(hours: number = getAdminSessionTtlHours()): string {
export function computeExpiry(
hours: number = getAdminSessionTtlHours(),
): string {
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}
function safeStringEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
if (leftBuffer.length !== rightBuffer.length) return false;
return timingSafeEqual(leftBuffer, rightBuffer);
}

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