Built-in web UI

Every skaidb node serves a small admin UI at http://<node>:<rest_port>/ui (default REST port 7080). It is embedded in the binary at compile time — no external assets, no JS toolchain, works air-gapped — and is a pure API client: everything it does goes through the same REST endpoints, HTTP Basic auth, and per-statement RBAC as any other client.

http://127.0.0.1:7080/ui

It is the node-local operator surface: check health, run SQL, inspect schema and storage, do the occasional admin op. It is not a dashboard — /metrics and Grafana (GRAFANA.md) own that.

Tabs

Four tabs. The selected one lives in location.hash (#overview, #query, #data, #admin), so a reload or a pasted link lands where you left off. Arrow keys move between tabs; Home and End jump to the first and last available tab. The header and tab bar stay visible while the page scrolls, and tables scroll within their panels on narrow screens.

  • overview — identity, per-node health, and live throughput; an advisor card (from /ui/advice) with the findings the viewer may see, the summary, suggestion, actions, the model's explanation and the bug report under a row on click, and — for ADVISOR ON * / ADMIN — a runs sub-card (from /ui/advisor) listing the advisor's last runs.
  • node (version, ready, uptime) and, on a clustered node, cluster (members, epoch, ring membership, replication factor, consistency levels, hints, resharding, and resync progress while a node backfills from empty). A standalone node has no ring, so the cluster card does not appear.
  • throughput — queries/s, mean latency, in flight, rows scanned/s, rows written/s, bytes returned/s, connections; the rates carry canvas sparklines over a 5-minute window sampled every 5 s.
  • nodes — one row per cluster member joining ring standing to live system stats: ring state (with the configured-vs-ring discrepancies the server surfaces explicitly), CPU, RAM used/total (cgroup-aware in containers), process RSS, data-directory disk space, replication lag, hinted-handoff backlog, uptime, restarts (with OOM kills flagged), and how long ago the node last reported. Members that have gone quiet dim and keep their last-known values; a member backfilling from empty carries a resync badge with progress and source; a configured member the ring never admitted still gets a row. Multi-node deployments get a cluster totals row. Data: GET /ui/hosts joined to GET /status.
  • witnesses — registered cross-region backup nodes (id, region, registered and last-seen durations, dimmed and flagged once a witness has gone quiet past the GC grace period) from GET /ui/witnesses, reading the replicated witnesses table alongside the grace period in effect from witness_gc_config; plus a sync column from the heartbeat's watermarks — synced table count, total rows, oldest sync age, with the full per-table list in the cell tooltip. A witness registers and heartbeats itself over an ordinary SQL connection with witness-scoped credentials (see the [witness] section in LLM.md). The card appears only once a witness registers.
  • drivers — live binary-protocol connections (node, endpoint, remote address, authenticated user, connected duration) from GET /ui/drivers, reading the replicated drivers table. REST connections are not tracked there, since REST is one request per connection.
  • rest activity — per request class (query / insert / es / prom / ui / ops / admin / other): request count and average response time, from skaidb_rest_requests_total{path=…} and its duration companion (also scrapable at /metrics).

Drivers, REST activity, and witnesses are collapsible, and their collapsed state persists in localStorage. The tab polls every 5 s while it is visible and stops when it is not. The drivers and witnesses fetches are independent of the core status fetch, so a failure there dims those cards without breaking the rest of the tab.

  • query — SQL console with a schema browser: databases and tables the logged-in role may read (GET /ui/schema, filtered server-side by the same RBAC check /query enforces — table, database, and global grants, role inheritance included). Filter by database or table name; Escape clears the filter. Clicking a table targets its database and pre-fills a SELECT. Ctrl/⌘+Enter runs, Alt+↑/↓ cycles history (kept in localStorage, statements only — never results or credentials), and when a result looks like a time series (a ts/time/bucket column plus numeric columns) a line chart renders above the table. Canned-statement and history dropdowns, CSV/JSON export, client-measured latency. The run button shows progress and repeated submissions are ignored while a query is running. Results render escaped; FTS HIGHLIGHT() snippets are the one exception, rendered by splitting on the literal <b>/</b> tokens. Display is capped at 1000 rows with a visible banner suggesting a LIMIT. USE <db> is tracked client-side and sent per request (POST /query accepts an optional "db" JSON key).

The search builder is a disclosure under the query bar. Pick a mode — MATCH, MATCH_PHRASE, MATCH_PREFIX, FUZZY, WILDCARD, REGEXP, MORE_LIKE_THIS, SEARCH (query string), SUGGEST, or NEAREST (vector) — fill in the table, column, and query text, and it composes the statement into the console textarea. FTS modes get score() ordering and a HIGHLIGHT() snippet column; NEAREST takes a literal vector ([0.1, …]) or, on a managed EMBED index, text the server embeds. "build & run" runs it through the ordinary run path, so results, exports, history, and highlight rendering behave exactly like a typed statement; "to console" composes without running. The table list follows the console's current database.

  • data — the consolidated schema-and-storage view (GET /ui/inventory, RBAC-filtered like the schema browser): every database's tables (type — table / memory / timeseries —, key, placement — cluster default, rf n override, or pinned: <alias> with a highlighted → moving marker while a placement transition is open — witness mirroring (mirrored/excluded), TTL, approximate row count, tombstones, disk, file count) and indexes (secondary / vector / search, with paths, vector dim·metric·ef, entry/doc counts, disk). Clicking a table row targets its database and pre-fills a SELECT in the query console; hovering an index row shows its canonical DDL, the same string SHOW INDEXES reports. A constraints card lists every constraint of every kind — the SHOW CONSTRAINTS view: database, type (FOREIGN KEY / CHECK / NOT NULL / DEFAULT / IDENTITY / GENERATED), name, table, columns, then for a foreign key its referenced table and columns, ON DELETE / ON UPDATE actions and supporting index, or for the others the definition (qty DEFAULT 1, CHECK (qty > 0), id GENERATED ALWAYS AS IDENTITY, total GENERATED ALWAYS AS (qty * price) STORED), and whether it is validated — a NOT VALID constraint is highlighted; hovering a row shows its clause. A sequences card lists every sequence — the SHOW SEQUENCES view: database, name, the column that owns it (users.id for a SERIAL / identity column), last value (— before the first nextval), start, increment, range (∞ for an unbounded side), cache and cycle; hovering a row shows its CREATE SEQUENCE DDL. A views card lists every view — the SHOW VIEWS view: database, name, kind (view / matview), refresh interval, last refresh and stored row count (materialized views only) and the definition; clicking a row opens it in the query console like a table. The three cards appear only when a visible table or view declares one. Usage numbers are the serving node's, and counts are approximate until compaction.

  • admin — repair / reclaim / add node / remove node behind confirmation dialogs; the slow-query log (masked SQL); and the masked config (/admin/config), one card per section, with per-key set. A set states whether the key applied live or needs a restart; every set persists to the node's config file.

The admin tab only appears for roles with the Admin privilege (the UI probes /admin/config at login, and an #admin deep link falls back to overview without it). Hiding it is UX, not security — the server enforces RBAC on every request regardless.

Enable / disable

The UI is on by default and exactly as exposed as POST /query on the same port. To remove the surface entirely:

[ui]
enabled = false

ui.enabled is live-mutable — no restart, effective on the next request, and every /ui path returns the same 404 a UI-less build would:

skaidbsh> \ui            -- print each node's UI URL + enabled state
skaidbsh> \ui off        -- disable live (persists to the config file)
skaidbsh> \ui on
skaidbsh> \config set ui.enabled false   -- equivalent generic form

Security model

  • Auth: the shell page is static and secret-free, so it serves unauthenticated; every data call is a fetch() with an explicit Authorization: Basic header from the login form. Credentials live in JS memory, or sessionStorage with the "remember for this tab" opt-in — never localStorage, cookies, or URLs. Logging out drops them and clears fetched results from the screen.
  • No CSRF surface: no cookies and no server-side sessions means no ambient credential for a cross-site request to ride.
  • CSP: every /ui response carries default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' and X-Content-Type-Options: nosniff — the no-external-assets rule, enforced by the browser too. style-src 'self' also forbids inline styles, and a browser counts an element .style assignment as one, so the assets carry neither.
  • XSS: all server-derived text renders via textContent; the sole deliberate exception is the highlight-token renderer described above, which never interprets HTML.
  • GET /ui/meta (the one new JSON endpoint) carries version, node id, clustered flag, whether auth is required, and uptime — the same trust level as /health and /status.
  • Audit: console queries are ordinary /query calls and ride the existing query log / audit settings unchanged.
  • TLS: same as the REST endpoint — put a TLS-terminating proxy in front on untrusted networks; Basic auth wants it.

The BI app is elsewhere

This UI is an operator console, not a dashboard. Reporting lives on a BI node at /bi, which every node redirects to; a BI node badges itself <cluster>.bi.<alias> here, and the witness card carries its address. See BI.md.

Cluster notes

The UI talks only to the node that served it; the overview lists the peers' endpoints, and each peer serves its own /ui. There is no cross-node proxying.