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.

  • overview — identity, per-node health, and live throughput.
  • 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); 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. 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, placementcluster 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.

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.