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
resyncbadge 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/hostsjoined toGET /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 replicatedwitnessestable alongside the grace period in effect fromwitness_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 replicateddriverstable. 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/queryenforces — table, database, and global grants, role inheritance included); clicking a table targets its database and pre-fills aSELECT. Ctrl/⌘+Enter runs, Alt+↑/↓ cycles history (kept inlocalStorage, statements only — never results or credentials), and when a result looks like a time series (ats/time/bucketcolumn plus numeric columns) a line chart renders above the table. Canned-statement and history dropdowns, CSV/JSON export, client-measured latency. Results render escaped; FTSHIGHLIGHT()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 aLIMIT.USE <db>is tracked client-side and sent per request (POST /queryaccepts 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 —clusterdefault,rf noverride, orpinned: <alias>with a highlighted→ movingmarker 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 aSELECTin the query console; hovering an index row shows its canonical DDL, the same stringSHOW INDEXESreports. A constraints card lists every constraint of every kind — theSHOW CONSTRAINTSview: 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 UPDATEactions 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 — aNOT VALIDconstraint is highlighted; hovering a row shows its clause. A sequences card lists every sequence — theSHOW SEQUENCESview: database, name, the column that owns it (users.idfor aSERIAL/ identity column), last value (—before the firstnextval), start, increment, range (∞for an unbounded side), cache and cycle; hovering a row shows itsCREATE SEQUENCEDDL. A views card lists every view — theSHOW VIEWSview: 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 explicitAuthorization: Basicheader from the login form. Credentials live in JS memory, orsessionStoragewith the "remember for this tab" opt-in — neverlocalStorage, 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
/uiresponse carriesdefault-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'andX-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.styleassignment 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/healthand/status.- Audit: console queries are ordinary
/querycalls 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.