diff --git a/BLOG.md b/BLOG.md index 9bad89f..4b3f36d 100755 --- a/BLOG.md +++ b/BLOG.md @@ -4,6 +4,159 @@ Author: **Jeremy Anderson** · · --- +## v0.3.0 — 2026-09-11 (AI Gateway Edition: klanker-gate integrated) + +*(latest release: v0.4.1 — cockpit module detection; see the last entry below)* + +v0.3.0 answers the operator's question: *"lets take a look at this project klanker-gate, i believe its mainly coded for a windows platform. how much work would it be to port it to arch linux and into the sysdeck as a module."* The answer surprised the premise, so this entry records both the verdict and the evidence. + +### The verdict: zero source changes (it was never a Windows codebase) + +klanker-gate — internally "Frosty Deno" — is a **Deno 2 + TypeScript** LLM gateway by **TykoDev** (https://github.com/TykoDev/klanker-gate, Apache-2.0; **not SysDeck code** — full credit and license notes in `klanker-gate/ATTRIBUTION.md` and `THIRD_PARTY.md`). Deno runs identically on every platform. The Windows mentions in its 448-file tree are accommodations for a second-class Windows *dev* platform, not Windows-first code: + +- `apps/gateway/cluster.ts` — `reusePortSupported()` returns true **only for `linux` and `darwin`**. Windows is locked to single-process serving, and the failure message literally directs the operator to "Use Docker/Linux for multi-process". +- `Dockerfile` — both stages are Linux images (`denoland/deno:2.9.3`, `denoland/deno:alpine-2.9.3`). The Windows comment in it is about Defender locking files on a Windows *host*. +- `deploy/docker-entrypoint.sh` — a POSIX `/bin/sh` script. +- `scripts/` — `.sh` and Deno `.ts` tasks; not a single `.bat`/`.ps1` in the tree. +- `deno.lock` win32 entries — ordinary cross-platform lockfile records that every project using npm-native dependencies carries on every OS. + +So the "port" is a **packaging exercise**: install Deno + PostgreSQL, manage the process with systemd. On top of that, moving to Arch *unlocks* a feature Windows cannot have — `FROSTY_WORKERS` multi-process serving through `SO_REUSEPORT` (Linux/darwin only). + +### What ships in sysdeck-0.3.0-master.tar.bz2 + +- `/` — the cockpit edition: **27 plugins** (new: sysdeck-klanker), 28 bridge modules. +- `/web` — the SysDeck Web Edition: **29 bridge modules** (new: klanker, the AI Gateway panel). +- `/web/mini-services/fester` — Fester, vendored + pre-integrated (independent version 0.2.1), unchanged from v0.2.0. +- `/klanker-gate` — **klanker-gate vendored + pre-integrated** (by TykoDev — https://github.com/TykoDev/klanker-gate — independent version 0.9.0, Apache-2.0, credited in its `ATTRIBUTION.md`), including the new `arch/` packaging directory. + +### Run without Cockpit — the complete runbook (0.3.0) + +v0.3.0 also answers: *"make better instructions on running sysdeck without cockpit with just the nextjs backend."* The web edition is fully standalone — no cockpit, no Python bridge, no systemd, no root — and the instructions now exist in three kept-in-sync forms: + +- **the "Run without Cockpit" panel** in the web console (system group, right under Overview): prerequisites, the one-command `make web-dev` quickstart, the granular commands, the standalone production build (bun + node-only paths), both systemd units (web + fester), the `.env` reference table, the reverse-proxy/WebSocket-gateway configs (Caddy + nginx), and a troubleshooting matrix — every command block copy-to-clipboard. +- **`web/README.md`** in the master tarball — the same runbook as plain markdown. +- **QUICKSTART §11** — the condensed version, plus §12 for the skin below. + +### The web-edition skin for Cockpit (0.3.0) + +*"…or completely theme cockpit to look like the web edition 0.3.0 as demoed"* — done, without touching a single module: + +- `shared/sysdeck-web.css` — the skin: every plugin's `index.html` links it after the base stylesheet. It ports the Next.js console's midnight/teal design (accent `#3fc9b0`, soft-tinted badges, 10px radii, tabular numerals, teal-edged scrollbars, reduced-motion support) onto the `.sysdeck-*` / `.suite-*` vocabulary. Revert: delete the file. +- `sudo make install-branding` — themes the Cockpit **shell** chrome itself (sidebar, header, login) via `/usr/share/cockpit/branding.css`, Cockpit's documented override point; covers PatternFly v4 (`pf-c-*`) and v5 (`pf-v5-*`) generations, backs up any distro `branding.css` first, `make uninstall-branding` restores it. + +### The Arch packaging (`klanker-gate/arch/`) + +- `PKGBUILD` — self-packaging (files come from the tree the file lives in): gateway tree → `/usr/share/klanker-gate`, wrapper → `/usr/bin/klanker-gate`, env → `/etc/klanker-gate/env` (pacman `backup=()`), unit + sysusers + tmpfiles in their canonical locations. `depends=('deno>=2.9')`; postgres is an `optdepends` (remote `FROSTY_PG_URL` is equally supported). +- `klanker-gate.service` — hardened systemd unit: `StateDirectory=klanker-gate`, `WorkingDirectory=/var/lib/klanker-gate` (so the permission contract's `--allow-write=data` resolves to `/var/lib/klanker-gate/data`), `ProtectSystem=full`, `PrivateTmp`, empty `CapabilityBoundingSet`. Deliberately no `MemoryDenyWriteExecute` (V8's JIT needs W^X pages). +- `run.sh` — the ExecStart wrapper: one-time `deno cache --frozen` warmup into the service user's DENO_DIR, then exec with the upstream permission flags, plus `--allow-run` scoped to the Deno binary **only** when `FROSTY_WORKERS>1` — mirroring the upstream entrypoint's own escalation policy. +- `INSTALL-ARCH.md` — the full runbook: `makepkg -si`, local postgres provisioning (role + database), env configuration, `systemctl enable --now`, healthcheck curls, the multi-worker bonus, the optional control-UI build, and the SysDeck wiring for both editions. + +### The klanker module (both editions) + +- `bridge/klanker.py` — stdlib-only REST client (urllib, 4s timeout — never outliving the panel's 5s poll), 10 subcommands: `status` (healthz + version, merged + enriched), `providers`, `models`, `vkeys`, `logs --limit`, `analytics --window`, `runtime`, `service ` (systemctl wrapper for klanker-gate.service), `journal [N]` (journalctl tail, ANSI-stripped, token-masked, 32 KB cap), `localstack` (probes ollama/llama.cpp/koboldcpp/LM Studio/SGLang/vLLM `/v1/models` on this host, 0.4s each in parallel threads, returns wiring recipes + env/admin-API examples). `KLANKER_URL` (default `http://127.0.0.1:8080`) and `KLANKER_ADMIN_TOKEN` (header-only, never echoed) drive it. Connection failures are graceful JSON with a remediation hint — same contract as fester.py. +- `plugins/sysdeck-klanker/` — full panel: gateway status card, stat grid (providers, virtual keys, requests 24h, spend 24h — upstream money is integer **micro-USD**, displayed as `$X.XXXX`), providers table with health dots, virtual keys table, recent-requests table, model catalog, **local stack wiring card** (live-probed backend matrix + copyable env/curl recipes), runtime topology card, service control (start/stop/restart with confirm) and a journal viewer. 5s auto-refresh that preserves the service/journal card state. +- Web edition — the **AI Gateway** panel under Integrations: hybrid live/demo. It probes the gateway first (`KLANKER_URL`, 1.5s timeout, Bearer `KLANKER_ADMIN_TOKEN`); unreachable → seeded demo rows, clearly badged, with a note explaining that this sandbox has no Deno/Postgres. On a host running the gateway, it flips to `source: live` untouched. +- `shared/bridge.js` — the klanker surface: 11 methods. `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216/27 — the audit pass added auth readers+certs). + +### The local stack is first-class (0.3.0 follow-up) + +A fair question after the integration: *"it seems to be mainly for SaaS +account linking, i personally only run a local stack such as ollama, +llama.cpp, koboldcpp"* — can all features be met locally? Yes: the +upstream provider registry has **five keyless self-hosted types** — +`ollama`, `lmstudio`, `sgl`, and the generic `openai-compatible` / +`anthropic-compatible` ("user supplies the base URL and an optional +key", per the registry's own comment) — and llama-server/KoboldCpp/vLLM +all speak the OpenAI wire. Governance, virtual keys, budgets, fallback ++ weighted load-balancing, model auto-discovery (`refresh-models`), the +request ring and analytics are all provider-agnostic, so they work +unchanged over a local stack — spend just reads ~$0. + +What the release adds on top: the `localstack` probe + the **Local +stack wiring** cards in both editions, env/admin-API recipes with the +8080 port-collision warning (llama-server's default = the gateway's +port), QUICKSTART §10.1, and a local-first re-seeded web demo dataset +(ollama + llama-server + koboldcpp + lmstudio + sglang + one groq +overflow row — 24h spend ≈ $0.001, all of it the cloud fallback). + +And the follow-up question — *"it should be easy to toggle this ai +gateway on or off in case i decide to use something like pi"* — is now +a first-class shell feature: **module visibility toggles** (web +edition). Every sidebar row carries a power control; a disabled module +vanishes from navigation and the command palette, lands in a +**Disabled (N)** section for one-click re-enable, and the state +persists in SQLite (`shell.disabled`, new `shell` bridge module, +audited). The `POST /api/bridge` envelope also got a correctness fix: +command-level `fail()` responses now pass through top-level instead of +being double-wrapped, so panels actually render command errors. + + +### The 0.3.0 security audit (follow-up) + +*"do a security audit on klankergate code and the full sysdeck codebase +afterwards"* — done, end to end, and the release is better for it. Four +audit passes covered the vendored gateway (Deno routes, crypto, admin +surface, compose), the cockpit bridge (28 helpers), the 27 plugin +panels, and the web edition (bridge dispatcher, 31 modules, panels, +fester service). + +**SysDeck code: fixed, not filed.** The two criticals in the cockpit +bridge were both "the guard exists 100 lines away and this command +forgot it" — `policy.py cgroup-set` wrote `/` +as root (arbitrary file overwrite → one-prompt persistent root via +`/etc/cron.d`), and `builder.py artifacts-clear` rmtree'd an +unvalidated profile argument. Both now resolve-and-bound exactly like +their siblings; the same treatment went to build-log/build-delete/ +artifacts/profile-create (traversal + config injection into +root-executed build configs). hwalert carried the tree's only `sudo sh +-c` f-string — a literal root shell injection, latent only because +the module isn't wired to a panel yet — now a direct write behind a +sysfs device-path guard. The db module's start/stop/restart accepted +arbitrary unit names (`db stop sshd`) and its `query` promised +DDL-refusal in a comment while running any statement; both are honest +now. The 8 oldest panels rendered live tool output unescaped into +`innerHTML` (package names from repos, USB descriptors, fwupd metadata +— the XSS→cockpit-session→bridge-RCE chain); all escape now, and the +27 manifests dropped `unsafe-eval`. The web edition bound every +surface to 0.0.0.0 unauthenticated — dev server, fester service, and +the port gateway's wildcard transform — so everything now pins +loopback, the bridge gained a body cap + per-IP rate limit, and +unexpected errors return generic text (details to the server log). + +**klanker-gate: audited, credited, and left unmodified.** The vendoring +contract (byte-identical upstream tree, SysDeck adds only `arch/`) +holds — so the audit's 10 upstream findings (3 critical: the admin API +runs unauthenticated when `FROSTY_ADMIN_TOKEN` is unset, the gateway +binds 0.0.0.0 by default, and `/v1/*` is open until the first virtual +key exists) are documented in `klanker-gate/arch/SECURITY-UPSTREAM.md` +as an advisory that can travel upstream, while the SysDeck packaging +layer mitigates what packaging can: the systemd unit now refuses to +start without the token (fail-closed `ExecStartPre`, opt-out via +drop-in), and `INSTALL-ARCH.md` §9 carries the firewall + create-a-vkey +-immediately runbook. The audit also recorded what upstream does +*well* — AES-256-GCM envelope key storage, SHA-256-hashed vkeys, +redacted provider views, an opt-in-only content log with deep secret +redaction, and a properly sandboxed Code Mode worker. + +**Verified:** `make check` 254/254, `check-bridge-subcommands` 218/28 (the audit pass added auth readers+certs), +python compile checks on all 8 touched bridge files, panel import +checks, `bun run lint` clean, and the tarball rebuilt with new builder +guards that fail the build if any of the fixes regress. + +### Verification + +- Cockpit tree: `make check` — **all 254 tests + guards pass** with the new module (bridge subcommands cross-check, manifest consistency, version sync across all 9 surfaces now reporting 0.3.0). +- Bridge offline smoke: `status`/`service status`/`journal` all return graceful structured JSON in the sandbox (no Deno, no systemd units here — exactly the degradation path designed). +- Web edition: `bun run lint` clean; bridge smoke — all 7 klanker commands return `ok:true` with `source:'demo'`; panel render + mobile verified with zero page/console errors. +- Master tarball rebuilt by `scripts/make-master-tarball.sh` with the vendored `klanker-gate/`; verified by extraction, file count, sha256. + +### Notes + +- klanker-gate's version (0.9.0) is intentionally independent from SysDeck's (0.3.0), same as Fester's (0.2.1) — each mirrors its own repository's version line inside the bundle. +- The gateway cannot run inside the web edition's dev sandbox (no Deno runtime, no PostgreSQL); that is what the honest DEMO badge communicates. On the operator's Arch host, `arch/INSTALL-ARCH.md` is the 5-minute path to the LIVE badge. + +--- + ## v0.2.0 — 2026-08-20 (Master Edition: one tarball, two editions, Fester pre-integrated) v0.2.0 turns SysDeck into a single distributable that ships **both** editions with **Fester vendored and wired in**. The release answers the operator's framing directly: *"fester exists as a separate repository — it deserves its own. generate a master tarball of sysdeck with fester pre-integrated."* @@ -2753,3 +2906,172 @@ check: check-metainfo-consistency The v0.0.27 release notes claimed "each subcommand now verified against the actual COMMANDS dict." That verification was done by hand at authoring time — and hand-verification rots the moment someone touches either side without re-running the verification. The new `check-bridge-subcommands` guard makes the verification automatic and continuous. Every `make check` from now on will catch any future bridge.js ↔ Python helper drift, with a message that names the exact file, line, and missing subcommand. + +--- + +## v0.3.1 — 2026-09-13 (the login gate) +### The cockpit-style login (0.3.1 follow-up) + +*"sysdeck is intended for lan side use not wan facing, so this is a +decision point for lack of crypto or auth in depth. a simple +cockpit-style login for our standalone is fine by me, lets get it +coded if it hasnt been added to the standalone nextjs side"* — the +operator made the posture call, and this entry records how it landed. + +The 0.3.0 audit ended with the web edition guarded (loopback binds, +rate limits, body caps) but still unauthenticated. That was honest +for a LAN-side console — and it left the door literally open to +anyone who could reach the port. 0.3.1 adds the one boundary that +matches the actual threat model (the roommate, the accidental +port-forward): **a shared password, exactly like the Cockpit login +the console's plugin pages already live behind.** + +Mechanically: `SYSDECK_WEB_PASSWORD` in `web/.env` (default +`sysdeck`, with an amber nag on the login screen until you change +it — the nag is intentional, so a default install advertises its own +default). The password compares in constant time; failures rate +limit per IP at 5/minute and both login and failure land in the +AuditLog with the source IP. A success mints an HttpOnly +SameSite=Lax cookie carrying an HMAC-SHA256-signed token with a 12h +expiry — and the signing key is generated per install and persisted +in the same SQLite store everything else uses, which buys a +property the project needed anyway: **the fester mini-service reads +that key straight out of the DB file and verifies the identical +token on its REST and WebSocket surface.** The browser's live DAG +event stream rides the same cookie, so the direct +`?XTransformPort=3010` path is gated, not just the proxied routes. +Until the web console has booted once, fester keeps its documented +standalone behavior (loopback, unauthenticated) — the gate arms +itself the first time the console renders. + +Everything visible is gated: the page server-renders a login screen +until the cookie verifies, every `/api/*` route answers 401 until +signed in, and a session expiring mid-flight reloads to the login +instead of plastering panels with error cards. What it is *not* is +also on the record (QUICKSTART §10.4): no user accounts, no MFA, no +online-attacker crypto — the loopback bind remains the outer +boundary, and `SYSDECK_SESSION_SECURE=1` arms the Secure cookie flag +when an operator fronts the console with TLS. + +**Verified:** `bun run lint` clean; curl smoke of the full auth +lifecycle (wrong password 401 + audited, login → cookie → bridge 200, +logout → 401 again, forged token rejected, 429 after the failure +cap, fester REST+WS gated, standalone-mode fallback); +`make check` 254/254 across the bumped release surfaces; the master +tarball rebuilt with new guards (session lib, gated routes, page +gate, fester gate, QUICKSTART §10.4). + +--- + +## v0.4.0 — 2026-09-12 (the Unix login) + +*"lets change from shared login to unix acc based login same way +cockpit does it"* — the operator's directive, and the one this whole +project was always pointed at: the console is named for the deck it +replaces, so its login should work the way that deck's login works. +You sign in with a **Unix account, and the host's PAM stack decides.** + +The mechanics are deliberately boring, because PAM already solved +this in 1997: `web/scripts/pam-auth.py` is a stdlib-only ctypes +client of `libpam` that runs the same sequence every login surface on +the box runs — `pam_start` → `pam_authenticate` → `pam_acct_mgmt` — +under the `sysdeck` service when `/etc/pam.d/sysdeck` exists, else +the stock `login` stack. The credentials arrive over stdin (argv is +world-readable in `/proc`, so it never touches argv), the +conversation callback answers only password/username prompts, and +the reply buffers come from the C allocator because Linux-PAM frees +them itself — the classic ctypes-PAM heap-corruption trap, found and +fixed the honest way (the sandbox crash first, then the malloc). + +The honest constraint surfaced early: pam_unix needs root to verify +*arbitrary* users (a non-root process only gets its own uid through +`unix_chkpwd` — a pam_unix guarantee, not ours). Cockpit answers that +by running cockpit-ws as root; SysDeck ships the same call as a +mode: `SYSDECK_AUTH_MODE=pam` (default) for the root systemd unit, +`pam+local` for unprivileged installs (PAM first, then a `SdUser` +scrypt table managed by `bun scripts/manage-users.mjs`), `local` for +console-accounts-only. A wedged PAM helper fails CLOSED — a health +incident, never a silent fallback. Failures rate limit per-IP **and** +per-username, wrong-user and wrong-password look identical, and the +AuditLog now records the unix username as the actor on both ends. + +The session token grew a spine: `v2...` — the +cookie is *bound to the account that earned it*. The shell wears the +identity cockpit-style: an account menu with avatar, `user@host`, +PAM/local provenance, the wheel "Administrative access" badge, and a +session-expiry countdown with a draining life bar; the status bar +carries `user@host` beside the vitals. The fester service verifies +the identical v2 token on REST and WebSocket. And v1 tokens still +verify — the 0.3.1 README promised restart-stable sessions, so the +0.4.0 upgrade keeps that promise: old cookies age out as legacy +"operator" sessions instead of logging anyone out. + +The login screen itself finally got the fester treatment: host +identity banner (hostname + OS pretty name — exactly what cockpit +leads its own login with), an aurora/grid backdrop that re-skins +under every console theme, caps-lock detection, a one-shot error +shake, Enter-to-advance from username to password, and the amber +default-password nag for the seeded local account until it's +rotated. Reduced-motion users get the same scene, still. + +**Verified:** PAM helper exercised against the live stack (wrong +password → PAM_AUTH_ERR, clean exits, no heap events); curl smoke of +the full lifecycle (401 pre-auth, login → v2 cookie → bridge 200, +logout → 401, forged v2 rejected by console AND fester, 429 after +the failure cap, legacy v1 acceptance); browser-driven pass over the +login scene, account menu, panel transitions; the cockpit bridge +guards re-run unchanged (218 calls / 28 modules, manifests clean) — +the plugin side is untouched by design. + +--- + +## v0.4.1 — 2026-09-12 (cockpit module detection) + +*"lets make sure any installed cockpit modules detected are also +loaded in the nextjs only side as well, such as the distro modules +like cockpit-machines and cockpit-podman for example. this will be +100% compatible at that point."* — the operator's compatibility +directive, and the honest last gap: 0.4.0 made the LOGIN cockpit-equal, +but a host with cockpit-machines or cockpit-podman installed still +showed those modules nowhere in the Next.js console. + +0.4.1 makes the console perform the same discovery the cockpit shell +does: scan `/usr/share/cockpit//manifest.json` and treat every +package with a `menu` entry as a module. That single rule carries the +whole feature — distro modules (machines, podman, networking, storage, +accounts, updates, SELinux, PCP metrics, kdump, tuned), 45Drives-style +addons, anything a packager ships with a manifest. `sysdeck-*` modules +are skipped because native panels already exist for them; `base1` and +`shell` never appear because they have no menu — the shell's own rules, +reused verbatim. Detection is pure filesystem, so it works with cockpit +stopped, absent, or merely staged (`SYSDECK_COCKPIT_SCAN` points at +extra roots, colon-separated, for DESTDIR installs). + +Every detected module **loads into the console's navigation**: a +"Cockpit" sidebar group with a LIVE/DEMO provenance badge, a ⌘K palette +group, and a per-module detail view — manifest identity, shipped files +with sizes, live backend presence probes (real `which()` checks for +`virsh`/`podman`/`nmcli`/`pkcon`/`getenforce`/`pmrep`..., on-demand +version probes), and a jump to the native panel that already covers the +domain: machines and podman → Containers & VMs, packagekit → Packages, +networkmanager → Network Security, metrics → Monitoring, storage → +Overview. A Cockpit Modules hub panel summarizes the scan: counts, +backend availability, native coverage. With no cockpit tree on the +host, the surface shows a clearly-badged typical-distro set so it stays +explorable — the same demo/live honesty the rest of the console +practices. + +The same directive retired the codenames: no more "web edition", no +edition subtitles. The console's identity is **SysDeck**, and its one +subtitle line is a single clean URL — **dcos.net** — on the login +banner, under the sidebar wordmark, and in the status bar. The page +title is just "SysDeck". + +**Verified:** live detection exercised end-to-end against a staged +cockpit tree (machines + podman detected LIVE with labels, orders, API +levels and file counts from their manifests; menu-less chrome and +sysdeck-* correctly excluded); demo fallback returns the 11-module +typical distro set badged DEMO; detail views, native-panel jumps, hub +table, and palette entries driven through a real browser; `make check` +still ALL PASS (218 calls / 28 modules, 254/254 tests, version sync at +0.4.1); lint and tsc clean on every touched file. diff --git a/Makefile b/Makefile index 3512c90..78af6a5 100755 --- a/Makefile +++ b/Makefile @@ -1,9 +1,13 @@ # SysDeck - Makefile # Author: Jeremy Anderson (https://dcos.net) # -# v0.2.0 MASTER EDITION: two distributions in one tree — -# / the cockpit edition: 26 standalone Cockpit plugins + shared bridge -# /web the SysDeck Web Edition (Next.js console, 28 bridge modules) +# v0.3.0 AI GATEWAY EDITION: two distributions in one tree — +# / the cockpit edition: 27 standalone Cockpit plugins + shared bridge +# /web the SysDeck Web Edition (Next.js console, 29 bridge modules) +# /klanker-gate — the Frosty Deno LLM gateway, vendored + pre-integrated +# (by TykoDev, https://github.com/TykoDev/klanker-gate, +# Apache-2.0 — not SysDeck code; own version 0.9.0, with +# arch/ packaging for Arch Linux) # /web/mini-services/fester — Fester, vendored + pre-integrated (own version 0.2.1) # Each plugin ships to /usr/share/cockpit/sysdeck-/ and appears as # its own sidebar entry in Cockpit. The Python bridge helpers stay at @@ -26,7 +30,7 @@ # Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS. PACKAGE := sysdeck -VERSION := 0.2.0 +VERSION := 0.4.1 LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE) PYTHON_DIR := $(LIB_DIR)/bridge SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE) @@ -55,7 +59,7 @@ SMOKE_TEST_SCRIPT := cockpit-smoke-test.sh # Generator script (regenerates plugins/ and shared/). GENERATOR := scripts/generate-plugins.py -.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master +.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master install-branding uninstall-branding # ─── plugins: regenerate from generator ────────────────────────────── plugins: @@ -86,6 +90,10 @@ install: install -m 0644 shared/manifest.json $(DESTDIR)/usr/share/cockpit/sysdeck-common/manifest.json install -m 0644 shared/bridge.js $(DESTDIR)/usr/share/cockpit/sysdeck-common/bridge.js install -m 0644 shared/sysdeck.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck.css + # v0.3.0: the web-edition skin (midnight/teal design of the Next.js + # console) — every plugin index.html links it after base sysdeck.css. + # Remove the file to revert plugin pages to the classic 0.1.x skin. + install -m 0644 shared/sysdeck-web.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck-web.css # Python bridge helpers: /usr/lib/sysdeck/bridge/ # v0.0.27: install each helper as an executable script (0755, not 0644) # so they can be invoked by absolute path: @@ -385,7 +393,7 @@ distcheck: dist @rm -rf /tmp/sysdeck-distcheck-$$ @echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct." -# ─── v0.2.0 master edition: web + fester ───────────────────────────── +# ─── v0.3.0 master edition: web + fester + klanker-gate ───────────────────────────── # Run these from an extracted master tarball (where web/ sits alongside # this Makefile) or the canonical dev tree with web/ present. @@ -403,6 +411,35 @@ web-install: cd $(FESTER_DIR) && bun install web-dev: web-install + +# ─── branding: theme the Cockpit SHELL chrome to the web-edition look ──── +# /usr/share/cockpit/branding.css is Cockpit's documented override point +# for the shell (sidebar, header, login). shared/branding.css ports the +# web edition 0.3.0 midnight/teal design onto it. Any pre-existing +# branding.css (shipped by the distro) is backed up first and restored +# by `make uninstall-branding`. +install-branding: + @echo ">>> Theming the Cockpit shell to the web-edition look" + -@if test -f $(DESTDIR)/usr/share/cockpit/branding.css; then \ + cp -a $(DESTDIR)/usr/share/cockpit/branding.css $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak; \ + echo " existing branding.css backed up (branding.css.sysdeck-bak)"; \ + fi + install -d $(DESTDIR)/usr/share/cockpit + install -m 0644 shared/branding.css $(DESTDIR)/usr/share/cockpit/branding.css + @echo " installed /usr/share/cockpit/branding.css" + @echo " reload the Cockpit page (hard refresh) to see the shell skin" + +uninstall-branding: + @echo ">>> Restoring the Cockpit shell branding" + rm -f $(DESTDIR)/usr/share/cockpit/branding.css + -@if test -f $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak; then \ + mv $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak $(DESTDIR)/usr/share/cockpit/branding.css; \ + echo " restored original branding.css from backup"; \ + else \ + echo " no backup found — the distro package owns branding.css"; \ + echo " reinstall the cockpit-bridge package to restore defaults"; \ + fi + @echo ">>> Starting fester in the background (log: /tmp/fester.log)" cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 & @echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)" diff --git a/QA.md b/QA.md index 979c5b7..57959b8 100755 --- a/QA.md +++ b/QA.md @@ -1072,3 +1072,135 @@ PASS — guard fails with a clear, actionable message naming the exact file, lin ### 11. Honest accounting The v0.0.27 release notes claimed "each subcommand now verified against the actual COMMANDS dict." That claim was overstated — the verification was hand-done at authoring time and was incomplete. The new `check-bridge-subcommands` guard makes the verification automatic, continuous, and enforced at build time. Hand-verification rots; machine verification doesn't. + +# MoE Quality Assurance Pass — v0.4.0 (Unix Login Edition) + +## v0.4.0 QA — unix-account login + web console revision + +**Scope:** the 0.4.0 revision replaces the 0.3.1 shared-password login +with Unix-account (PAM) login in the web edition and gives the web +console a visual revision. The cockpit edition is untouched; guards +were re-run to prove it. + +### 1. Authentication core + +- `web/scripts/pam-auth.py` exercised against the live host PAM stack + (wrong password → `PAM_AUTH_ERR`, code 7, clean exit 1; malformed + JSON → `bad-json`; oversized credentials rejected). The conversation + callback allocates replies from libc (strdup/malloc) — verified + heap-clean across repeated invocations (no `free(): invalid pointer` + after the fix). +- Login route policy matrix verified by curl: + - pre-auth: every `/api/*` → 401; page server-renders login screen. + - wrong username and wrong password return the identical generic + `incorrect username or password` (no account enumeration). + - failure cap: 6th bad attempt within the window → 429 with + Retry-After (per-IP AND per-username buckets). + - `pam+local` mode: PAM-definitive-failure falls through to the + SdUser scrypt store; seeded account authenticates; v2 cookie minted. + - wedged helper (timeout/protocol) → fail CLOSED (401), never a + silent fallback. pam-only with missing helper → 503 setup error. + +### 2. Session integrity + +- v2 token format `v2...`: forged + signature rejected by the console route layer AND by the fester + service (REST + WS upgrade path) — both derive the HMAC from the + shared SQLite secret. +- v1 (0.3.1) tokens still verify (legacy session, user=null) — + upgrade continuity confirmed by code inspection of both verifiers. +- Logout clears the cookie (maxAge 0) and audits with the unix + username as actor; login-failed audits never contain the attempted + secret. + +### 3. Cockpit edition compatibility (the operator's requirement) + +- `make check`: **ALL PASS** — metainfo structure + 26 launchables, + 28/28 manifests conform, Makefile recipes tab-indented, no broken + cockpit imports, no `python3 -m sysdeck.bridge` calls, **218 + bridge.js calls cross-checked against 28 Python COMMANDS dicts**, + version sync across 9 release surfaces, `py_compile` + `node --check` + clean, 254/254 unit tests (version-sync test now pins 0.4.0). +- Zero changes under `bridge/`, `plugins/`, `shared/`, `standalone-plugins/` + (except `bridge/__init__.py` version string + packaging metadata). + +### 4. Web console revision + +- `bun run lint` clean. `tsc --noEmit` clean for every new/modified + file (pre-existing strictness complaints in vendored fester and + glances remain untouched, build unaffected — `ignoreBuildErrors`). +- Visual QA (headless browser screenshots reviewed by a vision model): + login scene — "polished and premium, no visual bugs"; shell + account + menu — "release-quality, dropdown anchors perfectly"; fester and + firewall panels render with no error cards or overlaps. +- prefers-reduced-motion kills the aurora/shake/panel transitions. + +### 5. Summary + +The 0.4.0 revision does what the operator asked: log in with a Unix +account the way Cockpit does (host PAM decides), every cockpit module +keeps working (guards green, tree untouched), and the web console now +carries its identity — account menu, user@host, session countdown — +at the fester quality bar. Remaining honest limits are documented in +QUICKSTART §10.4: pam_unix needs root for arbitrary-user verification +(use the root systemd unit, or pam+local), and the local scrypt store +is an escape hatch, not the primary path. + +# MoE Quality Assurance Pass — v0.4.1 (cockpit module detection) + +## v0.4.1 QA — every installed cockpit module loads into the web console + +**Scope:** the console now performs the cockpit shell's own module +discovery (filesystem manifest scan) and loads every detected module +into its navigation. UI codenames retired; subtitle is dcos.net. + +### 1. Detection correctness + +- Live path exercised against a staged cockpit tree via + `SYSDECK_COCKPIT_SCAN`: machines + podman detected with correct + labels ("Virtual Machines", "Podman Containers"), menu orders, API + levels (`requires.cockpit`), real file counts and paths. +- Exclusion rules match the cockpit shell's own: manifest without a + `menu` block (base1, shell) never listed; `sysdeck-*` never listed + (native panels exist). Verified with deliberately staged traps for + both rules. +- Demo fallback: with no cockpit tree, the 11-module typical-distro + set returns badged DEMO with the honest note; backend `which()` + probes still run REAL binaries on that path. +- `info` returns the full manifest JSON, recursive file listing with + sizes, and an on-demand backend version probe only when the binary + is present (list stays cheap). + +### 2. Console integration + +- Sidebar "Cockpit" group renders every detected module with a + LIVE/DEMO badge and per-module icons; active state routes as + `cm:`; the ⌘K palette searches the same entries; the + Cockpit Modules hub table row-click deep-links into detail views. +- Native-panel jumps (machines/podman → Containers & VMs verified in a + real browser click path) ride the same `sysdeck:goto` event the + overview callout uses. +- Visual QA (browser screenshots + vision model): shell, machines + detail view, and hub all render clean — no overlaps, no cut-offs, no + error cards; sidebar subtitle (dcos.net) judged "clean and minimal". + +### 3. Branding sweep + +- No "web edition" string remains in any user-visible surface (login + banner, sidebar, status bar, overview badge, glances subtitle, + services/packages panel texts, page title/metadata). The subtitle is + a single URL — dcos.net — on the login banner, sidebar and status + bar. README version line dropped the edition codename. + +### 4. Cockpit edition + guards + +- `make check`: ALL PASS — 218 bridge.js calls / 28 modules, 28 + manifests, version sync at 0.4.1 across all release surfaces, + 254/254 unit tests. The cockpit tree itself is untouched. + +### 5. Summary + +The compatibility loop is closed: whatever cockpit modules the host +has, the console has — detected from disk, badged honestly, probed +live, and cross-linked to the native panels. With 0.4.0's unix login +and this, the console/host pair is 100% aligned. diff --git a/QUICKSTART.md b/QUICKSTART.md index a2e3713..9571796 100755 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -140,9 +140,9 @@ sudo systemctl restart cockpit.socket Open the in-panel error view: every SysDeck plugin's `index.html` installs `window.addEventListener('error')` and `'unhandledrejection'` handlers that replace the "Loading…" placeholder with the actual error message on the page — no devtools required. The same page tells you whether `cockpit.js` itself loaded, whether `bridge.js` imported cleanly, and whether the panel's `mount()` threw. -## 9. The Web Edition (master tarball, v0.2.0) +## 9. The Web Edition (master tarball) -The master tarball also ships the **SysDeck Web Edition** at `web/` — a standalone browser console (no cockpit required) with 28 bridge modules, real `/proc` / `/sys` collectors, and **Fester pre-integrated** (vendored at `web/mini-services/fester`, independent version 0.2.1): +The master tarball also ships the **SysDeck Web Edition** at `web/` — a standalone browser console (no cockpit required) with 29 bridge modules, real `/proc` / `/sys` collectors, and **Fester pre-integrated** (vendored at `web/mini-services/fester`, independent version 0.2.1): ```bash make web-dev # fester service in the background (:3010) + web console (:3000) @@ -158,4 +158,270 @@ bun run dev # web console on :3000 Open `http://localhost:3000`. The master tarball can be rebuilt any time with `make master`. +## 10. The AI Gateway (master tarball, v0.3.0) + +The master tarball also vendors **klanker-gate** — the Frosty Deno LLM gateway (independent version 0.9.0, Apache-2.0, **by TykoDev: https://github.com/TykoDev/klanker-gate — not SysDeck code**, see `klanker-gate/ATTRIBUTION.md`) — at `klanker-gate/`, with the new **AI Gateway** module in both editions. On Arch Linux the whole gateway is one package away: + +```bash +cd klanker-gate/arch +pacman -S --needed deno base-devel # deno is in [extra] +makepkg -si # /usr/share/klanker-gate + systemd unit +sudoedit /etc/klanker-gate/env # FROSTY_PG_URL + one provider key (+ token) +sudo systemctl enable --now klanker-gate +curl http://localhost:8080/healthz +``` + +Then point SysDeck at it (cockpit bridge env, or `web/.env` for the web edition, then restart): + +```bash +KLANKER_URL=http://127.0.0.1:8080 +KLANKER_ADMIN_TOKEN= +``` + +Both the cockpit AI Gateway panel and the web edition's AI Gateway panel flip from their offline/demo state to live data automatically. The full runbook — postgres provisioning, multi-worker serving (`FROSTY_WORKERS`, an Arch bonus via `SO_REUSEPORT`), the optional control-UI build — is `klanker-gate/arch/INSTALL-ARCH.md`. + +### 10.1 Running an all-local stack (ollama · llama.cpp · koboldcpp) + +The gateway is **not SaaS-only** — no API key is required anywhere in this +setup. Five provider types are local-first upstream: `ollama`, `lmstudio`, +`sgl` (SGLang) natively, plus the generic `openai-compatible` type that +llama.cpp (llama-server), KoboldCpp, vLLM and TGI all speak: + +| backend | provider type | base URL | auth | +|---|---|---|---| +| Ollama | `ollama` | `http://127.0.0.1:11434/v1` | none | +| llama.cpp (llama-server) | `openai-compatible` | `http://127.0.0.1:8081/v1` | optional | +| KoboldCpp | `openai-compatible` | `http://127.0.0.1:5001/v1` | optional | +| LM Studio | `lmstudio` | `http://127.0.0.1:1234/v1` | none | +| SGLang | `sgl` | `http://127.0.0.1:30000/v1` | none | + +Env wiring (in `/etc/klanker-gate/env` or the gateway's `.env`): + +```bash +OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 +OLLAMA_MODELS=qwen3:14b,llama3.1:8b,nomic-embed-text +LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1 +OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8081/v1 # ONE openai-wire server +``` + +Env registers one `openai-compatible` account — to run llama.cpp **and** +koboldcpp (and vLLM) side by side, register each via the admin API, then +auto-discover its catalog: + +```bash +curl -s http://127.0.0.1:8080/api/providers -H 'Authorization: Bearer $FROSTY_ADMIN_TOKEN' \ + -H 'content-type: application/json' \ + -d '{"id":"llama-server","type":"openai-compatible","baseUrl":"http://127.0.0.1:8081/v1","enabled":true}' +curl -s -X POST http://127.0.0.1:8080/api/providers/llama-server/refresh-models \ + -H 'Authorization: Bearer $FROSTY_ADMIN_TOKEN' +``` + +**Port note:** llama-server defaults to `:8080` — the same port the gateway +listens on. Run it on another port (`--port 8081`) or move the gateway. + +Both editions ship a **Local stack wiring** card (in the AI Gateway panel) +that live-probes each backend's `/v1/models` from the host and shows these +recipes with copy buttons — `klanker localstack` at the bridge level. + +### 10.2 Turning the AI Gateway off (module toggles) + +Not using the gateway (or switched to a different assistant stack)? +Both editions let you remove it from the console without uninstalling +anything: + +- **web edition** — every sidebar module carries a power toggle (hover + a row → ⏻). Clicking it hides the module from the sidebar AND the + ⌘K palette; a **Disabled (N)** section appears at the sidebar bottom + with one-click re-enable (plus a restore-all ↻). State is persisted + in SQLite (`shell.disabled` via the `shell` bridge module) and + survives restarts; Overview is protected. If you disable the module + you are viewing, the console jumps back to Overview. +- **cockpit edition** — plugins are discovered by directory: `sudo rm + -rf /usr/share/cockpit/sysdeck-klanker` removes the sidebar entry + (bridge helper stays at `/usr/lib/sysdeck/bridge/klanker.py` for + scripts); restore with `sudo make install`. + +### 10.3 The 0.3.0 security audit (both editions + the vendored gateway) + +A full-codebase security review shipped with 0.3.0 — the cockpit bridge +helpers, the 27 plugin panels, the web edition, and the vendored +klanker-gate tree. What changed: + +- **bridge helpers fail closed now.** `cgroup-set` validates both the + cgroup path (must resolve under `/sys/fs/cgroup`) and the control-file + name (real controller knobs only); `artifacts-clear` / + `build-delete` / `build-log` / `artifacts` validate ids as single + path components before touching state/artifacts/logs dirs; + `profile-create` rejects names that aren't single components (was + directory traversal + config injection into root-executed build + configs); hwalert's `sudo sh -c` is gone (direct write, device path + validated under the scanned sysfs bases); `db start/stop/restart` + resolve engines through the registry; `db query` now actually + enforces the read-only promise (SELECT/WITH/SHOW/… only); + `themes set` rejects newlines (cockpit.conf section injection); + `packages install/remove/update` reject option-shaped names. +- **every plugin escapes its data.** The 8 oldest panels (packages, + benchmark, auth, sensors, vault, firmware, mesh, and the auth quick + actions) now escape every interpolated string — package metadata, + USB reader descriptors, fwupd device fields, sensor labels, spawn + errors — before it lands in `innerHTML`. All 27 manifests dropped + `unsafe-eval` from their CSP. Every external link carries + `rel="noopener noreferrer"`. +- **the web edition binds loopback.** `bun run dev` → `127.0.0.1:3000`, + the fester service → `127.0.0.1:3010`, the production start script + pins `HOSTNAME=127.0.0.1`; the bridge endpoint gained a body-size + cap, a per-IP rate limit and generic error responses (details go to + the server log). +- **the vendored gateway got audited, not modified.** Findings live in + `klanker-gate/arch/SECURITY-UPSTREAM.md` (10 findings, 3 critical: + no-token admin mode, 0.0.0.0 default bind, open `/v1/*` until the + first virtual key exists). Upstream source stays byte-identical per + the attribution contract; the SysDeck `arch/` packaging layer + mitigates: the systemd unit refuses to start without + `FROSTY_ADMIN_TOKEN`, `INSTALL-ARCH.md` §9 carries the firewall + + first-vkey runbook. +- **fixed along the way (functional):** the Packages panel's + firewall-backend install path (`packages.py install --` choke), the + auth panel's quick-action buttons (called a bridge.spawn that never + existed), and the mesh panel's table (read a data shape the bridge + never returned). + +### 10.4 The web edition login (Unix accounts, cockpit-style) + +SysDeck is a **LAN-side console** — loopback binding stays the outer +boundary. What 0.4.0 changes is the login itself: instead of the 0.3.1 +shared password, you now sign in with a **Unix account — the username +and password are verified by the host's PAM stack**, exactly the +mechanism Cockpit uses at its own login screen. The host decides; the +console keeps no password data of its own. + +- **PAM path:** `web/scripts/pam-auth.py` (stdlib-only ctypes client of + `libpam`) runs the `pam_start` → `pam_authenticate` → `pam_acct_mgmt` + sequence under the **`sysdeck`** service when `/etc/pam.d/sysdeck` + exists, else the stock **`login`** stack. Credentials travel over + stdin (never argv — `/proc` would leak them). Ship your own + `/etc/pam.d/sysdeck` (e.g. `auth required pam_unix.so`, plus + `pam_google_authenticator` for MFA if you want it) to tailor the + stack — `SYSDECK_PAM_SERVICE` renames it. +- **Root, or pam+local:** pam_unix needs root to read `/etc/shadow` + for *arbitrary* users (non-root processes only get the invoking uid + via `unix_chkpwd` — a pam_unix guarantee). So the modes are + `SYSDECK_AUTH_MODE=pam` (default; run the service as root, like + cockpit-ws), `pam+local` (PAM first, then the `SdUser` scrypt table + for installs that can't run privileged), or `local` (console + accounts only). Manage the local table with + `bun scripts/manage-users.mjs list|add|passwd|disable|enable|remove` + from `web/`. +- **Session:** an HttpOnly, SameSite=Lax cookie (`sd_session`) holding + an HMAC-SHA256-signed token **bound to the username** + (`v2...`), **12h** expiry. The HMAC key is random + per install and persists in the SQLite DB, so sessions survive + restarts — including the 0.3.1 → 0.4.0 upgrade (old v1 tokens still + verify as a legacy "operator" session until they age out). +- **Gate scope:** the page itself is server-rendered as the login + screen until the cookie verifies, every `/api/*` route answers 401 + until signed in, and the **fester service verifies the identical + v2 token** on its REST + WebSocket surface — no unauthenticated path + into the console's data. +- **Lockout:** wrong attempts are rate limited per-IP **and** + per-username (5 per 60s each — the same shape the sshd stack + applies). Wrong-user and wrong-password return the same generic + answer; nothing enumerates accounts. +- **Identity in the shell:** the header carries an account menu — + avatar, `user@host`, unix-account provenance (PAM vs local), the + wheel/sudo "Administrative access" badge, and a live session-expiry + countdown with a draining life bar; the status bar shows + `user@host` next to the vitals. Login/logout are audited with the + unix username as the actor. +- **TLS:** LAN deployments typically run plain http; front the console + with TLS and set `SYSDECK_SESSION_SECURE=1` to add the `Secure` + cookie flag. Sign out lives in the account menu (clears the cookie). + +The login/logout actions are audited (`module: web`, actions +`login` / `login-failed` / `logout`, actor = the unix username, with +source IP). This is deliberately *not* MFA-by-default or rate-proof +crypto — it is the host's own account system doing what it already +does at every other login surface on the box, recorded here so nobody +mistakes it for more or less than that. + +### 10.5 Cockpit module detection in the web console (v0.4.1) + +The console scans the host the same way the cockpit shell discovers +pages — every `/usr/share/cockpit//manifest.json` with a `menu` +entry is a module — and **loads each one into its own navigation**: + +- a **Cockpit** sidebar group (with a LIVE/DEMO provenance badge) lists + every detected module — distro modules (`cockpit-machines`, + `cockpit-podman`, networking, storage, accounts, updates, SELinux, + PCP metrics, kdump, tuned...) and third-party addons alike; +- each module opens a detail view with its manifest identity, shipped + files, **live backend presence probes** (`virsh`/`podman`/`nmcli`/ + `pkcon`/... — real `which()` checks), and a jump to the native + console panel covering the domain when one exists; +- `sysdeck-*` modules never duplicate (native panels already ship), and + menu-less chrome (`base1`, `shell`) is skipped — exactly the cockpit + shell's own rules; +- `SYSDECK_COCKPIT_SCAN` (colon-separated paths) adds extra scan roots + for staged trees; with no cockpit tree on the host, a clearly-badged + typical-distro set keeps the surface explorable; +- the **Cockpit Modules** hub panel (Integrations group) summarizes + detection: counts, backend availability, native coverage, and the + scan paths in play. + +This is the piece that makes the console/host pair 100% compatible: +install a cockpit module on the box, and it shows up here — no cockpit +login required to browse it. + +## 11. Run without Cockpit (the complete standalone runbook, v0.3.0) + +The web edition needs **nothing from sections 1–8** — no cockpit, no Python +bridge, no systemd, no root. One Bun runtime serves the whole console: + +```bash +tar xjf sysdeck-0.4.1-master.tar.bz2 +cd sysdeck-0.4.1-master +make web-dev # bun install + db:push + fester + next dev :3000 +``` + +Production path (standalone build, systemd on Arch, reverse proxy with the +`?XTransformPort=` websocket gateway, environment reference, troubleshooting): + +```bash +cd web +bun run build # self-contained .next/standalone/ +PORT=3000 HOSTNAME=0.0.0.0 bun run start # or: node .next/standalone/server.js +``` + +The complete runbook — with the two systemd units (web + fester), the +`.env` reference table, the Caddy/nginx websocket-gateway configs and a +troubleshooting matrix — lives in two places, kept in sync: + +- **`web/README.md`** in this tarball (plain markdown) +- the **"Run without Cockpit" panel** in the web console (system group, + right under Overview) — every command block has a copy button + +## 12. The web-edition skin for Cockpit (v0.3.0) + +Since 0.3.0 the Cockpit plugin pages wear the **web-edition skin** by +default: every plugin's `index.html` links +`../sysdeck-common/sysdeck-web.css` after the base stylesheet, porting +the Next.js console's midnight/teal design (teal accent `#3fc9b0`, +soft-tinted badges, 10px radii, tabular numerals, thin teal-edged +scrollbars) onto the classic cockpit panels. Nothing else changes — the +class vocabulary, the bridge, and every module are untouched. + +```bash +# revert the plugin pages to the classic 0.1.x skin: +sudo rm /usr/share/cockpit/sysdeck-common/sysdeck-web.css + +# also theme the Cockpit SHELL chrome (sidebar, header, login) to match: +sudo make install-branding # backs up any existing branding.css first +sudo make uninstall-branding # restore the backup +``` + +`install-branding` installs `shared/branding.css` as +`/usr/share/cockpit/branding.css` — Cockpit's documented override point +for the shell. It targets both PatternFly v5 (`pf-v5-*`, Cockpit ≥ 300) +and v4 (`pf-c-*`) selector generations, so unmatched rules simply no-op. + Author: **Jeremy Anderson** · · diff --git a/README.md b/README.md index 5a46c38..4dbcc96 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **A drop-in plugin for an existing Cockpit install — twenty-six domain modules behind one dashboard.** Author: **Jeremy Anderson** · · -Version: **0.2.0** (Master Edition) · License: **MIT** +Version: **0.4.1** · License: **MIT** --- @@ -13,6 +13,128 @@ SysDeck is a cockpit-native plugin that consolidates the day-to-day work of a Li The plugin ships as static HTML+JS+CSS plus a Python bridge helper package. It installs under `/usr/share/cockpit/sysdeck-*/` and is discovered automatically by the cockpit-bridge. No separate web server, no Node.js runtime, no database — the plugin runs inside the cockpit web service. +### v0.4.1 highlights (cockpit module detection — 100% console/host parity) + +0.4.0 brought the Unix login. 0.4.1 closes the last compatibility gap: +**every cockpit module installed on the host is now detected and loaded +into the Next.js console too** — distro modules like cockpit-machines +and cockpit-podman, addons, anything with a `menu` entry in its +`/usr/share/cockpit//manifest.json`: + +- **Detection is pure filesystem** — the same discovery the cockpit + shell performs. `sysdeck-*` modules are skipped (native panels already + ship here) and chrome without a menu (`base1`, `shell`) never shows. + Works with cockpit stopped or absent; `SYSDECK_COCKPIT_SCAN` adds + extra scan roots (colon-separated) for staged/DESTDIR trees. With no + cockpit tree the surface shows a clearly-badged typical-distro set. +- **A "Cockpit" sidebar group** appears with every detected module — + each opens a detail view: manifest identity, shipped files with + sizes, live backend presence probes (`virsh`, `podman`, `nmcli`, + `pkcon`...) plus on-demand version probes, and a jump to the native + console panel covering the domain (machines/podman → Containers & + VMs, packagekit → Packages, networkmanager → Network Security, + metrics → Monitoring...). The ⌘K palette searches them too, and the + **Cockpit Modules** hub panel lists everything with LIVE/DEMO + provenance. +- **The UI codenames are retired** — no more "web edition" or edition + subtitles anywhere in the console; the identity is simply **SysDeck** + with a single clean subtitle: **dcos.net** (login banner, sidebar, + status bar). The page title is "SysDeck". + +### v0.4.0 highlights (Unix Login Edition) + +0.3.1 gated the web console behind one shared password. 0.4.0 replaces +it with the login model the whole project is named after: **sign in +with a Unix account, verified by the host's PAM stack — the same +mechanism Cockpit uses at its own login screen.** The host decides; +the console keeps no password data of its own. + +- **PAM login** — `web/scripts/pam-auth.py`, a stdlib-only ctypes client + of `libpam`, runs `pam_start` → `pam_authenticate` → `pam_acct_mgmt` + under the `sysdeck` service when `/etc/pam.d/sysdeck` exists, else the + stock `login` stack. Credentials travel over stdin, never argv. + Ship your own `/etc/pam.d/sysdeck` to tailor the stack (MFA modules + included, if you want them). +- **User-bound sessions** — the `sd_session` cookie becomes + `v2...`; the shell shows a cockpit-style account + menu (avatar, `user@host`, PAM/local provenance, the wheel/sudo + "Administrative access" badge, a live session-expiry countdown with a + draining life bar) and the status bar carries `user@host`. 0.3.1 v1 + tokens still verify as legacy sessions — upgrades don't log anybody + out. The fester service gates its REST + WS surface on the same v2 + token. +- **Three auth modes** — `SYSDECK_AUTH_MODE=pam` (default, cockpit + faithful: run the service as root so any unix account can sign in), + `pam+local` (PAM first, `SdUser` scrypt accounts as the fallback for + unprivileged installs), `local` (console accounts only). Local + accounts are managed with `bun scripts/manage-users.mjs + list|add|passwd|disable|enable|remove`. +- **Lockout like sshd** — failures rate limited per-IP **and** + per-username (5/min each); wrong-user and wrong-password return the + same generic answer; every attempt audited with the unix username as + actor. A wedged PAM helper fails CLOSED, never silently falls back. +- **The login screen got the fester treatment** — host identity banner + (hostname + OS, exactly what cockpit leads with), aurora/grid + backdrop in the active console theme, caps-lock detection, a one-shot + error shake, and the amber default-password nag (local modes) until + the seeded account is rotated. +- **Cockpit edition untouched** — all 29 modules keep working under + cockpit exactly as before; the bridge guards (`check-bridge-subcommands` + 218 calls / 28 modules, manifest consistency) still pass. This + revision's changes live in `web/` and the docs. + +### v0.3.1 highlights (login gate for the web edition) + +The 0.3.0 audit left one honest gap: the web edition had guards but no +login. 0.3.1 closed it the LAN-side way — a **cockpit-style shared +password** (superseded by 0.4.0's Unix-account login): + +- **one shared password** — `SYSDECK_WEB_PASSWORD` in `web/.env` + (default `sysdeck`; the login screen nags in amber until you set + your own). Constant-time compare, per-IP failure rate limit + (5/min), every attempt audited with the source IP. +- **HMAC-signed session cookie** — HttpOnly, SameSite=Lax, **12h** + expiry; the signing key is random per install and persists in + SQLite, so restarts don't log you out and the **fester + mini-service verifies the identical token** straight from the same + DB — the browser's live WebSocket event stream is gated too, not + just the REST routes. +- **every surface gated** — the page server-renders the login screen + until the cookie verifies; all `/api/*` routes answer 401 until + signed in; a mid-flight expiry reloads to the login screen instead + of erroring. Logout button in the shell header. +- The posture stays LAN-side: loopback binds remain the outer + boundary, `SYSDECK_SESSION_SECURE=1` adds the `Secure` cookie flag + when the console fronts TLS. See QUICKSTART §10.4. + +### v0.3.0 highlights (AI Gateway Edition) + +v0.3.0 integrates **klanker-gate** — the Frosty Deno LLM gateway (Deno 2 + TypeScript, OpenAI-compatible API, governance, virtual keys, caching, MCP) — as the new **AI Gateway** module, vendored at `/klanker-gate` (own independent version 0.9.0, Apache-2.0). **klanker-gate is not SysDeck code** — it is by [TykoDev](https://github.com/TykoDev/klanker-gate) and is credited in `/klanker-gate/ATTRIBUTION.md` and `THIRD_PARTY.md`. + +The headline finding of this release: **"porting klanker-gate to Arch Linux" required zero upstream source changes.** The codebase is Linux-first, not Windows-first (the Windows mentions in the tree are accommodations: `reusePortSupported()` is linux/darwin-only, the Docker/entrypoint path is POSIX, `deno.lock` win32 entries are ordinary cross-platform lockfile records). The work was packaging — and it ships: + +- **`klanker-gate/arch/`** — the complete Arch packaging: `PKGBUILD` (self-packaging, `makepkg -si`), a hardened systemd unit (StateDirectory, `ProtectSystem=full`, empty `CapabilityBoundingSet`), sysusers/tmpfiles, a `/usr/bin/klanker-gate` run wrapper (module-cache warmup + `--allow-run` scoped to the Deno binary only when `FROSTY_WORKERS>1`, mirroring the upstream entrypoint's escalation policy), and `INSTALL-ARCH.md` (the full runbook: postgres provisioning, env, verification, SysDeck wiring). Bonus: moving to Arch **unlocks** `FROSTY_WORKERS` multi-process serving via `SO_REUSEPORT` — impossible on Windows. +- **cockpit side** — `bridge/klanker.py` (10 subcommands: status, providers, models, vkeys, logs, analytics, runtime, service, journal, localstack — stdlib REST client against `KLANKER_URL`, Bearer `KLANKER_ADMIN_TOKEN`, graceful offline JSON, token never echoed) and the fully-built `plugins/sysdeck-klanker/` panel (status card, spend in µUSD→USD, providers/vkeys/recent-requests tables, runtime topology, local stack wiring card, service control + journal viewer). +- **web side** — the hybrid **AI Gateway** panel (Integrations group): live REST against the gateway when it runs, clearly-badged demo data when it doesn't (this sandbox has no Deno/Postgres); flips to `source: live` automatically with `KLANKER_URL` set. +- **local stack first-class (both editions)** — the gateway is *not* SaaS-only: `ollama`/`lmstudio`/`sgl` are native keyless provider types and llama.cpp (llama-server)/KoboldCpp/vLLM plug in via the generic `openai-compatible` type. New **Local stack wiring** card live-probes each backend's `/v1/models` from the host (`klanker localstack` bridge subcommand) and shows env + admin-API wiring with copy buttons; the web demo dataset re-seeded local-first (spend/24h ≈ $0.001 — cloud overflow only). See QUICKSTART §10.1. +- **module toggles (web edition)** — every sidebar module can be turned OFF (hidden from the sidebar + ⌘K palette) and back ON from a **Disabled** section — one click, persisted in SQLite, survives restarts; disabling the active module returns to Overview. Turning the AI Gateway off when you switch stacks is now a hover + click. See QUICKSTART §10.2. +- **guards** — `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216; the audit pass added auth readers+certs); 28 plugin manifests conform. +- **0.3.0 security audit** — a full-codebase review (bridge helpers, plugin panels, web edition, vendored klanker-gate): bridge write primitives now fail closed (cgroup-set path+control validation, artifacts-clear/build-delete/build-log id validation, profile-create name validation, hwalert's `sudo sh -c` removed, db start/stop/restart registry-gated, db query read-only-guarded, themes set newline-guarded, packages argument-injection-guarded); the 8 oldest panels escape all interpolations and all 27 manifests dropped `unsafe-eval`; the web edition binds loopback (dev, fester, production start) with bridge body-cap + rate limit; the vendored gateway is audited-but-unmodified with findings in `klanker-gate/arch/SECURITY-UPSTREAM.md` and packaging-layer mitigations (systemd unit refuses to start without `FROSTY_ADMIN_TOKEN`). See QUICKSTART §10.3. + +Wire it up (either edition): + + KLANKER_URL=http://127.0.0.1:8080 + KLANKER_ADMIN_TOKEN= # see klanker-gate/arch/INSTALL-ARCH.md + +Quick start (web edition, from an extracted master tarball): + + make web-dev # fester service (background, :3010) + web console (:3000) + +Two more 0.3.0 additions close the loop between the editions: + +- **"Run without Cockpit" runbook** — the web edition is fully standalone (no cockpit, no Python bridge, no systemd, no root). The complete deployment guide — dev, standalone production build, the two systemd units, `.env` reference, reverse proxy + `?XTransformPort=` websocket gateway, troubleshooting — ships twice, kept in sync: as `web/README.md` in the tarball and as a first-class **panel** in the web console (system group, right under Overview, copy-buttons on every command block). +- **the web-edition skin for Cockpit** — since 0.3.0 every Cockpit plugin page links `shared/sysdeck-web.css` after the base stylesheet, porting the Next.js console's midnight/teal design (accent `#3fc9b0`, soft-tinted badges, 10px radii, tabular numerals) onto the classic panels; `sudo make install-branding` additionally themes the Cockpit **shell** chrome (sidebar/header/login, PatternFly v4+v5 covered, distro `branding.css` backed up first). Revert either with `make uninstall-branding` / removing the skin file. See QUICKSTART §12. + ### v0.2.0 highlights (Master Edition) v0.2.0 ships as a **master tarball — `sysdeck-0.2.0-master.tar.bz2`** — bundling the cockpit edition (this tree), the new **SysDeck Web Edition** (`web/` — a standalone Next.js console with 28 bridge modules, an Overview landing view, and the previously-orphaned Hardware Alerts panel), and **Fester pre-integrated**. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index d0471a1..dd8014d 100755 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -6,14 +6,41 @@ integration invokes the external tool as a **separate process** via suite (MIT) and the external tools remain independent programs. This file satisfies the attribution requirements of the licenses listed -below and documents every external integration point. +below and documents every external integration point — including the +one vendored project in the master tarball (klanker-gate, below). --- -## Bundled Dependencies (shipped with the suite) +## Vendored Project (master tarball only) + +### klanker-gate — the "Frosty Deno" LLM gateway (the AI Gateway module) + +**klanker-gate is not SysDeck's code.** All credit belongs to its +author, **TykoDev**. The master tarball vendors the upstream tree +unmodified, as a sibling of the suite, under its own Apache-2.0 +license; the suite's modules talk to it as a separate process over +REST (same no-linking rule as every other entry in this file). + +| Field | Value | +|-------|-------| +| **Project** | klanker-gate ("Frosty Deno" LLM Gateway) | +| **Author** | **TykoDev** | +| **Source** | https://github.com/TykoDev/klanker-gate | +| **License** | Apache-2.0 (full text kept at `klanker-gate/LICENSE`; notice kept at `klanker-gate/ATTRIBUTION.md`) | +| **Vendored at** | `klanker-gate/` in the master tarball, own version **0.9.0** (independent from SysDeck's version) | +| **SysDeck additions** | `klanker-gate/arch/` only (PKGBUILD, systemd unit, sysusers/tmpfiles, run wrapper, runbook) — zero upstream source changes | +| **Modules** | `sysdeck-klanker` (cockpit edition: `bridge/klanker.py` + `plugins/sysdeck-klanker/`) and the web edition `klanker` bridge + AI Gateway panel — both are thin REST *clients* containing no upstream code | +| **Integration** | REST against `KLANKER_URL` (default `http://127.0.0.1:8080`), `Authorization: Bearer ` — a separate process invoked over HTTP, never linked or embedded | +| **License compat** | MIT suite + Apache-2.0 vendored tree redistributed in source form with LICENSE and notices retained — compliant; the two programs remain independent works | + +--- + +## Bundled Dependencies (shipped with the suite itself) None. The suite is self-contained MIT-licensed code with no vendored -third-party libraries. +third-party libraries. (The master tarball separately vendors the +klanker-gate project — see the section above; it is a sibling tree, +not part of the suite.) --- @@ -247,7 +274,7 @@ For MIT/LGPL/BSD/Apache tools: fully compatible with the suite's MIT license. --- -## v0.0.46 — In-Suite 3rd-Party Module Installer +### v0.0.46 — In-Suite 3rd-Party Module Installer Prior to v0.0.46, the only way to install third-party Cockpit modules (45Drives Navigator, cockpit-pacman, cockpit-identities, etc.) was the diff --git a/bridge/__init__.py b/bridge/__init__.py index 5380505..49a9d0e 100755 --- a/bridge/__init__.py +++ b/bridge/__init__.py @@ -22,7 +22,7 @@ import os import subprocess from typing import Literal -__version__ = "0.2.0" +__version__ = "0.4.1" __author__ = "Jeremy Anderson" __url__ = "https://dcos.net" diff --git a/bridge/__pycache__/__init__.cpython-312.pyc b/bridge/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7723a3a Binary files /dev/null and b/bridge/__pycache__/__init__.cpython-312.pyc differ diff --git a/bridge/__pycache__/auth.cpython-312.pyc b/bridge/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..d97f382 Binary files /dev/null and b/bridge/__pycache__/auth.cpython-312.pyc differ diff --git a/bridge/__pycache__/benchmark.cpython-312.pyc b/bridge/__pycache__/benchmark.cpython-312.pyc new file mode 100644 index 0000000..b6cd3b3 Binary files /dev/null and b/bridge/__pycache__/benchmark.cpython-312.pyc differ diff --git a/bridge/__pycache__/builder.cpython-312.pyc b/bridge/__pycache__/builder.cpython-312.pyc new file mode 100644 index 0000000..aca94f3 Binary files /dev/null and b/bridge/__pycache__/builder.cpython-312.pyc differ diff --git a/bridge/__pycache__/containers.cpython-312.pyc b/bridge/__pycache__/containers.cpython-312.pyc new file mode 100644 index 0000000..ae51a90 Binary files /dev/null and b/bridge/__pycache__/containers.cpython-312.pyc differ diff --git a/bridge/__pycache__/db.cpython-312.pyc b/bridge/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000..51a0637 Binary files /dev/null and b/bridge/__pycache__/db.cpython-312.pyc differ diff --git a/bridge/__pycache__/fester.cpython-312.pyc b/bridge/__pycache__/fester.cpython-312.pyc new file mode 100644 index 0000000..056793f Binary files /dev/null and b/bridge/__pycache__/fester.cpython-312.pyc differ diff --git a/bridge/__pycache__/firewall.cpython-312.pyc b/bridge/__pycache__/firewall.cpython-312.pyc new file mode 100644 index 0000000..a07fbfc Binary files /dev/null and b/bridge/__pycache__/firewall.cpython-312.pyc differ diff --git a/bridge/__pycache__/firmware.cpython-312.pyc b/bridge/__pycache__/firmware.cpython-312.pyc new file mode 100644 index 0000000..779fae5 Binary files /dev/null and b/bridge/__pycache__/firmware.cpython-312.pyc differ diff --git a/bridge/__pycache__/fleet.cpython-312.pyc b/bridge/__pycache__/fleet.cpython-312.pyc new file mode 100644 index 0000000..f9d3a90 Binary files /dev/null and b/bridge/__pycache__/fleet.cpython-312.pyc differ diff --git a/bridge/__pycache__/glances.cpython-312.pyc b/bridge/__pycache__/glances.cpython-312.pyc new file mode 100644 index 0000000..29b2a4c Binary files /dev/null and b/bridge/__pycache__/glances.cpython-312.pyc differ diff --git a/bridge/__pycache__/grafana.cpython-312.pyc b/bridge/__pycache__/grafana.cpython-312.pyc new file mode 100644 index 0000000..1109fdb Binary files /dev/null and b/bridge/__pycache__/grafana.cpython-312.pyc differ diff --git a/bridge/__pycache__/hwalert.cpython-312.pyc b/bridge/__pycache__/hwalert.cpython-312.pyc new file mode 100644 index 0000000..ee3df05 Binary files /dev/null and b/bridge/__pycache__/hwalert.cpython-312.pyc differ diff --git a/bridge/__pycache__/integrity.cpython-312.pyc b/bridge/__pycache__/integrity.cpython-312.pyc new file mode 100644 index 0000000..b8da240 Binary files /dev/null and b/bridge/__pycache__/integrity.cpython-312.pyc differ diff --git a/bridge/__pycache__/jellyfin.cpython-312.pyc b/bridge/__pycache__/jellyfin.cpython-312.pyc new file mode 100644 index 0000000..3f5bb53 Binary files /dev/null and b/bridge/__pycache__/jellyfin.cpython-312.pyc differ diff --git a/bridge/__pycache__/kata.cpython-312.pyc b/bridge/__pycache__/kata.cpython-312.pyc new file mode 100644 index 0000000..281f7f6 Binary files /dev/null and b/bridge/__pycache__/kata.cpython-312.pyc differ diff --git a/bridge/__pycache__/klanker.cpython-312.pyc b/bridge/__pycache__/klanker.cpython-312.pyc new file mode 100644 index 0000000..49ca729 Binary files /dev/null and b/bridge/__pycache__/klanker.cpython-312.pyc differ diff --git a/bridge/__pycache__/mesh.cpython-312.pyc b/bridge/__pycache__/mesh.cpython-312.pyc new file mode 100644 index 0000000..90b29d2 Binary files /dev/null and b/bridge/__pycache__/mesh.cpython-312.pyc differ diff --git a/bridge/__pycache__/mining.cpython-312.pyc b/bridge/__pycache__/mining.cpython-312.pyc new file mode 100644 index 0000000..8530da2 Binary files /dev/null and b/bridge/__pycache__/mining.cpython-312.pyc differ diff --git a/bridge/__pycache__/modules3p.cpython-312.pyc b/bridge/__pycache__/modules3p.cpython-312.pyc new file mode 100644 index 0000000..7507c0c Binary files /dev/null and b/bridge/__pycache__/modules3p.cpython-312.pyc differ diff --git a/bridge/__pycache__/netsec.cpython-312.pyc b/bridge/__pycache__/netsec.cpython-312.pyc new file mode 100644 index 0000000..7c0a634 Binary files /dev/null and b/bridge/__pycache__/netsec.cpython-312.pyc differ diff --git a/bridge/__pycache__/packages.cpython-312.pyc b/bridge/__pycache__/packages.cpython-312.pyc new file mode 100644 index 0000000..c1261bf Binary files /dev/null and b/bridge/__pycache__/packages.cpython-312.pyc differ diff --git a/bridge/__pycache__/photos.cpython-312.pyc b/bridge/__pycache__/photos.cpython-312.pyc new file mode 100644 index 0000000..c116028 Binary files /dev/null and b/bridge/__pycache__/photos.cpython-312.pyc differ diff --git a/bridge/__pycache__/policy.cpython-312.pyc b/bridge/__pycache__/policy.cpython-312.pyc new file mode 100644 index 0000000..edd7f00 Binary files /dev/null and b/bridge/__pycache__/policy.cpython-312.pyc differ diff --git a/bridge/__pycache__/prometheus.cpython-312.pyc b/bridge/__pycache__/prometheus.cpython-312.pyc new file mode 100644 index 0000000..ec1e451 Binary files /dev/null and b/bridge/__pycache__/prometheus.cpython-312.pyc differ diff --git a/bridge/__pycache__/remotefs.cpython-312.pyc b/bridge/__pycache__/remotefs.cpython-312.pyc new file mode 100644 index 0000000..45f0ab7 Binary files /dev/null and b/bridge/__pycache__/remotefs.cpython-312.pyc differ diff --git a/bridge/__pycache__/sensors.cpython-312.pyc b/bridge/__pycache__/sensors.cpython-312.pyc new file mode 100644 index 0000000..b25b48a Binary files /dev/null and b/bridge/__pycache__/sensors.cpython-312.pyc differ diff --git a/bridge/__pycache__/themes.cpython-312.pyc b/bridge/__pycache__/themes.cpython-312.pyc new file mode 100644 index 0000000..86a4de6 Binary files /dev/null and b/bridge/__pycache__/themes.cpython-312.pyc differ diff --git a/bridge/__pycache__/vault.cpython-312.pyc b/bridge/__pycache__/vault.cpython-312.pyc new file mode 100644 index 0000000..e8cb8d5 Binary files /dev/null and b/bridge/__pycache__/vault.cpython-312.pyc differ diff --git a/bridge/auth.py b/bridge/auth.py index b798e46..84f8a22 100755 --- a/bridge/auth.py +++ b/bridge/auth.py @@ -31,6 +31,7 @@ Usage: import json import os import re +import shutil import subprocess import sys from typing import Any @@ -84,6 +85,25 @@ def readers() -> list[dict[str, str]]: ] +def certs() -> dict[str, Any]: + """PKCS#11 objects of type cert via pkcs11-tool. + + v0.1.4: the auth panel's "List Certificates" button used to call a + bridge.spawn() that bridge.js never exported — the button has + always thrown. The listing now lives here (fixed argv list, no + shell), matching every other spawn in this suite. + """ + if not shutil.which("pkcs11-tool"): + return {"available": False, + "reason": "pkcs11-tool not installed (opensc)", + "count": 0, "output": ""} + raw = run(["pkcs11-tool", "--list-objects", "--type", "cert"]) + lines = [line for line in raw.splitlines() if line.strip()] + return {"available": True, + "count": sum(1 for line in lines if "Certificate" in line), + "output": "\n".join(lines) or "(no certificates on any slot)"} + + def pcscd_state() -> str: """pcscd.service state via systemctl.""" raw = run(["systemctl", "is-active", "pcscd"]).strip() @@ -238,6 +258,7 @@ COMMANDS = { "summary": lambda _args: summary(), "slots": lambda _args: slots(), "readers": lambda _args: readers(), + "certs": lambda _args: certs(), "identities": lambda _args: identities(), "ssh-keys": lambda _args: ssh_keys(), "kerberos": lambda _args: kerberos(), diff --git a/bridge/benchmark.py b/bridge/benchmark.py index e329ff7..85991d3 100755 --- a/bridge/benchmark.py +++ b/bridge/benchmark.py @@ -21,6 +21,7 @@ Usage: """ import json +import re import subprocess import sys from typing import Any @@ -96,6 +97,13 @@ def run_test(args: list[str]) -> dict[str, Any]: "error": "no test name provided", } test_name = args[0] + # v0.1.4 SECURITY: the test name is passed to `sysbench run` + # as one argv element — a leading dash makes it an OPTION (e.g. + # --config=…), so validate it as a plain identifier (the sysbench + # builtin test vocabulary is cpu/memory/threads/mutex/fileio/oltp_*). + if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", test_name) or test_name.startswith("-"): + return {"raw": "", "events_per_sec": None, "latency_ms": None, + "error": f"invalid sysbench test name: {test_name!r}"} # Some sysbench tests (fileio) require a prepare step before run. # We deliberately keep this simple — for arbitrary test names, just # invoke ``sysbench run``. If the user wants fileio with diff --git a/bridge/builder.py b/bridge/builder.py index 2a1539b..fafdf59 100755 --- a/bridge/builder.py +++ b/bridge/builder.py @@ -485,6 +485,34 @@ BUILDER_LOGS_DIR = Path("/var/lib/sysdeck/builder/logs") BUILDER_ARTIFACTS_DIR = Path("/var/lib/sysdeck/builder/artifacts") +# v0.1.4 SECURITY: build-ids and profile names are used to build paths +# under the three dirs above (state/.json, logs/.log, +# artifacts//). They arrive as raw argv from the bridge caller, +# so they must be validated as a single safe path component before any +# filesystem use — otherwise `build-log ../../etc/foo` reads arbitrary +# *.log files, `build-delete ` unlinks arbitrary *.json/*.log, +# and `artifacts-clear /etc` would rmtree an arbitrary directory as root +# (found by the 0.3.0 security audit; every one of these now fails closed). +_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +def _valid_id(token: str) -> bool: + """True if token is a safe single path component (no separators, + no traversal, no leading dash, bounded length).""" + if not isinstance(token, str) or not token: + return False + return bool(_SAFE_ID_RE.match(token)) and ".." not in token + + +def _under_dir(path: Path, root: Path) -> bool: + """True if (resolved) path stays inside root (defends symlinks + traversal).""" + try: + path.resolve().relative_to(root.resolve()) + return True + except (ValueError, RuntimeError, OSError): + return False + + def _ensure_state_dirs() -> None: """Create the state/logs/artifacts dirs. Best-effort; the cockpit superuser channel handles root perms when needed.""" @@ -1092,6 +1120,15 @@ def profile_create(args: list[str]) -> dict[str, Any]: backend_id = positional[1] base = positional[2] if len(positional) > 2 else None + # v0.1.4 SECURITY: the name becomes /etc/mkosi/profiles// (or + # /etc/vmdb2/.yaml) and is .format()-ed into the scaffold's + # config templates — a name containing '/', '..' or newlines is + # directory traversal plus arbitrary config-line injection into + # files that mkosi/vmdb2 later execute as root during builds. + if not _valid_id(name): + return {"error": "profile name must be a single path component " + "(letters, digits, '.', '_', '-'; no slashes, no '..', no newlines)"} + # Validate backend. valid_backends = ("mkosi", "vmdb2") if backend_id not in valid_backends: @@ -1870,6 +1907,10 @@ def build_log(args: list[str]) -> dict[str, Any]: if not args: return {"error": "build-id required"} build_id = args[0] + # v0.1.4 SECURITY: build-id is used to build the log path under + # BUILDER_LOGS_DIR — a traversal id would read arbitrary *.log files. + if not _valid_id(build_id): + return {"error": "invalid build-id (must be a single path component)"} log_path = _build_log_path(build_id) if not log_path.is_file(): return {"error": f"no log file for build {build_id}", "build_id": build_id} @@ -1893,6 +1934,11 @@ def artifacts(args: list[str]) -> dict[str, Any]: if not BUILDER_ARTIFACTS_DIR.is_dir(): return {"artifacts": [], "by_profile": {}} profile_filter = args[0] if args else None + # v0.1.4 SECURITY: a traversal profile filter would list an + # arbitrary directory's contents (names/sizes/mtimes) to the browser. + if profile_filter and (not _valid_id(profile_filter) or + not _under_dir(BUILDER_ARTIFACTS_DIR / profile_filter, BUILDER_ARTIFACTS_DIR)): + return {"error": "invalid profile filter (must be a single path component)"} by_profile: dict[str, list[dict[str, Any]]] = {} if profile_filter: profiles_to_scan = [BUILDER_ARTIFACTS_DIR / profile_filter] @@ -1954,6 +2000,13 @@ def artifacts_clear(args: list[str]) -> dict[str, Any]: if not args: return {"error": "usage: artifacts-clear "} profile = args[0] + # v0.1.4 SECURITY: profile is used to rmtree a directory as root — + # an absolute path ('/etc') or traversal ('../..') escapes the + # artifacts root. Validate as a single component AND resolve the + # target under BUILDER_ARTIFACTS_DIR (same guard artifact-delete + # has had since v0.0.31; artifacts-clear missed it). + if not _valid_id(profile) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile, BUILDER_ARTIFACTS_DIR): + return {"error": f"refusing to clear: '{profile}' is not a profile directory under {BUILDER_ARTIFACTS_DIR}"} prof_dir = BUILDER_ARTIFACTS_DIR / profile if not prof_dir.is_dir(): return {"error": f"no artifacts directory for profile '{profile}'"} @@ -1989,6 +2042,10 @@ def build_delete(args: list[str]) -> dict[str, Any]: if not args: return {"error": "build-id required"} build_id = args[0] + # v0.1.4 SECURITY: build-id builds state/log paths that get unlinked + # as root — a traversal id would delete arbitrary *.json/*.log files. + if not _valid_id(build_id): + return {"error": "invalid build-id (must be a single path component)"} delete_artifacts = "--artifacts" in args[1:] deleted = [] errors = [] @@ -2018,13 +2075,18 @@ def build_delete(args: list[str]) -> dict[str, Any]: errors.append(f"log: {exc}") # Optionally delete artifacts. if delete_artifacts and profile_name: - prof_dir = BUILDER_ARTIFACTS_DIR / profile_name - if prof_dir.is_dir(): - try: - shutil.rmtree(prof_dir) - deleted.append(str(prof_dir) + "/ (artifacts dir)") - except (PermissionError, OSError) as exc: - errors.append(f"artifacts: {exc}") + # v0.1.4 SECURITY: profile_name comes from the (deleted) state + # file's JSON — treat it as untrusted before rmtree'ing with it. + if not _valid_id(profile_name) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile_name, BUILDER_ARTIFACTS_DIR): + errors.append(f"artifacts: refusing to clear untrusted profile path {profile_name!r}") + else: + prof_dir = BUILDER_ARTIFACTS_DIR / profile_name + if prof_dir.is_dir(): + try: + shutil.rmtree(prof_dir) + deleted.append(str(prof_dir) + "/ (artifacts dir)") + except (PermissionError, OSError) as exc: + errors.append(f"artifacts: {exc}") if not deleted and not errors: return {"error": f"no build found with id '{build_id}'"} return {"deleted": True, "build_id": build_id, "profile": profile_name, diff --git a/bridge/db.py b/bridge/db.py index 520e808..d638938 100755 --- a/bridge/db.py +++ b/bridge/db.py @@ -288,6 +288,16 @@ def cmd_status(engine_id): return {"error": f"Unknown engine: {engine_id}"} +def _engine_registered(engine_id): + """v0.1.4 SECURITY: True if engine_id is in ENGINE_REGISTRY. The + status/connections/query subcommands already resolved engines + through the registry, but start/stop/restart passed the raw id into + `systemctl {engine_id}.service` — letting any cockpit + session stop/start ARBITRARY system units as root (db stop sshd). + All unit-control subcommands now require a registered engine.""" + return any(e[0] == engine_id for e in ENGINE_REGISTRY) + + def cmd_start(engine_id): # v0.0.32: was `sudo systemctl start` — but sudo shell-out from # the bridge fails when the cockpit user has no passwordless sudo @@ -297,6 +307,9 @@ def cmd_start(engine_id): # action. The bridge runs systemctl directly as root (the cockpit # superuser channel escalates privileges via polkit when the # operator authenticates). + # v0.1.4 SECURITY: registry check added (see _engine_registered). + if not _engine_registered(engine_id): + return {"error": f"Unknown engine: {engine_id}"} rc, out, err = run_rc(["systemctl", "start", f"{engine_id}.service"], timeout=30) return {"action": "start", "engine": engine_id, "rc": rc, "output": out or err or "started", "success": rc == 0, @@ -304,6 +317,10 @@ def cmd_start(engine_id): def cmd_stop(engine_id): + # v0.1.4 SECURITY: registry check added (see _engine_registered) — + # without it, `db stop sshd` stopped arbitrary root units. + if not _engine_registered(engine_id): + return {"error": f"Unknown engine: {engine_id}"} rc, out, err = run_rc(["systemctl", "stop", f"{engine_id}.service"], timeout=30) return {"action": "stop", "engine": engine_id, "rc": rc, "output": out or err or "stopped", "success": rc == 0, @@ -311,6 +328,9 @@ def cmd_stop(engine_id): def cmd_restart(engine_id): + # v0.1.4 SECURITY: registry check added (see _engine_registered). + if not _engine_registered(engine_id): + return {"error": f"Unknown engine: {engine_id}"} rc, out, err = run_rc(["systemctl", "restart", f"{engine_id}.service"], timeout=30) return {"action": "restart", "engine": engine_id, "rc": rc, "output": out or err or "restarted", "success": rc == 0, @@ -331,13 +351,30 @@ def cmd_connections(engine_id): def cmd_query(engine_id, sql): - """Execute a SQL query against an engine (SQL family only).""" - # Safety: refuse DDL/DML for certain contexts + """Execute a read-only SQL query against an engine (SQL family only). + + v0.1.4 SECURITY: the old comment said "refuse DDL/DML" but no check + existed — any statement (DROP DATABASE, COPY ... TO PROGRAM) ran as + root through psql/mysql/sqlite. The guard is now real: only + SELECT/WITH/SHOW/EXPLAIN/DESCRIBE/PRAGMA-first-token statements + pass. Mutations belong in the engine's own tooling, not in a + dashboard query box. + """ for e in ENGINE_REGISTRY: if e[0] == engine_id: family, cli = e[2], e[5] if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"): return {"error": "Query only supported for SQL-family engines"} + # v0.1.4 SECURITY: read-only statement guard. + tokens = (sql or "").lstrip("(\t\r\n ").split(None, 1) + first_token = tokens[0].upper() if tokens else "" + read_only = first_token in ( + "SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC", + "PRAGMA", "TABLE", "ANALYZE", + ) + if not read_only: + return {"error": "read-only queries only — DDL/DML is rejected " + "(first token was not a read statement)"} if cli == "psql": out = run(["psql", "-tAc", sql], timeout=30) elif cli in ("mysql", "mariadb"): diff --git a/bridge/firewall.py b/bridge/firewall.py index 44cf174..f702228 100755 --- a/bridge/firewall.py +++ b/bridge/firewall.py @@ -1537,7 +1537,11 @@ def cmd_install_backend(args: list[str]) -> dict[str, Any]: for p in pkgs: if not _validate_filename(p): return {"error": f"invalid package name: {p!r}"} - cmd = [python3, packages_helper, "install", "--", *pkgs] + # v0.1.4 FIX: the trailing '--' separator made packages.py's + # install() see '--' as args[0] and fail with "no targets" — the + # backend-install path had never worked. packages.py now skips + # leading '--' argv elements anyway, so both sides are fixed. + cmd = [python3, packages_helper, "install", *pkgs] try: r = subprocess.run( cmd, capture_output=True, text=True, check=False, timeout=300, diff --git a/bridge/hwalert.py b/bridge/hwalert.py index 63e5771..13ea385 100755 --- a/bridge/hwalert.py +++ b/bridge/hwalert.py @@ -507,28 +507,61 @@ def cmd_dismiss(alert_id): return {"action": "dismiss", "alertId": alert_id, "status": "dismissed"} +def _device_path_ok(device_id): + """v0.1.4 SECURITY: device ids from the scanners are absolute sysfs + paths (/sys/bus/usb/devices/..., /sys/bus/thunderbolt/devices/..., + /sys/bus/pci/devices/...). block/unblock write to /authorized as + root, so the id must resolve inside one of those scanned bases — + otherwise cmd_block was an arbitrary file-overwrite ('0') and + cmd_unblock was a root shell injection via `sudo sh -c` with the + f-string path (found by the 0.3.0 security audit; both sudo + fallbacks are also gone: the cockpit superuser channel already + escalates this helper via polkit, so shelling out through sudo + only ever added the injection primitive).""" + if not isinstance(device_id, str) or not device_id.startswith("/"): + return False + bases = ( + "/sys/bus/usb/devices", + "/sys/bus/thunderbolt/devices", + "/sys/bus/pci/devices", + ) + p = os.path.realpath(device_id) + return any(p == b or p.startswith(b + "/") for b in bases) + + def cmd_block(device_id): """Block a device — for USB, writes '0' to authorized sysfs.""" - # Try USB authorization + if not _device_path_ok(device_id): + return {"action": "block", "deviceId": device_id, "result": "invalid-device-path"} auth_path = os.path.join(device_id, "authorized") if os.path.exists(auth_path): try: with open(auth_path, 'w') as f: f.write('0') return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize"} - except PermissionError: - # Need sudo - run(["sudo", "tee", auth_path], timeout=5) - return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize-sudo"} + except (PermissionError, OSError) as exc: + return {"action": "block", "deviceId": device_id, "result": "error", + "error": str(exc)} return {"action": "block", "deviceId": device_id, "result": "no-method-available"} def cmd_unblock(device_id): """Unblock a device.""" + if not _device_path_ok(device_id): + return {"action": "unblock", "deviceId": device_id, "result": "invalid-device-path"} auth_path = os.path.join(device_id, "authorized") if os.path.exists(auth_path): - run(["sudo", "sh", "-c", f"echo 1 > {auth_path}"], timeout=5) - return {"action": "unblock", "deviceId": device_id, "result": "unblocked"} + # v0.1.4 SECURITY: was `sudo sh -c f"echo 1 > {auth_path}"` — a + # device_id containing shell metacharacters was literal root RCE. + # Direct write (this helper already runs privileged through the + # cockpit superuser channel when the operator approves polkit). + try: + with open(auth_path, 'w') as f: + f.write('1') + return {"action": "unblock", "deviceId": device_id, "result": "unblocked"} + except (PermissionError, OSError) as exc: + return {"action": "unblock", "deviceId": device_id, "result": "error", + "error": str(exc)} return {"action": "unblock", "deviceId": device_id, "result": "no-method-available"} diff --git a/bridge/klanker.py b/bridge/klanker.py new file mode 100644 index 0000000..aed10b3 --- /dev/null +++ b/bridge/klanker.py @@ -0,0 +1,571 @@ +#!/usr/bin/env python3 +""" +SysDeck - Klanker Bridge Helper (AI Gateway) +Author: Jeremy Anderson (https://dcos.net) + +UPSTREAM ATTRIBUTION: this helper is a REST *client* of klanker-gate — +the "Frosty Deno" LLM gateway by TykoDev +(https://github.com/TykoDev/klanker-gate, Apache-2.0), which the master +tarball vendors unmodified at /klanker-gate. klanker-gate is NOT +SysDeck code and no upstream code is contained here — see +klanker-gate/ATTRIBUTION.md and THIRD_PARTY.md. + +v0.3.0 NEW MODULE. Client of the vendored klanker-gate service — the +Frosty Deno LLM gateway (Deno 2 + TypeScript, REST on 127.0.0.1:8080) — +so sysdeck ships an operator view of the local inference gateway: +providers, virtual keys, request logs, spend/cost rollups, cache and +runtime topology, plus systemd service control. + +This helper is a thin stdlib-only REST client (urllib.request + json, +4s timeout — no requests library, no curl dependency), the same +contract as bridge/fester.py. Every read subcommand prints the +service's JSON response; `status` merges the public /healthz and +/api/version probes and enriches them with the local connection +facts. HTTP error bodies (401 auth errors, 404s) are JSON on this +service and are surfaced verbatim. Connection failures are graceful: +{"ok": false, "error": ...} with a remediation hint, exit code 0 — +never a traceback. + +Authentication: operator routes under /api/* take +`Authorization: Bearer ` when the gateway has one +configured. The token is read from KLANKER_ADMIN_TOKEN here and sent +as a header ONLY — it is never echoed in any output, never placed in +a URL, and journal output is scrubbed of its value defensively. + +Subcommands: + status GET /healthz + /api/version, merged + enriched + providers GET /api/providers (browser-safe list) + models GET /v1/models (aggregated catalog) + vkeys GET /api/virtual-keys + logs [--limit N] GET /api/logs?limit=N (recent request ring, + default 25) + analytics GET /api/analytics (rollups: requests, spend, + cache, latency; optional --window 1h|24h|7d) + runtime GET /api/runtime (workers/cache/postgres) + service systemctl start|stop|restart|status|enable|disable + klanker-gate.service + journal [N] journalctl -u klanker-gate -n N --no-pager + (default 40, sanitized) + localstack probe local AI backends (ollama, llama.cpp, + koboldcpp, lmstudio, sglang, vllm) + wiring recipes + +Usage: + python3 /usr/lib/sysdeck/bridge/klanker.py status + python3 /usr/lib/sysdeck/bridge/klanker.py providers + python3 /usr/lib/sysdeck/bridge/klanker.py logs --limit 50 + python3 /usr/lib/sysdeck/bridge/klanker.py service restart + KLANKER_URL=http://10.0.0.5:8080 KLANKER_ADMIN_TOKEN=... \\ + python3 /usr/lib/sysdeck/bridge/klanker.py analytics +""" + +import json +import os +import re +import shutil +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +# The vendored klanker-gate service binds REST here by default +# (apps/gateway/main.ts: PORT env, default 8080). Override with +# KLANKER_URL when it lives elsewhere. +KLANKER_URL = os.environ.get("KLANKER_URL", "http://127.0.0.1:8080").rstrip("/") + +# Optional bearer token for the gateway's admin surface (/api/* takes +# Authorization: Bearer when the operator set one). +# Header-only usage — NEVER printed, NEVER in a URL. +KLANKER_ADMIN_TOKEN = os.environ.get("KLANKER_ADMIN_TOKEN") + +# The systemd unit the service subcommand wraps. The gateway itself +# ships no unit (docker-compose / `deno task gateway` are its native +# runners); operators who deploy it natively use this name, matching +# the fester-service convention. +KLANKER_SERVICE = "klanker-gate.service" + +# Strict 4s timeout — the panel polls every 5s, so a hung request must +# never outlive one refresh cycle. +KLANKER_TIMEOUT = 4 # seconds + +# Connection-level failure messages (callers print this and exit 0 — +# graceful, same contract as fester.py and the other bridge helpers). +UNREACHABLE_MSG = ( + "klanker-gate service unreachable at {url} — start it with " + "`systemctl start klanker-gate` (arch/ packaging) or `deno task dev` " + "in the vendored klanker-gate tree, or set KLANKER_URL" +) + + +def _unreachable() -> dict: + """Return the graceful offline response (remediation hint included).""" + return {"ok": False, "error": UNREACHABLE_MSG.format(url=KLANKER_URL)} + + +def _base_port() -> int: + """Port of the base URL (8080 for the default vendored service).""" + try: + return urllib.parse.urlparse(KLANKER_URL).port or 8080 + except ValueError: + return 8080 + + +def _request(path: str) -> dict: + """One HTTP GET against the gateway. Returns parsed JSON. + + HTTPError bodies are JSON on this service — surface them verbatim + (a 401 "Missing or invalid admin token." is a fact the operator + needs to see). Connection-level failures raise URLError/OSError; + the _get wrapper translates those into the graceful offline + response. + """ + url = KLANKER_URL + path + headers = {"Accept": "application/json"} + if KLANKER_ADMIN_TOKEN: + # Sent as a header only — the token value never appears in + # `url`, in any error string, or in any printed JSON. + headers["Authorization"] = f"Bearer {KLANKER_ADMIN_TOKEN}" + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=KLANKER_TIMEOUT) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + try: + raw = exc.read().decode("utf-8", errors="replace") + if raw.strip(): + return json.loads(raw) + except (OSError, ValueError): + pass + return {"ok": False, "error": f"HTTP {exc.code}: {exc.reason}"} + try: + return json.loads(raw) if raw.strip() else {"ok": False, "error": f"empty response from {url}"} + except json.JSONDecodeError: + return {"ok": False, "error": f"non-JSON response from {url}"} + + +def _get(path: str) -> dict: + """GET with graceful offline handling.""" + try: + return _request(path) + except (urllib.error.URLError, OSError, ValueError): + return _unreachable() + + +# ── subcommands ────────────────────────────────────────────────────── + + +def cmd_status(_args: list[str]) -> dict: + """GET /healthz + /api/version, merged + enriched with local facts. + + Both probes are public (no admin token required). The merge keeps + the gateway's own fields (status, version, timestamp) and layers: + port — port of the base URL (8080 default) + base_url — the URL this helper is talking to + deno — Deno runtime version from /api/version + transport — "rest" + auth — whether an admin token is configured HERE (boolean; + the token value itself is never reported) + """ + try: + data = _request("/healthz") + except (urllib.error.URLError, OSError, ValueError): + return _unreachable() + if not isinstance(data, dict) or data.get("status") != "ok": + # Non-healthy gateway (or an error body) — surface it verbatim. + if isinstance(data, dict) and data.get("ok") is not False: + data = dict(data) + data.setdefault("status", "error") + return data if isinstance(data, dict) else {"ok": False, "error": "non-object healthz response"} + + out = dict(data) + # /api/version is best-effort — a healthy gateway always serves it, + # but a failure here must not sink the status probe. + version = _get("/api/version") + if isinstance(version, dict) and version.get("ok") is not False: + out["deno"] = version.get("deno") + if version.get("version"): + out["version"] = version["version"] + out["ok"] = True + out["port"] = _base_port() + out["base_url"] = KLANKER_URL + out["transport"] = "rest" + out["auth"] = bool(KLANKER_ADMIN_TOKEN) + return out + + +def cmd_logs(args: list[str]) -> dict: + """GET /api/logs — the recent request ring, optionally limited. + + Usage: logs [--limit N] (default 25, range 1-500) + """ + limit = 25 + i = 0 + while i < len(args): + arg = args[i] + if arg == "--limit" and i + 1 < len(args): + raw = args[i + 1] + i += 2 + try: + limit = int(raw) + except ValueError: + return {"ok": False, "error": f"limit must be an integer between 1 and 500: {raw!r}"} + if not 1 <= limit <= 500: + return {"ok": False, "error": f"limit must be an integer between 1 and 500: {limit}"} + else: + return {"ok": False, "error": f"unknown argument: {arg}"} + return _get(f"/api/logs?limit={limit}") + + +def cmd_analytics(args: list[str]) -> dict: + """GET /api/analytics — rollups (requests, spend, cache, latency). + + Usage: analytics [--window 1h|24h|7d] (default 24h) + """ + window = "24h" + i = 0 + while i < len(args): + arg = args[i] + if arg == "--window" and i + 1 < len(args): + window = args[i + 1] + i += 2 + if window not in ("1h", "24h", "7d"): + return {"ok": False, "error": f"window must be one of 1h, 24h, 7d: {window!r}"} + else: + return {"ok": False, "error": f"unknown argument: {arg}"} + return _get(f"/api/analytics?window={window}") + + +# ── systemd service control ───────────────────────────────────────── +# +# Same pattern as bridge/jellyfin.py / bridge/mining.py: plain +# subprocess.run(["systemctl", ...]) with capture_output, check=False, +# a timeout, and a structured result. No `sudo` shell-out — the JS +# panel's bridgeCmd already passes superuser:'try' so cockpit prompts +# via polkit. + +SERVICE_ACTIONS = ("start", "stop", "restart", "status", "enable", "disable") + + +def _have(binary: str) -> bool: + """True if binary is on PATH.""" + return shutil.which(binary) is not None + + +def _service_status() -> dict: + """Read-only unit state: active/sub + enabled-at-boot.""" + if not _have("systemctl"): + return {"ok": False, "error": "systemctl not on PATH"} + try: + r = subprocess.run( + ["systemctl", "show", KLANKER_SERVICE, + "--property=ActiveState,SubState,UnitFileState,ActiveEnterTimestamp"], + capture_output=True, text=True, timeout=5, + ) + props = dict( + line.split("=", 1) + for line in r.stdout.strip().splitlines() + if "=" in line + ) + active = props.get("ActiveState", "unknown") + sub = props.get("SubState", "unknown") + enabled = props.get("UnitFileState", "unknown") + return { + "ok": True, + "service": KLANKER_SERVICE, + "active": active, + "sub": sub, + "enabled": enabled, + "running": active == "active", + } + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + return {"ok": False, "error": str(exc)} + + +def cmd_service(args: list[str]) -> dict: + """systemctl wrapper for klanker-gate.service. + + Usage: service + """ + if not args: + return {"ok": False, "error": "action required: service "} + action = args[0] + if action not in SERVICE_ACTIONS: + return {"ok": False, "error": f"unknown action: {action!r} (expected one of {', '.join(SERVICE_ACTIONS)})"} + if len(args) > 1: + return {"ok": False, "error": f"unknown argument: {args[1]}"} + + if action == "status": + return _service_status() + + if not _have("systemctl"): + return {"ok": False, "error": "systemctl not on PATH"} + timeout = 30 if action == "restart" else 15 + try: + r = subprocess.run( + ["systemctl", action, KLANKER_SERVICE], + capture_output=True, text=True, timeout=timeout, + ) + return { + "ok": r.returncode == 0, + "action": action, + "service": KLANKER_SERVICE, + "rc": r.returncode, + "output": (r.stdout or "").strip(), + "stderr": (r.stderr or "").strip(), + } + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + return {"ok": False, "action": action, "service": KLANKER_SERVICE, + "rc": 1, "stderr": str(exc)} + + +# ── journal ───────────────────────────────────────────────────────── +# +# journalctl tail, sanitized like the suite's other raw-output +# helpers (netsec/grafana _sanitize_output pattern): ANSI escapes and +# non-printable control chars are stripped, the output is capped, and +# — defensively — the admin token value is masked if it somehow ends +# up in a log line. + +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]") +_JOURNAL_MAX_CHARS = 32_768 # 32 KB cap — the panel renders it monospace + + +def _sanitize_journal(text: str) -> str: + """Strip ANSI/control chars, cap length, mask the admin token.""" + if not text: + return "" + text = _ANSI_RE.sub("", text) + text = "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text) + if KLANKER_ADMIN_TOKEN: + # NEVER echo the token, even if the service logged it. + text = text.replace(KLANKER_ADMIN_TOKEN, "***") + if len(text) > _JOURNAL_MAX_CHARS: + text = text[:_JOURNAL_MAX_CHARS] + " ... (truncated)" + return text + + +def cmd_journal(args: list[str]) -> dict: + """journalctl -u klanker-gate -n N --no-pager (default 40 lines). + + Usage: journal [N] (range 1-1000) + """ + lines = 40 + if args: + try: + lines = int(args[0]) + except ValueError: + return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {args[0]!r}"} + if not 1 <= lines <= 1000: + return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {lines}"} + if len(args) > 1: + return {"ok": False, "error": f"unknown argument: {args[1]}"} + + if not _have("journalctl"): + return {"ok": False, "error": "journalctl not on PATH"} + try: + r = subprocess.run( + ["journalctl", "-u", "klanker-gate", + "-n", str(lines), "--no-pager"], + capture_output=True, text=True, timeout=10, + ) + log = _sanitize_journal(r.stdout or "") + return { + "ok": r.returncode == 0, + "service": "klanker-gate", + "lines": lines, + "count": log.count("\n") + 1 if log else 0, + "log": log, + "stderr": (r.stderr or "").strip(), + } + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + return {"ok": False, "error": str(exc)} + + +# ── local stack wiring (probed) ────────────────────────────────────── + +# The gateway is NOT SaaS-only: ollama / lmstudio / sglang are native +# keyless provider types upstream (packages/contracts/src/provider- +# registry.ts), and llama.cpp (llama-server) / koboldcpp / vLLM / TGI / +# any OpenAI-wire server plug in through the generic "openai-compatible" +# type. This catalog mirrors the web edition's LOCAL_BACKENDS and the +# upstream defaults (packages/providers/src/openai_compat.ts). +LOCAL_BACKENDS = [ + { + "id": "ollama", + "name": "Ollama", + "provider_type": "ollama", + "base_url": "http://127.0.0.1:11434/v1", + "auth": "none", + "caps": "streaming, tools, embeddings", + "env_wiring": "OLLAMA_BASE_URL=http://127.0.0.1:11434/v1\n" + "OLLAMA_MODELS=qwen3:14b,llama3.1:8b,nomic-embed-text", + "note": "native provider type — keyless local daemon", + }, + { + "id": "llamacpp", + "name": "llama.cpp (llama-server)", + "provider_type": "openai-compatible", + "base_url": "http://127.0.0.1:8081/v1", + "auth": "key optional", + "caps": "streaming, tools", + "env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8081/v1\n" + "OPENAI_COMPAT_DEFAULT_MODEL=qwen2.5-coder-7b", + "note": "llama-server DEFAULTS TO :8080 — the gateway's own port. " + "Run it elsewhere (8081 here) or move the gateway", + }, + { + "id": "koboldcpp", + "name": "KoboldCpp", + "provider_type": "openai-compatible", + "base_url": "http://127.0.0.1:5001/v1", + "auth": "key optional", + "caps": "streaming, tools", + "env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:5001/v1", + "note": "koboldcpp serves the OpenAI wire on its main port", + }, + { + "id": "lmstudio", + "name": "LM Studio", + "provider_type": "lmstudio", + "base_url": "http://127.0.0.1:1234/v1", + "auth": "none", + "caps": "streaming, tools, embeddings", + "env_wiring": "LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1", + "note": "native provider type", + }, + { + "id": "sglang", + "name": "SGLang", + "provider_type": "sgl", + "base_url": "http://127.0.0.1:30000/v1", + "auth": "none", + "caps": "streaming, tools, embeddings", + "env_wiring": None, + "note": "native provider type — self-hosted serving framework", + }, + { + "id": "vllm", + "name": "vLLM", + "provider_type": "openai-compatible", + "base_url": "http://127.0.0.1:8000/v1", + "auth": "key optional", + "caps": "streaming, tools", + "env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8000/v1", + "note": "via the generic openai-compatible account", + }, +] + +# Probes run in parallel threads (0.4s timeout each) so the whole +# subcommand answers in well under the 4s bridge budget. +LOCAL_PROBE_TIMEOUT = 0.4 + + +def _probe_one(backend: dict) -> dict: + """GET /models with a 0.4s timeout; offline = reachable:False.""" + url = backend["base_url"].rstrip("/") + "/models" + req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + started = time.monotonic() + try: + with urllib.request.urlopen(req, timeout=LOCAL_PROBE_TIMEOUT) as resp: + ok = 200 <= resp.status < 300 + except (urllib.error.URLError, OSError, ValueError): + ok = False + out = dict(backend) + out["reachable"] = ok + out["latency_ms"] = round((time.monotonic() - started) * 1000) if ok else None + return out + + +def cmd_localstack(_args: list[str]) -> dict: + """Probe the local AI-stack backends from this host and return the + wiring recipes (provider type, base URL, env / admin-API examples). + + Always LIVE — the probes do not involve the gateway at all: green + means that local daemon answered /v1/models on this machine. This + is the wiring aid for an all-local inference stack (ollama, + llama.cpp, koboldcpp, LM Studio, SGLang, vLLM). + """ + workers = [] + threads = [] + for backend in LOCAL_BACKENDS: + worker = {"backend": backend, "result": None} + workers.append(worker) + + def run(b=backend, w=worker): + w["result"] = _probe_one(b) + + thread = threading.Thread(target=run) + thread.start() + threads.append(thread) + for thread in threads: + thread.join(timeout=LOCAL_PROBE_TIMEOUT + 0.2) + + backends = [w["result"] or {**w["backend"], "reachable": False, "latency_ms": None} for w in workers] + reachable = sum(1 for b in backends if b.get("reachable")) + token_note = "$KLANKER_ADMIN_TOKEN" + example_lines = [ + f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' " + "-H 'content-type: application/json' " + "-d '{\"id\":\"llama-server\",\"type\":\"openai-compatible\"," + "\"baseUrl\":\"http://127.0.0.1:8081/v1\",\"enabled\":true," + "\"models\":[\"qwen2.5-coder-7b\"]}'", + f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' " + "-H 'content-type: application/json' " + "-d '{\"id\":\"koboldcpp\",\"type\":\"openai-compatible\"," + "\"baseUrl\":\"http://127.0.0.1:5001/v1\",\"enabled\":true," + "\"models\":[\"mistral-nemo-12b\"]}'", + "# auto-discover the model catalog after registering:", + f"curl -s -X POST {KLANKER_URL}/api/providers/llama-server/refresh-models " + f"-H 'Authorization: Bearer {token_note}'", + ] + return { + "ok": True, + "source": "live", + "backends": backends, + "count": len(backends), + "reachable": reachable, + "base_url": KLANKER_URL, + "admin_register_example": "\n".join(example_lines), + "note": ( + f"probed from this host (0.4s timeout each): {reachable}/{len(backends)} " + "local backends answered /v1/models. ollama + lmstudio register via env; " + "llama.cpp + koboldcpp + vllm register as openai-compatible accounts " + "(env registers ONE such account — use POST /api/providers for several). " + "Port note: llama-server defaults to :8080, the gateway's own port" + ), + } + + +# ── dispatch table ─────────────────────────────────────────────────── + +COMMANDS = { + "status": lambda _args: cmd_status(_args), + "providers": lambda _args: _get("/api/providers"), + "models": lambda _args: _get("/v1/models"), + "vkeys": lambda _args: _get("/api/virtual-keys"), + "logs": cmd_logs, + "analytics": cmd_analytics, + "runtime": lambda _args: _get("/api/runtime"), + "service": cmd_service, + "journal": cmd_journal, + "localstack": cmd_localstack, +} + + +def main(argv: list[str]) -> int: + if not argv or argv[0] in ("-h", "--help"): + print(__doc__) + return 0 + cmd = COMMANDS.get(argv[0]) + if not cmd: + print(f"Unknown subcommand: {argv[0]}", file=sys.stderr) + print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr) + return 2 + print(json.dumps(cmd(argv[1:]), indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/bridge/modules/__pycache__/__init__.cpython-312.pyc b/bridge/modules/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ca6260f Binary files /dev/null and b/bridge/modules/__pycache__/__init__.cpython-312.pyc differ diff --git a/bridge/packages.py b/bridge/packages.py index 2d96af1..cd3c428 100755 --- a/bridge/packages.py +++ b/bridge/packages.py @@ -295,6 +295,34 @@ def info(args: list[str]) -> dict[str, Any]: return fn(args) if fn else {} +def _pkg_name_ok(pkg: str) -> bool: + """v0.1.4 SECURITY: package names are passed to the system package + manager as one argv element. A leading dash turns them into manager + OPTIONS (pacman --config=…, dnf --setopt=…) and a URL makes dnf + fetch a remote RPM — argument injection, not shell injection. One + safe component: no leading dash, no whitespace/control chars, no + URL scheme, bounded length.""" + return ( + isinstance(pkg, str) + and 0 < len(pkg) <= 256 + and not pkg.startswith("-") + and "://" not in pkg + and not re.search(r"[\s\x00\x1b]", pkg) + ) + + +def _first_pkg_arg(args: list[str]) -> str | None: + """v0.1.4 FIX: firewall.py's install-backend used to call this + helper as `packages.py install -- ` (a `--` argv separator, + shell convention) — install() read args[0] == '--' and the + backend-install path has been broken since it shipped. Skip any + leading '--' separators instead of choking on them.""" + for a in args: + if a != "--": + return a + return None + + def install(args: list[str]) -> dict[str, str]: """Install a package — actually runs the package manager via subprocess. @@ -315,7 +343,14 @@ def install(args: list[str]) -> dict[str, str]: """ if not args: return {"error": "No package name provided"} - pkg = args[0] + pkg = _first_pkg_arg(args) + if not pkg: + return {"error": "No package name provided"} + if not _pkg_name_ok(pkg): + return {"error": f"invalid package name: {pkg!r}"} + # _pkg_name_ok already rejects leading-dash/URL names (argument + # injection), so no '--' end-of-options separator is needed here — + # pacman in particular does not accept one. cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg], "dnf": ["dnf", "install", "-y", pkg], "apt": ["apt", "install", "-y", pkg]} @@ -336,7 +371,11 @@ def remove(args: list[str]) -> dict[str, str]: """Remove a package — actually runs the package manager. See install().""" if not args: return {"error": "No package name provided"} - pkg = args[0] + pkg = _first_pkg_arg(args) + if not pkg: + return {"error": "No package name provided"} + if not _pkg_name_ok(pkg): + return {"error": f"invalid package name: {pkg!r}"} cmd_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg], "dnf": ["dnf", "remove", "-y", pkg], "apt": ["apt", "remove", "-y", pkg]} @@ -354,7 +393,11 @@ def update(args: list[str]) -> dict[str, str]: """Update a package — actually runs the package manager. See install().""" if not args: return {"error": "No package name provided"} - pkg = args[0] + pkg = _first_pkg_arg(args) + if not pkg: + return {"error": "No package name provided"} + if not _pkg_name_ok(pkg): + return {"error": f"invalid package name: {pkg!r}"} cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg], "dnf": ["dnf", "upgrade", "-y", pkg], "apt": ["apt", "upgrade", "-y", pkg]} diff --git a/bridge/policy.py b/bridge/policy.py index 3778eb1..abc9e2b 100755 --- a/bridge/policy.py +++ b/bridge/policy.py @@ -245,6 +245,28 @@ def cmd_acl_default(args: list[str]) -> dict[str, Any]: CGROUP_ROOT = Path("/sys/fs/cgroup") +def _cgroup_path_ok(path: Path) -> bool: + """v0.1.4 SECURITY: True if (resolved) path stays inside the unified + cgroup hierarchy. cmd_cgroup_create has always enforced a prefix + check, but the cgroup-* siblings didn't — cgroup-set wrote to + `/` as root (arbitrary file overwrite: + `cgroup-set /etc/cron.d x '* * * * * root curl ...'` was a one-prompt + persistent-root primitive; found by the 0.3.0 security audit). All + cgroup subcommands now resolve + bound-check the same way.""" + try: + path.resolve().relative_to(CGROUP_ROOT.resolve()) + return True + except (ValueError, RuntimeError, OSError): + return False + + +# v0.1.4 SECURITY: cgroup control files are a closed vocabulary (cgroup.* +# + controller knobs). Restricting the filename to this shape blocks +# using cgroup-set as an arbitrary-named file writer. +_CGROUP_CTRL_RE = re.compile(r"^(cgroup\.(procs|controllers|subtree_control|type|freeze|kill)|" + r"(memory|cpu|io|pids|rdma|misc|hugetlb)\.[A-Za-z0-9_.-]{1,32})$") + + def _cgroup_v2_available() -> bool: """True if /sys/fs/cgroup/ is a cgroups v2 unified hierarchy.""" return (CGROUP_ROOT / "cgroup.controllers").is_file() @@ -315,6 +337,11 @@ def cmd_cgroup_show(args: list[str]) -> dict[str, Any]: if not args: return {"error": "cgroup path required"} path = Path(args[0]) + # v0.1.4 SECURITY: cgroup paths are client-supplied; every cgroup + # subcommand must keep them inside the unified hierarchy — see the + # _cgroup_path_ok guard comment below cmd_cgroup_create. + if not _cgroup_path_ok(path): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} if not path.is_dir(): return {"error": f"{path} is not a directory"} info: dict[str, Any] = {"path": str(path), "name": path.name} @@ -356,6 +383,8 @@ def cmd_cgroup_procs(args: list[str]) -> dict[str, Any]: """List PIDs in a cgroup (just the PIDs, no metadata).""" if not args: return {"error": "cgroup path required"} + if not _cgroup_path_ok(Path(args[0])): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} procs_file = Path(args[0]) / "cgroup.procs" if not procs_file.is_file(): return {"error": f"{procs_file} not found"} @@ -373,7 +402,9 @@ def cmd_cgroup_create(args: list[str]) -> dict[str, Any]: path = Path(args[0]) if not _cgroup_v2_available(): return {"available": False, "reason": "cgroups v2 not mounted"} - if not str(path).startswith(str(CGROUP_ROOT)): + # v0.1.4 SECURITY: upgraded from a str().startswith() prefix check + # (which "/sys/fs/cgroup-evil" would satisfy) to resolve + bound. + if not _cgroup_path_ok(path): return {"error": f"cgroup path must be under {CGROUP_ROOT}"} try: path.mkdir(parents=True, exist_ok=False) @@ -390,6 +421,8 @@ def cmd_cgroup_move(args: list[str]) -> dict[str, Any]: if len(args) < 2: return {"error": "usage: cgroup-move "} pid, cgrp = args[0], args[1] + if not _cgroup_path_ok(Path(cgrp)): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} procs_file = Path(cgrp) / "cgroup.procs" if not procs_file.is_file(): return {"error": f"{procs_file} not found"} @@ -407,6 +440,16 @@ def cmd_cgroup_set(args: list[str]) -> dict[str, Any]: if len(args) < 3: return {"error": "usage: cgroup-set "} path, control, value = args[0], args[1], args[2] + # v0.1.4 SECURITY: this command writes as root. Both halves of the + # target are client-supplied, so BOTH are validated: the cgroup path + # must resolve under /sys/fs/cgroup (was missing — arbitrary root + # file overwrite, see _cgroup_path_ok) and the control file must be + # a real cgroup controller knob name, not a traversal/probe. + if not _cgroup_path_ok(Path(path)): + return {"error": f"cgroup path must be under {CGROUP_ROOT}"} + if not _CGROUP_CTRL_RE.match(control): + return {"error": f"'{control}' is not a cgroup control file name " + "(expected e.g. memory.max, cpu.weight, cgroup.procs)"} # control is a filename like 'memory.max' or 'cpu.weight' target = Path(path) / control if not target.parent.is_dir(): diff --git a/bridge/themes.py b/bridge/themes.py index 7261436..11e7d66 100755 --- a/bridge/themes.py +++ b/bridge/themes.py @@ -292,10 +292,23 @@ def cmd_set(args: list[str]) -> dict[str, Any]: """Set one key in cockpit.conf. Usage: set
. Creates the section if absent. + + v0.1.4 SECURITY: section/key/value arrive as raw argv and are + serialized into /etc/cockpit/cockpit.conf with naive `f"{k} = {v}" + lines. A value containing a newline could inject whole new + sections/keys into cockpit.conf ([WebService]/[Session] knobs) the + next time cockpit parses it (found by the 0.3.0 security audit). + Newlines, NULs, brackets in section names and '=' in keys are now + rejected; write-config remains the operator's explicit raw editor. """ if len(args) < 3: return {"error": "usage: set
"} section, key, value = args[0], args[1], args[2] + if re.search(r"[\r\n\0]", section + key + value) or re.search(r"[\[\]]", section): + return {"error": "refusing to set: section/key/value must be single-line " + "(no newlines, no NULs; no brackets in section names)"} + if "=" in key: + return {"error": "refusing to set: key must not contain '='"} text = _read_text() sections = _parse_conf(text) sections.setdefault(section, {})[key] = value diff --git a/compat/compat-manifest.json b/compat/compat-manifest.json index fd8a180..36b034c 100755 --- a/compat/compat-manifest.json +++ b/compat/compat-manifest.json @@ -1,6 +1,6 @@ { "_comment": "Compatibility Manifest — sysdeck v0.1.3", - "version": "0.2.0", + "version": "0.4.1", "suite_requires": { "cockpit": ">=239", "python": ">=3.9" }, "modules": { "containers": { diff --git a/klanker-gate/.dockerignore b/klanker-gate/.dockerignore new file mode 100755 index 0000000..6299a5d --- /dev/null +++ b/klanker-gate/.dockerignore @@ -0,0 +1,11 @@ +# Docker build context excludes (context root = frosty-deno). +# The Control UI is built inside the image (multi-stage Dockerfile), so host +# node_modules (Windows/Linux platform mismatch) and any prebuilt / host-locked +# dist must never enter the build context. Keeping these out also fixes the +# Windows "EPERM rm" failure that blocks a clean host `deno task build-ui`. +**/node_modules +apps/control-ui/dist +apps/control-ui/dist_new +data +*.log +.env diff --git a/klanker-gate/.editorconfig b/klanker-gate/.editorconfig new file mode 100755 index 0000000..7223b34 --- /dev/null +++ b/klanker-gate/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = false +end_of_line = lf +charset = utf-8 + +[*.{js,jsx,ts,tsx,mjs,json,md,css,scss,html}] +insert_final_newline = false diff --git a/klanker-gate/.env.example b/klanker-gate/.env.example new file mode 100755 index 0000000..9325a64 --- /dev/null +++ b/klanker-gate/.env.example @@ -0,0 +1,328 @@ +# ============================================================================= +# Frosty Deno - full configuration surface +# ============================================================================= +# Every operator knob the gateway reads, grouped the same way as +# docs/reference/environment-variables.md so the two can be diffed. For the +# smallest config that boots, use `.env.example.dev` instead. +# +# cp .env.example .env +# docker compose up -d postgres # REQUIRED (section 4) +# deno task setup && deno task dev +# +# Conventions in this file: +# * A blank value means "leave the feature off" - it is never a placeholder to +# be filled in blindly. Secrets ship blank on purpose. +# * A value that IS filled in is either a real default or an inert format +# example (a URL shape, a model list), safe to copy as-is. +# * Anything absent from this file has a safe default. Adding a new knob means +# a bounded parse, a row in the reference doc above, and a line here. +# +# Sections +# 1 Provider credentials and provider catalogs +# 2 Azure OpenAI, Bedrock, and Vertex AI +# 3 Generic compatible endpoints +# 4 Core gateway and PostgreSQL <- required +# 5 Worker topology and shared governance +# 6 Admin protection and origin control +# 7 Cache and vector store +# 8 MCP and Code Mode +# 9 Logging, analytics display, and observability +# 10 Pricing sync, encryption, plugins, HTTP client +# 11 Not operator knobs (harness, migration, supervisor-set) + +# ============================================================================= +# 1. Provider credentials and provider catalogs +# ============================================================================= +# Any subset. A provider registers itself at boot when its credential is set, +# and stays absent otherwise, so a blank key is a supported state rather than a +# broken one. Persisted config from /api/providers overlays these and WINS on an +# id collision. + +# OpenAI-wire vendors. +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GEMINI_API_KEY= +OPENROUTER_API_KEY= +GROQ_API_KEY= +MISTRAL_API_KEY= +XAI_API_KEY= +PERPLEXITY_API_KEY= +CEREBRAS_API_KEY= +NEBIUS_API_KEY= +PARASAIL_API_KEY= + +# Native adapters (their own wire formats, translated to canonical internally). +HF_TOKEN= +COHERE_API_KEY= +# Audio only: /v1/audio/speech and /v1/audio/transcriptions. +ELEVENLABS_API_KEY= + +# Ollama needs no key; it registers on BASE_URL alone. MODELS is a +# comma-separated catalog, because Ollama's tag list is host-specific. +OLLAMA_BASE_URL= +OLLAMA_MODELS= + +# ============================================================================= +# 2. Azure OpenAI, Bedrock, and Vertex AI +# ============================================================================= +# These three do not take a bare API key alone: each needs its own coordinates +# before a model name can be resolved to an endpoint. + +# Azure routes per DEPLOYMENT, not per model, so the deployment list is what +# makes models addressable. Comma-separated. +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com +AZURE_OPENAI_API_VERSION=2024-02-15-preview +AZURE_OPENAI_DEPLOYMENTS=gpt-4o-deployment,gpt-35-deployment + +# AWS Bedrock (SigV4). SESSION_TOKEN only for temporary credentials. +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_SESSION_TOKEN= +BEDROCK_MODELS= + +# Vertex AI. The service-account JSON goes in as ONE line, quotes intact. +VERTEX_PROJECT_ID= +VERTEX_LOCATION=us-central1 +VERTEX_SERVICE_ACCOUNT_JSON= +VERTEX_MODELS=gemini-2.5-pro + +# ============================================================================= +# 3. Generic compatible endpoints +# ============================================================================= +# Point Frosty at any OpenAI-wire or Anthropic-wire server. Each stays off until +# its BASE_URL is set; the API key is optional because many local servers take +# none. + +# Any OpenAI-wire server (vLLM, llama.cpp, TGI, ...). Id: `openai-compatible`. +OPENAI_COMPAT_BASE_URL= +OPENAI_COMPAT_API_KEY= +OPENAI_COMPAT_DEFAULT_MODEL= + +# Any Anthropic Messages-wire server. Id: `anthropic-compatible`. +ANTHROPIC_COMPAT_BASE_URL= +ANTHROPIC_COMPAT_API_KEY= +ANTHROPIC_COMPAT_DEFAULT_MODEL= + +# LM Studio (local OpenAI-compatible server; its default base URL shown). +LMSTUDIO_BASE_URL=http://localhost:1234/v1 +LMSTUDIO_API_KEY= +LMSTUDIO_DEFAULT_MODEL= + +# ============================================================================= +# 4. Core gateway and PostgreSQL +# ============================================================================= +PORT=8080 + +# Provider account used for model names without a `provider/` prefix. Must match +# a registered id from section 1-3. +FROSTY_DEFAULT_PROVIDER=openai + +# --- PostgreSQL: the gateway's ONE stateful dependency, and it is REQUIRED ---- +# Holds the control-plane config, governance counters, the request-log trail, +# the L2 response cache, and the pgvector embedding index. There is no fallback: +# an unreachable or unset URL ABORTS BOOT rather than serving with empty +# governance state (decision-log 61). Deno KV, which used to hold this, is +# retired - migrate an existing data/frosty.kv with +# `deno task migrate:kv-pg -- --commit`. +# +# docker compose up -d postgres +# +# Credentials are required; the Compose service sets user/password/db to +# `frosty`. Use `localhost` from the host, `postgres` from inside Compose. +FROSTY_PG_URL=postgres://frosty:frosty@localhost:5432/frosty + +# Session-stable connection used ONLY for LISTEN (cross-process cache +# invalidation). Defaults to FROSTY_PG_URL, which is correct until a pooler sits +# in between: LISTEN through PgBouncer's transaction mode stops delivering +# SILENTLY. With `--profile pgbouncer` up, point FROSTY_PG_URL at :6432 and +# leave this one on :5432. +FROSTY_PG_DIRECT_URL= + +# Connections held PER PROCESS (default 8, max 100). The number PostgreSQL sees +# is this times FROSTY_WORKERS, plus one LISTEN connection per process. +FROSTY_PG_POOL_SIZE= + +# Table holding the semantic cache's embedding vectors. +FROSTY_PG_TABLE=frosty_vectors + +# ============================================================================= +# 5. Worker topology and shared governance +# ============================================================================= +# Worker processes sharing one port through SO_REUSEPORT. Unset or 1 = single +# process. LINUX/macOS ONLY - Windows has no SO_REUSEPORT and the second bind +# fails with os error 10048, so the gateway logs why and serves single-process +# instead. See docs/guides/multi-process.md. +FROSTY_WORKERS= + +# Fleet-wide rate limiting. Fixed rate/token windows live in an in-process Map +# by default, which is exact for ONE process and admits N times the limit across +# N. `auto` (default) moves them to a shared PostgreSQL authority only when +# FROSTY_WORKERS>1, because that reservation costs ~1.8 ms per governed request +# versus ~1 us in memory. Set `on` when running separate REPLICAS (auto cannot +# see those); `off` accepts N-times-the-limit. auto|on|off +FROSTY_SHARED_RATE_LIMIT= + +# How often each process re-reads durable config, in ms (0-3600000, 0 disables). +# Config changes normally arrive over LISTEN/NOTIFY within milliseconds; this +# poll is the backstop that bounds staleness when a notification is lost, so a +# revoked virtual key stops working even then. Default 30000. +FROSTY_CONFIG_RECONCILE_MS= + +# ============================================================================= +# 6. Admin protection and origin control +# ============================================================================= +# Unset = explicit local admin mode (no auth on /api/*). Set to require +# "Authorization: Bearer " on all /api/* config routes. +FROSTY_ADMIN_TOKEN= + +# Admin origin guard allow-list (DNS-rebind defense). localhost, 127.0.0.1, and +# ::1 are always allowed; add your public host(s) here (comma-separated) when +# the gateway is reachable beyond localhost. Applies to every /api/* request. +FROSTY_ALLOWED_HOSTS= + +# ============================================================================= +# 7. Cache and vector store +# ============================================================================= +# Unset = off, "exact" = exact-match, "semantic" adds embedding similarity via +# the embed model below (the provider must support embeddings). +FROSTY_CACHE= +FROSTY_CACHE_TTL_MS= + +# Sent VERBATIM and case-sensitive, so it must match an id the provider serves +# (check GET /v1/models). Lookups are fail-open, so a wrong id reduces the cache +# to exact-match only; every failed lookup warns `semantic cache lookup degraded +# to miss`, and frosty_cache_events_total stays at 100% result="miss". +FROSTY_CACHE_EMBED_MODEL=text-embedding-3-small + +# Similarity vectors live in-process unless FROSTY_VECTOR_STORE=pgvector puts +# them in the same PostgreSQL as everything else, which is also what makes them +# survive a restart. The Redis (RediSearch) option was removed: Compose +# provisioned it and no shipped configuration ever selected it, so it was a +# dependency that served zero requests (decision-log 60). +FROSTY_VECTOR_STORE= + +# ============================================================================= +# 8. MCP and Code Mode +# ============================================================================= +# MCP client transports are configured per server via POST /api/mcp/clients +# ("transport": "http-sse" (default) | "streamable-http" | "auto" | "stdio"). +# Servers that only speak streamable-http need an explicit transport (or "auto" +# for spec-order negotiation). +# +# stdio (subprocess) MCP servers are DISABLED by default. Enabling them requires +# BOTH this flag ("1" or "true") AND running with --allow-run, which is outside +# the standard permission set on purpose (see permissions.md). +FROSTY_MCP_ALLOW_STDIO= + +# Background MCP health sweeps (0/unset = on-demand via /api/mcp/health only). +FROSTY_MCP_HEALTH_INTERVAL_MS= + +# Code Mode is default-off behind TWO independent gates. The VFS metadata +# surface is live and inert; the sandboxed executor is HARD-OFF and +# experimental. off|on (default off). The executor additionally requires a +# per-request `x-frosty-code-mode: run` header and still refuses (501) because +# the run primitive is intentionally stubbed. +FROSTY_CODE_MODE=off +FROSTY_CODE_MODE_VFS=on + +# ============================================================================= +# 9. Logging, analytics display, and observability +# ============================================================================= +# Durable request-log store, in the same PostgreSQL as the rest of the state. ON +# by default. Disable with FROSTY_LOG_STORE=off. The legacy value `kv` is still +# accepted and means "on" - it named the retired Deno KV backend, and rejecting +# it would break existing .env files over a backend that is gone. +FROSTY_LOG_STORE=pg +FROSTY_LOG_STORE_MAX=5000 + +# Paths kept OUT of the Logs dashboard trail (live stream + durable store). The +# container healthcheck and the Prometheus scrape hit the gateway on a fixed +# interval, so without this they accumulate until they are ~99% of the capped +# trail and real requests get pruned away. Console access logging is NOT +# affected: `docker logs` still shows every request. Blank uses the default +# below; set to `off` to log everything; `/prefix/*` matches a subtree. +FROSTY_LOG_EXCLUDE_PATHS=/healthz,/metrics,/favicon.ico + +# Request/response CONTENT capture in the durable log store (OPT-IN; default OFF +# for privacy; secrets are never captured). on to enable. +FROSTY_LOG_CONTENT= + +# Display currency: cost is accounted in USD internally; the Control UI presents +# euros by multiplying by this EUR-per-USD rate (default 0.92). Operator-only. +FROSTY_EUR_RATE=0.92 + +# OpenTelemetry OTLP/HTTP trace export. Unset = off. For Docker Compose use +# http://otel-collector:4318 and start `--profile observability`; spans go to +# Tempo for drill-down and to Prometheus as RED metrics. +OTEL_EXPORTER_OTLP_ENDPOINT= +OTEL_FLUSH_INTERVAL_MS=5000 + +# Distinct models admitted as the `frosty.metrics.model` span-metric label +# before the rest fold to "other". Bounds Prometheus series growth; traces keep +# the real model either way. Non-positive/unparseable falls back to the default. +FROSTY_OTEL_MODEL_CARDINALITY_CAP=11 + +# ============================================================================= +# 10. Pricing sync, encryption, plugins, HTTP client +# ============================================================================= +# LiteLLM pricing sync. Opt-in and DEFAULT OFF (offline/no-outbound default): +# set FROSTY_PRICING_SYNC=on to fetch model prices + metadata at boot and +# refresh on an interval. Operator /api/pricing overrides always win over synced +# prices. POST /api/pricing/force-sync triggers a sync on demand even when this +# is off. +FROSTY_PRICING_SYNC= +# Refresh cadence (ms); default 24h, floored at 60s. +FROSTY_PRICING_SYNC_INTERVAL_MS=86400000 +# Source URL for the price list (server-side only; never taken from a request). +FROSTY_PRICING_URL=https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json + +# --- Config secret encryption-at-rest (OPT-IN; default OFF = plaintext) ------- +# Set a base64-encoded 32-byte key (preferred) OR a strong passphrase (PBKDF2). +# When set, provider API keys / AWS secret+session / Vertex SA-JSON / proxy +# password / CA cert / virtual-key tokens / MCP header values are AES-256-GCM +# encrypted in PostgreSQL. Fail-closed: once a store has encrypted data, an +# unset or wrong key REFUSES boot. The key is effectively set-once (rotate via +# _OLD). +# Generate: `deno eval "console.log(btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))))"` +FROSTY_ENCRYPTION_KEY= +# Rotation only: set to the previous key alongside a new FROSTY_ENCRYPTION_KEY +# to rewrap the data-encryption key offline (data is not re-encrypted). +FROSTY_ENCRYPTION_KEY_OLD= + +# JSON-repair plugin (OPT-IN; default OFF). Repairs invalid-JSON model output +# post-response and, for streams, post-completion via the reconstructed message +# (the live client stream is never mutated). on|1|true|yes to enable. +FROSTY_JSON_REPAIR= + +# Request mocker: short-circuits upstream calls with synthetic responses, for +# offline dev/demo/load-testing. OPT-IN and OFF unless set to on|1|true|yes - +# leave it empty for a realistic deployment. Rules come from +# FROSTY_MOCKER_CONFIG (inline JSON starting with `{`, or a file path). +FROSTY_MOCKER= +FROSTY_MOCKER_CONFIG= + +# Provider HTTP client: default per-request timeout in ms (default 120000; 0 +# disables). Never total-caps an in-progress SSE/eventstream. FROSTY_NO_PROXY +# takes comma-separated bypass patterns (*, .example.com, *.example.com, or an +# exact host). +FROSTY_HTTP_TIMEOUT_MS=120000 +FROSTY_NO_PROXY= + +# ============================================================================= +# 11. Not operator knobs +# ============================================================================= +# Listed for parity with the code, so a reader who greps for one of these finds +# out why it is not above. Do NOT set these in a deployment .env. +# +# FROSTY_BASE_URL target for scripts/full_suite.ts and the browser +# harness; defaults to http://localhost:8080 +# FROSTY_BENCH_TARGET scripts/load-bench.ts only +# FROSTY_BENCH_KEY scripts/load-bench.ts only +# FROSTY_BENCH_MODEL scripts/load-bench.ts only +# FROSTY_KV_PATH read ONLY by scripts/migrate_kv_to_pg.ts, to find an +# existing data/frosty.kv to migrate. It configures +# nothing at runtime; Deno KV is retired. +# FROSTY_WORKER_ROLE set BY the supervisor on each child it spawns +# FROSTY_WORKER_INDEX set BY the supervisor on each child it spawns diff --git a/klanker-gate/.env.example.dev b/klanker-gate/.env.example.dev new file mode 100755 index 0000000..b56a765 --- /dev/null +++ b/klanker-gate/.env.example.dev @@ -0,0 +1,71 @@ +# ============================================================================= +# Frosty Deno - minimal local development configuration +# ============================================================================= +# cp .env.example.dev .env +# docker compose up -d postgres # REQUIRED, see below +# deno task setup # one-time +# deno task dev # gateway on http://localhost:8080 +# +# Everything omitted here has a safe default, so this is the smallest config +# that boots a useful gateway. For the full surface copy `.env.example` and read +# docs/reference/environment-variables.md. + +# --- REQUIRED: PostgreSQL ----------------------------------------------------- +# The gateway keeps ALL durable state here (control-plane config, governance +# counters, the request-log trail, the L2 response cache, the pgvector index) +# and refuses to boot without it - `FROSTY_PG_URL is required` (decision-log +# 61). There is no embedded fallback; Deno KV used to hold this and is retired. +# +# docker compose up -d postgres +# +# The Compose service sets user/password/db to `frosty`. Use `localhost` when +# the gateway runs on the host (`deno task dev`); inside Compose the gateway +# service already defaults itself to the `postgres` hostname. +FROSTY_PG_URL=postgres://frosty:frosty@localhost:5432/frosty + +# --- REQUIRED: one provider + the default route ------------------------------- +# The gateway boots with no key, but has nothing to route to. Set ONE of the +# blocks below and point FROSTY_DEFAULT_PROVIDER at its id. + +# a) A hosted vendor. +OPENAI_API_KEY= +# ANTHROPIC_API_KEY= + +# b) Any OpenAI-compatible server (vLLM, llama.cpp, LM Studio, TGI, ...). +# Registers as provider id `openai-compatible`. +# OPENAI_COMPAT_BASE_URL=http://localhost:8000/v1 +# OPENAI_COMPAT_API_KEY= +# OPENAI_COMPAT_DEFAULT_MODEL= + +# Provider used for model names without a `provider/` prefix. Must match an id +# above (`openai`, `anthropic`, `openai-compatible`, ...). +FROSTY_DEFAULT_PROVIDER=openai + +# --- Gateway basics (optional; defaults shown) -------------------------------- +PORT=8080 +# Unset = admin API open on localhost, which is what local dev wants. Set it to +# require `Authorization: Bearer ` on every /api/* route. +FROSTY_ADMIN_TOKEN= + +# --- OPTIONAL: response cache ------------------------------------------------- +# Unset = off. "exact" needs nothing beyond the Postgres above. "semantic" also +# needs an embedding model, and FROSTY_VECTOR_STORE=pgvector to keep the vectors +# in that same database rather than in-process (so they survive a restart). +# +# FROSTY_CACHE=semantic +# FROSTY_VECTOR_STORE=pgvector +# +# One thing that fails SILENTLY here, because cache lookups are fail-open: an +# embed model id the provider does not serve 404s on every lookup. The id below +# is sent verbatim and is case-sensitive - check GET /v1/models. Boot logs +# `semantic cache lookup degraded to miss: ...` when it is wrong. +# FROSTY_CACHE_EMBED_MODEL=text-embedding-3-small + +# --- OPTIONAL: metrics and traces --------------------------------------------- +# `GET /metrics` is always on and needs nothing. Traces need a collector: +# +# docker compose --profile observability up -d +# +# then set the endpoint below (it is deliberately not defaulted, because without +# that profile the host does not resolve). Grafana lands on http://localhost:3000. +# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 diff --git a/klanker-gate/.gitattributes b/klanker-gate/.gitattributes new file mode 100755 index 0000000..7310309 --- /dev/null +++ b/klanker-gate/.gitattributes @@ -0,0 +1,21 @@ +# Force LF line endings in the repository AND on checkout across every platform. +# `eol=lf` (not just `text=auto`) is what keeps Windows working trees from +# drifting to CRLF, which `deno fmt` rejects. This is the authoritative codebase +# line-ending rule; `.editorconfig` mirrors it for editors. +* text=auto eol=lf + +# Shell scripts must always be LF (kept explicit for clarity). +*.sh text eol=lf + +# Binary assets: never normalize EOL, never diff as text. +*.kv binary +*.kv-shm binary +*.kv-wal binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary diff --git a/klanker-gate/.github/CODEOWNERS b/klanker-gate/.github/CODEOWNERS new file mode 100755 index 0000000..1236e14 --- /dev/null +++ b/klanker-gate/.github/CODEOWNERS @@ -0,0 +1,75 @@ +# CODEOWNERS - review ownership for Frosty Deno (klanker-gate) +# +# Each line maps a path pattern to one or more owners. The LAST matching pattern +# for a changed file wins. Owners are auto-requested for review on pull requests +# that touch their paths (requires the repository to be on GitHub and the owners +# to have write access). +# +# NOTE: the handles below are PLACEHOLDERS. Replace @frosty-maintainers and the +# team handles with the real GitHub users or teams for this repository before +# relying on auto-review. Do not leave placeholder handles in a live repo - an +# unresolvable owner silently disables review requests for that path. + +# --------------------------------------------------------------------------- +# Default owner for everything not matched more specifically below. +# --------------------------------------------------------------------------- +* @frosty-maintainers + +# --------------------------------------------------------------------------- +# Gateway composition root and HTTP surface (middleware onion, routing, context). +# --------------------------------------------------------------------------- +/apps/gateway/ @frosty-maintainers +/apps/gateway/main.ts @frosty-maintainers +/apps/gateway/context.ts @frosty-maintainers + +# --------------------------------------------------------------------------- +# Control-plane SPA (design system ds-r2, same-origin, no external origins). +# --------------------------------------------------------------------------- +/apps/control-ui/ @frosty-maintainers +/apps/control-ui/src/styles/ @frosty-maintainers +/apps/control-ui/CONVENTIONS.md @frosty-maintainers + +# --------------------------------------------------------------------------- +# Core pipeline: canonical translation, streaming, router, orchestrator. +# --------------------------------------------------------------------------- +/packages/core/ @frosty-maintainers + +# Provider adapters (one family per vendor; wire translation). +/packages/providers/ @frosty-maintainers + +# Governance: virtual keys, hierarchy, budgets, pricing, rate limiting. +/packages/governance/ @frosty-maintainers + +# Cache, MCP + Code Mode, telemetry, config/secrets, plugins, contracts. +/packages/cache/ @frosty-maintainers +/packages/mcp/ @frosty-maintainers +/packages/telemetry/ @frosty-maintainers +/packages/config/ @frosty-maintainers +/packages/plugins/ @frosty-maintainers +/packages/contracts/ @frosty-maintainers + +# --------------------------------------------------------------------------- +# Security-sensitive surfaces: crypto-at-rest, origin guard, admin auth, +# permission contract. Changes here warrant extra scrutiny. +# --------------------------------------------------------------------------- +/packages/config/src/crypto.ts @frosty-maintainers +/apps/gateway/routes/origin-guard.ts @frosty-maintainers +/apps/gateway/routes/admin.ts @frosty-maintainers +/permissions.md @frosty-maintainers +/SECURITY.md @frosty-maintainers + +# --------------------------------------------------------------------------- +# Infrastructure, deployment and observability. +# --------------------------------------------------------------------------- +/Dockerfile @frosty-maintainers +/docker-compose.yml @frosty-maintainers +/deploy/ @frosty-maintainers +/.github/ @frosty-maintainers + +# --------------------------------------------------------------------------- +# Documentation and decision records. +# --------------------------------------------------------------------------- +/docs/ @frosty-maintainers +/docs/contracts/decision-log.md @frosty-maintainers +/CLAUDE.md @frosty-maintainers +/AGENTS.md @frosty-maintainers diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/bug_report.yml b/klanker-gate/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100755 index 0000000..08a9e3a --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,129 @@ +name: Bug report +description: Report a problem or regression in Bifrost +title: "[Bug]: " +labels: [bug] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out a bug report! Please provide as much detail as possible. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + options: + - label: I have searched existing issues and discussions to avoid duplicates + required: true + - label: I am using the latest version (or have tested against main/nightly) + required: false + + - type: textarea + id: description + attributes: + label: Description + description: What happened? Include screenshots if helpful. + placeholder: Clear and concise description of the bug + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Provide a minimal, reproducible example. Link to a repo, gist, or include exact steps. + placeholder: | + 1. Go to '...' + 2. Run '...' + 3. Observe '...' + validations: + required: true + + - type: input + id: expected + attributes: + label: Expected behavior + placeholder: What did you expect to happen? + validations: + required: true + + - type: input + id: actual + attributes: + label: Actual behavior + placeholder: What actually happened? + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Affected area(s) + multiple: true + options: + - Core (Go) + - Framework + - Transports (HTTP) + - Plugins + - UI (Next.js) + - Docs + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Affected version(s). + placeholder: e.g., v1.0.3 + validations: + required: true + + - type: textarea + id: env + attributes: + label: Environment + description: Include as many as apply. + placeholder: | + - OS: macOS 14.5, Linux x.y, Windows 11 + - Go: 1.22.x + - Node: 20.x, npm/pnpm/yarn version + - Browser (if UI): Chrome/Firefox/Safari versions + - Bifrost components and versions (core, transports, ui) + - Any relevant environment flags/config + render: text + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Relevant logs/output + description: Paste error logs, stack traces, or console output. + render: shell + placeholder: | + + validations: + required: false + + - type: input + id: regression + attributes: + label: Regression? + description: If this worked in a previous version, which version? + placeholder: e.g., Worked in v0.8.0, broke in v0.9.0 + validations: + required: false + + - type: dropdown + id: severity + attributes: + label: Severity + options: + - Low (minor issue or cosmetic) + - Medium (some functionality impaired) + - High (major functionality broken) + - Critical (blocks releases or production) + validations: + required: true diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/config.yml b/klanker-gate/.github/ISSUE_TEMPLATE/config.yml new file mode 100755 index 0000000..3ba13e0 --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/docs_issue.yml b/klanker-gate/.github/ISSUE_TEMPLATE/docs_issue.yml new file mode 100755 index 0000000..f874724 --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/docs_issue.yml @@ -0,0 +1,43 @@ +name: Documentation issue +description: Report missing, unclear, or incorrect documentation +title: "[Docs]: " +labels: [documentation] +assignees: [] +body: + - type: markdown + attributes: + value: | + Help us improve the docs! Please provide links and suggestions. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + options: + - label: I have searched existing issues and docs to avoid duplicates + required: true + + - type: input + id: page + attributes: + label: Affected page(s) + description: Provide the path or URL to the affected doc(s) + placeholder: docs/usage/providers.md or https://... + validations: + required: true + + - type: textarea + id: issue + attributes: + label: What’s wrong or missing? + description: Be as specific as possible. + validations: + required: true + + - type: textarea + id: suggestion + attributes: + label: Suggested change + description: Propose wording or structure improvements. + validations: + required: false diff --git a/klanker-gate/.github/ISSUE_TEMPLATE/feature_request.yml b/klanker-gate/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100755 index 0000000..2ff1857 --- /dev/null +++ b/klanker-gate/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,67 @@ +name: Feature request +description: Suggest an idea or enhancement for Bifrost +title: "[Feature]: " +labels: [enhancement] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for proposing a feature! Please fill out the details below. + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + options: + - label: I have searched existing issues and discussions to avoid duplicates + required: true + + - type: textarea + id: problem + attributes: + label: Problem to solve + description: What problem does this feature solve? Who benefits? + placeholder: Describe the problem clearly. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe your proposed API/UX/CLI. Include examples if helpful. + placeholder: Provide details about how this should work. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What other solutions or workarounds did you consider? + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area(s) + multiple: true + options: + - Core (Go) + - Framework + - Transports (HTTP) + - Plugins + - UI (Next.js) + - Docs + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Add any other context, sketches, or references here. + validations: + required: false diff --git a/klanker-gate/.github/pull_request_template.md b/klanker-gate/.github/pull_request_template.md new file mode 100755 index 0000000..7237e68 --- /dev/null +++ b/klanker-gate/.github/pull_request_template.md @@ -0,0 +1,73 @@ +## Summary + +Briefly explain the purpose of this PR and the problem it solves. + +## Changes + +- What was changed and why +- Any notable design decisions or trade-offs + +## Type of change + +- [ ] Bug fix +- [ ] Feature +- [ ] Refactor +- [ ] Documentation +- [ ] Chore/CI + +## Affected areas + +- [ ] Gateway / core +- [ ] Providers/Integrations +- [ ] Config / persistence +- [ ] Plugins +- [ ] Control UI +- [ ] Docs / CI + +## How to test + +Describe the steps to validate this change. Include commands and expected +outcomes. + +```sh +# Gateway (Deno) — run from repository root +deno fmt --check +deno lint +deno task check +deno task test + +# Control UI (driven through Deno; no npm) +deno task setup # once: installs the whole workspace +deno task test-ui +deno task build-ui +``` + +If adding new configs or environment variables, document them here. + +## Screenshots/Recordings + +If UI changes, add before/after screenshots or short clips. + +## Breaking changes + +- [ ] Yes +- [ ] No + +If yes, describe impact and migration instructions. + +## Related issues + +Link related issues and discussions. Example: Closes #123 + +## Security considerations + +Note any security implications (auth, secrets, PII, sandboxing, etc.). + +## Checklist + +- [ ] I read `README.md` and followed the guidelines +- [ ] I added/updated tests where appropriate +- [ ] I updated documentation where needed +- [ ] I verified builds succeed (Deno gateway and control UI) +- [ ] I ran the full gate locally and pasted the commands and results below + (nothing runs it automatically - there is no CI in this repository) diff --git a/klanker-gate/.gitignore b/klanker-gate/.gitignore new file mode 100755 index 0000000..5fc221a --- /dev/null +++ b/klanker-gate/.gitignore @@ -0,0 +1,49 @@ +.env +.vscode +.DS_Store +*_creds* +**/venv/ +**/__pycache__/** +private.* +.venv +test-coverage-local.sh +.harness-state/ +skillset-saves/ +.playwright-mcp/ +DENO_KB + +# Temporary directories +**/temp/ +node_modules +/dist +apps/control-ui/dist/ +**/tmp/ +temp*/ +tmp/ +tmp-* +private + +# Sqlite DBs +*.db +*.db-shm +*.db-wal + +# Test reports +test-reports + +# Diagram render checks: throwaway PNG rasterizations used to eyeball an SVG in +# docs/assets/diagrams/ before committing it. Root-anchored so it cannot swallow +# a real asset under docs/. +/*-check.png + +# Editor / assistant +.claude +.cursor/ + +# Build outputs +build/ +data/ +target/ + +# Coverage output (deno test --coverage) +cov_profile/ diff --git a/klanker-gate/AGENTS.md b/klanker-gate/AGENTS.md new file mode 100755 index 0000000..fd60d49 --- /dev/null +++ b/klanker-gate/AGENTS.md @@ -0,0 +1,183 @@ +# AGENTS.md - Technical Documentation and Agent Guidelines + +This file consolidates technical documentation and development guidelines into a +single agent-readable brief. It is the primary reference for AI agents, +copilots, and developers working on Frosty Deno ("klanker-gate"). It is +intentionally consistent with [CLAUDE.md](CLAUDE.md); where deeper detail is +needed, it links into [docs/](docs/). + +Frosty Deno is a clean-room Deno 2 + TypeScript rebuild of an LLM gateway +(reference: the retired Go implementation, Bifrost). One `Deno.serve` process +fronts 20+ model providers behind an OpenAI-compatible API, adds governance, +caching, MCP tooling and telemetry, and serves the React control-plane SPA +same-origin from the same port. + +## Section 1: Persona and Role + +- **Persona and Role:** Senior development architect. A proactive expert focused + on robust, secure, scalable Deno TypeScript. +- **Primary Goal:** Translate user requests into high-quality, production-ready + code that fits the existing structure. +- **Core Traits:** Analytical, systematic, supportive, solutions-oriented, a + clear communicator. +- **Core Expertise:** Full-stack implementation, architectural design, code + quality, and complex problem deconstruction. + +## Section 2: Default Workflow + +- **Step 1 - Build:** Default to building the complete, working solution, in one + cohesive, fully-commented change that matches surrounding code. +- **Step 2 - Fallback:** Only if a request is too large or ambiguous, propose a + concise Solution Design (stack, components, data flow), offer a step-by-step + plan, and stop for explicit approval. +- **Step 3 - Plan:** After approval, write the full plan as a single Markdown + document. + +## Section 3: Guiding Principles (non-negotiable) + +- **Security by Design:** Fail closed. Unknown hierarchy references deny; a + broken durable budget authority denies; a failed crypto boot refuses to start; + the Code Mode capability probe defaults to `false` on any error. Secrets never + reach the browser. +- **Architectural Integrity:** OpenAI's `chat.completion` / + `chat.completion.chunk` SSE is the single canonical wire format. Every + non-OpenAI surface is produced by translating that one canonical stream in + [packages/core/src/translate.ts](packages/core/src/translate.ts), never by a + parallel per-vendor pipeline. +- **Code Quality:** Strict TypeScript (`"strict": true`). Clean, idiomatic, DRY. + The control UI is Tailwind v4 with the `ds-r2` design system. +- **Clarity:** Comment the "why," not the "what." Money is integer micro-USD + everywhere in accounting - never floating point. + +## Section 4: Project-Specific Code Patterns + +- **Errors** always go through `GatewayError` / `errorResponse` and the + canonical `{error:{message,type,param,code}}` envelope. No ad-hoc JSON error + bodies. +- **Request schemas are `.passthrough()`** (`ChatCompletionRequest`, + `CompletionRequest`, `EmbeddingRequest`, `AnthropicMessagesRequest`) so + unknown vendor fields survive to provider egress. Response schemas stay + strict. +- **Streaming is Web Streams end to end.** A passive tee + (`withStreamCompletion` + `StreamAccumulator`) reconstructs the assistant + message for plugins, cost and logging while byte-identical bytes reach the + client. Never buffer a client stream to inspect it. +- **Providers** implement `IProviderAdapter` + ([packages/providers/src/types.ts](packages/providers/src/types.ts)); only + `chatCompletions` is required. `dispatchWithFallback` reroutes only on + 429/5xx/network `TypeError`, never on a client abort or a 4xx. + +## Section 5: Quality Assurance (pre-response check) + +Before providing code, verify: Goal Alignment, Code Integrity (compiles, fits +the seam), Clarity, Assumption Handling (state assumptions), and a Security +Review (fail-closed, secret handling, permission surface). + +## Section 6: Documentation Overview + +- [docs/index.md](docs/index.md) - the documentation landing page. +- [docs/getting-started/](docs/getting-started/) - install, configure, local + dev. +- [docs/guides/](docs/guides/) - deploying-to-production, setting-up-monitoring, + run-tests, and development-planning. +- [docs/concepts/](docs/concepts/) - architectural-overview (embeds the three + SVG diagrams), security-model, and + [functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md) + (the authoritative capability inventory). +- [docs/design/ui-design.md](docs/design/ui-design.md) - the UI design system, + components, screens and flows. +- [docs/reference/](docs/reference/) - api-endpoints, data-model, + environment-variables, commands-scripts, dependencies, docker-reference, and + [sbom.md](docs/reference/sbom.md) plus the machine-readable + [sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json). +- [docs/assets/diagrams/](docs/assets/diagrams/) - the canonical + [logic-flow.svg](docs/assets/diagrams/logic-flow.svg), + [data-flow.svg](docs/assets/diagrams/data-flow.svg) and + [resource-flow.svg](docs/assets/diagrams/resource-flow.svg). +- [TODO.md](TODO.md) - the register of known follow-ups and accepted risks, and + the replacement for the numbered decision log retired on 2026-07-30. Check it + before changing behavior. Beware provenance numbers cited in code: several + early deferrals (Bedrock streaming, GenAI/Cohere compat streaming, stdio MCP, + Code Mode) have since shipped, so an early item is not proof a feature is + still missing. + +## Section 7: Rules and Guidelines + +- **Branching:** `feature/...` and `bugfix/...`. **Commits:** Conventional + Commits (e.g. `fix(gateway): ...`). **PRs:** fill the template and link + issues. +- **The gate:** `deno fmt --check`, `deno lint`, `deno task check`, + `deno task test`, plus `deno task check-ui` / `test-ui` / `build-ui` for the + UI. No automation runs it - there is no CI workflow in this repository, so + every step is the author's responsibility. +- **`deno task test` ignores `apps/control-ui`, `tests/browser` and + `tests/live`.** The live vector-store suite drives `docker compose` itself and + is reached only through `deno task test:live`, which needs a running Docker + daemon. +- **Definition of done:** tests in the matching suite prove the behavior (a + fixed bug gets a regression test that fails on the old code), the full gate is + green, the exact commands you ran are recorded as evidence, and docs moved + with the code. Missing evidence is treated as incomplete, not implied success. +- Performance work is measure-first: a win inside measurement noise is rejected. + +## Section 8: File and Folder Structure + +- `apps/gateway/` - the composition root ([context.ts](apps/gateway/context.ts)) + and the HTTP surface. The middleware onion in [main.ts](apps/gateway/main.ts) + is load-bearing and ordered on purpose. +- `apps/control-ui/` - the React + Vite + TypeScript control plane (built to + `dist/` and served same-origin). Binding contract: + [apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md). +- `packages/*` - plain directories imported by relative path (no manifests). + Flow: + `contracts -> core/providers/governance/cache/mcp/config -> apps/gateway`. + `packages/auth/` is reserved and currently empty. +- `deploy/`, `Dockerfile`, `docker-compose.yml` - infrastructure. There is no + Kubernetes/Helm packaging (decision-log item 55). +- `tests/` - contract, e2e and integration run in `deno task test`; `live/` + (Docker-backed vector stores) and the separate `browser/` Playwright harness + are both outside it and have their own tasks. + +## Section 9: SDKs and Dependencies + +- **Runtime:** Deno 2.9.x. JSR: `@std/assert`, `@std/http`, `@std/path`. npm via + Deno specifiers: `zod@4` (validation), `postgres@3` (pgvector cache), No npm + CLI - the UI builds through Deno `npm:` specifiers. +- **Control UI:** React 19 + React DOM 19, Vite 8, TypeScript 7, Vitest 4, + Tailwind CSS 4, `lucide-react`, `clsx`, `tailwind-merge`. +- See [docs/reference/sbom.md](docs/reference/sbom.md) for the complete + component inventory and + [docs/reference/dependencies.md](docs/reference/dependencies.md) for + rationale. + +## Section 10: Configuration + +- Config is env-first, then overlaid by persisted PostgreSQL config; **persisted + wins on id collision**. `dev` / `start` load `.env` via `--env-file`; the + container does not (Compose supplies the process env). +- Every subsystem (cache, OTel, log store, pricing sync, Code Mode, encryption) + is an env-gated field on `AppContext`. [.env.example](.env.example) documents + the 76 checked-in gateway knobs; the full list and exact parse behavior live + in + [docs/reference/environment-variables.md](docs/reference/environment-variables.md). +- The Deno permission flag set is part of the contract: + `--unstable-net --unstable-worker-options --allow-net --allow-env --allow-read --allow-write=data`. + See [permissions.md](permissions.md). + +## Section 11: Core Components and Logic + +The request lifecycle is: client -> alias rewrite -> `errorHandler` -> plugin +transport hooks -> request logger -> metrics -> admin origin guard -> admin +token auth -> governance admission -> innermost telemetry -> router -> route +handler -> zod validation -> semantic cache lookup -> provider resolve -> +dispatch with narrow fallback -> streaming tee -> edge translation -> response, +then an unwind that bills usage (except on cache hits) and emits metrics and +spans. Provider credentials and config live in PostgreSQL, optionally +AES-256-GCM encrypted at rest. Background work (MCP health sweep, pricing sync, +OTel flush, durable counter sinks) runs strictly off the request path. + +The canonical visual representations are +[logic-flow.svg](docs/assets/diagrams/logic-flow.svg), +[data-flow.svg](docs/assets/diagrams/data-flow.svg), and +[resource-flow.svg](docs/assets/diagrams/resource-flow.svg), explained in +[docs/concepts/architectural-overview.md](docs/concepts/architectural-overview.md). diff --git a/klanker-gate/ATTRIBUTION.md b/klanker-gate/ATTRIBUTION.md new file mode 100644 index 0000000..64865c2 --- /dev/null +++ b/klanker-gate/ATTRIBUTION.md @@ -0,0 +1,41 @@ +# Attribution + +## Upstream project + +This tree is a **vendored, unmodified copy** of **klanker-gate** — the +"Frosty Deno" LLM gateway — distributed inside the SysDeck master +tarball. + +| Field | Value | +|---------------|------------------------------------------------------| +| **Project** | klanker-gate (Frosty Deno LLM Gateway) | +| **Author** | **TykoDev** | +| **Source** | https://github.com/TykoDev/klanker-gate | +| **License** | Apache-2.0 (full text: [`LICENSE`](LICENSE)) | +| **Version** | 0.9.0 (independent from SysDeck's version) | + +**klanker-gate is NOT SysDeck code.** All credit for the gateway — +the Deno 2 + TypeScript OpenAI-compatible API surface, provider +management, virtual keys, governance, caching, MCP integration, and +the same-origin React control plane — belongs to TykoDev. + +## What SysDeck added + +SysDeck's integration work is **additive only** — the upstream source +required zero changes (the "port" to Linux was packaging, not code): + +- `arch/` — Arch Linux packaging (PKGBUILD, hardened systemd unit, + sysusers/tmpfiles, run wrapper, `INSTALL-ARCH.md` runbook), written + by the SysDeck project for the SysDeck master tarball. +- Outside this tree, SysDeck ships two *clients* of the gateway + (they contain no upstream code): `sysdeck-klanker` — a Cockpit + panel + Python bridge helper — and the Web Edition "AI Gateway" + panel, which talk to the gateway over its REST API. + +Everything else in this tree is upstream klanker-gate code by +TykoDev, redistributed under the Apache-2.0 license, which permits +redistribution in source form provided the license and copyright +notices are retained (they are — see `LICENSE`). + +Upstream releases, issues, and development happen at +https://github.com/TykoDev/klanker-gate. diff --git a/klanker-gate/CHANGELOG.md b/klanker-gate/CHANGELOG.md new file mode 100755 index 0000000..25c54a6 --- /dev/null +++ b/klanker-gate/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Added + +- A complete Diataxis-oriented documentation set under [docs/](docs/), including + tutorials, guides, concepts, design reference, technical reference, and + standalone SVG architecture diagrams. +- A human-readable and machine-readable SBOM generated from the checked-in + manifests, lockfile, and Docker assets. +- A repository-local SBOM generation script at `scripts/generate_sbom.ts`. + +### Changed + +- Root documentation was aligned with the current PostgreSQL-backed + architecture, same-origin control-plane flow, and shipped observability stack. +- Contributor-facing documentation now points at the new documentation index and + the checked-in validation and SBOM workflows. + +### Security + +- Security documentation now points directly at the current security model, + SBOM, and private-reporting workflow. + +## Historical note + +The repository does not currently expose a tag-based release history. Earlier +release entries are therefore not reconstructed here from commit names alone, +because that would require guessing at version boundaries and release dates. diff --git a/klanker-gate/CLAUDE.md b/klanker-gate/CLAUDE.md new file mode 100755 index 0000000..24305c0 --- /dev/null +++ b/klanker-gate/CLAUDE.md @@ -0,0 +1,251 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. + +## What this is + +Frosty Deno ("klanker-gate") is a clean-room Deno 2 + TypeScript rebuild of an +LLM gateway (reference: the retired Go implementation, Bifrost). One +`Deno.serve` process fronts 20+ model providers behind an OpenAI-compatible API, +adds governance / caching / MCP tooling / telemetry, and serves the React +control-plane SPA **same-origin** from the same port. + +## Commands + +Run everything from the repo root. There is no `npm`/`node` toolchain — the +control UI is driven through Deno (`deno run -A npm:vite`, `npm:vitest`, +`npm:typescript`). Do not reintroduce an npm CLI step. + +```bash +deno task setup # one-time bootstrap (deno install --allow-scripts=npm:esbuild) +deno task dev # gateway on :8080 with --watch, loads .env +deno task start # same without --watch + +# The full gate - run it yourself; nothing runs it for you: +deno task test:all # every stage below, in order, one verdict + # (+ live and browser stages; skips are reported, never silent) + +# Or the same checks one at a time: +deno fmt --check +deno lint +deno task check # backend typecheck (deno check) +deno task test # unit + contract + integration + e2e (ignores control-ui, tests/browser, tests/live) +deno task check-ui # control-ui tsc --noEmit +deno task test-ui # control-ui vitest (jsdom) +deno task build-ui # control-ui production build -> apps/control-ui/dist +``` + +Single test / filter — pass the same unstable flags the task does: + +```bash +deno test --unstable-net --unstable-worker-options -A tests/integration/cache_test.ts +deno test --unstable-net -A --filter "fallback" packages/providers/ +``` + +Other suites: `deno task test:e2e`, `deno task test:live` (drives +`docker compose up -d --wait postgres` itself, so it needs a running Docker +daemon), `deno task test:load` (`scripts/load-bench.ts`), `deno task bench` (23 +micro-benchmarks in six `*_bench.ts` files colocated next to the source they +measure). The two performance harnesses answer different questions: `test:load` +measures end-to-end request cost, `bench` isolates a single pure hot-path +function, which is what decision-log 45's measure-first rule needs. Neither is +part of `test:all`; both are recorded in +[docs/benchmark-report.md](docs/benchmark-report.md) (decision-log 78). +`tests/browser/` is a Node/Playwright harness with its own `package.json`, +outside `deno task test` — it is the `browser` stage of `deno task test:all` and +needs a running gateway. + +The `test` task carries `--ignore=apps/control-ui,tests/browser,tests/live`. +`--ignore` _replaces_ the `test.exclude` list in `deno.jsonc` rather than adding +to it, which is why all three are repeated in the task string; and a +config-level exclude would also filter the explicit path `test:live` passes, so +`tests/live` can only be dropped from the gate at the task level. + +The gateway runs API-only until `deno task build-ui` has produced +`apps/control-ui/dist`; boot logs say which mode you are in. + +## Architecture + +Read +[docs/concepts/architectural-overview.md](docs/concepts/architectural-overview.md) +for the full picture with diagrams. The parts that matter before you edit: + +**OpenAI's wire format is the lingua franca.** Internally every response is a +canonical `chat.completion` / `chat.completion.chunk` SSE stream ending in +`data: [DONE]`. Provider adapters translate _inbound_ to canonical; every +non-OpenAI surface the gateway exposes (Anthropic Messages, OpenAI Responses, +Google GenAI, Cohere v2, legacy completions) is produced by translating the +canonical stream in `packages/core/src/translate.ts` — never by a second +parallel pipeline per vendor. This is what keeps provider count × surface count +from multiplying. + +**The middleware onion order in [apps/gateway/main.ts](apps/gateway/main.ts) is +load-bearing.** The order is recorded here, not in code comments - long +rationale lives in the register and code carries JSDoc plus short notes only +(the rule retired decision-log item 68 stated). Outermost `errorHandler` (so +even plugin-hook failures return the canonical envelope) → plugin transport +hooks → compat prefix rewrite → request logger → metrics → admin origin guard → +admin token auth → governance → telemetry (innermost, so usage is captured even +with zero virtual keys) → router → SPA fallback. Alias prefixes (`/openai`, +`/anthropic`, `/litellm`, `/langchain`, `/pydanticai`) are pure URL rewrites +applied _before_ auth/governance so aliased paths are admitted identically to +`/v1/*`. + +**`AppContext` ([apps/gateway/context.ts](apps/gateway/context.ts)) is the +composition root.** `createContext()` is env-only and is what unit tests use; +`createDefaultContext()` is the production path — it opens **PostgreSQL and +refuses to start without it**, attaches optional encryption-at-rest, seeds +providers from env then overlays persisted config (**persisted wins on id +collision**), and wires durable counter sinks. Deno KV was retired for it +(decision-log 57): everything durable now lives in one Postgres, reached through +the `StateStore` seam in `packages/config/src/store.ts` — `PostgresStateStore` +in production, `MemoryStateStore` in tests, with one shared contract +(`store_contract.ts`) run against both so they cannot drift. Nearly every +optional subsystem (cache, OTel, log store, pricing sync, Code Mode) is an +env-gated field on this object. + +**Package layout.** `packages/*` are plain directories with **no manifests** — +they are imported by relative path (`../../contracts/src/mod.ts`), not by a +workspace alias. Only `apps/control-ui` is a Deno workspace member. The ten +directories are `cache`, `config`, `contracts`, `core`, `governance`, `mcp`, +`plugins`, `providers`, `telemetry`, `testing`. Only `contracts`, `core`, +`providers`, and `testing` have `src/mod.ts` barrels; the rest are imported +file-by-file. Dependency flow is +`contracts → core/providers/governance/cache/mcp/config → apps/gateway`. A +change that wants to cross a package boundary usually means the seam is wrong — +`mcp` must not import from `core`'s dependents, and `providers` deliberately has +no `governance` import (the budget guard is a structural interface satisfied by +`ProviderBudgetTracker`, wired in `context.ts`). + +**Providers.** One adapter per vendor family implementing `IProviderAdapter` +([packages/providers/src/types.ts](packages/providers/src/types.ts)) — only +`chatCompletions` is required; everything else (`completions`, `embeddings`, +`listModels`, `generateImage`, `rawProxy`, `countTokens`) is optional and its +absence has a defined fallback. `ProviderManager.resolve()` maps +`provider/model` or a bare name to an account; `resolveChain()` adds +request-level `fallbacks` plus the pool; `dispatchWithFallback` reroutes only on +429 / 5xx / network `TypeError` — never on a client abort or a 4xx. + +**Streaming** is Web Streams end to end. A passive tee (`withStreamCompletion` + +`StreamAccumulator`) reconstructs the assistant message for plugins, cost, and +logging while byte-identical bytes still reach the client. Never buffer a client +stream to inspect it. The plugin stream-complete tap runs on the **canonical** +stream, before edge translation. + +## Conventions that will bite you + +- **Errors** always go through `GatewayError` / `errorResponse` and the + canonical `{error:{message,type,param,code}}` envelope. No ad-hoc JSON error + bodies. +- **Request schemas are `.passthrough()`** (`ChatCompletionRequest`, + `CompletionRequest`, `EmbeddingRequest`, `AnthropicMessagesRequest`) so + unknown vendor fields survive to provider egress. Do not "tidy" this into + `.strict()`. Everything else uses bare `z.object()`, which _strips_ unknown + keys rather than rejecting them. The two `.strict()` schemas in the contracts + package are `GatewayConfigSchema` (`config.ts`) and `ModelPriceSchema` + (`pricing.ts`), for the same reason (decision-log 46): both are whole-object + replaces of persisted operator data, where silent key-stripping wipes fields + the operator never meant to clear. The pricing one has a deliberate non-strict + twin, `PersistedModelPriceSchema`, so a catalog written by a newer gateway + still loads on an older one. Two response families are deliberately + `.passthrough()` because their vendor payloads vary: `TranscriptionResponse` + (`audio.ts`) and the file/batch family (`file_batch.ts`). +- **Money is integer micro-USD** everywhere in accounting. No floating-point + accumulation. +- **Fail closed.** Unknown hierarchy references deny; a broken durable budget + authority denies; a failed crypto boot refuses to start; the Code Mode + capability probe defaults to `false` on any error. +- **The Deno permission flag set is part of the contract**: + `--unstable-net --unstable-worker-options --allow-net --allow-env --allow-read --allow-write=data`. + Needing more is a design escalation — see [permissions.md](permissions.md). + `--unstable-worker-options` _narrows_ (it lets the Code Mode worker spawn with + everything denied); it grants the process nothing. `--allow-run` is opt-in for + stdio MCP only and is kept solely in the `test` task for a fixture. +- **Secrets never reach the browser.** The admin API returns redacted views with + `hasX` presence markers. Gateway `PUT` is a shallow top-level merge, so a + nested group you send _replaces_ the stored group — diff and send only changed + groups. +- **Code Mode is default-off** (`FROSTY_CODE_MODE`) behind two independent + gates. The VFS metadata surface is live and inert; the executor requires both + the app gate and a passing boot probe. +- New config knobs need an env var with a bounded parse, a row in + [docs/reference/environment-variables.md](docs/reference/environment-variables.md), + and `.env.example` coverage. +- **Comments are JSDoc plus short notes.** Public API gets JSDoc (editors + surface it); a non-obvious constraint gets a note under four lines at the + point of use. Long rationale goes in [TODO.md](TODO.md), which is the register + now that the numbered decision log is retired - a duplicated explanation in + code drifts and then misleads. Cite it by **stable key** + (`TODO.md D-REBUILD-HEADERS`), never by item number: the numbered items are + reading order and renumber as items close, which is exactly how the previous + scheme became uncitable. Add a keyed entry only when the constraint genuinely + will not fit in a note at the point of use. Test files are exempt: a comment + explaining why an assertion exists has no other home. +- Gateway-specific request/response headers are `x-frosty-*` + (`confirm-side-effects`, `mcp-tools`, `cache`, `cache-type`, `code-mode`, + `virtual-key`, `responses-passthrough`). + +## Before you change behavior + +The numbered decision log that recorded every deliberate divergence from the Go +original was **retired on 2026-07-30**; [TODO.md](TODO.md) is the register in +its place, and item 1 there carries the consequences. Item numbers still cited +in code and below are provenance only and resolve to nothing on disk. Still-live +"missing on purpose" items: **serving** a cache hit as a stream (cache _reads_ +are non-streaming; completed streams _are_ stored via the passive tee), +Bedrock-native ingress under the aggregator prefixes (explicit 501 stubs), and +Kubernetes/Helm packaging (item 55). Do **not** assume the older deferrals still +hold — Bedrock streaming, GenAI/Cohere compat streaming, stdio MCP, Code Mode, +and **multi-replica deployment** all started as deferrals and have since +shipped, each with a follow-up entry. Multi-process serving in particular is +live (`FROSTY_WORKERS`, decision-log 62/70/71): budgets and rate-limit windows +are fleet-wide through PostgreSQL, and the residual per-process gap is named in +item 73. Reopening a decision is fine, but do it explicitly, and record any new +divergence as a [TODO.md](TODO.md) item with its mechanism, evidence, "done +means" and reopen trigger. + +Definition of done per +[docs/guides/development-planning.md](docs/guides/development-planning.md): +tests in the matching suite prove the behavior (a fixed bug gets a regression +test that fails on the old code), the full gate is green, **the exact commands +you ran are recorded as evidence** (missing evidence is treated as incomplete, +not implied success), and docs moved with the code. Nothing enforces the gate +automatically — there is no CI workflow in this repository, so running it and +reporting the result honestly is entirely on the author. + +Performance work is measure-first: a win inside measurement noise is rejected +and reverted (see decision-log item 45 and +[docs/benchmark-report.md](docs/benchmark-report.md)). + +## Control UI + +[apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) is the binding +contract for the SPA — design system `ds-r2`, tokens in `src/styles/tokens.css`, +`PageHeader` on every view, `DataTable` for every resource list, hash router +keyed off the first segment, and all transport through `src/api.ts` (no `fetch` +in views). Hard taste rules there include **zero em/en dashes anywhere** (plain +hyphen only), one cool-blue accent, lucide-react icons only, and same-origin +only — no external CDN/font/script origins. + +## Related + +`AGENTS.md` holds the shorter agent-facing brief. `docs/` is the deep reference: +[getting-started/](docs/getting-started/), [guides/](docs/guides/), +[reference/](docs/reference/), [concepts/](docs/concepts/), +[design/](docs/design/). + +Four pages carry more ground truth than the rest and are worth reading before a +non-trivial change: + +- [docs/concepts/functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md) + — the authoritative capability inventory, including a "gaps and partial + implementations" table and a list of stale claims found in older docs. +- [TODO.md](TODO.md) - every accepted risk and known follow-up with the + constraint holding it and the trigger that reopens it, since the separate + open-risks register was retired. +- [docs/reference/sbom.md](docs/reference/sbom.md) plus + [sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json) — the + component-level bill of materials. +- [docs/assets/diagrams/](docs/assets/diagrams/) — the canonical + `logic-flow.svg`, `data-flow.svg`, `resource-flow.svg`. diff --git a/klanker-gate/CODE_OF_CONDUCT.md b/klanker-gate/CODE_OF_CONDUCT.md new file mode 100755 index 0000000..c714daf --- /dev/null +++ b/klanker-gate/CODE_OF_CONDUCT.md @@ -0,0 +1,129 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +akshay@getmaxim.ai. All complaints will be reviewed and investigated promptly +and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/klanker-gate/CONDUCT.md b/klanker-gate/CONDUCT.md new file mode 100755 index 0000000..130cb6e --- /dev/null +++ b/klanker-gate/CONDUCT.md @@ -0,0 +1,71 @@ +# Conduct and Contribution Quickstart + +This file collects the practical contribution and collaboration rules for Frosty +Deno. Community behavior expectations still apply through the separate +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). + +## How you can contribute + +- Report bugs through the forms in + [.github/ISSUE_TEMPLATE/](.github/ISSUE_TEMPLATE/). +- Suggest improvements or new capabilities through issues before implementing + large behavioral changes. +- Improve the documentation in [docs/](docs/), especially when code and docs + drift. +- Contribute code for provider adapters, governance, caching, observability, + MCP, control-plane UI work, tests, or deployment hardening. + +## Development setup + +Detailed tutorials live under [docs/getting-started/](docs/getting-started/). +The shortest verified setup is: + +```bash +git clone klanker-gate +cd klanker-gate +deno task setup +cp .env.example .env +docker compose up -d postgres +deno task dev +``` + +Important repo-specific notes: + +- Deno 2.9.x is the primary toolchain. +- The gateway and control UI are driven through Deno, not the npm CLI. +- PostgreSQL is a hard dependency on the production bootstrap path. +- `tests/browser` is a separate Node-based Playwright harness and is the only + area that expects `npx`. + +## Submission guidelines + +- Branches should follow `feature/` or `bugfix/`. +- Commits should follow + [Conventional Commits](https://www.conventionalcommits.org/). +- Pull requests should link their issue when applicable and use the checked-in + [pull request template](.github/pull_request_template.md). +- Run the validation gate locally before opening the PR: + +```bash +deno fmt --check +deno lint +deno task check +deno task test +deno task check-ui +deno task test-ui +deno task build-ui +``` + +- If the change affects dependencies, regenerate the SBOM with + `deno run -A scripts/generate_sbom.ts`. +- If the change affects public or operator-facing behavior, update the relevant + documentation under [docs/](docs/). + +## Coding standards + +- Preserve the canonical error envelope and shared request/response contracts. +- Preserve the load-bearing middleware order unless the change explicitly + requires a routing or security move. +- Keep same-origin control-plane assumptions intact. +- Follow [apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) for UI + work. diff --git a/klanker-gate/CONTRIBUTING.md b/klanker-gate/CONTRIBUTING.md new file mode 100755 index 0000000..189792c --- /dev/null +++ b/klanker-gate/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to Frosty Deno + +Thank you for considering a contribution. Frosty Deno is a clean-room Deno 2 + +TypeScript LLM gateway, and it improves fastest when fixes, provider additions, +UI refinements and documentation all come from people who use it. Whether you +are fixing a typo, adding a provider adapter, or hardening the governance layer, +your work is welcome and valued. + +## Ways to contribute + +- **Report bugs** and **request features** through GitHub Issues using the issue + forms in [.github/ISSUE_TEMPLATE/](.github/ISSUE_TEMPLATE/). +- **Improve the docs** in [docs/](docs/) - they move with the code, so a + docs-only PR that corrects a stale claim is a real contribution. +- **Write code**: pick up an issue labelled `good first issue`, or open an issue + first for anything that changes behavior so the approach can be agreed. + +## Development setup + +Full instructions live in [docs/getting-started/](docs/getting-started/) and +[docs/index.md](docs/index.md). The short version: + +```bash +git clone klanker-gate +cd klanker-gate +deno task setup # one-time bootstrap (installs esbuild for the UI build) +cp .env.example .env # then set at least one provider key +deno task dev # gateway on http://localhost:8080 (loads .env) +``` + +There is no `npm` / `node` toolchain. The control UI is driven entirely through +Deno (`deno task dev-ui`, `deno task build-ui`, `deno task test-ui`). Do not +reintroduce an npm CLI step. + +## Before you open a pull request + +Run the full gate yourself - there is no CI in this repository, so nothing runs +it for you and nothing blocks a pull request that skips it: + +```bash +deno fmt --check +deno lint +deno task check # backend typecheck +deno task test # unit + contract + integration + e2e +deno task check-ui # control-UI typecheck +deno task test-ui # control-UI tests +deno task build-ui # control-UI production build +``` + +- **Tests prove the behavior.** A fixed bug must come with a regression test + that fails on the old code. New behavior needs tests in the matching suite. +- **Record your evidence.** Paste the exact commands you ran and their result in + the PR. Missing evidence is treated as incomplete, not implied success. +- **Docs move with the code.** If you change a knob, endpoint or behavior, + update the relevant file under [docs/reference/](docs/reference/) (and + [.env.example](.env.example) for a new env var). If the change affects setup + or operations, update the matching tutorial or guide under [docs/](docs/). +- **Check the register.** Deliberate divergences and accepted risks are recorded + in [TODO.md](TODO.md), which replaced the numbered decision log retired on + 2026-07-30. If your change reopens one, say so, and record a new divergence + there with its mechanism, evidence, "done means" and reopen trigger. + +## Submission guidelines + +- **Branches:** `feature/` or `bugfix/`. +- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/), for + example `fix(gateway): reject empty Azure api-version` or + `feat(providers): add adapter`. +- **Pull requests:** fill out the + [pull request template](.github/pull_request_template.md), link the issue it + closes, and keep the change focused. +- **Coding standards:** `deno fmt` and `deno lint` are the source of truth for + style. The control UI additionally follows + [apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) (design + system `ds-r2`, `lucide-react` icons only, same-origin only, plain hyphens - + no em or en dashes). + +## Code of conduct + +By participating you agree to uphold the standards in [CONDUCT.md](CONDUCT.md). + +See also [AGENTS.md](AGENTS.md) for the deeper technical and agent-facing brief. diff --git a/klanker-gate/Dockerfile b/klanker-gate/Dockerfile new file mode 100755 index 0000000..f85f53d --- /dev/null +++ b/klanker-gate/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1 + +# --- Stage 1: build the Control UI bundle (Vite/React/TS) INSIDE the image --- +# The runtime no longer depends on a prebuilt apps/control-ui/dist on the host +# (which, on Windows, can be locked by Defender / Docker file-sharing and block +# `deno task build-ui`). The builder is the glibc (Debian) Deno image, thrown away +# after emitting dist/ - glibc dodges the musl/rollup/oxide native-binding edge +# cases, and the emitted bundle is static, so the runtime still runs deno:alpine. +# There is no npm in the repo: the UI builds THROUGH Deno via npm: specifiers. +FROM denoland/deno:2.9.3 AS ui-builder +WORKDIR /app +# Dep-install layer: cache on the workspace root config + lockfile + the member +# manifest only. `deno task setup` == `deno install --allow-scripts=npm:esbuild`; +# esbuild's postinstall is required or the Vite build cannot start. +COPY deno.jsonc deno.lock ./ +COPY apps/control-ui/package.json ./apps/control-ui/ +RUN deno task setup +# The UI imports shared types via ../../../packages, so mirror the repo layout +# (packages as a sibling of apps) before building. +COPY packages ./packages +COPY apps/control-ui ./apps/control-ui +RUN deno task build-ui + +# --- Stage 2: Deno runtime --- +# Deno 2 base image (decision D3). The previous 1.40.4 pin predated `jsr:` +# specifier support and could not `deno cache` this workspace. +FROM denoland/deno:alpine-2.9.3 + +WORKDIR /app + +COPY deno.jsonc deno.lock ./ +# deno.jsonc declares apps/control-ui as a workspace member, so its manifest must be +# present for config resolution. The gateway itself stays on Deno's global module +# cache via --node-modules-dir=none, so NO node_modules is baked into the runtime +# image (identical to pre-migration behavior; only the build stage uses one). +COPY apps/control-ui/package.json ./apps/control-ui/ +COPY packages ./packages +COPY apps/gateway ./apps/gateway +# The Control UI bundle comes from the builder stage above, so the image always +# serves the UI same-origin without any prebuilt host dist. +COPY --from=ui-builder /app/apps/control-ui/dist ./apps/control-ui/dist + +COPY deploy/docker-entrypoint.sh /usr/local/bin/frosty-entrypoint + +RUN deno cache --node-modules-dir=none apps/gateway/main.ts \ + && mkdir -p /app/data \ + && chmod +x /usr/local/bin/frosty-entrypoint \ + && chown -R deno:deno /app + +EXPOSE 8080 +USER deno + +# The entrypoint mirrors the `start` task in deno.jsonc and permissions.md, and +# adds a Deno-scoped --allow-run only when FROSTY_WORKERS>1. --unstable-net is +# REQUIRED for multi-process serving: Deno.serve({reusePort:true}) throws +# "Unstable API 'Deno.listen({ reusePort: true })'" without it. +ENTRYPOINT ["/usr/local/bin/frosty-entrypoint"] diff --git a/klanker-gate/LICENSE b/klanker-gate/LICENSE new file mode 100755 index 0000000..dc8841f --- /dev/null +++ b/klanker-gate/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 H3 Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/klanker-gate/README.md b/klanker-gate/README.md new file mode 100755 index 0000000..b3099a9 --- /dev/null +++ b/klanker-gate/README.md @@ -0,0 +1,84 @@ +# Frosty Deno LLM Gateway + +[![Deno](https://img.shields.io/badge/Deno-2.9-white?logo=deno&logoColor=black)](https://deno.com) +[![Version](https://img.shields.io/badge/version-0.9.0-blue.svg)](CHANGELOG.md) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![API](https://img.shields.io/badge/API-OpenAI--compatible-green.svg)](docs/reference/api-endpoints.md) + +Frosty Deno is a clean-room Deno 2 + TypeScript LLM gateway with a same-origin +React control plane. One gateway surface fronts 20+ provider types behind +OpenAI-compatible APIs, adds governance and pricing controls, optional exact or +semantic caching, MCP integration, and telemetry, and stores durable state in +PostgreSQL. + +## Overview + +This repository is for teams that want one operational surface for many model +providers instead of many separate SDKs, credential stores, budget systems, and +observability paths. Frosty Deno gives you one API boundary, one operator +control plane, one governance layer, and one place to wire caching, logging, +metrics, tracing, and MCP tooling. + +## Key features + +- One gateway surface for chat, completions, embeddings, images, audio, files, + batches, and provider-specific compatibility families. +- Built-in governance with virtual keys, rate limits, request and cost budgets, + team and customer rollups, and pricing-aware metering. +- Same-origin control plane for providers, settings, logs, runtime diagnostics, + pricing, cache, and MCP management. +- Optional exact and semantic cache backed by PostgreSQL and pgvector. +- Optional observability profile with Prometheus, Grafana, OTEL Collector, + MinIO, and Tempo. + +## Getting started + +Prerequisites: + +- Deno 2.9.x +- Docker and Docker Compose v2.20+ if you want the shipped PostgreSQL service + +Fastest verified local path: + +```bash +git clone klanker-gate +cd klanker-gate +deno task setup +cp .env.example .env +docker compose up -d postgres +deno task dev +``` + +At minimum, set one provider credential and `FROSTY_PG_URL` in `.env`. + +Useful checks: + +```bash +curl http://localhost:8080/healthz +curl http://localhost:8080/v1/models +``` + +Build the control UI when you want the same-origin operator interface: + +```bash +deno task build-ui +``` + +## Documentation + +Start with [docs/index.md](docs/index.md). + +- Tutorials: [docs/getting-started/](docs/getting-started/) +- Guides: [docs/guides/](docs/guides/) +- Concepts: [docs/concepts/](docs/concepts/) +- Design: [docs/design/ui-design.md](docs/design/ui-design.md) +- Reference: [docs/reference/](docs/reference/) + +## Contributing + +Contribution workflow, setup, and validation expectations are documented in +[CONTRIBUTING.md](CONTRIBUTING.md) and [CONDUCT.md](CONDUCT.md). + +## License + +Licensed under the [Apache License 2.0](LICENSE). diff --git a/klanker-gate/SECURITY.md b/klanker-gate/SECURITY.md new file mode 100755 index 0000000..cf80428 --- /dev/null +++ b/klanker-gate/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +## Overview + +Security is a first-class implementation concern in Frosty Deno. The gateway +fails closed around durable state, governance, and config encryption; redacts +secrets before they reach the browser; and keeps its runtime permissions +intentionally narrow. + +The detailed implementation model is documented in +[docs/concepts/security-model.md](docs/concepts/security-model.md). The full +dependency inventory and SBOM are documented in +[docs/reference/sbom.md](docs/reference/sbom.md). + +## Supported versions + +The current checked-in gateway version is `0.9.0`. The repository does not +publish a richer tagged release matrix, so the support statement is +intentionally conservative. + +| Version line | Supported | +| ------------------------------------- | --------------------------------------- | +| `0.9.x` | Yes | +| Earlier or untagged historical states | No support commitment published in-repo | + +## Reporting a vulnerability + +Do not open a public GitHub issue for a security vulnerability. + +Use a private channel instead: + +1. If the repository is hosted on GitHub with security advisories enabled, use + the repository's **Security** tab and choose **Report a vulnerability**. +2. If that private advisory flow is unavailable in the hosting environment, + contact the maintainers through a private maintainer channel rather than a + public issue. + +No dedicated security email address is defined in the checked-in repository +files, so this document intentionally does not invent one. + +Include the following in your report: + +- a clear description of the issue and why it matters +- affected routes, components, or integrations +- reproduction steps, including required configuration +- the version or commit you tested +- any logs, payloads, or proof-of-concept details that help reproduce the issue + safely + +## Disclosure process + +The intended process is: + +1. Acknowledge the report privately. +2. Reproduce the issue and assess scope and severity. +3. Prepare a fix and matching regression coverage. +4. Release or publish the remediation. +5. Coordinate public disclosure after a fix exists. + +## Dependency security + +The checked-in SBOM and the repository-local SBOM generator are the source of +truth for dependency inventory: + +- [docs/reference/sbom.md](docs/reference/sbom.md) +- [docs/reference/sbom/sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json) +- `scripts/generate_sbom.ts` + +Recommended follow-up scans are documented in the SBOM itself. diff --git a/klanker-gate/TODO.md b/klanker-gate/TODO.md new file mode 100755 index 0000000..14a1883 --- /dev/null +++ b/klanker-gate/TODO.md @@ -0,0 +1,313 @@ +# TODO + +Deliberate, known follow-ups. Each item names what is missing, why it was left, +and what has to move for it to be done. + +This file used to be a **pointer** to two owning records - a numbered decision +log and an open-risks register. Both were retired on 2026-07-30 (item 1), and +this file is now **the register itself**: it is where a new deliberate +divergence gets recorded, and where a code comment points when its rationale is +too long to sit at the point of use. That raises the bar on what goes in it: an +entry needs the mechanism, the evidence in code, what "done" means, and the +trigger that reopens it, because there is no second document to carry the +rationale. + +Two kinds of entry live here, and they are kept apart on purpose: + +- **Open follow-ups** (the numbered items) - work that is not done. Their + numbers are reading order and will change as items are added and closed, so + **never cite an open item by number from code**. +- **[Decisions the code cites](#decisions-the-code-cites)** - closed decisions + whose rationale a source comment depends on. Each carries a **stable key** + such as `D-REBUILD-HEADERS`. Code cites the key, never a position, which is + the one property the retired numbered scheme had and the reason it was citable + at all. + +Bare numbers such as "decision-log 57" that survive in comments or in +`CLAUDE.md` are **provenance only**: they record that a decision was taken and +where it was once written down. They resolve to nothing on disk, and four of +them resolve to nothing anywhere (item 1). + +--- + +## 1. The decision log and open-risks register are retired, and 38 files still cite them + +**Status: decided 2026-07-30 (owner: retirement accepted). Links and code +citations closed; four items' rationale is unrecoverable.** +`docs/contracts/decision-log.md` (50 448 B at HEAD) and +`docs/guides/open-risks.md` (23 279 B) were deleted from the working tree, along +with nine other `docs/contracts/*` files, `docs/guides/admin-api-cookbook.md`, +both `docs/runbooks/*` files and `docs/assets/architecture-diagram.md`. The +owner accepted the retirement rather than restoring from HEAD. + +**Owner:** unassigned · **Severity:** Major · **Records:** this item + +**What makes it work rather than a clean deletion.** The numbered scheme was +load-bearing. 38 files in the tree cite it, and not only docs: + +| Citer | Was | Now | +| ------------------------------------------------------------------ | --- | ------------------------------------------ | +| `CLAUDE.md` | 10 | 0 links, inline numbers kept as provenance | +| [apps/gateway/context.ts](apps/gateway/context.ts) | 4 | 0 | +| [packages/core/src/translate.ts](packages/core/src/translate.ts) | 3 | 0 | +| [packages/core/src/middleware.ts](packages/core/src/middleware.ts) | 2 | 0 | +| `AGENTS.md`, `permissions.md`, tests, other docs | 19 | prose provenance only | + +15 of those citations were **markdown links**, and were dead links, in +`CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `docs/benchmark-report.md` and this +file; all 15 are gone. The nine in the three source files above were prose +references of the form "(decision-log 71)" and are gone too - see below for what +that cost. + +**Nine source citations, and only two carried content the comment did not.** +Reviewed one by one on 2026-07-30. Eight of the nine comments already stated the +constraint they cited, so the number was decoration and dropping it lost +nothing. The exception was `middleware.ts`, which said _"see decision-log 87 for +the three rebuild sites, the 15 affected routes and the accepted `serveDir` +consequence"_ - a forward reference to information held nowhere else. That +content is now [D-REBUILD-HEADERS](#d-rebuild-headers), re-verified against the +code rather than copied from memory, and the shared-rate-limit rationale is +[D-SHARED-RATE-LIMIT](#d-shared-rate-limit). + +**Four items are gone for good: 14, 15, 69 and 79.** They were cited by +`translate.ts` (14, 15, 79) and `context.ts` (69). Each cited comment states its +own constraint, so no behavior is undocumented: + +| Lost item | The constraint that survives, in the comment | +| --------- | ---------------------------------------------------------------------------------- | +| 14 | a zero token count means "the provider never told us", never "known and discarded" | +| 15 | edge translators run AFTER the plugin stream tap, so hooks see the canonical shape | +| 69 | config reload applies REMOVALS, because an upsert-only reload cannot revoke | +| 79 | one canonical execution path is what lets every ingress dialect share it | + +What is lost is the reasoning behind each, and any alternative that was +rejected. Reconstructing them would mean inventing rationale, so they are +recorded as lost rather than guessed at. If one of these four decisions is ever +reopened, treat it as undecided and re-derive it. + +The retired revision is also not recoverable. HEAD holds an _older_ lineage: its +log is the original Wave-1..Wave-5 prose with no numbered entries at all (items +57, 68, 81 and 87 return zero hits) and its risk register stops at `R19`. The +numbered 1..87 log and `R20`..`R25` existed only in the uncommitted tree. There +is no stash, no `checkout`/`reset` in the reflog, and no editor local-history +copy. + +**Consequence absorbed.** This file is the new home, and +[Decisions the code cites](#decisions-the-code-cites) is where a divergence +whose rationale a comment depends on gets recorded, keyed rather than numbered. +Work that planned to append entries 88-91 or risks R26-R27 records them there +instead, with a fresh key each, at the point the work lands rather than in +advance. + +**Still open:** + +- `docs/contracts/fixtures/` holds the two golden fixtures that + [tests/contract/golden_chat_test.ts:28](tests/contract/golden_chat_test.ts#L28) + reads at module load, and is now the only inhabitant of a retired directory. + Moving it under `tests/contract/` would finish the retirement; it was left + alone because the deletion of those two files is what made the suite red in + the first place and re-touching them was not worth bundling into that repair. +- The bare numbers left inline in `CLAUDE.md` and in `permissions.md`, tests and + other docs. They are provenance, not links, and are labelled as such in the + preamble here. + +**Reopen trigger:** a reader following a citation that resolves to nothing, or a +new divergence recorded as a bare number instead of a key. + +## 2. Telemetry does not cost the media surfaces + +**Status: done for the chat surfaces (2026-07-29); the media surfaces are +mid-build.** `INFERENCE_PATHS` is now an `isInferencePath()` predicate covering +the four canonical paths plus `/v1/messages`, `/cohere/v2/chat`, GenAI generate +actions, the Azure deployment-scoped ops and OpenRouter chat and embeddings, +with metrics, usage, spans and log enrichment widened together as required +below. + +**Owner:** unassigned · **Severity:** Minor · **Records:** this item; provenance +decision-log 56 (the gap) and 81 (the chat-surface closure), open-risks R19 + +What is left is `/v1/images/generations`, `/v1/audio/speech` and +`/v1/audio/transcriptions`. They still need `normalizeUsage` and the pricing +catalog to model per-image, per-character and per-second billing before they can +be observed without producing counted-but-uncosted records. + +| Route | Billing unit | +| -------------------------- | ------------------- | +| `/v1/images/generations` | per image | +| `/v1/audio/speech` | per character | +| `/v1/audio/transcriptions` | per second of audio | + +`/v1/batches`, `/v1/files`, `/v1/count_tokens` and `/v1/models` are correctly +outside the set: none is a per-request billable completion. + +**Why it was left.** The log trail was wired to the telemetry layer that already +existed; that did not change what the layer observes. Widening the predicate +moves billing-adjacent accounting - usage records feed governance budgets and +the analytics rollups - which is a materially larger blast radius than a log +column. + +**Done means:** metrics, usage records, spans and log enrichment all widen +**together**, not logs alone. + +**Reopen trigger:** any request to observe a non-`/v1`-canonical inference +surface, or a report that spend on one of the routes above is missing from +`/api/analytics` or the Grafana dashboards. + +## 3. `LOG_LEVEL` is documented and plumbed but read by nothing + +**Status: OPEN.** `LOG_LEVEL` has a row in the quick-reference table of +[docs/reference/environment-variables.md](docs/reference/environment-variables.md) +and a mention under "Core gateway and PostgreSQL", and +[docker-compose.yml](docker-compose.yml) forwards it into the container. No code +reads it, on any file type. An operator setting `LOG_LEVEL=debug` gets silence. + +**Owner:** unassigned · **Severity:** Minor · **Records:** this item + +It was removed from `.env.example` in the 2026-07-30 env cleanup, because an +example file that lists a dead knob is the defect. The docs row and the Compose +passthrough still promise it. + +**Done means:** either implement a bounded log-level parse and restore the +`.env.example` line, or drop the docs row and the Compose passthrough too. + +**Reopen trigger:** a report that log verbosity cannot be changed. + +## 4. `totalTokens` is an unclamped vendor sum + +**Status: OPEN, narrowed.** In +[apps/gateway/routes/telemetry.ts:135](apps/gateway/routes/telemetry.ts#L135) +`totalTokens` is `prompt + completion + (cacheCreation ?? 0)` with no ceiling. +It reaches the usage record (`:159`, `:190`) and the `gen_ai.usage.total_tokens` +span attribute (`:216`). It never passes through `costMicroUsd`, so the clamp +that protects the cost counter does not cover it. + +**Owner:** unassigned · **Severity:** Minor · **Records:** this item + +A provider returning three fields at `MAX_SAFE_INTEGER` yields a usage row and a +span attribute of `3 x MAX_SAFE_INTEGER`. Money is unaffected. + +**Done means:** the same bounded parse the cost path uses, applied **after** the +vendor sum rather than per field - clamping each field independently leaves +`2 x MAX_SAFE_INTEGER`, which is the measured failure of the per-field approach. + +**Reopen trigger:** an implausible token total in `/api/analytics` or a Tempo +span. + +## 5. Deliberate gaps that are still live + +Each is a decision, not an oversight; the row is here so it stays visible. The +evidence column is the code that implements the refusal. + +| Gap | Shape | Evidence | +| -------------------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- | +| OpenRouter native `GET /generation` and `GET /key` | Explicit 501 | [openrouter_ingress.ts:171](apps/gateway/routes/openrouter_ingress.ts#L171) | +| Aggregator-path Bedrock native ingress | Explicit 501 | [compat_families.ts:795](apps/gateway/routes/compat_families.ts#L795) | +| Serving a cache hit as a stream | Not implemented by design | cache reads are non-streaming; completed streams are stored via the passive tee | +| Code Mode executor | Two gates, and the run primitive stubs | [packages/mcp/src/codemode/](packages/mcp/src/codemode/), `FROSTY_CODE_MODE` | +| Kubernetes and Helm packaging | Not present | deployment assets are Docker and Compose only | +| Some provider-panel config fields | Persisted and surfaced, not enforced | field comments in [packages/contracts/src/config.ts](packages/contracts/src/config.ts) | + +The authoritative version of this table is the "Gaps and partial +implementations" section of +[docs/concepts/functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md). +Do not let the two drift; that file wins. + +## 6. Multi-process serving has a residual per-process gap + +**Status: shipped with a named residual.** `FROSTY_WORKERS` is live, and budgets +and rate-limit windows are fleet-wide through PostgreSQL. The residual gap is +per-process state that no shared authority covers. + +**Owner:** unassigned · **Severity:** Info · **Records:** this item; provenance +decision-log 62, 70, 71 (the shipped behavior) and 73 (the residual) + +**Reopen trigger:** an operator report of limit overshoot that shared-authority +rate limiting does not explain. + +--- + +# Decisions the code cites + +Closed decisions whose rationale a source comment depends on. Each has a +**stable key**; a comment cites the key and nothing else, so entries can be +added, split or reordered without invalidating a citation. A key is never reused +or renamed. + +Only decisions a comment genuinely cannot carry inline belong here. If the +constraint fits in a note under four lines at the point of use, that is where it +goes and no entry is needed - which is why this section is short and is expected +to stay short. + +## D-REBUILD-HEADERS + +**Rebuilding a `Response` around a new body invalidates the headers that +describe the old one, so the serving boundary drops all three.** + +`REBUILT_BODY_HEADERS` in +[packages/core/src/middleware.ts](packages/core/src/middleware.ts) is +`content-encoding`, `content-length`, `transfer-encoding`, matched lowercased. + +Why each one: + +- `Content-Encoding` - Deno's `fetch` decompresses transparently but **keeps the + header**. A rebuild loses the internal already-decoded flag, so the header + stops describing the bytes and becomes an instruction the client acts on and + fails. This is the one that corrupted responses rather than merely + mis-describing them. +- `Content-Length` - a provider's value describes the **encoded** bytes. +- `Transfer-Encoding` - hop-by-hop; the serving runtime owns framing. + +**Three sites rebuild a `Response` around a replacement body. Two strip, one +deliberately does not:** + +| Site | Behavior | +| --------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [middleware.ts:90](packages/core/src/middleware.ts#L90) (`makeRequestLogger`) | strips - this is the serving boundary | +| [routes/helpers.ts:147](apps/gateway/routes/helpers.ts#L147) (`rebuild`) | strips | +| [providers/src/client.ts:171](packages/providers/src/client.ts#L171) (`withBody`) | does **not** strip, and is safe because of the placement below | + +**The fix is at the serving boundary because that placement is terminal in both +directions.** `withBody` is INWARD of the request logger, so a stale header it +attaches is cleaned on the way out. `reshapeError` +([compat_families.ts:188](apps/gateway/routes/compat_families.ts#L188)) rebuilds +around a different body too, but lives inside `compatPrefixMiddleware`, which +`main.ts` composes OUTWARD of the logger - it is safe for the opposite reason, +because it copies headers the logger has already cleaned. A third header-copy at +[governance.ts:584](apps/gateway/routes/governance.ts#L584) reuses the _same_ +body, so its framing headers are still true. + +**A denylist, not an allowlist.** An allowlist would silently drop +`openai-organization`, `x-request-id` and the `x-ratelimit-*` family, which +reach the client today and must continue to. The trade is that a future provider +header this rebuild also invalidates is inherited rather than caught. + +**Accepted consequence.** The rule applies to every response passing the logger, +including static assets from `serveDir` +([main.ts:110](apps/gateway/main.ts#L110)). Those lose a `Content-Length` the +runtime then re-derives, so the cost is a recomputation, not a behavior change. + +**Provenance:** decided 2026-07-29 as decision-log 87 and measured then as +repairing 15 live-broken routes. That route count is recorded as it was measured +on that date and has not been re-derived since; the mechanism and the three +sites above were re-verified 2026-07-30. + +## D-SHARED-RATE-LIMIT + +**Fixed-window rate limiting moves to a shared PostgreSQL authority only when +more than one process shares the port, because a single process already is the +whole fleet.** + +Measured: a shared reservation costs **~1.8 ms** at 50 concurrent against local +PostgreSQL, versus **~1 us** for the in-process `Map`. In single-process mode +the Map is already fleet-accurate, so paying that buys nothing. + +`FROSTY_SHARED_RATE_LIMIT` is `auto|on|off`, resolved by +[`sharedRateLimitEnabled`](apps/gateway/context.ts) - `auto` keys off +`FROSTY_WORKERS`, `on` forces it for operators running separate replicas that +`auto` cannot detect, `off` accepts N-times-the-limit across N processes. When a +shared limiter is present, `VirtualKeyManager` stands its own in-process windows +down so exactly one authority counts. + +**Provenance:** decided as decision-log 71; the numbers live in +[docs/benchmark-report.md](docs/benchmark-report.md), which is their maintained +home. diff --git a/klanker-gate/apps/control-ui/CONVENTIONS.md b/klanker-gate/apps/control-ui/CONVENTIONS.md new file mode 100755 index 0000000..3227ec0 --- /dev/null +++ b/klanker-gate/apps/control-ui/CONVENTIONS.md @@ -0,0 +1,210 @@ +# Control UI conventions (flagship contract) + +Shared build patterns every control-ui view follows. Set by the Providers +flagship pass; later views (Model Catalog, Settings, Logs, MCP, Governance, +Dashboard) must match. When in doubt, read `ProvidersView.tsx` and its +`components/providers/*` for a worked example. + +Design system is **ds-r2** (dense neutral-monochrome dark console). Tokens live +in `src/styles/tokens.css` - do not hand-edit component CSS to diverge; consume +the Tailwind token classes. + +## Taste hard-rules (non-negotiable, from `taste.md`) + +- **Zero em/en dashes** anywhere. Plain hyphen only. (deno lint + review check.) +- **One cool-blue accent** (`ring`, links, active-nav indicator) - never a fill. + Primary is a monochrome emphasis surface, not a chromatic color. +- **Monochrome-first.** Status/semantic color (success/warning/destructive/info) + is a functional vocabulary only; never decorative. +- **Dual-theme AA** in both light and dark. Every text/surface pair is + contrast-checked. +- **6px radius family**, one system. `rounded-sm|md|lg|xl` map to 4/6/8/12px. +- **44px min hit area** (`hit-target` class), **2px focus ring @ 2px offset** + (global `:focus-visible`), **full keyboard path** on every control. +- **lucide-react icons only**; no hand-rolled decorative SVG, no glow, no + glassmorphism. Elevation via borders + surface steps. +- **Motion is feedback-only** (`--motion-fast|default|slow`); reduced-motion + collapses to 0. No decorative animation. +- **Same-origin only.** No external CDN/script/font/style/image origins. +- **Frosty identity.** Own brand, `sk-`/`vk-` prefixes; never the reference + product's name, logos, or hues. + +## Page structure + +- Every view opens with ``. The title is an + `h2` and the focus target on nav change (do not add a second `h2`). +- Primary actions go in `PageHeader actions` (right cluster). Destructive or + bulk actions live inside the relevant card/panel, not the header. +- **Keep a destructive operation away from a Save button.** When a panel mixes + configuration edits with immediate-effect operations, the operations go below + the panel's save footer behind a divider, in their own labelled block - see + `CacheOpsPanel` mounted under `CachingPanel`'s `PanelFooter` + (`CachingPanel.tsx:322-340`), so a destructive purge is never adjacent to the + Save button that applies configuration edits. +- Content max width is `--container-max` (110rem / 1760px; the app shell applies + it). Page gutters use `--gutter`, which tightens from 24px to 16px at <= 48rem + so a tablet does not spend a quarter of its width on padding. Tables and + dashboards may use the full width; anything text-heavy takes `.measure` + instead. +- Errors: `` quoting the gateway's `error.message` verbatim + (`ApiError.message`). Info/warn use `tone="info"|"warn"`. +- Loading: `TableSkeleton` / `TileSkeleton` / `Skeleton`, never a bare spinner + page. + +## Tabs: SubTabs vs UnderlineTabs vs Tabs + +- **`SubTabs`** (pill row) - dashboard-style section switcher across a wide + surface (e.g. Overview / Provider Usage / Model Rankings). +- **`UnderlineTabs`** - configuration panels and settings sub-navigation + (Providers config Network/Proxy/...; Settings Security/Compatibility/...). + This is the "sub-page within a view" bar. +- **`Tabs`** (segmented, on a muted track) - available for a small + binary/ternary local switch inside a card, but currently unused: no view + renders ``. Only `tabPanelProps(value)` and the `TabItem` type are + consumed today. Logs Live/Stored is a `Button` with `aria-pressed`, not a Tabs + instance. Reach for `Tabs` only when a real segmented switch appears; + otherwise prefer `SubTabs` / `UnderlineTabs`. +- Always pass a unique `label`; spread `tabPanelProps(value)` on the matching + panel container for the `role="tabpanel"` wiring. + +## Tables: `DataTable` + +Use `DataTable` for every resource list. Never hand-roll `` sorting. + +- `columns`: `{ key, header, cell, sortValue?, align?, width? }`. Provide + `sortValue` to make a column sortable; `headerLabel` when `header` is not + plain text. +- `rowMenu`: return a `` for per-row actions (Edit / Make default + / Delete). Keep row action clusters out of cells; the kebab is the pattern. +- `pageSize`: set to enable the "Showing X-Y of Z" footer + prev/next. +- `caption` is required (labels the scroll region + screen readers). +- `empty`: pass a node; default copy is "No results." Prefer the shared empty + string **"No data available"** for analytics-style empties (see EmptyState). + +## Forms + +- Field grid: **`.field-grid`** (index.css). It is + `repeat(auto-fit, minmax(min(100%, 16rem), 22rem))` - as many columns as fit, + each capped at 22rem. Do NOT go back to `sm:grid-cols-2`: that sized every + field to half the container, so one "ID" input was 350px in a wide pane and + 560px at the current container width. The 22rem cap is the point - a field + stops growing at a width appropriate to its content and leftover space stays + empty. Use **`.field-wide`** (a direct child of `.field-grid`) for fields that + genuinely need the row: JSON blobs, PEM, long descriptions. +- Wrap prose and single-column form panels in **`.measure`** (`--measure-max`, + 60rem). Raising `--container-max` to 110rem means an uncapped label-control + pair can span 1760px, which puts the label a screen away from its input. +- **`Field`** (`label` + control + hint/error) for text inputs (`Input`, + `Textarea`, `NativeSelect`). `NativeSelect` is the only select for plain + option lists (G9). +- **`NumberField`** for numeric config (unit suffix + help). Emits a raw string + so empty stays representable; parse with a `numOrUndef` helper on save. +- **`KeyValueRows`** for repeatable Name/Value editors (extra headers). +- **`PemTextarea`** for PEM/cert blobs (non-blocking validity hint). +- **`SegmentedSelect`** for inline 2-3 option choices (beta-header override + default/enabled/disabled). +- **`Combobox`** for searchable single-select (reset periods, model/customer + filters). +- **`ToggleGridItem`** for a labelled switch tile in a responsive grid. +- **`Switch`** for a lone boolean; wrap with a label + description row. + +## Secrets (never render values) + +The server returns **redacted** views: secrets become `hasX` presence markers +(`hasApiKey`, `hasCloudCredentials`, `hasProxy`, `hasProxyPassword`, +`hasCaCert`). The raw value never reaches the browser. + +- Show a **marker** ("Configured" + `••••••••`) plus a **Replace** affordance + that reveals an input to submit a _new_ secret. Use + `components/providers/SecretReenter` for single-line secrets; `PemTextarea` + (with a "Configured" badge) for `caCertPem`. +- Blank input = keep the current server value where possible. +- `MaskedSecretCell` (reveal + copy) is for values the browser legitimately + holds once - e.g. a freshly minted `vk-` token in a create response - never + for a redacted-at-rest secret you cannot actually reveal. +- **Gateway PUT is a shallow top-level merge** (`{...existing, ...patch}`). A + nested group you send _replaces_ the stored group, dropping any redacted + secret it contains. So: diff each config group against its loaded state and + send only changed groups (see `ProviderConfigPanel`), and warn when a save + would clear a stored secret the operator did not re-enter. + +## Status pills (`Badge`) + +- `tone="muted"` for neutral labels (CUSTOM, default, counts). Prefer muted to + stay monochrome. +- `tone="ok|warn|err|info"` only for genuine status semantics (enabled, missing + key, error, read-only). Soft variant by default; `solid` sparingly. +- Key presence in lists: plain `"set"` / `"missing"` micro-text (muted / + warning), not a loud pill. + +## Empty states + +- ``; use `tone="info"` for "feature off / coming + later" notices. Standard analytics empty copy is **"No data available"** to + match the reference. + +## Fixed-width rails must clip + +Any fixed-width flex rail (`TwoPane`'s `aside`) needs `overflow-hidden`, and any +badge/marker cluster inside a flex row needs `shrink-0`. Without both, a row +whose intrinsic content exceeds the rail paints its trailing badges OUTSIDE the +rail and on top of the neighbouring pane - the Providers overlap bug. The +combination makes a crowded row a truncation problem instead of an overlap one. +Locked by `two-pane.overflow.test.tsx`. + +## Density & spacing + +- Body 13.5px (`text-base`), secondary `text-sm`, micro labels `text-2xs` + uppercase tracking-wide muted. Mono (`font-mono`) for all telemetry: ids, + keys, latency, cost, tokens, versions. +- Control heights: `--control-h` (34px) default, `--control-h-sm` (30px) dense. +- Card padding `px-5 py-4`; section gaps `gap-4`/`gap-5`; page section spacing + `mb-5`/`mb-6`. + +## Navigation & routing + +- The IA is grouped: **Overview** (Dashboard, Logs, Status) / **Gateway** + (Providers, Model Catalog, Extensions) / **Governance** (Virtual keys, Teams, + Customers, Pricing) / **System** (Settings). +- **Status is an Overview leaf**, not System: it answers "is the gateway healthy + right now", which sits with Dashboard and Logs rather than with configuration. +- **Cache and Config are Settings tabs**, not views. `#/cache` and `#/config` + are kept alive by `REDIRECTS` in `App.tsx`, which `history.replaceState`s them + onto `#/settings/caching` and `#/settings/config`. When you fold a view into a + tab, add the redirect - a bookmark that lands on the fallback view reads as a + broken link, not as a reorganization. +- Hash router in `App.tsx` keys off the **first** hash segment (`baseSegment`), + so a view owning sub-pages uses `#//` and manages its own sub-nav + + `history.replaceState` (see `SettingsView`). Add a view by extending `NAV` + + `renderView`; the sidebar, search, and Cmd/Ctrl-K palette pick it up + automatically. +- **Pinned browser contract:** keep nav leaves matchable by accessible name + `Providers`, `Status`, `Logs`, `Extensions` (unique). Do not introduce sibling + elements whose accessible name _contains_ a pinned token (avoid a button named + "Refresh providers" - collides with "Providers"; keep aria labels distinctive, + e.g. "Reload configuration"). + +## api.ts client (Phase 3b endpoints, already wired) + +`src/api.ts` owns the transport, types, and endpoint clients. Consume these; do +not add `fetch` calls in views. + +- `getCatalog(): CatalogView` - `GET /api/catalog` (Model Catalog). +- `getSettings(): SettingsView` / `putSettings(update): SettingsView` - + `/api/settings`. Each group has `{ values, sources }`; `sources[field]` is + `default|env|override` (drive a provenance pill) and `enforcement["g.field"]` + is whether the gateway enforces it. +- `getCodeModeVfs(binding): CodeModeVfsView` - `GET /api/mcp/codemode/vfs`. +- `getRuntime(): RuntimeView` - `GET /api/runtime` (Status > Runtime). Process + topology, saturation, and limit state. Two of its numbers are **per-process** + (`concurrency.*` and per-window `rateLimit`), and the UI must label them as + such on the tile itself, not only in a footnote: under `FROSTY_WORKERS=N` an + unqualified "12 in flight" reads as fleet-wide and under-reports load by a + factor of N. Budgets are unaffected - those run on shared atomic counters. + Render `workers.reason` verbatim; it is the gateway's own explanation of why + fan-out did or did not happen. +- Providers: `getConfig`, `createProvider`, `updateProvider`, `deleteProvider`, + `refreshModels`, `setDefaultProvider`. + +All clients normalize partial/malformed bodies and (catalog) treat a 404 as +"feature off", so consumers stay total. diff --git a/klanker-gate/apps/control-ui/deno.jsonc b/klanker-gate/apps/control-ui/deno.jsonc new file mode 100755 index 0000000..89c8954 --- /dev/null +++ b/klanker-gate/apps/control-ui/deno.jsonc @@ -0,0 +1,15 @@ +{ + "tasks": { + "dev": "deno run -A npm:vite", + "build": "deno run -A npm:typescript@7.0.2/tsc && deno run -A npm:vite build", + "preview": "deno run -A npm:vite preview", + "check": "deno run -A npm:typescript@7.0.2/tsc", + "test": "deno run -A npm:vitest run" + }, + "fmt": { + "exclude": ["node_modules", "dist"] + }, + "lint": { + "exclude": ["node_modules", "dist"] + } +} diff --git a/klanker-gate/apps/control-ui/index.html b/klanker-gate/apps/control-ui/index.html new file mode 100755 index 0000000..af62be8 --- /dev/null +++ b/klanker-gate/apps/control-ui/index.html @@ -0,0 +1,28 @@ + + + + + + Klanker Gateway Manager + + + +
+ + + diff --git a/klanker-gate/apps/control-ui/package.json b/klanker-gate/apps/control-ui/package.json new file mode 100755 index 0000000..9cec716 --- /dev/null +++ b/klanker-gate/apps/control-ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "control-ui", + "private": true, + "version": "0.7.0", + "type": "module", + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^1.25.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.6", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.1", + "jsdom": "^30.0.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vitest": "^4.1.11", + "zod": "^4.4.3" + } +} diff --git a/klanker-gate/apps/control-ui/src/App.nav.test.tsx b/klanker-gate/apps/control-ui/src/App.nav.test.tsx new file mode 100755 index 0000000..252255b --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.nav.test.tsx @@ -0,0 +1,49 @@ +// Navigation restructure: Status moved to Overview, Cache and Config folded +// into Settings tabs, and the legacy hashes kept working. +// +// The redirect cases are the ones worth locking. Removing a nav leaf is +// visible immediately; a bookmark that silently lands on the wrong view is not, +// and reads as a broken link rather than as a reorganization. + +import { describe, expect, it } from "vitest"; +import { redirectFor } from "./App"; + +describe("legacy hash redirects", () => { + it("sends the old Cache page to the Settings caching tab", () => { + expect(redirectFor("#/cache")).toBe("settings/caching"); + }); + + it("sends the old Config page to the Settings config tab", () => { + expect(redirectFor("#/config")).toBe("settings/config"); + }); + + it("tolerates the hash with and without a leading slash", () => { + expect(redirectFor("#cache")).toBe("settings/caching"); + expect(redirectFor("#/cache")).toBe("settings/caching"); + }); + + it("leaves current routes alone", () => { + for (const hash of ["#/status", "#/settings", "#/providers", "#/logs"]) { + expect(redirectFor(hash)).toBeNull(); + } + }); + + it("leaves an already-migrated settings sub-route alone", () => { + // Redirecting this would loop: the destination contains the source token. + expect(redirectFor("#/settings/caching")).toBeNull(); + expect(redirectFor("#/settings/config")).toBeNull(); + }); + + it("does not invent a destination for a deep legacy path", () => { + // "#/cache/anything" was never a route this app minted. Rewriting it would + // guess at an intent that was never expressed. + expect(redirectFor("#/cache/entry/123")).toBeNull(); + expect(redirectFor("#/config/export")).toBeNull(); + }); + + it("ignores an empty or unknown hash", () => { + expect(redirectFor("")).toBeNull(); + expect(redirectFor("#/")).toBeNull(); + expect(redirectFor("#/nonsense")).toBeNull(); + }); +}); diff --git a/klanker-gate/apps/control-ui/src/App.rebuild.test.tsx b/klanker-gate/apps/control-ui/src/App.rebuild.test.tsx new file mode 100755 index 0000000..0edec52 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.rebuild.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { LogsView } from "./views/LogsView"; +import { apiFetch, clearAdminToken, saveAdminToken } from "./api"; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function sseResponse(frames: string[]): Response { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const frame of frames) { + controller.enqueue(encoder.encode(frame)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +describe("LogsView fetch-SSE (security #9: no EventSource)", () => { + it("streams the log via fetch and renders replayed frames", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + (input) => { + const url = String(input); + if (url.includes("/api/logs/stream")) { + const entry = { + ts: "12:00:00", + level: "info", + message: "GET /v1/models", + status: 200, + }; + return Promise.resolve( + sseResponse([`data: ${JSON.stringify(entry)}\n\n`]), + ); + } + if (url.includes("/api/logs/stored")) { + return Promise.resolve(jsonResponse({ entries: [], total: 0 })); + } + return Promise.resolve(jsonResponse({})); + }, + ); + + render(); + + // The Logs view renders immediately (Live is the default source). + expect(screen.getByText(/Live request stream and stored history/)) + .toBeInTheDocument(); + + // The replayed SSE frame is decoded and rendered. + await waitFor(() => + expect(screen.getByText(/GET \/v1\/models/)).toBeInTheDocument() + ); + + // The stream was consumed via fetch, and no EventSource was constructed. + expect( + fetchSpy.mock.calls.some((call) => + String(call[0]).includes("/api/logs/stream") + ), + ).toBe(true); + const fake = (globalThis as Record).__FakeEventSource as { + instances: unknown[]; + }; + expect(fake.instances.length).toBe(0); + }); +}); + +describe("apiFetch auth scope (security #2)", () => { + it("attaches Bearer only to /api/* and not to /healthz or /v1/*", async () => { + saveAdminToken("secret-token-value"); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(() => Promise.resolve(jsonResponse({}))); + + await apiFetch("/api/config"); + await apiFetch("/healthz"); + await apiFetch("/v1/models"); + + const authFor = (path: string): string | null => { + const call = fetchSpy.mock.calls.find((c) => String(c[0]) === path); + return new Headers(call?.[1]?.headers).get("Authorization"); + }; + + expect(authFor("/api/config")).toBe("Bearer secret-token-value"); + expect(authFor("/healthz")).toBeNull(); + expect(authFor("/v1/models")).toBeNull(); + + clearAdminToken(); + }); +}); diff --git a/klanker-gate/apps/control-ui/src/App.test.tsx b/klanker-gate/apps/control-ui/src/App.test.tsx new file mode 100755 index 0000000..9c314dc --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.test.tsx @@ -0,0 +1,164 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import App from "./App"; +import { ProvidersView } from "./views/ProvidersView"; +import { StatusView } from "./views/StatusView"; + +function jsonOk(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function mockGateway() { + return vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = String(input); + if (url.includes("/api/config")) { + return Promise.resolve(jsonOk({ + defaultProvider: "openai", + providers: [ + { + id: "openai", + type: "openai", + enabled: true, + models: ["gpt-4o", "gpt-4o-mini"], + priority: 0, + hasApiKey: true, + }, + { + id: "anthropic", + type: "anthropic", + enabled: false, + models: [], + priority: 0, + hasApiKey: false, + }, + ], + })); + } + if (url.includes("/healthz")) { + return Promise.resolve(jsonOk({ + status: "ok", + version: "0.7.0", + timestamp: "2026-07-13T00:00:00Z", + })); + } + if (url.includes("/api/version")) { + return Promise.resolve(jsonOk({ version: "0.7.0", deno: "2.9.2" })); + } + if (url.includes("/v1/models")) { + return Promise.resolve(jsonOk({ + object: "list", + data: [{ id: "openai/gpt-4o", object: "model", owned_by: "openai" }], + })); + } + if (url.includes("/api/mcp/clients")) { + return Promise.resolve(jsonOk({ + clients: [{ + id: "weather", + url: "https://mcp.example.com/rpc", + enabled: true, + transport: "http-sse", + toolCount: 2, + lastSyncAt: "2026-07-13T00:00:00Z", + }], + })); + } + if (url.includes("/api/mcp/tools")) { + return Promise.resolve(jsonOk({ + tools: [{ + name: "get_weather", + clientId: "weather", + annotations: { readOnlyHint: true }, + }, { + name: "delete_notes", + clientId: "weather", + }], + })); + } + if (url.includes("/api/plugins")) { + return Promise.resolve(jsonOk({ plugins: ["tagger"] })); + } + return Promise.resolve(jsonOk({})); + }); +} + +describe("ProvidersView", () => { + it("renders the configured providers list from the gateway config", async () => { + mockGateway(); + render(); + + expect((await screen.findAllByText("openai")).length).toBeGreaterThan(0); + expect(screen.getAllByText("anthropic").length).toBeGreaterThan(0); + expect(screen.getByText("default")).toBeInTheDocument(); + // Traffic-light status badge: green "online" (enabled + key) vs red "disabled". + expect(screen.getByText("online")).toBeInTheDocument(); + expect(screen.getByText("disabled")).toBeInTheDocument(); + }); + + it("shows an add-provider form", async () => { + mockGateway(); + render(); + expect( + await screen.findByRole("button", { name: "Add provider" }), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("openai")).toBeInTheDocument(); + }); +}); + +describe("StatusView", () => { + it("shows health, runtime version, and the model catalog", async () => { + mockGateway(); + render(); + + expect(await screen.findByText("ok")).toBeInTheDocument(); + expect(screen.getByText(/gateway v0\.7\.0/)).toBeInTheDocument(); + expect(screen.getByText(/Deno 2\.9\.2/)).toBeInTheDocument(); + expect(await screen.findByText("openai/gpt-4o")).toBeInTheDocument(); + }); +}); + +describe("ExtensionsView", () => { + it("shows MCP servers, synced tools with safety badges, and plugins", async () => { + mockGateway(); + const user = userEvent.setup(); + const { ExtensionsView } = await import("./views/ExtensionsView"); + render(); + + // Default tab renders MCP servers: the client row plus the transport + // column and the add-form selector (default http-sse, decision D11). + expect((await screen.findAllByText("weather")).length).toBeGreaterThan(0); + expect(screen.getAllByText("http-sse").length).toBeGreaterThan(1); + + // Synced tools tab: tool names and per-call safety badges. + await user.click(screen.getByRole("tab", { name: "Synced tools" })); + expect(await screen.findByText("get_weather")).toBeInTheDocument(); + expect(screen.getByText("read-only")).toBeInTheDocument(); + expect(screen.getByText("needs confirmation")).toBeInTheDocument(); + + // Plugins tab: built-in plugin names from GET /api/plugins. + await user.click(screen.getByRole("tab", { name: "Plugins" })); + expect(await screen.findByText("tagger")).toBeInTheDocument(); + }); +}); + +describe("App", () => { + it("switches between tabs", async () => { + mockGateway(); + const user = userEvent.setup(); + render(); + + expect( + await screen.findByText("Configured Providers"), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Logs" })); + expect(screen.getByText(/Live request stream and stored history/)) + .toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Status" })); + expect(await screen.findByText("Gateway health")).toBeInTheDocument(); + }); +}); diff --git a/klanker-gate/apps/control-ui/src/App.tsx b/klanker-gate/apps/control-ui/src/App.tsx new file mode 100755 index 0000000..33366c3 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/App.tsx @@ -0,0 +1,337 @@ +import { useEffect, useRef, useState } from "react"; +import { + Activity, + Boxes, + Building2, + CircleDollarSign, + KeyRound, + LayoutDashboard, + Menu, + Plug, + Puzzle, + ScrollText, + SlidersHorizontal, + Users, +} from "lucide-react"; +import { ProvidersView } from "./views/ProvidersView"; +import { StatusView } from "./views/StatusView"; +import { LogsView } from "./views/LogsView"; +import { ExtensionsView } from "./views/ExtensionsView"; +import { ModelCatalogView } from "./views/ModelCatalogView"; +import { SettingsView } from "./views/SettingsView"; +import { VirtualKeysView } from "./views/VirtualKeysView"; +import { TeamsView } from "./views/TeamsView"; +import { CustomersView } from "./views/CustomersView"; +import { PricingView } from "./views/PricingView"; +import { DashboardView } from "./views/DashboardView"; +import { type NavItem, Sidebar } from "./components/shell/Sidebar"; +import { CommandPalette } from "./components/shell/CommandPalette"; +import { AdminTokenDialog } from "./components/shell/AdminTokenDialog"; +import { ToastProvider } from "./components/ui/toast"; +import { Banner } from "./components/ui/banner"; +import { Button } from "./components/ui/button"; +import { type AuthState, hasAdminToken, subscribeAuth } from "./api"; + +const NAV: NavItem[] = [ + { + id: "dashboard", + label: "Dashboard", + group: "Overview", + icon: LayoutDashboard, + }, + { id: "logs", label: "Logs", group: "Overview", icon: ScrollText }, + { id: "status", label: "Status", group: "Overview", icon: Activity }, + { id: "providers", label: "Providers", group: "Gateway", icon: Plug }, + { + id: "model-catalog", + label: "Model Catalog", + group: "Gateway", + icon: Boxes, + }, + { id: "extensions", label: "Extensions", group: "Gateway", icon: Puzzle }, + { + id: "virtual-keys", + label: "Virtual keys", + group: "Governance", + icon: KeyRound, + }, + { id: "teams", label: "Teams", group: "Governance", icon: Users }, + { id: "customers", label: "Customers", group: "Governance", icon: Building2 }, + { + id: "pricing", + label: "Pricing", + group: "Governance", + icon: CircleDollarSign, + }, + { + id: "settings", + label: "Settings", + group: "System", + icon: SlidersHorizontal, + }, +]; + +const IDS = NAV.map((item) => item.id); + +/** + * Hashes that pointed at views which are now Settings tabs. Without this a + * bookmarked #/cache would fall through to the default view, which looks like a + * broken link rather than a reorganization. + */ +const REDIRECTS: Record = { + cache: "settings/caching", + config: "settings/config", +}; + +/** Resolve a legacy hash to its replacement route, or null when current. */ +export function redirectFor(hash: string): string | null { + const raw = hash.replace(/^#\/?/, ""); + const base = raw.split("/")[0]; + const target = REDIRECTS[base]; + // Only redirect a BARE legacy hash. "#/cache/anything" is not a route this + // app ever minted, so rewriting it would invent a destination. + return target && raw === base ? target : null; +} + +/** First hash segment -> view id, tolerating sub-routes like "settings/mcp". */ +function baseSegment(hash: string): string { + return hash.replace(/^#\/?/, "").split("/")[0]; +} + +function hashToView(hash: string): string { + const base = baseSegment(hash); + return IDS.includes(base) ? base : "providers"; +} + +/** + * Rewrites a legacy hash in place before routing. Uses replaceState, not a + * push, so the browser Back button does not bounce between the old hash and + * its replacement. + */ +function applyRedirect(hash: string): boolean { + const target = redirectFor(hash); + if (!target) { + return false; + } + try { + history.replaceState(null, "", `#/${target}`); + } catch { + // hash write unavailable: fall through and route by state alone + } + return true; +} + +function renderView(id: string) { + switch (id) { + case "dashboard": + return ; + case "model-catalog": + return ; + case "settings": + return ; + case "status": + return ; + case "logs": + return ; + case "extensions": + return ; + case "virtual-keys": + return ; + case "teams": + return ; + case "customers": + return ; + case "pricing": + return ; + default: + return ; + } +} + +function App() { + const [view, setView] = useState(() => { + applyRedirect(location.hash); + return hashToView(location.hash); + }); + const [authState, setAuthState] = useState("unknown"); + const [authNonce, setAuthNonce] = useState(0); + const [tokenOpen, setTokenOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem("frosty.sidebar") === "rail"; + } catch { + return false; + } + }); + const [theme, setTheme] = useState<"dark" | "light">(() => + document.documentElement.classList.contains("dark") ? "dark" : "light" + ); + + useEffect(() => subscribeAuth(setAuthState), []); + + // Global command palette shortcut (Cmd/Ctrl-K); cleaned up on unmount. + useEffect(() => { + function onKey(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setPaletteOpen((open) => !open); + } + } + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, []); + + const firstRender = useRef(true); + useEffect(() => { + // Focus the active view heading on nav change so screen readers announce + // the new context (spec section 4). Skip the initial mount. + if (firstRender.current) { + firstRender.current = false; + return; + } + document.querySelector("#main h2")?.focus(); + }, [view]); + + useEffect(() => { + function onHash() { + // React only to known view hashes; in-page anchors (e.g. Extensions' + // "#tools") must not hijack the router. Sub-routes ("settings/mcp") map + // to their base view, which owns the sub-navigation. + applyRedirect(location.hash); + const base = baseSegment(location.hash); + if (IDS.includes(base)) { + setView(base); + } + } + globalThis.addEventListener("hashchange", onHash); + return () => globalThis.removeEventListener("hashchange", onHash); + }, []); + + function navigate(id: string) { + setView(id); + setMobileNavOpen(false); + try { + history.replaceState(null, "", `#/${id}`); + } catch { + // hash write unavailable: state is still authoritative + } + } + + function toggleCollapse() { + setCollapsed((current) => { + const next = !current; + try { + localStorage.setItem("frosty.sidebar", next ? "rail" : "expanded"); + } catch { + // preference is best-effort + } + return next; + }); + } + + function toggleTheme() { + setTheme((current) => { + const next = current === "dark" ? "light" : "dark"; + const root = document.documentElement; + root.classList.toggle("dark", next === "dark"); + root.dataset.theme = next; + try { + localStorage.setItem("frosty.theme", next); + } catch { + // preference is best-effort + } + return next; + }); + } + + const tokenStatus = authState === "denied" + ? "denied" + : hasAdminToken() + ? "ok" + : "none"; + + return ( + +
+ Skip to content + {mobileNavOpen && ( + + setTokenOpen(false)} + onTokenChange={() => setAuthNonce((n) => n + 1)} + /> + setPaletteOpen(false)} + items={NAV} + onSelect={navigate} + /> + + ); +} + +export default App; diff --git a/klanker-gate/apps/control-ui/src/api.ts b/klanker-gate/apps/control-ui/src/api.ts new file mode 100755 index 0000000..8fd1af8 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/api.ts @@ -0,0 +1,1102 @@ +import type { + ConfigExport, + ProviderAccountConfig, + ProviderAccountPublic, +} from "../../../packages/contracts/src/config.ts"; +import type { LogEntry } from "../../../packages/telemetry/src/logbus.ts"; + +export type { + ConfigExport, + LogEntry, + ProviderAccountConfig, + ProviderAccountPublic, +}; + +/* ----------------------------- auth state ------------------------------ */ + +const TOKEN_KEY = "frosty.admin-token"; + +export type AuthState = "unknown" | "ok" | "denied"; + +let authState: AuthState = "unknown"; +const authListeners = new Set<(state: AuthState) => void>(); + +function setAuthState(next: AuthState): void { + if (next === authState) { + return; + } + authState = next; + for (const listener of authListeners) { + listener(next); + } +} + +export function getAuthState(): AuthState { + return authState; +} + +export function subscribeAuth( + listener: (state: AuthState) => void, +): () => void { + authListeners.add(listener); + return () => { + authListeners.delete(listener); + }; +} + +export function getAdminToken(): string | null { + try { + return sessionStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +export function hasAdminToken(): boolean { + return Boolean(getAdminToken()); +} + +export function saveAdminToken(token: string): void { + try { + sessionStorage.setItem(TOKEN_KEY, token); + } catch { + // storage unavailable: the token lives for this page only + } + setAuthState("unknown"); +} + +export function clearAdminToken(): void { + try { + sessionStorage.removeItem(TOKEN_KEY); + } catch { + // ignore + } + setAuthState("unknown"); +} + +/* ------------------------------ transport ------------------------------ */ + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + this.name = "ApiError"; + } +} + +function isAdminSurface(path: string): boolean { + return path.startsWith("/api/") || path === "/metrics"; +} + +async function extractError(res: Response): Promise { + try { + const body = await res.json() as { error?: unknown }; + const err = body?.error; + if ( + typeof err === "object" && err !== null && + typeof (err as { message?: unknown }).message === "string" + ) { + return (err as { message: string }).message; + } + if (typeof err === "string") { + // Defensive fallback. The canonical envelope above is the contract; the + // stored-logs 404 no longer uses a flat {error: string} body, and only + // the pricing force-sync divergences (api-endpoints.md E2/E3) still do. + return err; + } + } catch { + // non-JSON body: fall through to the status line + } + return res.statusText || `HTTP ${res.status}`; +} + +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers); + if (init?.body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const admin = isAdminSurface(path); + if (admin) { + const token = getAdminToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + } + // fetch must receive a plain string URL (test mocks key on String(input)). + const res = await fetch(path, { ...init, headers }); + if (res.status === 401) { + if (admin) { + setAuthState("denied"); + } + throw new ApiError(401, await extractError(res)); + } + if (!res.ok) { + throw new ApiError(res.status, await extractError(res)); + } + if (admin) { + setAuthState("ok"); + } + if (res.status === 204) { + return undefined as T; + } + return await res.json() as T; +} + +/* ------------------------------- shapes -------------------------------- */ + +export interface HealthInfo { + status: string; + version: string; + timestamp: string; +} + +export interface VersionInfo { + version: string; + deno: string; +} + +export interface ModelInfo { + id: string; + object: string; + owned_by: string; +} + +export interface GatewayConfigView { + defaultProvider?: string; + providers: ProviderAccountPublic[]; + /** Operator EUR-per-USD display rate (FROSTY_EUR_RATE); micro-USD stays canonical. */ + eurRate?: number; +} + +export interface MCPClientView { + id: string; + url?: string; + enabled: boolean; + /** Header names only. Stored values never leave the gateway. */ + headerNames: string[]; + transport?: "auto" | "streamable-http" | "http-sse" | "stdio"; + requestTimeoutMs?: number; + /** True when a server-side stdio command is configured. */ + hasCommand: boolean; + /** True when stored URL user-info was removed from `url`. */ + hasUrlCredentials: boolean; + toolCount: number; + lastSyncAt?: string; +} + +export interface MCPClientInput { + id: string; + url?: string; + enabled: boolean; + headers?: Record; + transport?: string; + requestTimeoutMs?: number; + command?: string[]; +} + +export interface MCPToolView { + name: string; + description?: string; + clientId: string; + annotations?: { readOnlyHint?: boolean; [key: string]: unknown }; +} + +export interface MCPHealthView { + clientId: string; + status: "healthy" | "unhealthy" | "disabled"; + toolCount: number; + consecutiveFailures: number; + lastError?: string; + lastCheckedAt: string; +} + +export interface LimitWindow { + maxRequests?: number; + maxTokens?: number; + windowMs: number; +} + +export interface Budget { + maxRequests?: number; + maxCostUsd?: number; +} + +export interface VirtualKeyPublic { + id: string; + name: string; + enabled: boolean; + rateLimit?: { maxRequests: number; windowMs: number }; + tokenLimit?: { maxTokens: number; windowMs: number }; + budget?: Budget; + teamId?: string; + /** Admission scope; absent = unrestricted. */ + allowedProviders?: string[]; + allowedModels?: string[]; + usedRequests: number; + usedCostMicroUsd: number; + tokenHint: string; + usedCostUsd: number; +} + +export interface VirtualKeyInput { + name: string; + enabled?: boolean; + rateLimit?: { maxRequests: number; windowMs: number }; + tokenLimit?: { maxTokens: number; windowMs: number }; + budget?: Budget; + teamId?: string; + /** + * Admission scope. Create: omit for unrestricted (empty array is rejected). + * Update: an array sets scope, `null` explicitly clears it, omit leaves it. + */ + allowedProviders?: string[] | null; + allowedModels?: string[] | null; +} + +export interface Team { + id: string; + name: string; + enabled: boolean; + customerId?: string; + budget?: Budget; + usedRequests: number; + usedCostMicroUsd: number; +} + +export interface Customer { + id: string; + name: string; + enabled: boolean; + budget?: Budget; + usedRequests: number; + usedCostMicroUsd: number; +} + +export interface ModelPrice { + inputPerMTokUsd: number; + outputPerMTokUsd: number; +} + +export interface StoredLogsResult { + entries: LogEntry[]; + total: number; +} + +/* ------------------------------ analytics ------------------------------ */ + +/** Server rollup window; the dashboard only surfaces 1h/24h today. */ +export type AnalyticsWindow = "1h" | "24h" | "7d"; + +export interface AnalyticsTotals { + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costMicroUsd: number; + costUsd: number; + errorRatePct: number; + cacheHits: number; + cacheMisses: number; +} + +export interface AnalyticsBucket { + /** 1-based bucket ordinal ("1".."12"), aligned with buildSeries labels. */ + label: string; + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costMicroUsd: number; + errors: number; +} + +export interface AnalyticsModelRow { + model: string; + provider: string; + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + costMicroUsd: number; +} + +export interface AnalyticsProviderRow { + provider: string; + requests: number; + totalTokens: number; + costMicroUsd: number; +} + +export interface AnalyticsRollup { + /** False when the gateway does not track token/cost analytics. */ + tracked: boolean; + window: AnalyticsWindow; + generatedAt: string; + totals: AnalyticsTotals; + series: AnalyticsBucket[]; + byModel: AnalyticsModelRow[]; + byProvider: AnalyticsProviderRow[]; +} + +/* ---------------------------- status surface --------------------------- */ + +/** Process topology, saturation, and limit state. GET /api/runtime. */ +export interface RuntimeView { + workers: { + configured: number; + /** Processes actually serving; 1 wherever reusePort is unsupported. */ + effective: number; + index: number | null; + reusePortSupported: boolean; + platform: string; + /** Human-readable explanation, rendered verbatim. */ + reason: string; + }; + concurrency: { + /** Connections open right now, counted for their full lifetime. */ + active: number; + peak: number; + total: number; + completed: number; + /** Mean lifetime over the last 1000 completed connections, ms. */ + avgLifetimeMs: number; + maxLifetimeMs: number; + /** Age of the oldest connection still open, ms. */ + longestOpenMs: number; + /** Handlers executing right now; excludes time spent streaming a body. */ + dispatching: number; + peakDispatching: number; + since: string; + /** Always "per-process" - never render this number as fleet-wide. */ + scope: string; + }; + rateLimit: { + enforced: boolean; + keysWithLimits: number; + totalKeys: number; + /** "fleet" when one shared counter governs every worker. */ + scope: string; + windows: Array<{ keyId: string; maxRequests?: number; windowMs: number }>; + }; + postgres: { + poolSize: number; + estimatedFleetConnections: number; + /** host:port/database - the gateway strips credentials before sending. */ + target: string; + listenerActive: boolean; + }; + cache: { mode: string; sharedTier: boolean; localEntries: number }; + process: { uptimeSeconds: number; denoVersion: string; v8Version: string }; +} + +/** + * Runtime view. Normalized like every other client so a partial body from an + * older gateway renders as zeros instead of throwing mid-page. + */ +export async function getRuntime(): Promise { + const raw = await apiFetch>("/api/runtime"); + return { + workers: { + configured: raw.workers?.configured ?? 1, + effective: raw.workers?.effective ?? 1, + index: raw.workers?.index ?? null, + reusePortSupported: raw.workers?.reusePortSupported ?? false, + platform: raw.workers?.platform ?? "unknown", + reason: raw.workers?.reason ?? "", + }, + concurrency: { + active: raw.concurrency?.active ?? 0, + peak: raw.concurrency?.peak ?? 0, + total: raw.concurrency?.total ?? 0, + completed: raw.concurrency?.completed ?? 0, + avgLifetimeMs: raw.concurrency?.avgLifetimeMs ?? 0, + maxLifetimeMs: raw.concurrency?.maxLifetimeMs ?? 0, + longestOpenMs: raw.concurrency?.longestOpenMs ?? 0, + dispatching: raw.concurrency?.dispatching ?? 0, + peakDispatching: raw.concurrency?.peakDispatching ?? 0, + since: raw.concurrency?.since ?? "", + scope: raw.concurrency?.scope ?? "per-process", + }, + rateLimit: { + enforced: raw.rateLimit?.enforced ?? false, + keysWithLimits: raw.rateLimit?.keysWithLimits ?? 0, + totalKeys: raw.rateLimit?.totalKeys ?? 0, + scope: raw.rateLimit?.scope ?? "per-process", + windows: raw.rateLimit?.windows ?? [], + }, + postgres: { + poolSize: raw.postgres?.poolSize ?? 0, + estimatedFleetConnections: raw.postgres?.estimatedFleetConnections ?? 0, + target: raw.postgres?.target ?? "unknown", + listenerActive: raw.postgres?.listenerActive ?? false, + }, + cache: { + mode: raw.cache?.mode ?? "off", + sharedTier: raw.cache?.sharedTier ?? false, + localEntries: raw.cache?.localEntries ?? 0, + }, + process: { + uptimeSeconds: raw.process?.uptimeSeconds ?? 0, + denoVersion: raw.process?.denoVersion ?? "", + v8Version: raw.process?.v8Version ?? "", + }, + }; +} + +export function getHealth(): Promise { + return apiFetch("/healthz"); +} + +export function getVersion(): Promise { + return apiFetch("/api/version"); +} + +export async function getModels(): Promise { + const body = await apiFetch<{ data?: ModelInfo[] }>("/v1/models"); + return Array.isArray(body?.data) ? body.data : []; +} + +/* -------------------------- providers / config ------------------------- */ + +export async function getConfig(): Promise { + const body = await apiFetch("/api/config"); + return { + defaultProvider: body?.defaultProvider, + providers: Array.isArray(body?.providers) ? body.providers : [], + eurRate: typeof body?.eurRate === "number" ? body.eurRate : undefined, + }; +} + +export function createProvider( + input: ProviderAccountConfig, +): Promise { + return apiFetch("/api/providers", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateProvider( + id: string, + patch: Partial, +): Promise { + return apiFetch( + `/api/providers/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(patch) }, + ); +} + +export function deleteProvider(id: string): Promise { + return apiFetch(`/api/providers/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function refreshModels( + id: string, +): Promise<{ id: string; models: string[] }> { + return apiFetch<{ id: string; models: string[] }>( + `/api/providers/${encodeURIComponent(id)}/refresh-models`, + { method: "POST" }, + ); +} + +/** Read-only: the provider's full live model list, without changing which + * models are enabled (the account's `models`). Powers the catalog toggle grid. */ +export function getProviderAvailableModels( + id: string, +): Promise<{ id: string; models: string[] }> { + return apiFetch<{ id: string; models: string[] }>( + `/api/providers/${encodeURIComponent(id)}/available-models`, + ); +} + +/** Live provider reachability for the status badge. */ +export interface ProviderHealthView { + id: string; + type: string; + status: "ok" | "error" | "unknown" | "disabled"; + lastError?: string; + checkedAt: string; +} + +export async function getProviderHealth(): Promise { + const body = await apiFetch<{ health?: ProviderHealthView[] }>( + "/api/providers/health", + ); + return Array.isArray(body?.health) ? body.health : []; +} + +export function setDefaultProvider( + id: string | undefined, +): Promise<{ defaultProvider?: string }> { + return apiFetch<{ defaultProvider?: string }>("/api/config", { + method: "PUT", + body: JSON.stringify({ defaultProvider: id }), + }); +} + +export function exportConfig(includeSecrets: boolean): Promise { + return apiFetch( + includeSecrets + ? "/api/config/export?include_secrets=true" + : "/api/config/export", + ); +} + +export function importConfig( + payload: unknown, +): Promise<{ imported: boolean; providers: number }> { + return apiFetch<{ imported: boolean; providers: number }>( + "/api/config/import", + { method: "POST", body: JSON.stringify(payload) }, + ); +} + +export function reloadConfig(): Promise< + { reloaded: boolean; providers: number; defaultProvider?: string } +> { + return apiFetch< + { reloaded: boolean; providers: number; defaultProvider?: string } + >("/api/config/reload", { method: "POST" }); +} + +/* --------------------------------- logs -------------------------------- */ + +export async function getLogs(limit: number): Promise { + const body = await apiFetch<{ logs?: LogEntry[] }>( + `/api/logs?limit=${limit}`, + ); + return Array.isArray(body?.logs) ? body.logs : []; +} + +export async function getStoredLogs(params: { + q?: string; + status?: number; + limit?: number; + offset?: number; +}): Promise { + const search = new URLSearchParams(); + if (params.q) { + search.set("q", params.q); + } + if (typeof params.status === "number" && !Number.isNaN(params.status)) { + search.set("status", String(params.status)); + } + if (typeof params.limit === "number") { + search.set("limit", String(params.limit)); + } + if (typeof params.offset === "number") { + search.set("offset", String(params.offset)); + } + const body = await apiFetch>( + `/api/logs/stored?${search.toString()}`, + ); + return { + entries: Array.isArray(body?.entries) ? body.entries : [], + total: typeof body?.total === "number" ? body.total : 0, + }; +} + +export function clearStoredLogs(): Promise<{ deleted: number }> { + return apiFetch<{ deleted: number }>("/api/logs/stored", { + method: "DELETE", + }); +} + +/** + * Fetch-based SSE reader for /api/logs/stream (EventSource is forbidden: + * it cannot carry the admin bearer header). Resolves when the stream ends; + * the caller owns reconnection. Abort via the provided signal. + */ +export async function readLogStream( + onEntry: (entry: LogEntry) => void, + signal: AbortSignal, + onOpen?: () => void, +): Promise { + const headers = new Headers({ Accept: "text/event-stream" }); + const token = getAdminToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + const res = await fetch("/api/logs/stream", { headers, signal }); + if (res.status === 401) { + setAuthState("denied"); + throw new ApiError(401, "Missing or invalid admin token."); + } + if (!res.ok || !res.body) { + throw new ApiError(res.status, res.statusText || `HTTP ${res.status}`); + } + setAuthState("ok"); + onOpen?.(); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + let sep = buffer.indexOf("\n\n"); + while (sep >= 0) { + const frame = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + for (const line of frame.split("\n")) { + if (!line.startsWith("data:")) { + continue; + } + try { + onEntry(JSON.parse(line.slice(5).trim()) as LogEntry); + } catch { + // malformed frame: drop silently (security seed #11) + } + } + sep = buffer.indexOf("\n\n"); + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // already released + } + } +} + +/* ------------------------------ analytics ------------------------------ */ + +const EMPTY_ANALYTICS_TOTALS: AnalyticsTotals = { + requests: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + costMicroUsd: 0, + costUsd: 0, + errorRatePct: 0, + cacheHits: 0, + cacheMisses: 0, +}; + +/** A well-formed rollup that reads as "not tracked" for the empty state. */ +function emptyAnalyticsRollup(window: AnalyticsWindow): AnalyticsRollup { + return { + tracked: false, + window, + generatedAt: new Date().toISOString(), + totals: { ...EMPTY_ANALYTICS_TOTALS }, + series: [], + byModel: [], + byProvider: [], + }; +} + +/** + * Token/cost/model rollup for the dashboard. A 404 (older gateway) means the + * feature is off, so we resolve to a well-formed untracked rollup instead of + * throwing (mirrors the stored-logs 404 = "feature off" pattern). Partial or + * malformed bodies are normalized so downstream chart helpers stay total. + */ +export async function getAnalytics( + window: AnalyticsWindow, +): Promise { + try { + const body = await apiFetch>( + `/api/analytics?window=${window}`, + ); + return { + tracked: body?.tracked === true, + window: body?.window ?? window, + generatedAt: typeof body?.generatedAt === "string" + ? body.generatedAt + : new Date().toISOString(), + totals: { ...EMPTY_ANALYTICS_TOTALS, ...(body?.totals ?? {}) }, + series: Array.isArray(body?.series) ? body.series : [], + byModel: Array.isArray(body?.byModel) ? body.byModel : [], + byProvider: Array.isArray(body?.byProvider) ? body.byProvider : [], + }; + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + return emptyAnalyticsRollup(window); + } + throw err; + } +} + +/* ----------------------------- MCP / plugins --------------------------- */ + +export async function getMCPClients(): Promise { + const body = await apiFetch<{ clients?: MCPClientView[] }>( + "/api/mcp/clients", + ); + return Array.isArray(body?.clients) ? body.clients : []; +} + +export function createMCPClient(input: MCPClientInput): Promise { + return apiFetch("/api/mcp/clients", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateMCPClient( + id: string, + patch: Partial, +): Promise { + return apiFetch( + `/api/mcp/clients/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(patch) }, + ); +} + +export function deleteMCPClient(id: string): Promise { + return apiFetch(`/api/mcp/clients/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function syncMCPClient( + id: string, +): Promise<{ id: string; tools: number }> { + return apiFetch<{ id: string; tools: number }>( + `/api/mcp/clients/${encodeURIComponent(id)}/sync`, + { method: "POST" }, + ); +} + +export function syncAllMCP(): Promise<{ synced: number }> { + return apiFetch<{ synced: number }>("/api/mcp/sync", { method: "POST" }); +} + +export async function getMCPTools(): Promise { + const body = await apiFetch<{ tools?: MCPToolView[] }>("/api/mcp/tools"); + return Array.isArray(body?.tools) ? body.tools : []; +} + +export async function getMCPHealth(): Promise { + const body = await apiFetch<{ health?: MCPHealthView[] }>("/api/mcp/health"); + return Array.isArray(body?.health) ? body.health : []; +} + +export async function getPlugins(): Promise { + const body = await apiFetch<{ plugins?: string[] }>("/api/plugins"); + return Array.isArray(body?.plugins) ? body.plugins : []; +} + +/* -------------------------------- cache -------------------------------- */ + +export function clearCache(): Promise<{ cleared: number }> { + return apiFetch<{ cleared: number }>("/api/cache", { method: "DELETE" }); +} + +export function deleteCacheEntry( + requestBody: unknown, +): Promise<{ deleted: boolean }> { + return apiFetch<{ deleted: boolean }>("/api/cache/by-key", { + method: "DELETE", + body: JSON.stringify(requestBody), + }); +} + +/* ------------------------------ governance ----------------------------- */ + +export async function getVirtualKeys(): Promise { + const body = await apiFetch<{ virtualKeys?: VirtualKeyPublic[] }>( + "/api/virtual-keys", + ); + return Array.isArray(body?.virtualKeys) ? body.virtualKeys : []; +} + +/** The 201 body carries the full token exactly once; never store it. */ +export function createVirtualKey( + input: VirtualKeyInput, +): Promise { + return apiFetch("/api/virtual-keys", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateVirtualKey( + id: string, + patch: Partial, +): Promise { + return apiFetch( + `/api/virtual-keys/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(patch) }, + ); +} + +export function deleteVirtualKey(id: string): Promise { + return apiFetch(`/api/virtual-keys/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export async function getTeams(): Promise { + const body = await apiFetch<{ teams?: Team[] }>("/api/teams"); + return Array.isArray(body?.teams) ? body.teams : []; +} + +export function createTeam( + input: { + name: string; + enabled?: boolean; + customerId?: string; + budget?: Budget; + }, +): Promise { + return apiFetch("/api/teams", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateTeam( + id: string, + patch: Partial< + { name: string; enabled: boolean; customerId: string; budget: Budget } + >, +): Promise { + return apiFetch(`/api/teams/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +export function deleteTeam(id: string): Promise { + return apiFetch(`/api/teams/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export async function getCustomers(): Promise { + const body = await apiFetch<{ customers?: Customer[] }>("/api/customers"); + return Array.isArray(body?.customers) ? body.customers : []; +} + +export function createCustomer( + input: { name: string; enabled?: boolean; budget?: Budget }, +): Promise { + return apiFetch("/api/customers", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateCustomer( + id: string, + patch: Partial<{ name: string; enabled: boolean; budget: Budget }>, +): Promise { + return apiFetch(`/api/customers/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +export function deleteCustomer(id: string): Promise { + return apiFetch(`/api/customers/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export async function getPricing(): Promise> { + const body = await apiFetch<{ prices?: Record }>( + "/api/pricing", + ); + return body?.prices && typeof body.prices === "object" ? body.prices : {}; +} + +export function putPricing( + prices: Record, +): Promise<{ prices: Record }> { + return apiFetch<{ prices: Record }>("/api/pricing", { + method: "PUT", + body: JSON.stringify(prices), + }); +} + +/* ------------------------------ model catalog -------------------------- */ + +/** + * One row of the Model Catalog surface: a provider with its advertised models + * and 24h traffic/cost rollup. `custom` marks bring-your-own providers + * (openai-compatible / anthropic-compatible / lmstudio). + */ +export interface CatalogProviderRow { + id: string; + type: string; + custom: boolean; + models: string[]; + traffic24h: number; + cost24h: number; +} + +export interface CatalogTotals { + providers: number; + models: number; + requests24h: number; + cost24h: number; +} + +export interface CatalogView { + providers: CatalogProviderRow[]; + totals: CatalogTotals; +} + +const EMPTY_CATALOG_TOTALS: CatalogTotals = { + providers: 0, + models: 0, + requests24h: 0, + cost24h: 0, +}; + +/** + * Model + provider catalog for the Model Catalog view (Phase 3b). Partial or + * malformed bodies are normalized so consumers stay total; a 404 (older + * gateway) resolves to an empty catalog rather than throwing (mirrors the + * analytics "feature off" pattern). + */ +export async function getCatalog(): Promise { + try { + const body = await apiFetch>("/api/catalog"); + const rows = Array.isArray(body?.providers) ? body.providers : []; + return { + providers: rows.map((row) => ({ + id: String(row?.id ?? ""), + type: String(row?.type ?? ""), + custom: row?.custom === true, + models: Array.isArray(row?.models) ? row.models : [], + traffic24h: typeof row?.traffic24h === "number" ? row.traffic24h : 0, + cost24h: typeof row?.cost24h === "number" ? row.cost24h : 0, + })), + totals: { ...EMPTY_CATALOG_TOTALS, ...(body?.totals ?? {}) }, + }; + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + return { providers: [], totals: { ...EMPTY_CATALOG_TOTALS } }; + } + throw err; + } +} + +/* -------------------------------- settings ----------------------------- */ + +/** Gateway settings groups surfaced by the Settings view (Phase 3b). */ +export type SettingsGroup = + | "security" + | "compatibility" + | "performance" + | "caching" + | "mcp"; + +/** Where a settings value came from: a built-in default, an env var, or an + * operator override written through this UI. */ +export type SettingSource = "default" | "env" | "override"; + +export interface SettingsSection { + /** Field -> current value (shape is per-group; typed loosely on purpose). */ + values: Record; + /** Field -> provenance, drives the "default / env / override" pill. */ + sources: Record; +} + +export type SettingsMap = Partial>; + +export interface SettingsView { + settings: SettingsMap; + /** "group.field" -> whether the gateway currently enforces the value. */ + enforcement: Record; +} + +/** + * Partial write payload accepted by PUT /api/settings. The gateway schema reads + * groups FLAT off the root (e.g. `{ caching: {...} }`), not wrapped in + * `settings`/`values` (a wrapped body is silently dropped by zod). + */ +export type SettingsUpdate = Partial< + Record> +>; + +function normalizeSettings( + body: Partial | undefined, +): SettingsView { + const settings = (body?.settings && typeof body.settings === "object") + ? body.settings as SettingsMap + : {}; + const enforcement = + (body?.enforcement && typeof body.enforcement === "object") + ? body.enforcement as Record + : {}; + return { settings, enforcement }; +} + +/** Read the full settings tree. */ +export async function getSettings(): Promise { + const body = await apiFetch>("/api/settings"); + return normalizeSettings(body); +} + +/** Write a partial settings update; returns the full re-read tree. */ +export async function putSettings( + update: SettingsUpdate, +): Promise { + const body = await apiFetch>("/api/settings", { + method: "PUT", + body: JSON.stringify(update), + }); + return normalizeSettings(body); +} + +/* ---------------------------- MCP code mode VFS ------------------------ */ + +/** Binding granularity for the generated Code Mode virtual file system. */ +export type CodeModeBinding = "server" | "tool"; + +export interface CodeModeVfsFile { + path: string; + server: string; + tools: string[]; + sizeBytes: number; + sha256: string; + source: string; +} + +export interface CodeModeVfsView { + bindingLevel: string; + files: CodeModeVfsFile[]; + generatedAt: string; +} + +/** + * Generated Code Mode VFS listing for the MCP tooling surface (Phase 3b). The + * binding query selects server- vs tool-level bundling. Bodies are normalized + * so downstream tree/preview components stay total. + */ +export async function getCodeModeVfs( + binding: CodeModeBinding, +): Promise { + const body = await apiFetch>( + `/api/mcp/codemode/vfs?binding=${binding}`, + ); + const files = Array.isArray(body?.files) ? body.files : []; + return { + bindingLevel: typeof body?.bindingLevel === "string" + ? body.bindingLevel + : binding, + files: files.map((file) => ({ + path: String(file?.path ?? ""), + server: String(file?.server ?? ""), + tools: Array.isArray(file?.tools) ? file.tools : [], + sizeBytes: typeof file?.sizeBytes === "number" ? file.sizeBytes : 0, + sha256: String(file?.sha256 ?? ""), + source: String(file?.source ?? ""), + })), + generatedAt: typeof body?.generatedAt === "string" + ? body.generatedAt + : new Date().toISOString(), + }; +} diff --git a/klanker-gate/apps/control-ui/src/components/catalog/ProviderModelsDialog.tsx b/klanker-gate/apps/control-ui/src/components/catalog/ProviderModelsDialog.tsx new file mode 100755 index 0000000..d75584f --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/catalog/ProviderModelsDialog.tsx @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import { + type CatalogProviderRow, + getProviderAvailableModels, + updateProvider, +} from "../../api"; +import { Dialog } from "../ui/dialog"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Banner } from "../ui/banner"; +import { ToggleGridItem } from "../ui/toggle-grid-item"; +import { ProviderIcon } from "../ui/provider-icon"; +import { useToast } from "../ui/toast"; + +export interface ProviderModelsDialogProps { + /** The clicked catalog row; null closes the dialog. */ + provider: CatalogProviderRow | null; + onClose: () => void; + /** Fired after a successful save so the catalog can reload. */ + onSaved: () => void; +} + +/** + * Per-provider model enablement grid. Opens from a Model Catalog row, fetches + * the provider's full live model list, and shows every model (the live list + * unioned with the currently-enabled ones) as an on/off tile. Saving writes the + * enabled subset back to the account's `models` - the set the gateway routes + * on. Providers without live listing fall back to their stored models. + */ +export function ProviderModelsDialog( + { provider, onClose, onSaved }: ProviderModelsDialogProps, +) { + const toast = useToast(); + const [available, setAvailable] = useState([]); + const [enabled, setEnabled] = useState>(new Set()); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [noLiveListing, setNoLiveListing] = useState(false); + + const open = provider !== null; + const providerId = provider?.id ?? null; + + useEffect(() => { + if (!provider) { + return; + } + let alive = true; + setQuery(""); + setError(null); + setNoLiveListing(false); + setEnabled(new Set(provider.models)); + setAvailable(provider.models); + setLoading(true); + getProviderAvailableModels(provider.id) + .then((res) => { + if (alive) setAvailable(res.models); + }) + .catch((err) => { + if (!alive) return; + // A 400 means the provider type cannot list models live; the stored + // enabled set is still editable, so degrade instead of failing. + setNoLiveListing(true); + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [providerId]); + + // Union of the live list and the enabled set, so a model that is enabled but + // no longer advertised still shows (and can be turned off). + const allModels = useMemo(() => { + const set = new Set(available); + for (const m of enabled) set.add(m); + return [...set].sort((a, b) => a.localeCompare(b)); + }, [available, enabled]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return q ? allModels.filter((m) => m.toLowerCase().includes(q)) : allModels; + }, [allModels, query]); + + function toggle(model: string, on: boolean) { + setEnabled((prev) => { + const next = new Set(prev); + if (on) next.add(model); + else next.delete(model); + return next; + }); + } + + function setAll(on: boolean) { + setEnabled((prev) => { + const next = new Set(prev); + for (const m of filtered) { + if (on) next.add(m); + else next.delete(m); + } + return next; + }); + } + + async function save() { + if (!provider) return; + setSaving(true); + try { + const models = [...enabled].sort((a, b) => a.localeCompare(b)); + await updateProvider(provider.id, { models }); + toast.success(`Models updated for "${provider.id}"`); + onSaved(); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + toast.error(message); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+
+ {provider && ( + + )} + + {enabled.size} of {allModels.length} enabled + +
+ + +
+
+ +
+
+ + {noLiveListing && ( + + This provider type does not support live model listing. Editing the + models it already advertises. + + )} + {error && !noLiveListing && {error}} + +
+ {loading + ? ( +

+ Loading models... +

+ ) + : filtered.length === 0 + ? ( +

+ {allModels.length === 0 + ? "No models available." + : `No models match "${query}".`} +

+ ) + : ( +
+ {filtered.map((model) => ( + toggle(model, on)} + /> + ))} +
+ )} +
+
+
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/dashboard/ChartCard.tsx b/klanker-gate/apps/control-ui/src/components/dashboard/ChartCard.tsx new file mode 100755 index 0000000..53625f9 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/dashboard/ChartCard.tsx @@ -0,0 +1,166 @@ +import { type ReactNode, useState } from "react"; +import { BarChart3, LineChart as LineChartIcon } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { + Chart, + ChartLegend, + type ChartSeries, + type LegendItem, +} from "../ui/chart"; +import { cn } from "../../lib/utils"; + +export interface ChartCardProps { + title: string; + /** Accessible name for the SVG chart. */ + ariaLabel: string; + series?: ChartSeries[]; + legend?: LegendItem[]; + defaultType?: "line" | "bar"; + /** Right-aligned header controls (e.g. a model / provider Combobox). */ + filter?: ReactNode; + /** Evenly spaced x-axis tick labels rendered under a time-series chart. */ + xTicks?: string[]; + /** Micro unit hint (e.g. "USD", "tokens", "ms"). */ + unit?: string; + loading?: boolean; + /** Force the empty state (feature off / dimension not recorded / filtered). */ + empty?: boolean; + /** One-line muted note under the empty message explaining the gap. */ + emptyNote?: string; + /** Hide the bar/line toggle (untracked cards have nothing to toggle). */ + hideToggle?: boolean; + className?: string; +} + +/** + * Dashboard analytics card: title, optional filter + bar/line toggle, a legend + * row, the dependency-free SVG Chart, and a graceful "No data available" state. + * A card with no positive value collapses to the empty state automatically, so + * an all-zero window never renders a misleading flat line. + */ +export function ChartCard( + { + title, + ariaLabel, + series = [], + legend = [], + defaultType = "line", + filter, + xTicks, + unit, + loading, + empty, + emptyNote, + hideToggle, + className, + }: ChartCardProps, +) { + const [type, setType] = useState<"line" | "bar">(defaultType); + + const hasData = series.some((s) => s.values.some((v) => v > 0)); + const showEmpty = Boolean(empty) || (!loading && !hasData); + const showToggle = !hideToggle && !showEmpty; + + return ( + + + {title} +
+ {filter} + {showToggle && ( +
+ setType("bar")} + > + + + setType("line")} + > + + +
+ )} +
+
+ + {showEmpty + ? ( +
+

+ No data available +

+ {emptyNote && ( +

+ {emptyNote} +

+ )} +
+ ) + : loading + ?
+ : ( +
+ {(legend.length > 0 || unit) && ( +
+ + {unit && ( + + {unit} + + )} +
+ )} + + {xTicks && xTicks.length > 0 && ( +
+ {xTicks.map((tick, i) => ( + + {tick} + + ))} +
+ )} +
+ )} + + + ); +} + +function ToggleButton( + { label, active, onClick, children }: { + label: string; + active: boolean; + onClick: () => void; + children: ReactNode; + }, +) { + return ( + + ); +} diff --git a/klanker-gate/apps/control-ui/src/components/dashboard/adapters.ts b/klanker-gate/apps/control-ui/src/components/dashboard/adapters.ts new file mode 100755 index 0000000..3a0b933 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/dashboard/adapters.ts @@ -0,0 +1,260 @@ +import type { + AnalyticsBucket, + AnalyticsModelRow, + AnalyticsProviderRow, + AnalyticsRollup, + LogEntry, +} from "../../api"; +import type { ChartSeries, LegendItem } from "../ui/chart"; +import type { ComboboxOption } from "../ui/combobox"; +import type { CsvColumn } from "../../lib/csv"; +import { percentile } from "../../lib/analytics"; +import { getEurRate } from "../../lib/currency"; + +const MICRO = 1_000_000; + +/* ----------------------------- overview trends ------------------------- */ + +/** Request Volume: success (requests - errors) vs error count per bucket. */ +export function requestVolumeSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Success", + color: "2", + values: rollup.series.map((b) => Math.max(0, b.requests - b.errors)), + }, + { name: "Error", color: "4", values: rollup.series.map((b) => b.errors) }, + ]; +} + +/** + * Token Usage: input (prompt) vs output (completion) tokens per bucket. The + * gateway does not record cached-token counts per bucket, so the Cached measure + * stays a flat zero series: present for legend parity, never fabricated. + */ +export function tokenUsageSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Input", + color: "1", + values: rollup.series.map((b) => b.promptTokens), + }, + { + name: "Output", + color: "2", + values: rollup.series.map((b) => b.completionTokens), + }, + { name: "Cached", color: "5", values: rollup.series.map(() => 0) }, + ]; +} + +/** Cost trend: micro-USD per bucket converted to euros. */ +export function costTrendSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Cost", + color: "3", + values: rollup.series.map((b) => (b.costMicroUsd / MICRO) * getEurRate()), + }, + ]; +} + +/** Model Usage trend: total tokens per bucket across every model. */ +export function tokenTrendSeries(rollup: AnalyticsRollup): ChartSeries[] { + return [ + { + name: "Total tokens", + color: "1", + values: rollup.series.map((b) => b.totalTokens), + }, + ]; +} + +/* ------------------------------- latency ------------------------------- */ + +/** Split entries into ordered time buckets (span when every ts parses). */ +function bucketByTime(entries: LogEntry[], bucketCount: number): LogEntry[][] { + const buckets: LogEntry[][] = Array.from({ length: bucketCount }, () => []); + if (entries.length === 0 || bucketCount <= 0) { + return buckets; + } + const parsed = entries.map((e) => Date.parse(e.ts)); + const valid = parsed.filter((t) => !Number.isNaN(t)); + const min = valid.length > 0 ? Math.min(...valid) : 0; + const max = valid.length > 0 ? Math.max(...valid) : 0; + const useTime = valid.length === entries.length && max > min; + for (let i = 0; i < entries.length; i++) { + const index = useTime + ? Math.min( + bucketCount - 1, + Math.floor(((parsed[i] - min) / (max - min)) * bucketCount), + ) + : Math.min( + bucketCount - 1, + Math.floor((i / entries.length) * bucketCount), + ); + buckets[index].push(entries[i]); + } + return buckets; +} + +/** + * Latency trend: avg / p90 / p95 / p99 of durationMs per time bucket. Derived + * from stored request logs (the rollup carries no latency), reusing the shared + * nearest-rank percentile helper. + */ +export function latencyTrendSeries( + entries: LogEntry[], + bucketCount = 12, +): ChartSeries[] { + const groups = bucketByTime(entries, bucketCount); + const avg: number[] = []; + const p90: number[] = []; + const p95: number[] = []; + const p99: number[] = []; + for (const group of groups) { + const durations = group + .map((e) => e.durationMs) + .filter((d): d is number => typeof d === "number" && d >= 0) + .sort((a, b) => a - b); + const mean = durations.length === 0 + ? 0 + : durations.reduce((sum, v) => sum + v, 0) / durations.length; + avg.push(mean); + p90.push(percentile(durations, 90)); + p95.push(percentile(durations, 95)); + p99.push(percentile(durations, 99)); + } + return [ + { name: "Avg", color: "1", values: avg }, + { name: "P90", color: "2", values: p90 }, + { name: "P95", color: "3", values: p95 }, + { name: "P99", color: "4", values: p99 }, + ]; +} + +/* --------------------------- provider breakdown ------------------------ */ + +/** Providers sorted by total tokens (descending), optionally to one row. */ +function filterProviders( + rows: AnalyticsProviderRow[], + filter: string, +): AnalyticsProviderRow[] { + const sorted = [...rows].sort((a, b) => b.totalTokens - a.totalTokens); + return filter === "all" + ? sorted + : sorted.filter((row) => row.provider === filter); +} + +/** Provider Cost: one bar per provider (euros), chart-3. */ +export function providerCostSeries( + rollup: AnalyticsRollup, + filter: string, +): ChartSeries[] { + return [ + { + name: "Cost", + color: "3", + values: filterProviders(rollup.byProvider, filter).map((r) => + (r.costMicroUsd / MICRO) * getEurRate() + ), + }, + ]; +} + +/** Provider Token Usage: one bar per provider (total tokens), chart-1. */ +export function providerTokenSeries( + rollup: AnalyticsRollup, + filter: string, +): ChartSeries[] { + return [ + { + name: "Total tokens", + color: "1", + values: filterProviders(rollup.byProvider, filter).map((r) => + r.totalTokens + ), + }, + ]; +} + +/** Provider names behind the single provider bar series, in bar order. */ +export function providerLegend( + rollup: AnalyticsRollup, + filter: string, + color: LegendItem["color"], +): LegendItem[] { + return filterProviders(rollup.byProvider, filter).map((row) => ({ + name: row.provider, + color, + })); +} + +/* ----------------------------- filter options -------------------------- */ + +/** Distinct model options for the per-card model filter ("All Models" first). */ +export function modelOptions(rollup: AnalyticsRollup): ComboboxOption[] { + const seen = new Set(); + const options: ComboboxOption[] = [{ value: "all", label: "All Models" }]; + for (const row of rollup.byModel) { + if (!seen.has(row.model)) { + seen.add(row.model); + options.push({ value: row.model, label: row.model }); + } + } + return options; +} + +/** Distinct provider options ("All Providers" first). */ +export function providerOptions(rollup: AnalyticsRollup): ComboboxOption[] { + const seen = new Set(); + const options: ComboboxOption[] = [{ value: "all", label: "All Providers" }]; + for (const row of rollup.byProvider) { + if (!seen.has(row.provider)) { + seen.add(row.provider); + options.push({ value: row.provider, label: row.provider }); + } + } + return options; +} + +/* -------------------------------- csv ---------------------------------- */ + +/** Overview export: the analytics time-series buckets. */ +export const overviewCsvColumns: CsvColumn[] = [ + { header: "bucket", value: (b) => b.label }, + { header: "requests", value: (b) => b.requests }, + { header: "errors", value: (b) => b.errors }, + { header: "prompt_tokens", value: (b) => b.promptTokens }, + { header: "completion_tokens", value: (b) => b.completionTokens }, + { header: "total_tokens", value: (b) => b.totalTokens }, + { + header: "cost_eur", + value: (b) => ((b.costMicroUsd / MICRO) * getEurRate()).toFixed(6), + }, +]; + +/** Provider Usage export: the by-provider rollup rows. */ +export const providerCsvColumns: CsvColumn[] = [ + { header: "provider", value: (r) => r.provider }, + { header: "requests", value: (r) => r.requests }, + { header: "total_tokens", value: (r) => r.totalTokens }, + { + header: "cost_eur", + value: (r) => ((r.costMicroUsd / MICRO) * getEurRate()).toFixed(6), + }, +]; + +/** Model Rankings export: the by-model rollup rows. */ +export const modelCsvColumns: CsvColumn[] = [ + { header: "model", value: (r) => r.model }, + { header: "provider", value: (r) => r.provider }, + { header: "requests", value: (r) => r.requests }, + { header: "prompt_tokens", value: (r) => r.promptTokens }, + { header: "completion_tokens", value: (r) => r.completionTokens }, + { header: "total_tokens", value: (r) => r.totalTokens }, + { + header: "cost_eur", + value: (r) => ((r.costMicroUsd / MICRO) * getEurRate()).toFixed(6), + }, +]; diff --git a/klanker-gate/apps/control-ui/src/components/logs/ColumnPicker.tsx b/klanker-gate/apps/control-ui/src/components/logs/ColumnPicker.tsx new file mode 100755 index 0000000..bd58a85 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/ColumnPicker.tsx @@ -0,0 +1,99 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { Columns3 } from "lucide-react"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import type { ColumnMeta } from "./logs-model"; + +export interface ColumnPickerProps { + columns: ColumnMeta[]; + visible: Set; + onToggle: (key: string, checked: boolean) => void; +} + +/** + * Show/hide column control (spec: Logs top bar). A disclosure button opens a + * checkbox panel; Escape and click-outside close it. The last visible column + * cannot be hidden so the table never collapses to nothing. + */ +export function ColumnPicker( + { columns, visible, onToggle }: ColumnPickerProps, +) { + const panelId = useId(); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + function onPointerDown(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + } + } + document.addEventListener("mousedown", onPointerDown, true); + document.addEventListener("keydown", onKeyDown, true); + return () => { + document.removeEventListener("mousedown", onPointerDown, true); + document.removeEventListener("keydown", onKeyDown, true); + }; + }, [open]); + + const shownCount = columns.reduce( + (n, column) => (visible.has(column.key) ? n + 1 : n), + 0, + ); + + return ( +
+ + {open && ( +
+

+ Columns +

+
    + {columns.map((column) => { + const checked = visible.has(column.key); + const lockLast = checked && shownCount === 1; + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/LogsAnalytics.tsx b/klanker-gate/apps/control-ui/src/components/logs/LogsAnalytics.tsx new file mode 100755 index 0000000..8b2b742 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/LogsAnalytics.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; +import type { LogEntry } from "../../api"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { StatTile } from "../ui/stat-tile"; +import { Chart, ChartLegend, type ChartSeries } from "../ui/chart"; +import { buildSeries } from "../../lib/analytics"; +import { classifyOutcome, entryTokens, formatCostUsd } from "./logs-model"; +import { cn } from "../../lib/utils"; + +/** + * KPI row over the currently visible logs. Every tile derives from recorded + * fields: Total Requests / Success Rate / Avg Latency from the base request + * fields, Total Tokens / Total Cost from the telemetry enrichment carried on + * inference entries. A window with no inference traffic still shows an honest + * "N/A" for the last two rather than a fabricated zero, because "no request + * recorded usage" and "usage was zero" are different facts. + */ +export function LogsKpiRow( + { entries, loading }: { entries: LogEntry[]; loading: boolean }, +) { + let success = 0; + let error = 0; + let cancelled = 0; + let latencyCount = 0; + let latencySum = 0; + let tokenTotal = 0; + let tokenEntries = 0; + let costMicroUsd = 0; + let costEntries = 0; + for (const entry of entries) { + const outcome = classifyOutcome(entry); + if (outcome === "success") { + success += 1; + } else if (outcome === "error") { + error += 1; + } else if (outcome === "cancelled") { + cancelled += 1; + } + if (typeof entry.durationMs === "number") { + latencyCount += 1; + latencySum += entry.durationMs; + } + const tokens = entryTokens(entry); + if (tokens !== null) { + tokenTotal += tokens; + tokenEntries += 1; + } + if (typeof entry.costMicroUsd === "number") { + costMicroUsd += entry.costMicroUsd; + costEntries += 1; + } + } + + const terminal = success + error + cancelled; + const successRate = terminal > 0 ? (success / terminal) * 100 : null; + const avgLatency = latencyCount > 0 ? latencySum / latencyCount : null; + + return ( +
+ + + + + +
+ ); +} + +/** + * Collapsible Request Volume card: success vs error counts bucketed over the + * visible logs' time range. Both series are derived from recorded status/level; + * an empty window shows the standard "No data available" state. + */ +export function RequestVolumeCard({ entries }: { entries: LogEntry[] }) { + const [open, setOpen] = useState(true); + + const series = buildSeries(entries, 12); + const volume: ChartSeries[] = [ + { name: "Success", color: "2", values: series.map((b) => b.success) }, + { name: "Error", color: "4", values: series.map((b) => b.errors) }, + ]; + const hasData = volume.some((s) => s.values.some((v) => v > 0)); + + return ( + + + + + + {open && ( + + {hasData + ? ( + + ) + : ( +
+

+ No data available +

+
+ )} +
+ )} +
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/LogsFacetRail.tsx b/klanker-gate/apps/control-ui/src/components/logs/LogsFacetRail.tsx new file mode 100755 index 0000000..edaecd1 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/LogsFacetRail.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import { PanelLeftClose, Search } from "lucide-react"; +import { type FacetGroup, FacetRail } from "../ui/facet-rail"; +import { Collapsible } from "../ui/collapsible"; +import { Checkbox } from "../ui/checkbox"; +import type { LogEntry } from "../../api"; +import { + type FacetSelection, + facetValues, + HONEST_FACETS, + OUTCOME_LABEL, + OUTCOME_ORDER, + type OutcomeCounts, + VALUE_FACETS, + type ValueFacet, +} from "./logs-model"; + +export interface LogsFacetRailProps { + /** Selected outcome classes (facet-rail controlled model). */ + outcome: string[]; + onOutcomeChange: (values: string[]) => void; + /** Per-class counts over the currently loaded (time+search filtered) logs. */ + counts: OutcomeCounts; + /** Entries the live value facets enumerate their options from. */ + entries: LogEntry[]; + /** Selected values per live facet (model / provider / type). */ + selection: FacetSelection; + onSelectionChange: (id: ValueFacet["id"], values: string[]) => void; + onHide: () => void; +} + +/** + * Left filter rail for the Logs view. Outcome, Models, Provider, and Type are + * live facets backed by recorded fields (Type is projected from the recorded + * path). The groups below them are the faithful professional shell shown + * honest-empty, because the gateway records none of those dimensions on a log + * entry - except Cost, which is recorded per entry but has no range filter. + */ +export function LogsFacetRail( + { + outcome, + onOutcomeChange, + counts, + entries, + selection, + onSelectionChange, + onHide, + }: LogsFacetRailProps, +) { + const outcomeGroup: FacetGroup = { + id: "outcome", + label: "Outcome", + defaultOpen: true, + options: OUTCOME_ORDER.map((value) => ({ + value, + label: OUTCOME_LABEL[value], + count: counts[value], + })), + }; + + return ( +
+
+

+ Filters +

+ +
+ + { + /* Wrapped so the group keeps a bottom divider: FacetRail strips the + border on its last group, which is the only group we pass it. */ + } +
+ onOutcomeChange(values)} + /> +
+ + {VALUE_FACETS.map((facet) => ( + onSelectionChange(facet.id, values)} + /> + ))} + + {HONEST_FACETS.map((facet) => ( + +
+ +
+
+ ))} +
+ ); +} + +/** + * One live facet: distinct recorded values over the loaded entries, each with + * an occurrence count. Renders the same empty affordance as the honest groups + * when the current window happens to contain no entry carrying the dimension. + */ +function ValueFacetGroup( + { facet, entries, selected, onChange }: { + facet: ValueFacet; + entries: LogEntry[]; + selected: string[]; + onChange: (values: string[]) => void; + }, +) { + const [query, setQuery] = useState(""); + const options = facetValues(entries, facet); + const needle = query.trim().toLowerCase(); + const shown = needle + ? options.filter((option) => option.value.toLowerCase().includes(needle)) + : options; + + const toggle = (value: string, checked: boolean) => { + onChange( + checked + ? [...selected, value] + : selected.filter((entry) => entry !== value), + ); + }; + + return ( + +
+ {facet.searchable && ( +
+
+ )} + {options.length === 0 + ? + : ( +
    + {shown.map((option) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} + +/** A recorded dimension that simply has no values in the current window. */ +function NoneInWindow() { + return ( +

+ None in this range +

+ ); +} + +/** + * Affordance for a group with no filter. `recorded` distinguishes "the gateway + * stores nothing for this" from "it is stored per entry but has no control". + */ +function NotRecorded({ recorded }: { recorded?: boolean }) { + return recorded + ? ( +

+ No filter yet +

+ ) + : ( +

+ Not recorded yet +

+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/LogsTable.tsx b/klanker-gate/apps/control-ui/src/components/logs/LogsTable.tsx new file mode 100755 index 0000000..b0a3b4e --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/LogsTable.tsx @@ -0,0 +1,268 @@ +import { RefreshCw } from "lucide-react"; +import type { LogEntry } from "../../api"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { type Column, DataTable } from "../ui/data-table"; +import { + ALL_COLUMNS, + classifyOutcome, + type ColumnKey, + entryTokens, + formatCostUsd, + formatLatency, + formatTimestamp, + requestType, +} from "./logs-model"; +import { cn } from "../../lib/utils"; + +export type Connection = "connecting" | "streaming" | "disconnected"; + +type Row = LogEntry & { _id: string }; + +const reduceMotion = () => + globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; + +/** + * Honest placeholder for a row that carries no value in this column. Inference + * requests record provider/model/tokens; a health probe or an admin API call + * has none, so those rows keep saying N/A rather than borrowing a value. + */ +function NaCell({ mono }: { mono?: boolean }) { + return ( + + N/A + + ); +} + +/** Tokens cell: total, with the prompt/completion split and cost in the title. */ +function TokensCell({ entry }: { entry: LogEntry }) { + const total = entryTokens(entry); + if (total === null) { + return ; + } + const parts = [ + `${entry.promptTokens ?? 0} prompt`, + `${entry.completionTokens ?? 0} completion`, + ]; + if (typeof entry.costMicroUsd === "number") { + parts.push(formatCostUsd(entry.costMicroUsd)); + } + return ( + + {total.toLocaleString()} + + ); +} + +function MessageCell({ entry }: { entry: LogEntry }) { + const head = [entry.method, entry.path].filter(Boolean).join(" "); + return ( +
+ {head && ( +
+ {head} +
+ )} +
+ {entry.message} +
+
+ ); +} + +function StatusCell({ entry }: { entry: LogEntry }) { + const outcome = classifyOutcome(entry); + if (outcome === "success") { + return success; + } + if (outcome === "error") { + return ( + + {typeof entry.status === "number" ? entry.status : "error"} + + ); + } + if (outcome === "cancelled") { + return cancelled; + } + return processing; +} + +const COLUMN_DEFS: Record> = { + time: { + key: "time", + header: "Time", + sortValue: (row) => Date.parse(row.ts) || 0, + cell: (row) => ( + + {formatTimestamp(row.ts)} + + ), + }, + type: { + key: "type", + header: "Type", + sortValue: (row) => requestType(row) ?? "", + cell: (row) => { + const type = requestType(row); + return type + ? {type} + : ; + }, + }, + provider: { + key: "provider", + header: "Provider", + sortValue: (row) => row.provider ?? "", + cell: (row) => + row.provider + ? ( + + {row.provider} + + ) + : , + }, + model: { + key: "model", + header: "Model", + sortValue: (row) => row.model ?? "", + cell: (row) => + row.model + ? ( + + {row.model} + + ) + : , + }, + message: { + key: "message", + header: "Message", + cell: (row) => , + }, + latency: { + key: "latency", + header: "Latency", + sortValue: (row) => row.durationMs ?? -1, + cell: (row) => { + const latency = formatLatency(row.durationMs); + return latency + ? {latency} + : ; + }, + }, + tokens: { + key: "tokens", + header: "Tokens", + sortValue: (row) => entryTokens(row) ?? -1, + cell: (row) => , + }, + status: { + key: "status", + header: "Status", + cell: (row) => , + }, +}; + +export interface LogsTableProps { + entries: LogEntry[]; + visibleColumns: Set; + live: boolean; + connection: Connection; + loading: boolean; + onReconnect: () => void; +} + +export function LogsTable( + { entries, visibleColumns, live, connection, loading, onReconnect }: + LogsTableProps, +) { + const rows: Row[] = entries.map((entry, index) => ({ + ...entry, + _id: `${index}-${entry.ts}-${entry.requestId ?? ""}`, + })); + + const columns = ALL_COLUMNS + .filter((column) => visibleColumns.has(column.key)) + .map((column) => COLUMN_DEFS[column.key]); + + return ( +
+ {live && } + + caption="Request logs" + rows={rows} + columns={columns} + getRowId={(row) => row._id} + pageSize={25} + loading={loading} + initialSort={{ key: "time", dir: "desc" }} + minWidth="60rem" + empty={ +
+

+ No results found +

+

+ Try adjusting your filters and/or time range. +

+
+ } + /> +
+ ); +} + +function LiveBar( + { connection, onReconnect }: { + connection: Connection; + onReconnect: () => void; + }, +) { + const label = connection === "streaming" + ? "Listening for logs" + : connection === "connecting" + ? "Connecting to log stream" + : "Disconnected from log stream"; + + return ( +
+
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/logs/logs-model.ts b/klanker-gate/apps/control-ui/src/components/logs/logs-model.ts new file mode 100755 index 0000000..c04c3b2 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/logs/logs-model.ts @@ -0,0 +1,257 @@ +import type { LogEntry } from "../../api"; + +export type Outcome = "success" | "error" | "processing" | "cancelled"; + +/** + * Classify a log line into a request outcome using only recorded fields. + * - cancelled: HTTP 499 (client closed request convention) + * - error: level "error", or a status >= 400 + * - success: any other numeric status (< 400) + * - processing: no numeric status yet (in-flight or non-request log line) + * + * Every branch is a real predicate over recorded data; "cancelled" simply + * matches rarely (the gateway seldom emits 499), which is honest, not faked. + */ +export function classifyOutcome(entry: LogEntry): Outcome { + if (entry.status === 499) { + return "cancelled"; + } + if ( + entry.level === "error" || + (typeof entry.status === "number" && entry.status >= 400) + ) { + return "error"; + } + if (typeof entry.status === "number") { + return "success"; + } + return "processing"; +} + +export const OUTCOME_ORDER: Outcome[] = [ + "success", + "error", + "processing", + "cancelled", +]; + +export const OUTCOME_LABEL: Record = { + success: "Success", + error: "Error", + processing: "Processing", + cancelled: "Cancelled", +}; + +export type OutcomeCounts = Record; + +export function emptyCounts(): OutcomeCounts { + return { success: 0, error: 0, processing: 0, cancelled: 0 }; +} + +/** Time-range value -> window length in ms (matches DEFAULT_TIME_RANGES). */ +export const WINDOW_MS: Record = { + "1h": 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, +}; + +/** Human timestamp; falls back to the raw string when unparseable. */ +export function formatTimestamp(ts: string): string { + const date = new Date(ts); + return Number.isNaN(date.getTime()) ? ts : date.toLocaleString(); +} + +/** "{n}ms" for a recorded duration, or null when latency is not recorded. */ +export function formatLatency(durationMs: number | undefined): string | null { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { + return null; + } + return `${durationMs}ms`; +} + +/* ------------------------- derived request type ------------------------- */ + +const TYPE_BY_PATH: Record = { + "/v1/chat/completions": "chat", + "/v1/completions": "text", + "/v1/responses": "responses", + "/v1/embeddings": "embedding", + "/v1/messages": "messages", + "/v1/images/generations": "image", + "/v1/audio/speech": "speech", + "/v1/audio/transcriptions": "transcription", +}; + +/** + * Request type derived from the recorded path, or null when the path is not an + * inference surface (admin API, static asset, health probe). + */ +export function requestType(entry: LogEntry): string | null { + return entry.path ? TYPE_BY_PATH[entry.path] ?? null : null; +} + +/* ----------------------------- tokens + cost ---------------------------- */ + +/** Total tokens for an entry, or null when no usage was recorded. */ +export function entryTokens(entry: LogEntry): number | null { + if (typeof entry.totalTokens === "number") { + return entry.totalTokens; + } + const prompt = entry.promptTokens; + const completion = entry.completionTokens; + if (typeof prompt !== "number" && typeof completion !== "number") { + return null; + } + return (prompt ?? 0) + (completion ?? 0); +} + +/** + * Formats integer micro-USD as a USD string. Sub-cent costs keep enough + * precision to stay non-zero, which matters because a single small completion + * routinely costs well under a cent. + */ +export function formatCostUsd(costMicroUsd: number): string { + const usd = costMicroUsd / 1_000_000; + if (usd === 0) { + return "$0.00"; + } + if (usd < 0.01) { + return `$${usd.toFixed(6)}`; + } + return `$${usd.toFixed(usd < 1 ? 4 : 2)}`; +} + +/* ----------------------------- table columns ---------------------------- */ + +export type ColumnKey = + | "time" + | "type" + | "provider" + | "model" + | "message" + | "latency" + | "tokens" + | "status"; + +export interface ColumnMeta { + key: ColumnKey; + label: string; + /** true when the column is backed by a recorded field. */ + real: boolean; +} + +export const ALL_COLUMNS: ColumnMeta[] = [ + { key: "time", label: "Time", real: true }, + { key: "type", label: "Type", real: true }, + { key: "provider", label: "Provider", real: true }, + { key: "model", label: "Model", real: true }, + { key: "message", label: "Message", real: true }, + { key: "latency", label: "Latency", real: true }, + { key: "tokens", label: "Tokens", real: true }, + { key: "status", label: "Status", real: true }, +]; + +export const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = ALL_COLUMNS.map((c) => + c.key +); + +/* -------------------------- honest-empty facets ------------------------- */ + +export interface HonestFacet { + id: string; + label: string; + /** + * false when the gateway records nothing for this dimension; true when it is + * recorded per entry but has no filter control yet. The two cases get + * different affordance text so neither overstates the other. + */ + recorded?: boolean; +} + +export const HONEST_FACETS: HonestFacet[] = [ + { id: "selectedKeys", label: "Selected Keys" }, + { id: "virtualKeys", label: "Virtual Keys" }, + { id: "aliases", label: "Aliases" }, + { id: "routingEngines", label: "Routing Engines" }, + { id: "routingRules", label: "Routing Rules" }, + { id: "user", label: "User" }, + { id: "session", label: "Session" }, + // Recorded per entry (costMicroUsd) but a range filter is not built. + { id: "cost", label: "Cost", recorded: true }, + { id: "stopReason", label: "Stop Reason" }, + { id: "metadata", label: "Metadata" }, +]; + +/* --------------------------- live value facets -------------------------- */ + +/** A recorded dimension the rail can filter on by exact value. */ +export interface ValueFacet { + id: "model" | "provider" | "type"; + label: string; + /** Recorded (or derived) value for an entry, or null when it has none. */ + valueOf: (entry: LogEntry) => string | null; + /** Rendered with a search box above the option list. */ + searchable?: boolean; +} + +export const VALUE_FACETS: ValueFacet[] = [ + { + id: "model", + label: "Models", + valueOf: (entry) => entry.model ?? null, + searchable: true, + }, + { + id: "provider", + label: "Provider", + valueOf: (entry) => entry.provider ?? null, + }, + { id: "type", label: "Type", valueOf: requestType }, +]; + +/** Distinct values of a facet across `entries`, with counts, sorted by value. */ +export function facetValues( + entries: LogEntry[], + facet: ValueFacet, +): Array<{ value: string; count: number }> { + const counts = new Map(); + for (const entry of entries) { + const value = facet.valueOf(entry); + if (value) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + } + return [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => a.value.localeCompare(b.value)); +} + +/** Selected values per live facet id; an empty array means "no constraint". */ +export type FacetSelection = Partial>; + +/** Applies every non-empty live-facet selection (AND across facets). */ +export function applyValueFacets( + entries: LogEntry[], + selection: FacetSelection, +): LogEntry[] { + const active = VALUE_FACETS.filter((facet) => + (selection[facet.id]?.length ?? 0) > 0 + ); + if (active.length === 0) { + return entries; + } + return entries.filter((entry) => + active.every((facet) => { + const value = facet.valueOf(entry); + return value !== null && selection[facet.id]!.includes(value); + }) + ); +} + +export function outcomeCounts(entries: LogEntry[]): OutcomeCounts { + const counts = emptyCounts(); + for (const entry of entries) { + counts[classifyOutcome(entry)] += 1; + } + return counts; +} diff --git a/klanker-gate/apps/control-ui/src/components/providers/AddCustomProviderForm.tsx b/klanker-gate/apps/control-ui/src/components/providers/AddCustomProviderForm.tsx new file mode 100755 index 0000000..c997de5 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/providers/AddCustomProviderForm.tsx @@ -0,0 +1,187 @@ +import { type FormEvent, useState } from "react"; +import { Plus } from "lucide-react"; +import type { ProviderAccountConfig } from "../../api"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Field } from "../ui/label"; +import { NativeSelect } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Banner } from "../ui/banner"; +import { ToggleGridItem } from "../ui/toggle-grid-item"; +import { CUSTOM_BASE_FORMATS, REQUEST_TYPES } from "./constants"; + +type ProviderType = ProviderAccountConfig["type"]; + +export interface AddCustomProviderFormProps { + busy: boolean; + onSubmit: (payload: ProviderAccountConfig) => void; + onCancel: () => void; +} + +function defaultRequestTypes(): Record { + return Object.fromEntries(REQUEST_TYPES.map((r) => [r.key, true])); +} + +/** + * Inline Add Custom Provider form (spec: Name, Base Format, Base URL, an "Is + * Keyless" switch, and a two-column Allowed Request Types grid). Rendered in the + * detail pane like the standard add form rather than a modal. The config + * contract has no per-endpoint path or request-type storage, so the grid is an + * advisory capability picker (labelled as such) - create posts only the fields + * the gateway persists: id, wire type, base URL, and an optional key. + */ +export function AddCustomProviderForm( + { busy, onSubmit, onCancel }: AddCustomProviderFormProps, +) { + const [name, setName] = useState(""); + const [format, setFormat] = useState("openai-compatible"); + const [baseUrl, setBaseUrl] = useState(""); + const [keyless, setKeyless] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [allowed, setAllowed] = useState>( + defaultRequestTypes, + ); + const [error, setError] = useState(null); + + function submit(event: FormEvent) { + event.preventDefault(); + const id = name.trim(); + if (id === "") { + setError("A name is required."); + return; + } + if (baseUrl.trim() === "") { + setError("A base URL is required for a custom provider."); + return; + } + setError(null); + const payload: ProviderAccountConfig = { + id, + type: format, + enabled: true, + models: [], + priority: 0, + baseUrl: baseUrl.trim(), + }; + if (!keyless && apiKey.trim() !== "") { + payload.apiKey = apiKey.trim(); + } + onSubmit(payload); + } + + return ( +
+
+

+ Add custom provider +

+

+ Point the gateway at any OpenAI- or Anthropic-compatible endpoint. + Keys are stored server-side and never shown again. +

+
+ +
+ + setName(e.target.value)} + /> + + + setFormat(e.target.value as ProviderType)} + > + {CUSTOM_BASE_FORMATS.map((f) => ( + + ))} + + + + setBaseUrl(e.target.value)} + /> + +
+ +
+ + +
+ + {!keyless && ( + + setApiKey(e.target.value)} + /> + + )} + +
+
+

+ Allowed Request Types +

+

+ Advisory capability picker. The gateway routes every request type + its wire format supports; per-endpoint path overrides are not + persisted. +

+
+
+ {REQUEST_TYPES.map((rt) => ( + + setAllowed((prev) => ({ ...prev, [rt.key]: checked }))} + /> + ))} +
+
+ + {error && {error}} + +
+ + +
+ + ); +} diff --git a/klanker-gate/apps/control-ui/src/components/providers/AddProviderDialog.tsx b/klanker-gate/apps/control-ui/src/components/providers/AddProviderDialog.tsx new file mode 100755 index 0000000..e9e3668 --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/providers/AddProviderDialog.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { Plus, Search } from "lucide-react"; +import { Dialog } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; +import { ProviderIcon } from "../ui/provider-icon"; +import { cn } from "../../lib/utils"; +import { PROVIDER_PRESETS, type ProviderPreset } from "./constants"; + +export interface AddProviderDialogProps { + open: boolean; + onClose: () => void; + /** Pick a vendor preset: prefills the add form with its type + base URL. */ + onPick: (preset: ProviderPreset) => void; + /** "Custom / other" escape hatch: open the blank / custom-provider flow. */ + onCustom: () => void; +} + +/** + * One-click provider gallery. Lists the vendor presets as filterable cards with + * their brand logo; picking one prefills the add form. A trailing "Custom" card + * routes to the bring-your-own flow for anything not in the catalog. + */ +export function AddProviderDialog( + { open, onClose, onPick, onCustom }: AddProviderDialogProps, +) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const matches = needle + ? PROVIDER_PRESETS.filter((p) => + p.displayName.toLowerCase().includes(needle) || + p.key.toLowerCase().includes(needle) || + p.type.toLowerCase().includes(needle) + ) + : PROVIDER_PRESETS; + + return ( + +
+
+
+ +
+ {matches.map((preset) => ( + + ))} + {matches.length === 0 && ( +

+ No providers match "{query}". +

+ )} +
+ +
+

+ Cannot find it? Add any OpenAI- or Anthropic-compatible endpoint. +

+ +
+
+
+ ); +} diff --git a/klanker-gate/apps/control-ui/src/components/providers/AddProviderForm.tsx b/klanker-gate/apps/control-ui/src/components/providers/AddProviderForm.tsx new file mode 100755 index 0000000..1e2af0d --- /dev/null +++ b/klanker-gate/apps/control-ui/src/components/providers/AddProviderForm.tsx @@ -0,0 +1,333 @@ +import { type FormEvent, useState } from "react"; +import { Plus } from "lucide-react"; +import type { ProviderAccountConfig } from "../../api"; +import { Field } from "../ui/label"; +import { Input, Textarea } from "../ui/input"; +import { NativeSelect } from "../ui/select"; +import { Button } from "../ui/button"; +import { Banner } from "../ui/banner"; +import { CLOUD_TYPES, PROVIDER_LABELS, PROVIDER_TYPES } from "./constants"; + +type ProviderType = ProviderAccountConfig["type"]; + +interface FormValues { + id: string; + type: ProviderType; + apiKey: string; + baseUrl: string; + endpoint: string; + apiVersion: string; + modelName: string; + deploymentName: string; + awsRegion: string; + awsAccessKeyId: string; + awsSecretAccessKey: string; + awsSessionToken: string; + projectId: string; + location: string; + serviceAccountJson: string; +} + +function empty(): FormValues { + return { + id: "", + type: "openai", + apiKey: "", + baseUrl: "", + endpoint: "", + apiVersion: "", + modelName: "", + deploymentName: "", + awsRegion: "", + awsAccessKeyId: "", + awsSecretAccessKey: "", + awsSessionToken: "", + projectId: "", + location: "", + serviceAccountJson: "", + }; +} + +function assemble(v: FormValues): ProviderAccountConfig { + const cloud = CLOUD_TYPES.has(v.type); + const out: ProviderAccountConfig = { + id: v.id.trim(), + type: v.type, + enabled: true, + models: [], + priority: 0, + }; + if (!cloud && v.apiKey.trim() !== "") { + out.apiKey = v.apiKey.trim(); + } + if (v.baseUrl.trim() !== "") { + out.baseUrl = v.baseUrl.trim(); + } + if (v.type === "azure") { + if (v.endpoint.trim()) out.endpoint = v.endpoint.trim(); + if (v.apiVersion.trim()) out.apiVersion = v.apiVersion.trim(); + // Azure routes on the deployment name (the URL segment the client calls as + // `azure/`); the model name is a catalog alias. Both feed the + // advertised model list so the account is routable once created. + out.models = [ + ...new Set([v.deploymentName.trim(), v.modelName.trim()]), + ].filter((m) => m !== ""); + } + if (v.type === "bedrock") { + if (v.awsRegion.trim()) out.awsRegion = v.awsRegion.trim(); + if (v.awsAccessKeyId.trim()) out.awsAccessKeyId = v.awsAccessKeyId.trim(); + if (v.awsSecretAccessKey) out.awsSecretAccessKey = v.awsSecretAccessKey; + if (v.awsSessionToken) out.awsSessionToken = v.awsSessionToken; + } + if (v.type === "vertex") { + if (v.projectId.trim()) out.projectId = v.projectId.trim(); + if (v.location.trim()) out.location = v.location.trim(); + if (v.serviceAccountJson) out.serviceAccountJson = v.serviceAccountJson; + } + return out; +} + +export interface AddProviderFormProps { + busy: boolean; + onSubmit: (payload: ProviderAccountConfig) => void; + /** Prefill from a gallery preset (id/type/baseUrl). Remount (via `key`) to reset. */ + initial?: Partial; +} + +/** + * Inline add-provider form shown in the detail pane when no provider is + * selected. Covers the common path (id, wire type, key, base URL) plus the + * cloud/Azure credential fields, and posts a ProviderAccountConfig on submit. + */ +export function AddProviderForm( + { busy, onSubmit, initial }: AddProviderFormProps, +) { + const [v, setV] = useState(() => ({ ...empty(), ...initial })); + const [error, setError] = useState(null); + + const set = (key: K, value: FormValues[K]) => + setV((prev) => ({ ...prev, [key]: value })); + + const cloud = CLOUD_TYPES.has(v.type); + + function submit(event: FormEvent) { + event.preventDefault(); + if (v.id.trim() === "") { + setError("Provider ID is required."); + return; + } + if (v.type === "azure") { + if (v.endpoint.trim() === "") { + setError("An endpoint is required for Azure OpenAI."); + return; + } + if (v.deploymentName.trim() === "") { + setError("A deployment name is required for Azure OpenAI."); + return; + } + } + if (v.type === "vertex" && v.serviceAccountJson.trim() !== "") { + try { + JSON.parse(v.serviceAccountJson); + } catch { + setError("Service account JSON must be valid JSON."); + return; + } + } + setError(null); + onSubmit(assemble(v)); + } + + return ( +
+
+

Add provider

+

+ Connect an account the gateway can route inference to. Keys are stored + server-side and never shown again. +

+
+ +
+ + set("id", e.target.value)} + /> + + + set("type", e.target.value as ProviderType)} + > + {PROVIDER_TYPES.map((t) => ( + + ))} + + + + {!cloud && ( + + set("apiKey", e.target.value)} + /> + + )} + {v.type !== "azure" && ( + + set("baseUrl", e.target.value)} + /> + + )} + + {v.type === "azure" && ( + <> + + set("endpoint", e.target.value)} + /> + + + set("apiVersion", e.target.value)} + /> + + + set("deploymentName", e.target.value)} + /> + + + set("modelName", e.target.value)} + /> + + + )} + + {v.type === "bedrock" && ( + <> + + set("awsRegion", e.target.value)} + /> + + + set("awsAccessKeyId", e.target.value)} + /> + + + set("awsSecretAccessKey", e.target.value)} + /> + + + set("awsSessionToken", e.target.value)} + /> + + + )} + + {v.type === "vertex" && ( + <> + + set("projectId", e.target.value)} + /> + + + set("location", e.target.value)} + /> + + + + Monospace textarea for PEM blocks; identity carried by fill, label, and focus ring. +
+ +

Checkbox and switch

+
+ + + +
+
+ + + +
+ +

Badges

+

Soft is the default shape for tables and lists; solid is reserved for the single most important state in a region.

+
+ success + warning + error + info + default + neutral +
+

Application status vocabulary (locked D-CONTRACT strings). ds-r2: default is now a solid primary chip.

+
+ set + missing + default + enabled + disabled + http-sse + streamable-http + auto + read-only + needs confirmation + streaming + disconnected + healthy + unhealthy + ok + error +
+

The pulse dot appears only on streaming (a real live-connection state), at most once per view.

+ +

Chips and tags

+

Model chips (removable) and the CUSTOM provider tag.

+
+ gpt-4o + claude-sonnet + llama-3.3-70b + CUSTOM +
+ +

Stat tiles

+

Small xs label, large 3xl mono value, xs mono delta. Four-up on the dashboard.

+
+
Total requests12,847+312 last hour
+
Success rate98.4%31 errors
+
Avg latency142 msp95 611 ms
+
Total cost$3.824.2M tokens
+
+ +

Chart cards

+

Bar/line toggle, legend keyed to --chart-1..5, and a graceful empty state. Axes and gridlines use --muted-foreground and --border.

+
+
+
+ Requests per hour +
+ + +
+
+ +
00:0006:0012:0018:00
+
requests
+
+ +
+
+ Latency p50 vs p95 +
+ + +
+
+ + + + + + + + + +
-6h-3hnow
+
+ p95 latency + p50 latency +
+
+ +
+
+ Cost by provider +
+ + +
+
+
+ No data available + No requests recorded in this window. +
+
cost
+
+
+ +

Data table (resources)

+

Sortable headers, inline secret reveal and copy, row edit and delete actions, status pills, pagination.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
id type keystatusactions
openaiopenai + + sk-............... + + + + default enabled + + +
anthropicanthropic + + sk-............... + + + + enabled + + +
azure-eu CUSTOMazurenot setmissing disabled + + +
+ + + +

Log table

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
timestatusrequestmodellatencytokenscost
07:41:23200POST /v1/chat/completionsopenai/gpt-4o612 ms1,204$0.0038
07:41:19200POST /v1/messagesanthropic/claude-sonnet1,847 ms2,388$0.0121
07:41:12500POST /v1/embeddingsopenai/text-embedding-393 ms0$0.0000
07:41:08processingPOST /v1/chat/completionsgroq/llama-3.3-70b………
+
+ +

Sidebar navigation

+
+ +

+ Top search box carries a Ctrl K / Cmd K affordance and opens the command + palette. Groups are nested-expandable (rotating chevron); an external-link + glyph marks items that leave the app. Active item: sidebar-accent fill, + sidebar-accent-foreground text, 2px sidebar-primary inset bar. Section + labels are 2xs mono muted. +

+
+ +

Command palette

+ +

Opened by Cmd K / Ctrl K. Popover surface, shadow-lg, radius-xl. Highlighted row uses the accent wash; each row may carry a trailing mono shortcut hint.

+ +

Tabs and configuration panel

+
+
+ + + + +
+
+
+
+ + +
+
+ + +
+
+
+ + + +
+
+ +
+

Horizontal tabs over a dense field grid with toggles and a sticky Save/Remove footer on the card surface.

+ +

Filter and facet sidebar

+
+ +

+ Collapsible facet groups with a searchable list, checkbox groups, and mono + counts. Group headers are 2xs mono muted with a rotating chevron. Selected + facets echo as removable soft-neutral chips above the results. +

+
+ +

Dialog

+ + +

Custom-provider modal

+ + +

Sheet (detail panel)

+
+
+ Request details + +
+
+
Request id
req_8f3ka92
+
Model
openai/gpt-4o
+
Latency
612 ms
+
Prompt tokens
1,204
+
Completion tokens
356
+
Cost
$0.0038
+
+
+ +

Toast

+
+
+ Provider added + openai-eu is now serving requests. +
+
+ Sync failed + MCP server weather did not respond within 30s. +
+
+ +

Error banner

+ + +

Empty state

+
+ No providers configured + Add your first provider to start routing requests through the gateway. + +
+ +

Loading skeleton

+
+
+
+
+
+
+ +

Focus ring

+
+ +

+ Every interactive element shows a 2px ring at 2px offset in the accent + blue; the ring passes 3:1 against both canvases and 4.5:1 as link text. +

+
+
+ +
+

Application frame

+

+ Component preview of the app shell built from tokens: sidebar with a search + box and nested nav, content region with a page header, stat tiles, a chart + card, and a paginated resource table. +

+
+
+
+ Frosty + + + + + + + + gateway v0.9.0 on Deno 2.9.2 +
+
+
+ Configured providers + +
+
+
Providers41 disabled
+
Requests today12,84798.4% ok
+
Cost today$3.824.2M tokens
+
+
+
+ Requests per hour +
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
idtypekeystatus
openaiopenaisetdefault enabled
anthropicanthropicsetenabled
azure-euazuremissingdisabled
+ +
+
+
+
+
+ + + +
+ Frosty design system, revision ds-r2 (supersedes ds-r1). Generated from + docs/design/DESIGN.md and tokens.css. 0 contrast failures across 64 checked + pairs in both themes. +
+ + + + diff --git a/klanker-gate/docs/design/tokens.css b/klanker-gate/docs/design/tokens.css new file mode 100755 index 0000000..0470a50 --- /dev/null +++ b/klanker-gate/docs/design/tokens.css @@ -0,0 +1,331 @@ +/* ============================================================================ + Frosty design tokens (canonical) - revision ds-r2 + ---------------------------------------------------------------------------- + Product: Frosty Control Plane, the control plane for a Deno-native LLM + gateway. Dark is the default theme: applications mount with class="dark" on + the root element. Light theme is the :root base per shadcn/ui v4 convention. + "Auto" behavior: toggle the .dark class from prefers-color-scheme in app JS. + + ds-r2 SUPERSEDES ds-r1 (glacier-blue). The register is a dense, near-neutral + monochrome developer console (shadcn "new-york"/neutral). Neutrals carry a + whisper of cool (hue 265, chroma <= 0.006), never pure gray. Primary is the + shadcn-neutral inversion: near-white in dark (ink label), near-black in light + (paper label). A single restrained cool-blue accent (hue ~252 to 255) is used + ONLY for the focus ring, links, and the active-nav indicator, never as a fill. + Semantic status colors (success/warning/destructive/info) stay a functional + vocabulary. A --chart-1..5 family is added for the analytics dashboard. + + Format: shadcn/ui v4 compatible CSS custom properties in OKLCH, plus an + @theme inline mapping block for Tailwind CSS v4. Hex fallbacks are noted in + comments; every text/surface pair is contrast-verified in both themes (see + docs/design/DESIGN.md, Accessibility section: 0 failures, computed WCAG 2.x). + + Source of truth: docs/design/DESIGN.md. Do not hand-edit component CSS to + diverge from these values; change the token here and let it propagate. Token + NAMES are stable across ds-r1 and ds-r2 so the app re-themes by swapping + values, not names. + ========================================================================== */ + +/* ------------------------------------------------------------------------ */ +/* Light theme (base) + theme-independent tokens */ +/* ------------------------------------------------------------------------ */ +:root { + color-scheme: light; + + /* --- color: surfaces ------------------------------------------------- */ + --background: oklch(0.99 0.002 265); /* #fbfcfd canvas */ + --foreground: oklch(0.2 0.006 265); /* #151619 body text */ + --card: oklch(1 0 0); /* #ffffff raised surface */ + --card-foreground: oklch(0.2 0.006 265); /* #151619 */ + --popover: oklch(1 0 0); /* #ffffff dialogs, menus */ + --popover-foreground: oklch(0.2 0.006 265); /* #151619 */ + + /* --- color: primary (shadcn-neutral inversion: near-black fill) ------- */ + --primary: oklch(0.24 0.006 265); /* #1e1f22 ink */ + --primary-foreground: oklch(0.985 0.001 265); /* #fafafb paper label */ + + /* --- color: supporting surfaces --------------------------------------- */ + --secondary: oklch(0.965 0.003 265); /* #f2f3f5 */ + --secondary-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + --muted: oklch(0.965 0.003 265); /* #f2f3f5 */ + --muted-foreground: oklch(0.475 0.008 265); /* #5a5c61 */ + --accent: oklch(0.965 0.004 265); /* #f2f3f6 hover wash */ + --accent-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + + /* --- color: semantic status ------------------------------------------- */ + --destructive: oklch(0.52 0.2 25); /* #c21725 */ + --destructive-foreground: oklch(0.985 0.005 25); /* #fdf9f8 */ + --success: oklch(0.48 0.13 155); /* #00723b */ + --success-foreground: oklch(0.985 0.005 155); /* #f8fbf9 */ + --warning: oklch(0.52 0.11 70); /* #8f5d14 */ + --warning-foreground: oklch(0.985 0.005 80); /* #fcfaf6 */ + --info: oklch(0.5 0.15 255); /* #1762b6 */ + --info-foreground: oklch(0.985 0.005 250); /* #f8fafd */ + + /* --- color: lines and focus ------------------------------------------- */ + --border: oklch(0.92 0.004 265); /* #e3e4e7 hairline */ + --input: oklch(0.89 0.004 265); /* #d9dbdd field border */ + --ring: oklch(0.55 0.15 255); /* #2971c6 accent blue */ + + /* --- color: chart family (data marks; AA >= 3:1 non-text on card) ------ */ + --chart-1: oklch(0.52 0.15 255); /* #1f68bc blue */ + --chart-2: oklch(0.52 0.14 155); /* #007f43 green */ + --chart-3: oklch(0.6 0.12 70); /* #ad721c amber */ + --chart-4: oklch(0.52 0.2 25); /* #c21725 red */ + --chart-5: oklch(0.5 0.18 300); /* #7541b8 violet */ + + /* --- color: sidebar family -------------------------------------------- */ + --sidebar: oklch(0.975 0.003 265); /* #f6f7f9 */ + --sidebar-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + --sidebar-primary: oklch(0.52 0.15 255); /* #1f68bc accent blue */ + --sidebar-primary-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --sidebar-accent: oklch(0.955 0.005 265); /* #eef0f4 active item */ + --sidebar-accent-foreground: oklch(0.24 0.006 265); /* #1e1f22 */ + --sidebar-border: oklch(0.91 0.004 265); /* #e0e1e4 */ + --sidebar-ring: oklch(0.55 0.15 255); /* #2971c6 */ + + /* --- shadow ramp (near-neutral, never glacier) ------------------------- */ + --shadow-sm: 0 1px 2px 0 oklch(0.2 0.01 265 / 0.06); + --shadow-md: 0 2px 8px -1px oklch(0.2 0.01 265 / 0.1), + 0 1px 2px 0 oklch(0.2 0.01 265 / 0.06); + --shadow-lg: 0 8px 24px -4px oklch(0.2 0.01 265 / 0.16), + 0 2px 6px 0 oklch(0.2 0.01 265 / 0.08); + + /* --- typography -------------------------------------------------------- */ + /* JetBrains Mono (OFL-1.1, free) is an optional progressive enhancement: + it renders only where locally installed. No font files are shipped. */ + --font-family-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", + Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-family-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", + "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + + /* compact scale, px-snapped; 13.5px body for high dashboard density */ + --font-size-2xs: 0.6875rem; --line-height-2xs: 1rem; /* 11/16 micro */ + --font-size-xs: 0.75rem; --line-height-xs: 1rem; /* 12/16 caption */ + --font-size-sm: 0.8125rem; --line-height-sm: 1.125rem; /* 13/18 secondary */ + --font-size-base: 0.84375rem; --line-height-base: 1.25rem; /* 13.5/20 body */ + --font-size-lg: 1rem; --line-height-lg: 1.375rem; /* 16/22 emphasis */ + --font-size-xl: 1.125rem; --line-height-xl: 1.5rem; /* 18/24 section */ + --font-size-2xl: 1.3125rem; --line-height-2xl: 1.625rem; /* 21/26 page title */ + --font-size-3xl: 1.6875rem; --line-height-3xl: 2rem; /* 27/32 display, stat */ + + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + + --tracking-tight: -0.01em; /* titles 2xl and up */ + --tracking-wide: 0.02em; /* tiny mono labels */ + + /* --- spacing (base unit 4px) ------------------------------------------- */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + + /* --- radius (one family, derived from a single 6px base) --------------- */ + --radius: 0.375rem; /* 6px base */ + --radius-sm: calc(var(--radius) - 2px); /* 4px badges, small controls */ + --radius-md: var(--radius); /* 6px buttons, inputs */ + --radius-lg: calc(var(--radius) + 2px); /* 8px cards, panels */ + --radius-xl: calc(var(--radius) + 6px); /* 12px dialogs, sheets */ + --radius-full: 9999px; /* pills, switch */ + + /* --- layout and control metrics (denser than ds-r1) -------------------- */ + --border-w: 1px; + --ring-w: 2px; + --ring-offset: 2px; + --control-h-sm: 1.875rem; /* 30px dense-row controls */ + --control-h: 2.125rem; /* 34px default control height */ + --control-h-lg: 2.625rem; /* 42px prominent controls */ + --tap-target: 2.75rem; /* 44px minimum hit area (extended if visual < 44) */ + --sidebar-width: 15rem; + --sidebar-width-icon: 3rem; + /* Outer content cap. Was 78rem (1248px), which left ~1250px of a 2560px + monitor unused while tables scrolled horizontally inside it. 110rem + (1760px) is wide enough for dense telemetry tables and the two-pane + layouts without letting a page become a single ungrouped expanse. */ + --container-max: 110rem; + /* Inner cap for prose and single-column forms. A control row stretched to + 1760px puts its label a screen away from its input; text past ~75ch stops + being scannable. Views opt into this for text-heavy panels while their + tables use the full --container-max. */ + --measure-max: 60rem; + /* Page gutter, tightening on small screens so a tablet does not spend a + quarter of its width on padding. */ + --gutter: 1.5rem; + --opacity-disabled: 0.5; + + /* --- motion ------------------------------------------------------------- */ + --motion-fast: 100ms; /* hover, focus, pressed feedback */ + --motion-default: 150ms; /* menus, popovers, toggles, tabs */ + --motion-slow: 220ms; /* dialogs, sheets, page-level */ + --motion-spin: 800ms; /* continuous loading spinner */ + --motion-ease-out: cubic-bezier(0.2, 0, 0, 1); + --motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --motion-rise: -2px; /* hover lift distance */ + --motion-enter: 8px; /* enter-toward travel distance */ + + /* --- z-index ------------------------------------------------------------ */ + --z-sticky: 20; + --z-overlay: 40; + --z-modal: 50; + --z-toast: 60; +} + +/* ------------------------------------------------------------------------ */ +/* Dark theme (Frosty default: mount with class="dark") */ +/* ------------------------------------------------------------------------ */ +.dark { + color-scheme: dark; + + --background: oklch(0.15 0.004 265); /* #0a0b0d canvas */ + --foreground: oklch(0.985 0.001 265); /* #fafafb body text */ + --card: oklch(0.185 0.004 265); /* #121314 raised surface */ + --card-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --popover: oklch(0.205 0.004 265); /* #161719 dialogs, menus */ + --popover-foreground: oklch(0.985 0.001 265); /* #fafafb */ + + --primary: oklch(0.92 0.004 265); /* #e3e4e7 near-white */ + --primary-foreground: oklch(0.205 0.006 265); /* #16171a ink label */ + + --secondary: oklch(0.255 0.004 265); /* #222325 */ + --secondary-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --muted: oklch(0.235 0.004 265); /* #1d1e20 */ + --muted-foreground: oklch(0.712 0.008 265); /* #a0a2a7 */ + --accent: oklch(0.255 0.006 265); /* #212326 hover wash */ + --accent-foreground: oklch(0.985 0.001 265); /* #fafafb */ + + --destructive: oklch(0.665 0.19 25); /* #f25855 */ + --destructive-foreground: oklch(0.205 0.04 25); /* #280e0c ink label */ + --success: oklch(0.72 0.15 155); /* #43c07a */ + --success-foreground: oklch(0.18 0.04 155); /* #021709 */ + --warning: oklch(0.8 0.13 82); /* #e7b551 */ + --warning-foreground: oklch(0.24 0.04 82); /* #291d07 */ + --info: oklch(0.68 0.13 250); /* #549de5 */ + --info-foreground: oklch(0.17 0.04 250); /* #021020 */ + + --border: oklch(0.27 0.006 265); /* #252629 */ + --input: oklch(0.3 0.006 265); /* #2c2e31 */ + --ring: oklch(0.62 0.13 252); /* #4589d2 accent blue */ + + --chart-1: oklch(0.66 0.14 252); /* #4b95e5 blue */ + --chart-2: oklch(0.72 0.15 155); /* #43c07a green */ + --chart-3: oklch(0.8 0.13 82); /* #e7b551 amber */ + --chart-4: oklch(0.665 0.19 25); /* #f25855 red */ + --chart-5: oklch(0.62 0.16 300); /* #966cd7 violet */ + + --sidebar: oklch(0.13 0.004 265); /* #070709 */ + --sidebar-foreground: oklch(0.8 0.006 265); /* #bcbec2 */ + --sidebar-primary: oklch(0.62 0.13 252); /* #4589d2 accent blue */ + --sidebar-primary-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --sidebar-accent: oklch(0.235 0.006 265); /* #1d1e21 active item */ + --sidebar-accent-foreground: oklch(0.985 0.001 265); /* #fafafb */ + --sidebar-border: oklch(0.24 0.006 265); /* #1e1f22 */ + --sidebar-ring: oklch(0.62 0.13 252); /* #4589d2 */ + + --shadow-sm: 0 1px 2px 0 oklch(0.03 0.006 265 / 0.5); + --shadow-md: 0 2px 8px -1px oklch(0.03 0.006 265 / 0.6), + 0 1px 2px 0 oklch(0.03 0.006 265 / 0.5); + --shadow-lg: 0 10px 30px -5px oklch(0.03 0.006 265 / 0.7), + 0 4px 8px -2px oklch(0.03 0.006 265 / 0.5); +} + +/* ------------------------------------------------------------------------ */ +/* Reduced motion: durations collapse, travel distances zero out */ +/* ------------------------------------------------------------------------ */ +@media (prefers-reduced-motion: reduce) { + :root { + --motion-fast: 0ms; + --motion-default: 0ms; + --motion-slow: 0ms; + --motion-rise: 0px; + --motion-enter: 0px; + } +} + +/* ------------------------------------------------------------------------ */ +/* Tailwind CSS v4 mapping (shadcn/ui v4 convention) */ +/* Import this file in the app stylesheet after `@import "tailwindcss";`. */ +/* ------------------------------------------------------------------------ */ +@theme inline { + /* colors */ + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* typography */ + --font-sans: var(--font-family-sans); + --font-mono: var(--font-family-mono); + --text-2xs: var(--font-size-2xs); + --text-2xs--line-height: var(--line-height-2xs); + --text-xs: var(--font-size-xs); + --text-xs--line-height: var(--line-height-xs); + --text-sm: var(--font-size-sm); + --text-sm--line-height: var(--line-height-sm); + --text-base: var(--font-size-base); + --text-base--line-height: var(--line-height-base); + --text-lg: var(--font-size-lg); + --text-lg--line-height: var(--line-height-lg); + --text-xl: var(--font-size-xl); + --text-xl--line-height: var(--line-height-xl); + --text-2xl: var(--font-size-2xl); + --text-2xl--line-height: var(--line-height-2xl); + --text-3xl: var(--font-size-3xl); + --text-3xl--line-height: var(--line-height-3xl); + + /* radius */ + --radius-sm: calc(var(--radius) - 2px); + --radius-md: var(--radius); + --radius-lg: calc(var(--radius) + 2px); + --radius-xl: calc(var(--radius) + 6px); + + /* shadows */ + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + + /* easing */ + --ease-out: var(--motion-ease-out); + --ease-in-out: var(--motion-ease-in-out); +} diff --git a/klanker-gate/docs/design/ui-design.md b/klanker-gate/docs/design/ui-design.md new file mode 100755 index 0000000..050c76e --- /dev/null +++ b/klanker-gate/docs/design/ui-design.md @@ -0,0 +1,1372 @@ +# UI design (control plane) + +This document records the Frosty Deno control-plane SPA (`apps/control-ui`) +**exactly as implemented in the working tree**. It is a reference for the +interface a reader will actually see, not a wishlist. Where the shipped code +diverges from the declared design source [DESIGN.md](DESIGN.md), the code is +authoritative here and the divergence is called out. + +The control plane is served same-origin by the gateway from the same port +(default 8080); it renders API-only until `deno task build-ui` has produced +`apps/control-ui/dist`. See +[../concepts/architectural-overview.md](../concepts/architectural-overview.md) +for how the SPA fits the gateway (one process, or N under `FROSTY_WORKERS`), and +[../../apps/control-ui/CONVENTIONS.md](../../apps/control-ui/CONVENTIONS.md) for +the binding SPA contract. + +## Stack (verified) + +| Concern | Choice | Evidence | +| --- | --- | --- | +| Framework | React 19 (`^19.2.8`) + react-dom, mounted via `ReactDOM.createRoot` inside `React.StrictMode` | `package.json:9-10`, `src/main.tsx:6-10` | +| Styling | Tailwind CSS v4 (`^4.3.3`) CSS-first, wired through `@tailwindcss/vite`; **no `tailwind.config.*`, no `postcss.config.*`** | `vite.config.ts:7`, `package.json:14,22` | +| Class utility | `cn()` = `twMerge(clsx(...))` | `src/lib/utils.ts:4` | +| Icons | `lucide-react ^1.25.0` only (plus a documented brand-SVG exception) | `package.json:8` | +| Router | none - a hand-rolled hash router in `App.tsx` | grep: no router dependency | +| State / component libraries | none - no Radix, no shadcn runtime, no state library; every primitive is hand-written | `package.json`; grep | +| App version | `0.7.0` | `apps/control-ui/package.json` | +| Design revision | `ds-r2` (supersedes ds-r1 "glacier-blue"); dark is the default theme | `tokens.css:2,9`; `index.html:2` | + +Design tokens live in `apps/control-ui/src/styles/tokens.css` (340 lines, +OKLCH). A near-identical mirror ships at [tokens.css](tokens.css) in this folder +(CRLF, 331 lines pre-`deno fmt`); the two carry **zero value differences** and +are synced by hand, with no generator or CI check tying them together. + +--- + +## 1. Design system + +### 1.1 Identity and principles + +`ds-r2` is a dense, near-neutral monochrome developer console, register shadcn +"new-york"/neutral (`DESIGN.md:29-35`). The load-bearing rules, all verified +against code: + +- **Dark is the default and only pre-paint-resolved theme.** The document mounts + with `class="dark"` (`index.html:2`). +- **Neutrals are hue 265, chroma <= 0.006** - a whisper of cool, never pure gray + (`tokens.css:11-12`). +- **`--primary` is an emphasis surface, not a hue.** It is the shadcn-neutral + inversion: near-black in light, near-white in dark (`tokens.css:44,202`). +- **One cool-blue accent (hue 252 dark / 255 light), used only for the focus + ring, links, and the active-nav indicator - never as a fill.** Verified: there + is no `bg-ring` anywhere in the app. +- **Semantic status color (success/warning/destructive/info) is a functional + vocabulary only**, never decorative. +- **lucide-react icons only; same-origin only.** No external CDN, font, script, + or image origin exists in `index.html`, `index.css`, `tokens.css`, or any + component. No `@font-face`, no `` to a font, no font files shipped. + +Declared design dials (informational): DESIGN_VARIANCE 3, MOTION_INTENSITY 2, +VISUAL_DENSITY 8 (`DESIGN.md:37-41`). + +Token counts: **105 custom properties in `:root`**, of which **41 are +re-declared in `.dark`** (38 colors + 3 shadows) and **5 are re-declared under +`prefers-reduced-motion`**. Light values are the `:root` base +(`tokens.css:32-187`); dark is a `.dark` class override (`tokens.css:192-245`); +`color-scheme` is set per theme (`:33,193`) so native widgets follow. + +### 1.2 Color palette + +Every emitted value is OKLCH. The **hex** columns are the fallback hexes written +in the token file's own comments - documentation only, not the rendered value. +Line numbers reference `apps/control-ui/src/styles/tokens.css`. + +#### Surfaces + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--background` | `0.99 0.002 265` (36) | `#fbfcfd` | `0.15 0.004 265` (195) | `#0a0b0d` | `body` fill (`index.css:20`); sticky config-footer fill (`ProviderConfigPanel.tsx:370`) | +| `--foreground` | `0.2 0.006 265` (37) | `#151619` | `0.985 0.001 265` (196) | `#fafafb` | `body` text (`index.css:21`); every modal scrim as `bg-foreground/40` | +| `--card` | `1 0 0` (38) | `#ffffff` | `0.185 0.004 265` (197) | `#121314` | Card surface; every field fill; sticky table header; active tab/segment; Sheet body | +| `--card-foreground` | = foreground (39) | `#151619` | = foreground (198) | `#fafafb` | Card / Sheet text | +| `--popover` | `1 0 0` (40) | `#ffffff` | `0.205 0.004 265` (199) | `#161719` | Dialog, DropdownMenu, Combobox listbox, TimeRangePicker, ColumnPicker, CommandPalette, Toast, chart tooltip (`/95`), skip-link chip | +| `--popover-foreground` | = foreground (41) | `#151619` | = foreground (200) | `#fafafb` | Same set as popover | + +#### Primary (shadcn-neutral inversion - an emphasis surface, not a chromatic hue) + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--primary` | `0.24 0.006 265` (44) | `#1e1f22` | `0.92 0.004 265` (202) | `#e3e4e7` | Button `default` fill; Switch ON track; Checkbox `accent-primary`; Toast action link; Badge `primary` tone | +| `--primary-foreground` | `0.985 0.001 265` (45) | `#fafafb` | `0.205 0.006 265` (203) | `#16171a` | Button `default` label; Switch thumb when ON | + +#### Supporting surfaces + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--secondary` | `0.965 0.003 265` (48) | `#f2f3f5` | `0.255 0.004 265` (205) | `#222325` | Button `secondary`; active pill in NavTabs `pill`; TagInput chips | +| `--secondary-foreground` | = foreground (49) | `#1e1f22` | = foreground (206) | `#fafafb` | Button `secondary` label | +| `--muted` | `0.965 0.003 265` (50) | `#f2f3f5` | `0.235 0.004 265` (207) | `#1d1e20` | Skeleton bar; Switch OFF track; Tabs / SegmentedSelect track; provider-icon tile; Badge `muted`; table row hover (`/40`) | +| `--muted-foreground` | `0.475 0.008 265` (51) | `#5a5c61` | `0.712 0.008 265` (208) | `#a0a2a7` | All secondary text, placeholders, chart axis labels + gridlines, table column headers, icon-button rest color | +| `--accent` | `0.965 0.004 265` (52) | `#f2f3f6` | `0.255 0.006 265` (209) | `#212326` | The single hover/active wash across buttons, menus, combobox, nav tabs, masked-secret, etc. | +| `--accent-foreground` | `0.24 0.006 265` (53) | `#1e1f22` | = foreground (210) | `#fafafb` | Exactly one usage: the highlighted CommandPalette result (`CommandPalette.tsx:160`) | + +#### Semantic status + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--destructive` | `0.52 0.2 25` (56) | `#c21725` | `0.665 0.19 25` (212) | `#f25855` | Destructive buttons; Badge `err`; Banner `error`; required marker; field error text; `aria-invalid` border; Toast error icon; sidebar `denied` token dot | +| `--destructive-foreground` | `0.985 0.005 25` (57) | `#fdf9f8` | `0.205 0.04 25` (213) | `#280e0c` | Destructive button / solid badge label | +| `--success` | `0.48 0.13 155` (58) | `#00723b` | `0.72 0.15 155` (214) | `#43c07a` | Badge `ok`; Toast success icon; copied check; sidebar `ok` token dot | +| `--success-foreground` | `0.985 0.005 155` (59) | `#f8fbf9` | `0.18 0.04 155` (215) | `#021709` | Solid success badge label | +| `--warning` | `0.52 0.11 70` (60) | `#8f5d14` | `0.8 0.13 82` (216) | `#e7b551` | Badge `warn`; Banner `warn`; PEM hint | +| `--warning-foreground` | `0.985 0.005 80` (61) | `#fcfaf6` | `0.24 0.04 82` (217) | `#291d07` | Solid warn badge label | +| `--info` | `0.5 0.15 255` (62) | `#1762b6` | `0.68 0.13 250` (218) | `#549de5` | Badge `info`; Banner `info`; Toast info icon; EmptyState `tone="info"` icon | +| `--info-foreground` | `0.985 0.005 250` (63) | `#f8fafd` | `0.17 0.04 250` (219) | `#021020` | Solid info badge label | + +Deliberate hue note: light `--warning` is hue 70 while `--warning-foreground` is +hue 80; dark uses hue 82 for both (`DESIGN.md:62-63`, "amber 70 to 82"). + +#### Lines and focus + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--border` | `0.92 0.004 265` (66) | `#e3e4e7` | `0.27 0.006 265` (221) | `#252629` | Global `* { border-color: var(--border) }` hairline default (`index.css:8-10`) plus explicit borders | +| `--input` | `0.89 0.004 265` (67) | `#d9dbdd` | `0.3 0.006 265` (222) | `#2c2e31` | Every field border; Button `outline`; Checkbox; Switch OFF border | +| `--ring` | `0.55 0.15 255` (68) | `#2971c6` | `0.62 0.13 252` (223) | `#4589d2` | **Global focus ring only** (`index.css:29-32`). No `bg-ring` anywhere - the accent-blue-is-never-a-fill rule holding | + +#### Chart family + +Consumed only through literal Tailwind classes (`text-chart-1..5`, +`bg-chart-1..5`) in `chart.tsx`; dynamic `text-chart-${n}` is forbidden because +the Tailwind JIT would purge it (verified: no dynamic chart-class construction +exists). Contrast figures are declared in `DESIGN.md` (see 8.3). + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Role | +| --- | --- | --- | --- | --- | --- | +| `--chart-1` | `0.52 0.15 255` (71) | `#1f68bc` | `0.66 0.14 252` (225) | `#4b95e5` | blue | +| `--chart-2` | `0.52 0.14 155` (72) | `#007f43` | `0.72 0.15 155` (226) | `#43c07a` | green | +| `--chart-3` | `0.6 0.12 70` (73) | `#ad721c` | `0.8 0.13 82` (227) | `#e7b551` | amber | +| `--chart-4` | `0.52 0.2 25` (74) | `#c21725` | `0.665 0.19 25` (228) | `#f25855` | red | +| `--chart-5` | `0.5 0.18 300` (75) | `#7541b8` | `0.62 0.16 300` (229) | `#966cd7` | violet | + +#### Sidebar family + +| Token | Light (OKLCH) | Light hex | Dark (OKLCH) | Dark hex | Usage | +| --- | --- | --- | --- | --- | --- | +| `--sidebar` | `0.975 0.003 265` (78) | `#f6f7f9` | `0.13 0.004 265` (231) | `#070709` | Rail surface (`Sidebar.tsx:143`); always the recessed surface (darker than background in dark, lighter in light) | +| `--sidebar-foreground` | `0.24 0.006 265` (79) | `#1e1f22` | `0.8 0.006 265` (232) | `#bcbec2` | Rail text | +| `--sidebar-primary` | `0.52 0.15 255` (80) | `#1f68bc` | `0.62 0.13 252` (233) | `#4589d2` | Brand snowflake (`:154`); search focus border (`:200`); **active-nav left inset bar** `before:bg-sidebar-primary` (`:263`) | +| `--sidebar-primary-foreground` | `0.985 0.001 265` (81) | `#fafafb` | identical value (234) | `#fafafb` | **The only color token with an identical value in both themes; no code usage found** | +| `--sidebar-accent` | `0.955 0.005 265` (82) | `#eef0f4` | `0.235 0.006 265` (235) | `#1d1e21` | Search fill (`/40`); collapse/close hover; active leaf fill; leaf hover (`/60`) | +| `--sidebar-accent-foreground` | `0.24 0.006 265` (83) | `#1e1f22` | `0.985 0.001 265` (236) | `#fafafb` | Active nav label | +| `--sidebar-border` | `0.91 0.004 265` (84) | `#e0e1e4` | `0.24 0.006 265` (237) | `#1e1f22` | Rail borders | +| `--sidebar-ring` | `0.55 0.15 255` (85) | `#2971c6` | `0.62 0.13 252` (238) | `#4589d2` | Mapped to Tailwind; **zero usage in app code** | + +#### Alpha / tint conventions actually used + +- Soft badge: `text- bg-/16 border-/32` (`badge.tsx:7-14`). +- Solid badge: `bg- text--foreground` - the `solid` prop exists but + **no call site passes it** (dead prop, `badge.tsx:27`). +- Banner: `border-/32 bg-/12 text-foreground` - a 12% tint, not the + badge's 16% (`banner.tsx:8-16`). +- Modal scrim `bg-foreground/40`; table row hover `bg-muted/40`; sidebar leaf + hover `bg-sidebar-accent/60`. +- Chart tooltip `bg-popover/95` plus `backdrop-blur-sm` (`chart.tsx:245`) - the + only blur in the app, contradicting `DESIGN.md:300` ("never a blur"). + +### 1.3 Typography + +Families (`tokens.css:97-100`). No font files ship; JetBrains Mono is a +progressive enhancement that renders only where locally installed. + +| Token | Value | +| --- | --- | +| `--font-family-sans` | `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif` | +| `--font-family-mono` | `"JetBrains Mono", ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace` | + +Size / line-height scale (`tokens.css:103-118`). The token file states "13.5px +body for high dashboard density". + +| Step | font-size | px | line-height | px | Role (token comment) | Weight prescribed (`DESIGN.md:141-150`) | +| --- | --- | --- | --- | --- | --- | --- | +| 2xs | `0.6875rem` | 11 | `1rem` | 16 | micro | 500, tracking 0.02em | +| xs | `0.75rem` | 12 | `1rem` | 16 | caption | 400 | +| sm | `0.8125rem` | 13 | `1.125rem` | 18 | secondary | 400 | +| base | `0.84375rem` | **13.5** | `1.25rem` | 20 | body | 400 | +| lg | `1rem` | 16 | `1.375rem` | 22 | emphasis | 500 | +| xl | `1.125rem` | 18 | `1.5rem` | 24 | section | 600 | +| 2xl | `1.3125rem` | 21 | `1.625rem` | 26 | page title | 600, tracking -0.01em | +| 3xl | `1.6875rem` | 27 | `2rem` | 32 | display, stat | 600, tracking -0.01em | + +Weights: `--font-weight-regular: 400`, `--font-weight-medium: 500`, +`--font-weight-semibold: 600` (`tokens.css:120-122`). Note `--font-weight-regular` +is inert because Tailwind's key is `--font-weight-normal`. +Tracking: `--tracking-tight: -0.01em` (titles 2xl+), `--tracking-wide: 0.02em` +(tiny labels) (`tokens.css:124-125`). + +Where typography is actually applied: + +| Register | Class / rule | Site | +| --- | --- | --- | +| Global body | sans family, 13.5px / 20px, antialiased | `index.css:22-25` | +| Page title | `text-2xl font-semibold tracking-tight` (`h2`) | `page-header.tsx:29` | +| Card title | `text-lg font-semibold` (`h3`); ChartCard downgrades to `text-base` | `card.tsx:37`, `ChartCard.tsx:67` | +| Dialog / Sheet title | `text-lg font-semibold` (`h2`) | `dialog.tsx:50,119`, `sheet.tsx:49` | +| Settings sub-heading | `text-sm font-semibold` (`h4`) | `helpers.tsx:98` | +| Sidebar brand | `text-base font-semibold tracking-tight` (`h1`) | `Sidebar.tsx:158` | +| Table column header | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | `table.tsx:79-80` | +| StatTile value | `font-mono text-2xl font-semibold` | `stat-tile.tsx:20` | +| Sidebar / command-palette group header | `text-2xs ... uppercase tracking-wide text-muted-foreground` | `Sidebar.tsx:226`, `CommandPalette.tsx:139` | +| Form label / Button / Badge | `text-sm font-medium` / `text-sm font-medium` / `text-xs font-medium` | `label.tsx:11`, `button.tsx:60`, `badge.tsx:37` | + +Notes: `font-mono` is used for all telemetry (ids, keys, latency, cost, tokens, +versions), 52 sites app-wide. `text-2xs` (11px) is used at 15 render sites. +`text-xl` (18px) and `text-3xl` (27px) have **no usage in `components/ui`** - +`StatTile` uses `text-2xl`, not the `3xl` the spec calls the "one deliberately +large figure". Because `--tracking-*` / `--font-weight-*` are declared in an +unlayered `:root` block (which beats Tailwind's `@layer theme` defaults), +`tracking-tight`/`tracking-wide` resolve to -0.01em/0.02em and +`font-medium`/`font-semibold` to 500/600. + +### 1.4 Spacing scale + +`tokens.css:128-137`, base unit 4px. + +| Token | Value | px | +| --- | --- | --- | +| `--space-1` | `0.25rem` | 4 | +| `--space-2` | `0.5rem` | 8 | +| `--space-3` | `0.75rem` | 12 | +| `--space-4` | `1rem` | 16 | +| `--space-5` | `1.25rem` | 20 | +| `--space-6` | `1.5rem` | 24 | +| `--space-8` | `2rem` | 32 | +| `--space-10` | `2.5rem` | 40 | +| `--space-12` | `3rem` | 48 | +| `--space-16` | `4rem` | 64 | + +Critical usage fact: only `--space-2` (3 refs) and `--space-4` (1 ref) are +referenced anywhere outside the token file, and all four sit in the single +`.sr-only-focusable:focus-visible` rule (`index.css:142,143,150`). The other +eight `--space-*` tokens have **zero references**. Components use Tailwind's own +`--spacing`-derived utilities (`px-5`, `gap-3`, `py-2`), which coincidentally +share the 4px base but are not driven by these tokens - the `@theme inline` +block does not map `--space-*` onto `--spacing`. De-facto conventions +(`CONVENTIONS.md:145-152`): card padding `px-5 py-4`, section gaps +`gap-4`/`gap-5`, page-section spacing `mb-5`/`mb-6`. + +### 1.5 Border radii + +`tokens.css:140-145`. One family from a single 6px base. + +| Token | Value | px | Role | Utility usage | +| --- | --- | --- | --- | --- | +| `--radius` | `0.375rem` | 6 | base | base only (not a Tailwind key) | +| `--radius-sm` | `calc(base - 2px)` | 4 | badges, small controls | `rounded-sm` (~16 sites) | +| `--radius-md` | `= base` | 6 | buttons, inputs | `rounded-md` (~56 sites) | +| `--radius-lg` | `calc(base + 2px)` | 8 | cards, panels | `rounded-lg` (~12 sites) | +| `--radius-xl` | `calc(base + 6px)` | 12 | dialogs, sheets | `rounded-xl` x4 (`dialog.tsx:44,114`, `CommandPalette.tsx:95`) | +| `--radius-full` | `9999px` | - | pills, switch | **0 direct refs**; `rounded-full` (8 sites) resolves to Tailwind's built-in `calc(infinity * 1px)` because `--radius-full` is deliberately absent from `@theme inline` | + +One outlier: a bare `rounded` at `data-table.tsx:180` (Tailwind's default +0.25rem), outside the declared 6px family. + +### 1.6 Shadows + +| Token | Light | Dark | Usage | +| --- | --- | --- | --- | +| `--shadow-sm` | `0 1px 2px 0 oklch(0.2 0.01 265 / 0.06)` | `0 1px 2px 0 oklch(0.03 0.006 265 / 0.5)` | Card; all fields; Switch thumb; active tab / segment | +| `--shadow-md` | `0 2px 8px -1px .../0.1, 0 1px 2px 0 .../0.06` | `0 2px 8px -1px .../0.6, 0 1px 2px 0 .../0.5` | DropdownMenu, Combobox listbox, TimeRangePicker, ColumnPicker, chart tooltip, skip-link chip | +| `--shadow-lg` | `0 8px 24px -4px .../0.16, 0 2px 6px 0 .../0.08` | `0 10px 30px -5px .../0.7, 0 4px 8px -2px .../0.5` | Dialog, ConfirmDialog, Sheet, CommandPalette, Toast, VK token-reveal dialog | + +`--shadow-lg` is the **only** shadow whose geometry (not just alpha) differs by +theme - the dark ramp is deeper. The self-referential mapping +`--shadow-sm: var(--shadow-sm)` etc. (`tokens.css:331-334`) is the shadcn-v4 +`@theme inline` convention: the literal `var(...)` text is substituted so the +value re-resolves per theme at runtime. + +### 1.7 Layout and control metrics + +`tokens.css:148-170`. + +| Token | Value | px | Where used | +| --- | --- | --- | --- | +| `--border-w` | `1px` | 1 | **0 refs** (components use Tailwind `border`) | +| `--ring-w` | `2px` | 2 | `index.css:30`; `table.tsx:25` | +| `--ring-offset` | `2px` | 2 | `index.css:31` | +| `--control-h-sm` | `1.875rem` | 30 | Button `sm`/`icon-sm`, dense rows | +| `--control-h` | `2.125rem` | 34 | Default control height (Button, Input, Select, Combobox, ...) | +| `--control-h-lg` | `2.625rem` | 42 | **0 refs** (Button has no `lg` size) | +| `--tap-target` | `2.75rem` | 44 | `.hit-target::after` (`index.css:87,88`) | +| `--sidebar-width` | `15rem` | 240 | `Sidebar.tsx:145,148` | +| `--sidebar-width-icon` | `3rem` | 48 | `Sidebar.tsx:148` | +| `--container-max` | `110rem` | 1760 | content wrapper (`App.tsx:315`); raised from 78rem so dense tables stop scrolling horizontally inside unused whitespace | +| `--measure-max` | `60rem` | 960 | `.measure` and `.field-grid > .field-wide` (`index.css:51,74`); caps prose and single-column forms so a label is not a screen from its input | +| `--gutter` | `1.5rem` | 24 | page gutter, `px-(--gutter)` on `
` (`App.tsx:311`); tightens to `1rem` at `<= 48rem` (`index.css:39-43`) | +| `--opacity-disabled` | `0.5` | - | **0 refs**; components hardcode `opacity-50` (value matches, token does not drive it) | + +### 1.8 Motion + +`tokens.css:172-180`. Motion is feedback-only; there is no decorative animation. + +| Token | Value | Refs in code | +| --- | --- | --- | +| `--motion-fast` | `100ms` | ~21 (hover/focus/press feedback on Button, menus, tabs, toggles, ...) | +| `--motion-default` | `150ms` | 5 (Switch, Collapsible, sidebar drawer slide) | +| `--motion-slow` | `220ms` | **0 refs** | +| `--motion-spin` | `800ms` | 1 (`spinner.tsx:9`) | +| `--motion-ease-out` | `cubic-bezier(0.2,0,0,1)` | 0 direct (mapped to Tailwind `--ease-out`, unused) | +| `--motion-ease-in-out` | `cubic-bezier(0.4,0,0.2,1)` | 2 (the two keyframes) | +| `--motion-rise` | `-2px` | **0 refs** | +| `--motion-enter` | `8px` | **0 refs** | + +Reduced motion (`tokens.css:250-258`) collapses fast/default/slow/rise/enter to +`0ms`/`0px`; `--motion-spin` is deliberately not collapsed (the spinner instead +carries `motion-reduce:animate-none`). Two keyframes exist app-wide: +`.skeleton-pulse` (live) and `.stream-pulse` (dead CSS - never applied; the live +indicator is a spinning lucide `RefreshCw`). **Overlays (Dialog, ConfirmDialog, +Sheet, CommandPalette) have no enter/exit animation at all** - they appear +instantly. The only overlay motion is the sidebar drawer slide. + +### 1.9 Z-index layers + +`tokens.css:182-186`. Consumers use the Tailwind arbitrary-variable form +`z-(--z-*)`. + +| Token | Value | Sites (complete) | +| --- | --- | --- | +| `--z-sticky` | 20 | sticky `` (`data-table.tsx:142`); sticky provider config footer (`ProviderConfigPanel.tsx:370`) | +| `--z-overlay` | 40 | DropdownMenu panel, Combobox listbox, TimeRangePicker menu, ColumnPicker popover, mobile nav scrim | +| `--z-modal` | 50 | Dialog, ConfirmDialog, Sheet, CommandPalette, the whole sidebar `