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 — forADVISOR 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
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). Filter by database or table name; Escape clears the filter. 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. The run button shows progress and repeated submissions are ignored while a query is running. 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.
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.