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
146 changed files with 12037 additions and 8450 deletions

View file

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

View file

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

View file

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

View file

@ -13,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; copy_reasoning_to_reasoning_content?: boolean }): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<User>): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/users/${id}`, { method: 'DELETE' }),
regenerateApiKey: (id: number): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}/regenerate-api-key`, { method: 'POST' }),
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,8 +1,13 @@
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { createEffect, createMemo, createResource, createSignal, onCleanup, 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 { formatDurationMs } from '../ui/lib/format';
import {
BoxPlotChart,
ChartLegend,
@ -10,11 +15,11 @@ import {
CommandBar,
CommandBarGroup,
HistogramChart,
MetaCluster,
PageHeader,
Panel,
Select,
SummaryStrip,
Switch,
TimeSeriesChart,
} from '../ui';
@ -24,53 +29,71 @@ 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 [isAutoRefresh, setIsAutoRefresh] = createSignal(false);
const [refreshInterval, setRefreshInterval] = createSignal('10');
const [refreshKey, setRefreshKey] = createSignal(0);
const [dailyVolumeScale, setDailyVolumeScale] = createSignal<'linear' | 'log'>('linear');
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()),
backendId: backendFilter() === 'all' ? undefined : Number(backendFilter()),
key: refreshKey(),
}));
const [backends] = createResource(() => api.backends.getAll());
const [dailyTotals] = createResource(filters, (params) => api.analytics.getDailyTotals(params.backendId, params.days));
const [backendQuality] = createResource(filters, (params) => api.analytics.getBackendQuality(params.backendId, params.days));
const [modelTrends] = createResource(filters, (params) => api.analytics.getModelTrends({ backendId: params.backendId, days: params.days, limit: 8 }));
const [histogram] = createResource(filters, (params) => api.analytics.getResponseLengthHistogram({ backendId: params.backendId, days: params.days, bins: 20 }));
const [boxPlot] = createResource(filters, (params) => api.analytics.getResponseLengthBoxPlot(params.backendId, params.days));
const currentDailyTotals = createMemo(() => dailyTotals.latest ?? dailyTotals());
const currentBackendQuality = createMemo(() => backendQuality.latest ?? backendQuality());
const currentModelTrends = createMemo(() => modelTrends.latest ?? modelTrends());
const currentHistogram = createMemo(() => histogram.latest ?? histogram());
const currentBoxPlot = createMemo(() => boxPlot.latest ?? boxPlot());
const analyticsLoading = createMemo(() => dailyTotals.loading || backendQuality.loading || modelTrends.loading || histogram.loading || boxPlot.loading);
createEffect(() => {
if (!isAutoRefresh()) return;
const ms = Number(refreshInterval()) * 1000;
const id = setInterval(() => setRefreshKey((key) => key + 1), ms);
onCleanup(() => clearInterval(id));
});
const [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(() => {
@ -82,25 +105,31 @@ export const Analytics: Component = () => {
});
const dailyVolumeRows = createMemo(() =>
(currentDailyTotals() ?? []).map((row) => ({
(dailyTotals() ?? []).map((row) => ({
date: row.date,
requests: row.total_requests,
tokens: row.total_tokens,
}))
})),
);
const responseTimeRows = createMemo(() => {
const grouped = new Map<string, AnalyticsChartRow>();
for (const row of currentBackendQuality() ?? []) {
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
for (const row of backendQuality() ?? []) {
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((currentBackendQuality() ?? []).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}`,
@ -110,7 +139,7 @@ export const Analytics: Component = () => {
const reliabilityRows = createMemo(() => {
const grouped = new Map<string, { requests: number; errors: number }>();
for (const row of currentBackendQuality() ?? []) {
for (const row of backendQuality() ?? []) {
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
entry.requests += row.total_requests;
entry.errors += row.error_count;
@ -121,23 +150,32 @@ 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,
}));
});
const modelTrendRows = createMemo(() => {
const grouped = new Map<string, AnalyticsChartRow>();
for (const row of currentModelTrends() ?? []) {
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
for (const row of modelTrends() ?? []) {
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((currentModelTrends() ?? []).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,
@ -146,29 +184,53 @@ export const Analytics: Component = () => {
});
const summaryItems = createMemo(() => {
const totals = (currentDailyTotals() ?? []).reduce(
const totals = (dailyTotals() ?? []).reduce(
(acc, row) => {
acc.requests += row.total_requests;
acc.tokens += row.total_tokens;
return acc;
},
{ requests: 0, tokens: 0 }
{ requests: 0, tokens: 0 },
);
const qualityRows = currentBackendQuality() ?? [];
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: `Selected ${days()}-day window total` },
{ label: 'Avg Response', value: formatDurationMs(avgLatency), hint: 'Across visible backend series' },
{ label: 'Errors', value: formatInteger.format(errorCount), hint: 'Absolute backend error count' },
{
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) => {
@ -186,45 +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} />
</CommandBarGroup>
<CommandBarGroup>
<Switch
label="Auto refresh"
checked={isAutoRefresh()}
onChange={setIsAutoRefresh}
<Select
label="Window"
onChange={setDays}
options={dayOptions}
value={days()}
/>
<Select
label="Refresh Interval"
value={refreshInterval()}
options={[
{ value: '5', label: 'Every 5s' },
{ value: '10', label: 'Every 10s' },
{ value: '30', label: 'Every 30s' },
{ value: '60', label: 'Every 60s' },
{ value: '600', label: 'Every 10m' },
]}
onChange={setRefreshInterval}
label="Backend"
onChange={setBackendFilter}
options={backendOptions()}
value={backendFilter()}
/>
<div class="ui-divider--vertical" />
<button
class="ui-button analytics__refresh-button"
classList={{ 'ui-button--loading': analyticsLoading() }}
type="button"
onClick={() => setRefreshKey((key) => key + 1)}
disabled={analyticsLoading()}
aria-busy={analyticsLoading()}
>
<RefreshCw />
{analyticsLoading() ? 'Refreshing' : 'Refresh'}
</button>
</CommandBarGroup>
</CommandBar>
@ -232,51 +273,51 @@ export const Analytics: Component = () => {
<div class="ui-section-grid">
<Panel
title="Daily Volume"
description="Daily request and token totals on shared time axis."
actions={
<div style="display: flex; align-items: center; gap: 16px;">
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenDailySeries()}
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
<Select
label="Scale"
value={dailyVolumeScale()}
options={[
{ value: 'linear', label: 'Linear' },
{ value: 'log', label: 'Log' },
]}
onChange={setDailyVolumeScale}
/>
</div>
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenDailySeries()}
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
/>
}
description="Daily request and token totals on shared time axis."
title="Daily Volume"
>
<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"
yScaleType={dailyVolumeScale()}
/>
</Panel>
<Panel
title="Backend Reliability"
description="Success rate and absolute error count per day."
actions={
<ChartLegend
items={[
@ -285,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>
@ -299,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="Response time"
formatLeftValue={formatDurationMs}
tooltipTitle="Average backend response time"
yLeftLabel="Milliseconds"
/>
</Panel>
<Panel
title="Model Request Trends"
description="Top routed/response models by request volume over time."
actions={
<ChartLegend
items={modelTrendSeries()}
@ -331,34 +376,48 @@ 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="Log-scaled completion_tokens histogram across the selected window.">
<HistogramChart
data={currentHistogram() ?? []}
xTickUnit="tok"
yTickUnit="req"
<Panel
description="Histogram of completion token lengths across the selected window."
title="Response Length Distribution"
>
<MetaCluster
items={[{ key: 'Metric', value: 'completion_tokens' }]}
/>
<HistogramChart data={histogram() ?? []} />
</Panel>
<Panel title="Daily Response Length Spread" description="Log-scaled daily completion_tokens spread; outliers are hidden.">
<BoxPlotChart data={currentBoxPlot() ?? []} />
<Panel
description="Completion token box plot by day using min / q1 / median / q3 / max summary."
title="Daily Response Length Spread"
>
<MetaCluster
items={[{ key: 'Outliers', value: 'Hidden in this view' }]}
/>
<BoxPlotChart data={boxPlot() ?? []} />
</Panel>
</div>
</div>
</Layout>
);
};
export default Analytics;

View file

@ -1,11 +1,18 @@
import { For, createMemo, 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,20 +46,31 @@ const emptyForm = (): BackendFormState => ({
detail_logging: false,
});
export const Backends: Component = () => {
const Backends: Component = () => {
const [backends, { refetch }] = createResource(() => api.backends.getAll());
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
const [dialogOpen, setDialogOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
const [editingBackend, setEditingBackend] = createSignal<Backend | null>(null);
const [pendingDeleteBackend, setPendingDeleteBackend] = createSignal<Backend | null>(null);
const [editingBackend, setEditingBackend] = createSignal<Backend | null>(
null,
);
const [pendingDeleteBackend, setPendingDeleteBackend] =
createSignal<Backend | null>(null);
const [form, setForm] = createSignal<BackendFormState>(emptyForm());
const [submitting, setSubmitting] = createSignal(false);
const [notice, setNotice] = createSignal<{ tone: 'success' | 'danger'; message: string } | null>(null);
const [expandedBackendId, setExpandedBackendId] = createSignal<number | null>(null);
const [backendModels, setBackendModels] = createSignal<Record<number, BackendModelsResponse>>({});
const [notice, setNotice] = createSignal<{
tone: 'success' | 'danger';
message: string;
} | null>(null);
const [expandedBackendId, setExpandedBackendId] = createSignal<number | null>(
null,
);
const [backendModels, setBackendModels] = createSignal<
Record<number, BackendModelsResponse>
>({});
const modelStateTone = (backend: Backend): 'success' | 'warning' | 'danger' | 'neutral' => {
const modelStateTone = (
backend: Backend,
): 'success' | 'warning' | 'danger' | 'neutral' => {
switch (backend.model_cache_state) {
case 'ready':
return 'success';
@ -128,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);
}
@ -151,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);
}
@ -168,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.',
});
}
};
@ -179,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);
}
@ -192,99 +233,192 @@ 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
when={!backends.loading || (currentBackends()?.length ?? 0) > 0}
fallback={<EmptyState title="Loading backends" description="Reading upstream routing targets from the admin API." />}
fallback={
<EmptyState
description="Reading upstream routing targets from the admin API."
title="Loading backends"
/>
}
when={!backends.loading || (backends()?.length ?? 0) > 0}
>
<Show
when={(currentBackends()?.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={currentBackends() ?? []}
columns={[
{ id: 'id', header: 'ID', mono: true, cell: (backend) => <span>{backend.id}</span> },
{ id: 'name', header: 'Name', cell: (backend) => <span>{backend.name}</span> },
{
id: 'id',
header: 'ID',
mono: true,
cell: (backend) => <span>{backend.id}</span>,
},
{
id: 'name',
header: 'Name',
cell: (backend) => <span>{backend.name}</span>,
},
{
id: 'base_url',
header: 'Base URL',
class: 'ui-text-mono',
cell: (backend) => <span title={backend.base_url}>{backend.base_url}</span>,
cell: (backend) => (
<span title={backend.base_url}>{backend.base_url}</span>
),
},
{
id: 'detail_logging',
header: 'Detail Log',
cell: (backend) => <StatusBadge tone={backend.detail_logging ? 'warning' : 'neutral'}>{backend.detail_logging ? 'On' : 'Off'}</StatusBadge>,
cell: (backend) => (
<StatusBadge
tone={backend.detail_logging ? 'warning' : 'neutral'}
>
{backend.detail_logging ? 'On' : 'Off'}
</StatusBadge>
),
},
{
id: 'model_cache',
header: 'Model Cache',
cell: (backend) => <StatusBadge tone={modelStateTone(backend)}>{modelStateLabel(backend)}</StatusBadge>,
cell: (backend) => (
<StatusBadge tone={modelStateTone(backend)}>
{modelStateLabel(backend)}
</StatusBadge>
),
},
{
id: 'model_count',
header: 'Models',
cell: (backend) => <span>{backend.cached_model_count ?? 0}</span>,
cell: (backend) => (
<span>{backend.cached_model_count ?? 0}</span>
),
},
{
id: 'status',
header: 'Status',
cell: (backend) => <StatusBadge tone={backend.is_active ? 'success' : 'warning'}>{backend.is_active ? 'Active' : 'Inactive'}</StatusBadge>,
cell: (backend) => (
<StatusBadge
tone={backend.is_active ? 'success' : 'warning'}
>
{backend.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
),
},
]}
getRowKey={(backend) => backend.id}
loading={backends.loading && (currentBackends()?.length ?? 0) === 0}
loading={backends.loading}
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>
@ -298,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,9 +1,12 @@
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from 'solid-js';
import { useQuery } from '@tanstack/solid-query';
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
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 { formatDurationMs } from '../ui/lib/format';
import {
Button,
ChartLegend,
ComboChart,
CommandBar,
@ -14,7 +17,6 @@ import {
Panel,
Select,
SummaryStrip,
Switch,
TimeSeriesChart,
} from '../ui';
@ -24,53 +26,70 @@ 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 [isAutoRefresh, setIsAutoRefresh] = createSignal(false);
const [refreshInterval, setRefreshInterval] = createSignal('10');
const [refreshKey, setRefreshKey] = createSignal(0);
const [trafficVolumeScale, setTrafficVolumeScale] = createSignal<'linear' | 'log'>('linear');
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 summarySource = createMemo(() => ({ days: windowDays(), key: refreshKey() }));
const [summary] = createResource(summarySource, (value) => api.dashboard.getSummary(value.days));
const [backends] = createResource(() => api.backends.getAll());
const currentSummary = createMemo(() => summary.latest ?? summary());
// 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(),
}));
createEffect(() => {
if (!isAutoRefresh()) return;
const ms = Number(refreshInterval()) * 1000;
const id = setInterval(() => setRefreshKey((k) => k + 1), ms);
onCleanup(() => clearInterval(id));
});
const summary = () => summaryQuery.data;
const backends = () => backendsQuery.data;
const refetch = () => summaryQuery.refetch();
const backendNameById = createMemo(() => {
// 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(() =>
(currentSummary()?.series.daily_totals ?? []).map((row) => ({
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 currentSummary()?.series.backend_quality ?? []) {
for (const row of summary()?.series.backend_quality ?? []) {
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
entry.requests += row.total_requests;
entry.errors += row.error_count;
@ -81,63 +100,101 @@ 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 currentSummary()?.series.backend_quality ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
for (const row of summary()?.series.backend_quality ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? {
date: row.date,
};
entry[`backend_${row.backend_id}`] = row.avg_response_time_ms;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
};
const latencySeries = createMemo(() => {
const ids = Array.from(new Set((currentSummary()?.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 currentSummary()?.series.model_trends ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
for (const row of summary()?.series.model_trends ?? []) {
const entry: DashboardChartRow = grouped.get(row.date) ?? {
date: row.date,
};
entry[`model_${row.model}`] = row.request_count;
grouped.set(row.date, entry);
}
return Array.from(grouped.values()).sort((left, right) => left.date.localeCompare(right.date));
});
return Array.from(grouped.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
};
const modelSeries = createMemo(() => {
const models = Array.from(new Set((currentSummary()?.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 payload = currentSummary();
const latestTraffic = payload?.series.daily_totals[payload.series.daily_totals.length - 1];
const summaryItems = () => {
const payload = summary();
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 counts = currentSummary()?.health.cache_state_counts;
const cacheStateItems = () => {
const counts = summary()?.health.cache_state_counts;
if (!counts) return [];
return [
{ key: 'Ready', value: String(counts.ready) },
@ -145,33 +202,56 @@ export const Dashboard: Component = () => {
{ key: 'Error', value: String(counts.error) },
{ key: 'Inactive', value: String(counts.inactive) },
];
});
};
const scriptItems = createMemo(() => {
const payload = currentSummary();
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 payload = currentSummary();
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) => {
@ -189,105 +269,113 @@ 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."
title="Dashboard"
/>
<CommandBar class="analytics__filters">
<CommandBarGroup>
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
</CommandBarGroup>
<CommandBarGroup>
<Switch
label="Auto refresh"
checked={isAutoRefresh()}
onChange={setIsAutoRefresh}
/>
<Select
label="Refresh Interval"
value={refreshInterval()}
options={[
{ value: '5', label: 'Every 5s' },
{ value: '10', label: 'Every 10s' },
{ value: '30', label: 'Every 30s' },
{ value: '60', label: 'Every 60s' },
{ value: '600', label: 'Every 10m' },
]}
onChange={setRefreshInterval}
label="Window"
onChange={setDays}
options={dayOptions}
value={days()}
/>
<div class="ui-divider--vertical" />
<button
class="ui-button dashboard__refresh-button"
classList={{ 'ui-button--loading': summary.loading }}
type="button"
onClick={() => setRefreshKey((k) => k + 1)}
disabled={summary.loading}
aria-busy={summary.loading}
>
<RefreshCw />
{summary.loading ? 'Refreshing' : 'Refresh'}
</button>
</CommandBarGroup>
</CommandBar>
<SummaryStrip items={summaryItems()} />
<Show 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={
<div style="display: flex; align-items: center; gap: 16px;">
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenTrafficSeries()}
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
/>
<Select
label="Scale"
value={trafficVolumeScale()}
options={[
{ value: 'linear', label: 'Linear' },
{ value: 'log', label: 'Log' },
]}
onChange={setTrafficVolumeScale}
/>
</div>
<ChartLegend
items={[
{ key: 'requests', label: 'Requests', color: '#2357d8' },
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
]}
mutedKeys={hiddenTrafficSeries()}
onToggle={(key) =>
toggleHiddenKey(setHiddenTrafficSeries, key)
}
/>
}
description="Daily request and token totals for the selected window."
title="Traffic Volume"
>
<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"
yScaleType={trafficVolumeScale()}
/>
</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>
@ -295,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="Latency"
formatLeftValue={formatDurationMs}
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
when={(currentSummary()?.health.stale_backends.length ?? 0) > 0}
fallback={<EmptyState title="No stale backend syncs" description="All active backends synced within the freshness window." />}
fallback={
<EmptyState
description="All active backends synced within the freshness window."
title="No stale backend syncs"
/>
}
when={(summary()?.health.stale_backends.length ?? 0) > 0}
>
<div class="dashboard__status-list">
{currentSummary()?.health.stale_backends.map((backend) => (
<div class="dashboard__status-item">
<div>
<strong>{backend.name}</strong>
<p>Last sync: {backend.last_synced_at ? new Date(backend.last_synced_at).toLocaleString() : 'Never'}</p>
</div>
<span>{backend.state}</span>
</div>
))}
{
<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>
@ -366,3 +498,5 @@ export const Dashboard: Component = () => {
</Layout>
);
};
export default Dashboard;

View file

@ -1,9 +1,34 @@
import { createEffect, createMemo, createResource, createSignal, onCleanup, 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, extractAssistantConversationPreview, hasRenderableConversation } from '../ui';
interface FilterState {
month: string;
@ -15,7 +40,6 @@ interface FilterState {
}
const PAGE_SIZE_OPTIONS = [25, 50, 100];
const SEARCH_DEBOUNCE_MS = 350;
const emptyFilters = (): FilterState => ({
month: '',
@ -26,6 +50,31 @@ const emptyFilters = (): FilterState => ({
endpoint: '',
});
function extractAssistantPreview(responseBody?: string): string {
if (!responseBody) return '-';
try {
const parsed = JSON.parse(responseBody) as {
choices?: Array<{
message?: {
content?: unknown;
};
}>;
};
const content = parsed.choices?.[0]?.message?.content;
if (typeof content !== 'string') return '-';
const normalized = content.replace(/\r/g, '').replace(/\n+/g, ' ').trim();
if (!normalized) return '-';
return normalized.length > 50
? `${normalized.slice(0, 50)}...`
: normalized;
} catch {
return '-';
}
}
function prettyPrint(value?: string): string {
if (!value) return '';
@ -36,9 +85,8 @@ function prettyPrint(value?: string): string {
}
}
export const DetailLogs: Component = () => {
const DetailLogs: Component = () => {
const [filters, setFilters] = createSignal<FilterState>(emptyFilters());
const [searchDraft, setSearchDraft] = createSignal('');
const [page, setPage] = createSignal(1);
const [pageSize, setPageSize] = createSignal(25);
const [selectedLogId, setSelectedLogId] = createSignal<number | null>(null);
@ -61,37 +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 updateFilter = (key: keyof FilterState, value: string) => {
let changed = false;
setFilters((current) => {
if (current[key] === value) return current;
changed = true;
return { ...current, [key]: value };
});
if (changed) {
setPage(1);
}
};
createEffect(() => {
const nextQuery = searchDraft();
const id = window.setTimeout(() => updateFilter('q', nextQuery), SEARCH_DEBOUNCE_MS);
onCleanup(() => window.clearTimeout(id));
});
const requestPage = createMemo(() => (logs.state === 'ready' || logs.state === 'refreshing' ? logs.latest : undefined));
const requestPage = createMemo(() => logs());
const requestRows = createMemo(() => requestPage()?.rows ?? []);
const totalRows = createMemo(() => requestPage()?.total ?? 0);
const logsError = createMemo(() => {
if (!logs.error) return null;
return logs.error instanceof Error ? logs.error.message : 'Failed to load detailed logs.';
});
const pageCount = createMemo(() => Math.max(1, Math.ceil(totalRows() / pageSize())));
const rangeStart = createMemo(() => (totalRows() === 0 ? 0 : (page() - 1) * pageSize() + 1));
const 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();
@ -116,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();
@ -127,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) {
@ -139,23 +175,36 @@ export const DetailLogs: Component = () => {
const assistantPreviewById = createMemo(() => {
const previews = new Map<number, string>();
for (const row of requestRows()) {
previews.set(row.id, extractAssistantConversationPreview(row.response_body));
previews.set(row.id, extractAssistantPreview(row.response_body));
}
return previews;
});
const selectedLog = createMemo<RequestLog | undefined>(() => requestRows().find((row) => row.id === selectedLogId()));
const selectedLog = createMemo<RequestLog | undefined>(() =>
requestRows().find((row) => row.id === selectedLogId()),
);
const selectedLogHasConversation = createMemo(() =>
selectedLog() ? hasRenderableConversation(selectedLog()!.request_body, selectedLog()!.response_body) : false
selectedLog()
? hasRenderableConversation(
selectedLog()!.request_body,
selectedLog()!.response_body,
)
: false,
);
const userOptions = createMemo(() => [
{ value: '', label: 'All users' },
...((users() ?? []).map((user) => ({ value: String(user.id), label: `${user.id} - ${user.name}` }))),
...(users() ?? []).map((user) => ({
value: String(user.id),
label: `${user.id} - ${user.name}`,
})),
]);
const backendOptions = createMemo(() => [
{ value: '', label: 'All backends' },
...((backends() ?? []).map((backend) => ({ value: String(backend.id), label: `${backend.id} - ${backend.name}` }))),
...(backends() ?? []).map((backend) => ({
value: String(backend.id),
label: `${backend.id} - ${backend.name}`,
})),
]);
const endpointOptions = [
@ -164,36 +213,46 @@ export const DetailLogs: Component = () => {
];
const resetFilters = () => {
setSearchDraft('');
setFilters(emptyFilters());
setPage(1);
};
const updateFilter = (key: keyof FilterState, value: string) => {
setFilters((current) => ({ ...current, [key]: value }));
setPage(1);
};
return (
<Layout>
<div class="ui-app-page">
<PageHeader
title="Detail Logs"
description="Inspect verbose request logs with monthly filters, text search, and full request/response payload views."
actions={
<Button
class="detail-logs__refresh-button"
classList={{ 'ui-button--loading': logs.loading }}
onClick={() => void refetch()}
disabled={logs.loading}
aria-busy={logs.loading}
>
<Button onClick={() => void refetch()}>
<RefreshCcw />
{logs.loading ? 'Refreshing' : 'Refresh'}
Refresh
</Button>
}
description="Inspect verbose request logs with monthly filters, text search, and full request/response payload views."
title="Detail Logs"
/>
<SummaryStrip
items={[
{ label: 'Source Scope', value: sourceScope().value, hint: sourceScope().hint },
{ label: 'Active Filters', value: activeFilterCount(), hint: activeFilterHint() },
{ label: 'Page Window', value: pageWindow(), hint: `Page ${page()} of ${pageCount()} - ${pageSize()} per page` },
{
label: 'Source Scope',
value: sourceScope().value,
hint: sourceScope().hint,
},
{
label: 'Active Filters',
value: activeFilterCount(),
hint: activeFilterHint(),
},
{
label: 'Page Window',
value: pageWindow(),
hint: `Page ${page()} of ${pageCount()} - ${pageSize()} per page`,
},
]}
/>
@ -201,42 +260,97 @@ export const DetailLogs: Component = () => {
<CommandBarGroup>
<TextField
label="Search"
value={searchDraft()}
onInput={(event) => updateFilter('q', event.currentTarget.value)}
placeholder="Search body, headers, models, errors"
onInput={(event) => setSearchDraft(event.currentTarget.value)}
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',
@ -250,19 +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>
),
},
]}
getRowKey={(row) => row.id}
loading={logs.loading && requestRows().length === 0}
error={logsError()}
emptyMessage="No detailed logs matched the current filters."
getRowKey={(row) => row.id}
loading={logs.loading}
onRowClick={(row) => setSelectedLogId(row.id)}
pagination={{
page: page(),
@ -275,16 +400,29 @@ export const DetailLogs: Component = () => {
},
pageSizeOptions: PAGE_SIZE_OPTIONS,
}}
rows={requestRows()}
tableLayout="fixed"
/>
{!logs.loading && !logsError() && requestRows().length === 0 && (
<EmptyState title="No logs found" description="Try a different month, date, or search term." />
{!logs.loading && requestRows().length === 0 && (
<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">
@ -296,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>
@ -326,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>
@ -342,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>
@ -357,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,34 +46,44 @@ 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 currentOverview = createMemo(() => overview.state === 'ready' || overview.state === 'refreshing' ? overview.latest : undefined);
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
const currentRules = createMemo(() => rules.state === 'ready' || rules.state === 'refreshing' ? rules.latest : undefined);
const [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 currentBackends() ?? []) {
for (const backend of backends() ?? []) {
names.set(backend.id, backend.name);
}
return names;
});
const getBackendName = (backendId: number) => backendNameById().get(backendId) ?? `Backend ${backendId}`;
const getBackendName = (backendId: number) =>
backendNameById().get(backendId) ?? `Backend ${backendId}`;
const modelCatalogRows = createMemo(() =>
(currentOverview()?.models ?? []).map((entry) => ({
(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 = () => {
@ -89,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;
}
@ -119,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);
}
@ -132,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);
}
@ -147,68 +182,155 @@ export const Models: Component = () => {
<Layout>
<div class="ui-app-page">
<PageHeader
actions={
<Button
onClick={() =>
void Promise.all([refetchOverview(), refetchRules()])
}
>
Refresh
</Button>
}
description="Inspect cached backend model catalogs and manage global model rewrite rules."
title="Models"
description="Inspect cached backend model catalogs and manage chained global model rewrite rules."
actions={<Button onClick={() => void Promise.all([refetchOverview(), refetchRules()])}>Refresh</Button>}
/>
<SummaryStrip
items={[
{ label: 'Catalog Models', value: currentOverview()?.models.length ?? 0, hint: 'Unique models across active backends' },
{ label: 'Tracked Backends', value: currentOverview()?.backends.length ?? 0, hint: 'Memory cache status by backend' },
{ label: 'Rewrite Rules', value: currentRules()?.length ?? 0, hint: 'Global source -> target mappings' },
{
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
when={(currentOverview()?.backends.length ?? 0) > 0 || overview.loading}
fallback={<EmptyState title="No backend cache yet" description="Backend model states appear here after the server has seen active backends." />}
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}
>
<DataGrid
rows={currentOverview()?.backends ?? []}
columns={[
{
id: 'backend_id',
header: 'Backend',
class: 'models__catalog-column',
cell: (item) => <span title={getBackendName(item.backend_id)}>{getBackendName(item.backend_id)}</span>,
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 && (currentOverview()?.backends.length ?? 0) === 0}
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
when={modelCatalogRows().length > 0 || overview.loading}
fallback={<EmptyState title="No cached models yet" description="Model catalog entries appear here after backend model snapshots are available." />}
fallback={
<EmptyState
description="Model catalog entries appear here after backend model snapshots are available."
title="No cached models yet"
/>
}
when={modelCatalogRows().length > 0}
>
<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',
@ -219,96 +341,192 @@ export const Models: Component = () => {
},
]}
getRowKey={(item) => item.model_id}
loading={overview.loading && modelCatalogRows().length === 0}
loading={overview.loading}
rows={modelCatalogRows()}
/>
</Show>
</Panel>
</div>
<Panel
actions={
<IconButton
icon={<Plus />}
label="Add Rule"
onClick={openCreateDialog}
variant="primary"
/>
}
description="Force rules always rewrite. Fallback rules rewrite only when the original model has no usable backend."
title="Model Rewrite Rules"
description="Force rules always rewrite and continue through the chain. Fallback rules continue only when the current model has no usable backend."
actions={<IconButton variant="primary" icon={<Plus />} label="Add Rule" onClick={openCreateDialog} />}
>
<div class="ui-stack ui-stack--tight">
<Show
when={(currentRules()?.length ?? 0) > 0 || rules.loading}
fallback={<EmptyState title="No rewrite rules" description="Requests currently route using the original model name." />}
fallback={
<EmptyState
description="Requests currently route using the original model name."
title="No rewrite rules"
/>
}
when={(rules()?.length ?? 0) > 0}
>
<DataGrid
rows={currentRules() ?? []}
columns={[
{ id: 'source_model', header: 'Source', cell: (rule) => <span>{rule.source_model}</span> },
{ id: 'target_model', header: 'Target', cell: (rule) => <span>{rule.target_model}</span> },
{ id: 'mode', header: 'Mode', cell: (rule) => <StatusBadge tone={rule.force ? 'warning' : 'neutral'}>{rule.force ? 'Force' : 'Fallback'}</StatusBadge> },
{ id: 'is_active', header: 'Status', cell: (rule) => <StatusBadge tone={rule.is_active ? 'success' : 'warning'}>{rule.is_active ? 'Active' : 'Inactive'}</StatusBadge> },
{ id: 'note', header: 'Note', cell: (rule) => <span title={rule.note ?? '-'}>{rule.note ?? '-'}</span> },
{
id: 'source_model',
header: 'Source',
cell: (rule) => <span>{rule.source_model}</span>,
},
{
id: 'target_model',
header: 'Target',
cell: (rule) => <span>{rule.target_model}</span>,
},
{
id: 'mode',
header: 'Mode',
cell: (rule) => (
<StatusBadge tone={rule.force ? 'warning' : 'neutral'}>
{rule.force ? 'Force' : 'Fallback'}
</StatusBadge>
),
},
{
id: 'is_active',
header: 'Status',
cell: (rule) => (
<StatusBadge
tone={rule.is_active ? 'success' : 'warning'}
>
{rule.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
),
},
{
id: 'note',
header: 'Note',
cell: (rule) => (
<span title={rule.note ?? '-'}>{rule.note ?? '-'}</span>
),
},
]}
getRowKey={(rule) => rule.id}
loading={rules.loading && (currentRules()?.length ?? 0) === 0}
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 continue the chain when the current model is unavailable."
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 continue to the target model. When disabled, the target is used only if the current model has no backend."
checked={form().force}
onChange={(checked) => setForm((current) => ({ ...current, force: checked }))}
<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,27 +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 currentScripts = createMemo(() => scripts.state === 'ready' || scripts.state === 'refreshing' ? scripts.latest : undefined);
const currentUsers = createMemo(() => users.state === 'ready' || users.state === 'refreshing' ? users.latest : undefined);
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
const 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(() => (currentUsers() ?? []).map((user) => ({ value: String(user.id), label: user.name })));
const backendOptions = createMemo(() => (currentBackends() ?? []).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(() => (currentScripts() ?? []).filter((script) => script.is_active).length);
const selectedScript = createMemo(() => (currentScripts() ?? []).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) {
@ -138,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 = (currentUsers() ?? []).find((item) => item.id === script.target_user_id);
const backend = (currentBackends() ?? []).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 {
@ -174,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) {
@ -186,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) {
@ -194,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);
}
@ -218,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);
}
@ -231,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.',
});
}
};
@ -261,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);
}
@ -270,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;
}
@ -278,13 +400,16 @@ export const Scripts: Component = () => {
setTestResult(null);
try {
const result = await api.scripts.test(current.id, {
user: currentUsers()?.[0] || undefined,
backend: currentBackends()?.[0] || undefined,
user: users()?.[0] || undefined,
backend: backends()?.[0] || undefined,
request: {
method: 'POST',
path: '/v1/chat/completions',
headers: { 'Content-Type': 'application/json' },
body: { model: 'test', messages: [{ role: 'user', content: 'test' }] },
body: {
model: 'test',
messages: [{ role: 'user', content: 'test' }],
},
isStream: false,
},
});
@ -303,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>
)}
@ -317,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
when={!scripts.loading || (currentScripts()?.length ?? 0) > 0}
fallback={<EmptyState title="Loading scripts" description="Reading middleware definitions and target mappings." />}
fallback={
<EmptyState
description="Reading middleware definitions and target mappings."
title="Loading scripts"
/>
}
when={!scripts.loading || (scripts()?.length ?? 0) > 0}
>
<Show
when={(currentScripts()?.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={currentScripts() ?? []}
columns={[
{
id: 'name',
@ -358,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',
@ -367,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>
);
},
@ -376,11 +534,17 @@ 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}
loading={scripts.loading && (currentScripts()?.length ?? 0) === 0}
loading={scripts.loading}
onRowClick={(script) => syncForm(script)}
rowActions={(script) => (
<div class="ui-row-actions">
@ -389,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',
},
]}
/>
@ -472,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>
@ -503,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 },
]}
/>
@ -524,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 {
@ -38,7 +48,6 @@ interface UserFormState {
api_key: string;
is_active: boolean;
detail_logging: boolean;
copy_reasoning_to_reasoning_content: boolean;
}
const emptyForm = (): UserFormState => ({
@ -47,65 +56,86 @@ const emptyForm = (): UserFormState => ({
api_key: '',
is_active: true,
detail_logging: false,
copy_reasoning_to_reasoning_content: false,
});
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
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 currentUsers = createMemo(() => users.state === 'ready' || users.state === 'refreshing' ? users.latest : undefined);
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
const currentPermissions = createMemo(() => permissions.state === 'ready' || permissions.state === 'refreshing' ? permissions.latest : undefined);
const [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(() => {
const value = query().trim().toLowerCase();
const list = currentUsers() ?? [];
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(() => (currentUsers() ?? []).filter((user) => user.is_active).length);
const selectedUser = createMemo(() => (currentUsers() ?? []).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 (currentPermissions() ?? []).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(() =>
(currentBackends() ?? [])
(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>();
for (const backend of currentBackends() ?? []) {
for (const backend of backends() ?? []) {
names.set(backend.id, backend.name);
}
return names;
});
createEffect(() => {
const list = currentUsers() ?? [];
const list = users() ?? [];
const currentSelectedUserId = selectedUserId();
if (list.length === 0) {
@ -115,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);
}
});
@ -134,7 +167,6 @@ export const Users: Component = () => {
api_key: user.api_key,
is_active: user.is_active,
detail_logging: user.detail_logging,
copy_reasoning_to_reasoning_content: user.copy_reasoning_to_reasoning_content,
});
setDialogOpen(true);
};
@ -157,7 +189,6 @@ export const Users: Component = () => {
api_key: current.api_key.trim() || undefined,
is_active: current.is_active,
detail_logging: current.detail_logging,
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
});
setNotice({ tone: 'success', message: 'User updated.' });
} else {
@ -166,7 +197,6 @@ export const Users: Component = () => {
email: current.email.trim() || undefined,
api_key: current.api_key.trim() || undefined,
detail_logging: current.detail_logging,
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
});
setNotice({ tone: 'success', message: 'User created.' });
}
@ -176,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);
}
@ -185,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.',
});
}
};
@ -197,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.',
});
}
};
@ -218,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);
}
@ -234,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);
}
@ -277,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);
}
@ -287,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>
)}
@ -302,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>
@ -314,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',
@ -350,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',
@ -358,50 +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>,
},
{
id: 'reasoning_compat',
header: 'Reasoning Compat',
cell: (user) => <StatusBadge tone={user.copy_reasoning_to_reasoning_content ? 'success' : 'neutral'}>{user.copy_reasoning_to_reasoning_content ? 'On' : 'Off'}</StatusBadge>,
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>
),
},
]}
getRowKey={(user) => user.id}
loading={users.loading && filteredUsers().length === 0}
emptyMessage="No users match the current search."
getRowKey={(user) => user.id}
loading={users.loading}
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>
@ -410,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) => (
<>
@ -431,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}`}
loading={(permissions.loading || backends.loading) && permissionsForSelectedUser().length === 0}
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>
</>
)}
@ -490,72 +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 }))}
/>
<Checkbox
label="Copy reasoning to reasoning_content"
description="Enable for clients that only display thinking from reasoning_content."
checked={form().copy_reasoning_to_reasoning_content}
onChange={(checked) => setForm((current) => ({ ...current, copy_reasoning_to_reasoning_content: checked }))}
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) => (
@ -569,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,291 +1,4 @@
export type User = {
id: number;
api_key: string;
name: string;
email?: string;
is_active: boolean;
detail_logging: boolean;
copy_reasoning_to_reasoning_content: boolean;
created_at: string;
updated_at: string;
};
export type Backend = {
id: number;
name: string;
base_url: string;
api_key?: string;
is_active: boolean;
detail_logging: boolean;
created_at: string;
updated_at: string;
cached_model_count?: number;
last_model_sync_at?: string;
model_cache_initialized?: boolean;
model_cache_state?: 'ready' | 'uninitialized' | 'error' | 'inactive';
};
export type BackendModelSnapshot = {
id: number;
backend_id: number;
model_id: string;
raw_json?: string;
fetched_at: string;
created_at: string;
updated_at: string;
};
export type BackendModelCacheStatus = {
backend_id: number;
initialized: boolean;
state: 'ready' | 'uninitialized' | 'error' | 'inactive';
model_count: number;
last_synced_at?: string;
last_attempted_at?: string;
last_error?: string;
};
export type BackendModelsResponse = {
backend: Backend;
cache: BackendModelCacheStatus;
snapshots: BackendModelSnapshot[];
models: string[];
};
export type BackendModelCatalogEntry = {
model_id: string;
backend_ids: number[];
};
export type ModelCacheOverview = {
backends: BackendModelCacheStatus[];
models: BackendModelCatalogEntry[];
};
export type ModelRewriteRule = {
id: number;
source_model: string;
target_model: string;
is_active: boolean;
force: boolean;
note?: string;
created_at: string;
updated_at: string;
};
export type Permission = {
id: number;
user_id: number;
backend_id: number;
created_at: string;
};
export type RequestLog = {
id: number;
user_id: number;
backend_id: number;
endpoint: string;
request_model?: string;
routed_model?: string;
response_model?: string;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
status_code: number;
response_time_ms?: number;
error_message?: string;
detail_logged: boolean;
local_date: string;
request_headers?: string;
request_body?: string;
response_headers?: string;
response_body?: string;
created_at: string;
};
export type RequestLogPage = {
rows: RequestLog[];
total: number;
limit: number;
offset: number;
};
export type UsageStats = {
id: number;
user_id: number;
backend_id: number;
date: string;
total_requests: number;
total_tokens: number;
};
export type BackendMetrics = {
id: number;
backend_id: number;
date: string;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
};
export type AnalyticsDailyTotalsPoint = {
date: string;
total_requests: number;
total_tokens: number;
};
export type AnalyticsBackendQualityPoint = {
date: string;
backend_id: number;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
};
export type AnalyticsModelTrendPoint = {
date: string;
model: string;
request_count: number;
};
export type AnalyticsHistogramBin = {
bin_start: number;
bin_end: number;
count: number;
};
export type AnalyticsBoxPlotPoint = {
date: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
count: number;
};
export type DashboardHealthStatus = {
status: 'ok';
timestamp: string;
};
export type DashboardOverviewSummary = {
total_users: number;
active_users: number;
total_backends: number;
active_backends: number;
total_permissions: number;
total_scripts: number;
active_scripts: number;
};
export type DashboardHealthSummary = {
cache_state_counts: Record<Backend['model_cache_state'] extends infer T ? Extract<T, string> : never, number>;
stale_backends: Array<{
id: number;
name: string;
state: NonNullable<Backend['model_cache_state']>;
last_synced_at?: string;
}>;
public_health: DashboardHealthStatus;
admin_health: DashboardHealthStatus;
};
export type DashboardLoggingSummary = {
users_with_detail_logging: number;
backends_with_detail_logging: number;
};
export type DashboardScriptSummary = {
active_by_type: Record<ScriptType, number>;
total_by_type: Record<ScriptType, number>;
};
export type DashboardAccessSummary = {
permission_assignments: number;
users_without_permissions: number;
};
export type DashboardSummaryResponse = {
window_days: number;
generated_at: string;
overview: DashboardOverviewSummary;
health: DashboardHealthSummary;
logging: DashboardLoggingSummary;
scripts: DashboardScriptSummary;
access: DashboardAccessSummary;
series: {
daily_totals: AnalyticsDailyTotalsPoint[];
backend_quality: AnalyticsBackendQualityPoint[];
model_trends: AnalyticsModelTrendPoint[];
};
};
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
export type UserScript = {
id: number;
name: string;
script_type: ScriptType;
target_user_id: number | null;
target_backend_id: number | null;
script_code: string;
is_active: boolean;
created_at: string;
updated_at: string;
};
export type CreateScriptData = {
name: string;
script_type: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code: string;
is_active?: boolean;
};
export type UpdateScriptData = {
name?: string;
script_type?: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code?: string;
is_active?: boolean;
};
export type AdminAuthMode = 'env' | 'oidc' | 'both';
export type AdminPrincipal = {
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
displayName: string;
};
export type AdminSessionResponse = {
authenticated: boolean;
authMode: AdminAuthMode;
csrfToken: string | null;
principal: AdminPrincipal | null;
};
export type AdminApiTokenSummary = {
id: number;
name: string;
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
display_name: string;
token_prefix: string;
expires_at: string;
last_used_at?: string;
revoked_at?: string;
created_at: string;
updated_at: string;
};
// 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

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

View file

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

View file

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

View file

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

View file

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

9
client/src/vite-env.d.ts vendored 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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -1,6 +1,6 @@
{
"name": "kyush-llm-router-express",
"version": "1.0.10-express",
"name": "kyush-llm-router",
"version": "1.0",
"description": "LLM routing server with multi-user API key management",
"scripts": {
"dev": "pnpm --parallel dev",
@ -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,12 +1,8 @@
packages:
- shared
- server
- client
allowBuilds:
better-sqlite3: true
esbuild: true
isolated-vm: true
onlyBuiltDependencies:
- better-sqlite3
- esbuild

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,67 +1,41 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { ensureDir, getAnalyticsDbPath } from './db-paths';
function hasIndex(database: Database.Database, tableName: string, indexName: string): boolean {
const result = database.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name = ?`).get(tableName, indexName) as { name: string } | undefined;
return Boolean(result);
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 ensureAnalyticsIndexes(db: Database.Database): void {
const indexes: Array<{ name: string; table: string; sql: string }> = [
{ name: 'idx_usage_stats_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_backend_date ON usage_stats(backend_id, date)' },
{ name: 'idx_usage_stats_user_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_user_backend_date ON usage_stats(user_id, backend_id, date)' },
{ name: 'idx_backend_metrics_backend_date', table: 'backend_metrics', sql: 'CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend_date ON backend_metrics(backend_id, date)' },
];
for (const { name, table, sql } of indexes) {
if (!hasIndex(db, table, name)) {
db.exec(sql);
}
}
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;
}
let db: Database.Database;
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);
ensureAnalyticsIndexes(db);
}
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,63 +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');
}
if (hasColumn(database, 'users', 'copy_reasoning_to_reasoning_content') === false) {
database.exec('ALTER TABLE users ADD COLUMN copy_reasoning_to_reasoning_content INTEGER NOT NULL DEFAULT 0');
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,4 +0,0 @@
export function shouldIncludeModelListRoutingMetadata(): boolean {
const value = process.env.MODEL_LIST_INCLUDE_ROUTING_METADATA?.trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
}

View file

@ -1,75 +1,83 @@
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');
}
}
function ensureRequestLogsIndexes(db: Database.Database): void {
const existingIndexes = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'request_logs'").all() as Array<{ name: string }>;
const indexNames = new Set(existingIndexes.map((idx) => idx.name));
const indexes = [
['idx_request_logs_local_date_backend', 'CREATE INDEX IF NOT EXISTS idx_request_logs_local_date_backend ON request_logs(local_date, backend_id)'],
['idx_request_logs_completion_tokens', 'CREATE INDEX IF NOT EXISTS idx_request_logs_completion_tokens ON request_logs(completion_tokens)'],
];
for (const [name, sql] of indexes) {
if (!indexNames.has(name)) {
db.exec(sql);
}
}
}
export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
export function getRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
return existing;
}
if (existing) return existing;
const dbPath = getRequestLogsDbPath(monthKey);
ensureDir(path.dirname(dbPath));
const db = new Database(dbPath);
initRequestLogsSchema(db);
ensureRequestLogsIndexes(db);
connections.set(monthKey, db);
return db;
}
export function initRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
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,11 +0,0 @@
export type DetailStreamLogMode = 'compact' | 'raw' | 'both' | 'off';
export function getDetailStreamLogMode(): DetailStreamLogMode {
const value = process.env.DETAIL_STREAM_LOG_MODE?.trim().toLowerCase();
if (value === 'compact' || value === 'raw' || value === 'both' || value === 'off') {
return value;
}
return 'compact';
}

View file

@ -1,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 { createJsonBodyParser, JSON_BODY_LIMIT, requestBodyErrorHandler } from './utils/requestBody';
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(createJsonBodyParser());
app.use(requestBodyErrorHandler);
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') {

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