BI node (/bi)
A BI node is a third kind of skaidb node, next to a cluster member and
a witness. It mirrors the databases you report on and serves the
business-intelligence app at /bi, so dashboard queries never land on a
member. Every other node redirects /bi to it, which means you can hand
out https://<any node>/bi and change your mind later about which
machine runs the app.
A BI node serves: a catalog tree of what it mirrors, a tabbed SQL (and PromQL) editor whose drafts and saved queries live on the cluster, a typed result grid with CSV export, charts over any result, dashboards built from saved queries, and a Data page showing how old each mirrored table is.
Why a separate node
Analytical reads are the heaviest shape the engine runs: wide GROUP BY,
window functions and DISTINCT ON materialise the whole filtered set, and
a join pulls both sides to the coordinator. A long read holds the engine
lock, and the lock prefers writers, so one dashboard refresh can stall
every writer on the node that serves it. Putting that on a node of its own
turns a dashboard storm into a problem for a machine that serves nothing
else.
The mirror is a witness, so nothing about replication is new: the same pull loop, the same table selection, the same watermarks. What the BI node adds is an app and an address.
The three shapes
| Backend | [witness] enabled |
[bi] enabled |
Meaning |
|---|---|---|---|
standalone (cluster.seeds = []) |
true | true | a BI node — mirrors the cluster, serves /bi |
| standalone | false | true | BI over the node's own data (single-node deployments, dev) |
| cluster member | — | true | refused at start-up |
A member refuses because serving analytics there defeats the point. The error names the fix.
Configure one
A BI node is an ordinary install (deb, rpm or the Docker image) with two
sections added to /etc/skaidb/skaidb.toml:
[server]
read_only = true # required: the mirror must not diverge
[witness]
enabled = true
primary_sql_addrs = ["192.168.7.3:7000", "192.168.7.4:7000"]
primary_internode_addrs = ["192.168.7.3:7100", "192.168.7.4:7100"]
user = "bi_witness"
password = "..."
databases = ["sales", "ops"] # what the reports read — or ["*"]: every user
# database, new ones mirrored on the next cycle
witness_id = "bi1"
region = "eu"
[bi]
enabled = true
advertise_url = "https://bi.example.net:7443/bi"
The role on the primary needs the ordinary witness grants, plus the four
on _bi, where the app keeps saved queries and drafts:
CREATE USER bi_witness PASSWORD '...';
GRANT SELECT ON witnesses TO bi_witness;
GRANT INSERT ON witnesses TO bi_witness;
GRANT UPDATE ON witnesses TO bi_witness; -- one privilege per statement
GRANT SELECT ON DATABASE _bi TO bi_witness;
GRANT INSERT ON DATABASE _bi TO bi_witness;
GRANT UPDATE ON DATABASE _bi TO bi_witness;
GRANT DELETE ON DATABASE _bi TO bi_witness;
No CREATE grant is needed, and that is deliberate: CREATE DATABASE
requires CREATE ON GLOBAL, which would let a read-only mirror create
tables anywhere in the cluster. Every primary creates _bi at start-up
instead, alongside the witness registry it already creates, so the BI node
only ever reads and writes rows in it.
Who may use it
Using the BI app is its own privilege:
GRANT BI ON * TO analyst;
It is deliberately not a side-effect of SELECT. A role that may read a
table through a driver is not automatically someone who should have an
analytics console, a saved-query library and a dashboard on a shared node
— those are separate decisions, so they are separate grants. What a role
can then read is unchanged: its own SELECT grants, table by table.
This privilege opens the door; it does not widen the room.
ADMIN ON * implies it, as it implies everything. A role without it gets
403 naming the exact statement that fixes it, and the app shows that
message rather than "sign-in failed" — the password was right, and sending
someone to reset it wastes an afternoon.
Accounts on the BI node
BI users are local accounts on the BI node. It is a standalone node
with its own [auth], so its users are its own; nothing about them is
replicated from the cluster, and a BI login is not a cluster login.
-- on the BI node, as its superuser
CREATE USER analyst PASSWORD '<generated>';
GRANT BI ON * TO analyst;
GRANT SELECT ON DATABASE onet TO analyst; -- what they may read
Kerberos works here too. A BI node accepts SPNEGO exactly as any REST endpoint does, so an analyst with a ticket signs in without a password:
-- on the BI node, as its superuser
CREATE USER "[email protected]" GSSAPI; -- quoted: a principal is not a bare identifier
GRANT BI ON * TO "[email protected]";
GRANT SELECT ON DATABASE onet TO "[email protected]";
The app asks the node whether Kerberos is available (/bi/api/meta reports
sso) and, if it is, probes once without a credential so the browser can
answer the Negotiate challenge. Signed in that way, the badge shows the
principal, and Sign out means "do not sign me in that way again in this
tab" — clearing a header cannot end a Kerberos session, the browser simply
negotiates again.
Four preconditions, none of them code you write:
- the node runs the amd64 glibc package (Kerberos is compiled in there; the static musl build can never do SPNEGO),
auth.gssapi_enabled = truewith a readableauth.gssapi_keytab,- the keytab holds an
HTTP/<host>@REALMprincipal — browsers ask for the HTTP service name, notskaidb/<host>(see KERBEROS.md), - the browser trusts the host (Chrome
AuthServerAllowlist, Firefoxnetwork.negotiate-auth.trusted-uris), and that host is the onebi.advertise_urlpoints at.
Principals map to roles exactly — [email protected] is the role
[email protected], case and realm included. There is no auth_to_local
rewriting, so the CREATE USER has to match what the KDC issues.
Two things follow from local accounts, and both are load-bearing:
- Grant only what the reports need. The BI node mirrors whole
databases, and witness table selection is not access control — what the
node holds is what a
SELECT-on-database grant can read. This cuts both ways when choosing what to mirror: a database holding OAuth tokens or API keys puts those bytes on the BI node, where one broad grant exposes them. Mirror such a database by explicit table list (witness.tables), or not at all. - A departing analyst must be removed here too. There is no central
directory behind these accounts yet;
DROP USERon the BI node is what ends their access to it.
The node is server.read_only, which exempts the superuser — so CREATE
USER and GRANT work there as the superuser and nowhere else.
[bi] keys
| key | default | meaning |
|---|---|---|
enabled |
false |
serve /bi here and advertise this node. Live-mutable (config set bi.enabled …). |
advertise_url |
"" |
the absolute URL browsers reach this node's /bi at. Required when enabled. |
max_concurrent_queries |
4 |
statements at once; past it, 429 after a short queue. Live-mutable. |
max_result_rows |
100000 |
rows one response may carry; shown on Run, and a truncated result says so. Live-mutable. |
usage_days |
30 |
how far Insights looks back, and how long local run history is kept. Live-mutable. |
scan_row_budget / scan_byte_budget |
0 |
per-statement ceilings; 0 = the node's own budgets. |
A BI statement's time ceiling is the node's own
storage.statement_timeout_secs, not a [bi] key. On a dedicated BI node
that is the same thing — nothing else runs there — so a second knob would
only be a second place to look. (There was a bi.query_timeout_secs for
one day; it was never wired to anything, and a documented knob that does
nothing is worse than no knob.)
advertise_url cannot be derived. The REST gateway never learns its own
advertised name — its only other redirect rebuilds the URL from the
request's Host header — and a target that browsers must reach is not
something to guess. Point it at whatever your users actually type,
including a reverse proxy in front of the node.
Where saved work lives
Saved queries, editor drafts and (next) dashboards are kept in a _bi
database on the cluster, not on the BI node. They are the only thing
a BI node holds that nobody can regenerate, so they get what any cluster
table gets: replicated writes, point-in-time recovery, BACKUP CLUSTER,
anti-entropy. Lose the BI machine and deploy another: every saved query
is back on its first pull, and a second BI node in another region sees
the same ones.
The mechanics follow from that:
- The BI node stays
server.read_only. Metadata writes travel over the same SQL control connection the witness already holds to a primary, so the mirror can never diverge from what it mirrors. - Metadata reads go to the primary too, which buys read-your-writes: a query you just saved is in the list on the next request rather than after the next pull cycle.
_bidoes not appear inSHOW DATABASESfor a role withoutADMIN: it is server-owned bookkeeping, and an analyst has no use for its tables. This is tidiness, not a boundary — RBAC governs access to it either way, and an admin still sees it listed._biis created by the primaries, not by the BI node, and mirrored like any other database; that copy is the fallback. A cluster that does not have one yet costs the mirror nothing — the pull skips it and keeps going with the databases the operator actually asked for. When no primary answers, the app serves saved work from the mirror, says so, and refuses to save rather than writing where the next cycle would overwrite it.- Run history is the exception: it is telemetry, high-churn and worthless
after a rebuild, so it stays local to the BI node, in its own
_bi.runs. It records what ran, who ran it, how long it took and which tables it read; it is swept tobi.usage_days, and it is what Insights is computed from. It is written as the superuser, which is the one roleserver.read_onlyexempts — the same path node_stats and the driver registry take.
Visibility is per row. You always see your own; you see other people's
when they marked them shared, read-only; ADMIN ON * sees everything.
Drafts are never shared with anyone, admins included — an open editor tab
is a thought in progress, not a document.
Every saved query records the tables its statement reads, parsed when it is saved. That is the usage half of lineage, and it is what will answer "what breaks if I drop this column".
How /bi finds the node
A BI node writes roles and bi_url into its own row in the witnesses
registry on every heartbeat. That is the only place a primary can learn a
witness's HTTP address: the registry is pull-only by design, and nothing
else on a member knows one.
Every other node then answers /bi:
- A member reads the registry rows it already caches for
/status, so the redirect costs no query. - A witness uses the list its own pull cycle caches from the primary
(it has no local copy of
defaultto read). - A standalone primary reads its own registry, briefly cached. A negative answer is never cached, so a BI node you just deployed shows up as soon as it beats.
The response is 302 Found with the path and query preserved, so
https://member/bi/d/sales?range=7d lands on that dashboard on the BI
node: /bi/d/<slug> is a dashboard's shareable address, and opening a
board puts it in the URL bar. Slugs are not unique — two boards may share
a name — so a link resolves to the newest match, and a slug that no longer
exists says so rather than showing an empty board. It is a
302 rather than a permanent redirect because which node serves the app is
a current choice: a browser that cached a permanent one would keep going
to a decommissioned node.
Selection rules:
?bi=<witness_id>pins a node, so a shared link can name the one it was built against. A pinned node that has been quiet for a day falls through to whatever can actually answer — the row is evidence it was retired — while a pin naming a node the registry does not list at all gets the "no BI node" page rather than somebody else's dashboards, because substituting a different node on a name we know nothing about would be silent and wrong.?region=<name>prefers a BI node there; without it, the region of the node serving the request is preferred, so a browser that reached a London member is sent to the London BI node. A preference is not a filter: when that region has nothing live, the request is still answered from elsewhere.- A member states its region in its identity row rather than in config
— it has no
[witness]block — withUPDATE node_aliases SET region = 'eu-west' WHERE node_id = '<internode id>'. A witness, BI node or standalone uses[witness] region. - Otherwise the most recently seen node wins.
- A node that has not heartbeat for three beats — at least 5 minutes, and
longer if
witness.interval_secsis above 100 s — stops receiving traffic. - A node never redirects to itself, so a mistyped
advertise_urlpointing back at a non-BI node shows the "no BI node" page instead of bouncing.
When nothing is registered, /bi answers 200 with a page explaining
what a BI node is and the exact config to deploy one. API clients that ask
for JSON (Accept: application/json) get 404 {"error": "no bi node
deployed"} instead, which is a state they can branch on.
Using it
Open /bi on any node — you land on the BI node either way.
- Explore is the working surface: filter the tree, click a table to
see its first 100 rows, run with the button or ⌘/Ctrl+Enter. A click is
one thing — it shows you the table. The preview lands in a tab named
after it, and previewing another table reuses that tab rather than
filing a new one; a tab you have typed into is never overwritten. To
put a table's name in a statement you are writing, type it and let
auto-complete finish it. The « button at the top of the tree folds
the whole pane away to a rail and back, and this browser remembers
which way you left it. The pane keeps its width whatever the result
holds: a row whose cell carries four kilobytes of JSON scrolls inside
the grid, it does not push the sidebar off the screen. The Run button carries the row cap
(
bi.max_result_rows); a result that hits it says truncated rather than quietly clipping. Beside the timing sits data as of: the last successful pull cycle, which is the age of everything this node holds, because a cycle pulls every selected table. Before the first cycle completes it is blank rather than "now". - Expand gives the results the whole pane: the tab strip, the editor and the builder fold away, the toolbar stays — Run and the database are still what you want while reading a result — and Escape or the button brings them back. This browser remembers which way you left it.
- Tabs are drafts. Every open tab is stored under your name in
_bi, so the statements you left unfinished are still there tomorrow, on another machine, or on a BI node rebuilt from scratch. A dot marks a tab whose text is not saved as a query; closing a tab discards its draft. - Save (⌘/Ctrl+S) turns the current tab into a saved query with a name, optional tags and an optional shared flag. Only reads can be saved, and the refusal says so at save time rather than at run time, because a saved query is something a dashboard will run later when nobody is watching. The Saved panel lists them; clicking one opens it in a new tab and runs it, and someone else's shared query opens as a copy so you cannot edit theirs by accident.
- PromQL is the second language in the editor, for the mirrored
time-series tables. Pick a table, a range and a step, and write an
expression:
rate(cpu[5m]),sum by (host) (mem_used_bytes). The result comes back as a timestamp column and one column per series, so it charts, exports and becomes a dashboard tile exactly like a SQL result — a metrics tile is a tile. PromQL has noFROM, so the table you pick is the scope, and it is also what the query counts as reading: a metrics tile shows up in that table's lineage and Insights like any other query. - Sheet turns the grid into a spreadsheet: columns get letters (A, B,
C) above their names, the cell you are on shows its address, and a
formula bar appears. A formula that reads a RANGE is one answer about
the whole result and is shown in the bar —
=SUM(C1:C50),=ROUND(AVG(D1:D200),2). A formula that reads the row it is on becomes a COLUMN, filled down, and joins the grid as one: sortable, filterable, totalled, copied, and removable with the × on its header. Write=B*C,=ROUND(D/100,2),=IF(C>50,'high','low'),=[unit price] * [quantity]— a bare letter is that column of the current row,C7is row 7 of column C,A2:B9is a rectangle, and a name in brackets is a column whose name has spaces. Click a cell in a formula column and the bar shows the formula behind it.
About 30 functions: SUM, AVG, MIN, MAX, COUNT, COUNTA, COUNTIF, SUMIF,
ROUND, FLOOR, CEIL, ABS, SQRT, POWER, MOD, INT, SIGN, IF, AND, OR, NOT,
LEN, LOWER, UPPER, TRIM, LEFT, RIGHT, MID, CONCAT, COALESCE, ISBLANK,
NUMBER and TEXT, with + - * / % ^ &, comparisons and and/or. The
formula language is parsed and evaluated by the app itself — never
eval, which the gateway's content-security policy refuses anyway, and
which is the last thing anyone should point at a typed-in expression.
Formulas work on the rows on screen; to compute over the whole table,
put the expression in the statement, where the engine can push it down.
- The grid is a sheet. Click a cell and drag, or shift-click, to
select a block; the strip says what it adds up to — 4×3 selected · Σ
1,204 · x̄ 100 · min 3 · max 480 — and ⌘/Ctrl+C copies just that block
as tab-separated text. Arrow keys move the selection, shift-arrows
extend it, Escape lets it go. Columns are divided by a rule and the row
numbers sit in a gutter of their own, so a header never reads as part
of the column beside it; a numeric column's header sits over its own
values. Drag the rule at a column's right edge to resize it — the grip
straddles the rule, is twelve pixels wide, and holds the pointer for
the whole drag — or double-click it to fit that column to its widest
value. Freeze keeps the first column in view while you scroll
sideways. Above it: a find box across every column, a
filter box under each column name (a substring, or a comparison like
>100, <=0, =north), totals for the rows shown (sum and average
where a column is numeric, a range where it is time, distinct counts
otherwise), a copy button that puts exactly what is on screen on the
clipboard as tab-separated text, and Reset. Clicking a column sorts by
it; shift-clicking adds a second key, and a third, each numbered in the
header. The count says what it is showing — "9 of 60 rows" — and says
when the statement itself returned more than the grid holds. All of it
works on the rows in hand and none of it changes the statement, so
what you filtered is always what you can see, copy and total. To
narrow the QUERY rather than the view, double-click a cell: that
derives a statement on the server.
- Chart turns the current result into a picture: line, area, bar
(grouped or stacked), row, bar-plus-line on two axes, scatter, box plot,
pie, donut, treemap, funnel, waterfall, sankey, map, a single number with
its change against the previous row, a progress bar and a gauge against a
target. The app picks a first chart from the shape of the result — a time
column and a measure is a line, a label and a measure is a bar, many
categories is a row chart — and you change it from there. Types that read
an extra option show it and the others hide it: Size for a scatter's
third dimension, Target for a gauge or progress bar, Slices for
the types that fold a long tail into an explicit "other". The chart
belongs to the tab, so switching tabs does not lose it.
A time axis labels what it can fit and no more, and it recognises a
timestamp stored as a plain epoch-millisecond integer as one — a column
that came from a document store used to be labelled with thirteen-digit
numbers: the labels are thinned
against the MEASURED width of the axis font, the coarse unit is printed
where it changes rather than on every tick — "Apr 23 2026" then "May
19", "Jun 1" — the resolution follows the span from seconds up to
years, and the last tick is always drawn. A seven-month range used to
print twenty-three overlapping 04-23 20:00s with the year nowhere.
The map has no basemap: every external host is blocked by the app's
content-security policy, so points are plotted on a graticule rather
than over tiles fetched from somewhere else.
- Auto-complete offers tables, columns, functions and snippets as you
type, from the catalog you can read — the same filter the tree uses,
because a completion list that named a table you cannot select from
would answer "does this exist?" for anyone who can type. A declared
FOREIGN KEY becomes a JOIN … ON … suggestion, so the join the schema
already describes is one the editor writes for you; a join into a parent
you may not read is not offered, for the same reason the tree hides it. Arrows move,
Tab or Enter accepts, Escape dismisses.
- Object pages answer "what is this, and who depends on it". The ⓘ
beside a table in the tree opens one: what it holds and how big it is,
twenty sample rows, its indexes and constraints, its mirror freshness,
and its lineage. Upstream is what feeds it (a view's base tables, a
rollup's source, the parents its foreign keys point at). Downstream is
what reads it (views and rollups built on it, its child tables, and the
saved queries and dashboards that use it). Catalog edges cover views,
rollups, foreign keys, indexes, streams, partition children, and the
mirror edge that records a table as pulled from the cluster. They are
exact.
Usage edges come from the tables each saved query was parsed to read, so
an ad-hoc statement nobody saved is not in the graph, and the page says
so rather than implying the list is complete. Graph switches from the
two lists to the neighbourhood as a picture — a layered DAG, one to four
hops out, catalog edges solid and usage edges dashed, click a node to
open it. It is drawn as SVG because the content-security policy drops a
style attribute: an HTML-positioned graph would render as a pile of
cards in the corner, silently. The page also carries a
The Indexes and Constraints panes describe the CLUSTER's schema: a
witness mirrors views, foreign keys and check constraints along with the
data. Secondary indexes are mirrored only where the operator asked for
it (witness.mirror_indexes), and the pane says which half it can speak
for: with the catalog swept but index replay off, "no constraints" is a
real answer while the index list is explicitly not one. Until a node has
swept at all, neither is. An empty list and "this node cannot see them"
are otherwise the same picture and opposite facts, and the second read
as the first is how somebody concludes a column is unindexed when it is
not. The object page also carries a
description and tags anyone who can read the object may write (the
engine has no DDL for a comment on a table, so this is an overlay in
_bi, and the row records who wrote it; clearing it removes the row, so
"never described" and "deliberately blanked" are not two states). Tags
become facets over the catalog tree: a chip per tag, and selecting
more than one narrows, so a table has to carry all of them. The number
on a chip is what clicking it would leave — it counts the intersection
with the facets already on and respects the text filter, because a
count that promised more than the click delivered would be the reason
nobody trusted the next one. The Schema tab also answers "What
breaks?" per field: which saved queries read that column, kept in four
groups because they carry different certainty — naming it, selecting
every column, reaching it through a view or rollup (which may not carry
it through at all), and a bare column name with several tables in scope.
The answer states its own blind spots every time, including when it
finds nothing: it covers saved queries the asker can see, so ad-hoc
statements, other people's private work and a read reached only through
a subquery are all outside it. The object page also has an
Access tab listing
the roles whose grants reach the object. Access needs ADMIN ON * to
read, so a non-admin sees an explanation rather than a half-list that
would read as "nobody else can see this".
- Insights answers the question a catalog usually cannot: is this
object actually used, and by whom. Runs in the window, how many people,
typical and slowest time, the statements run most (named when they are
saved queries), and the tables queried alongside it — the join
partners, which is how you find out what a table is really used with.
Every panel states its scope, because the honest answer is narrow: this
is BI traffic on this node, over bi.usage_days (default 30). A driver,
a job or another cluster reading the table is invisible here, so "no
runs" never means "unused". Every statement in Most run carries a
Run button: a saved one opens as itself, with its language and its
parameters; an ad-hoc one opens in the editor and runs. A PromQL
evaluation is recorded against its scope table as a stand-in SELECT,
so it counts as usage but is not offered as a statement to re-run —
that text would scan the table rather than evaluate the expression.
- Chart it is one click from an object to a dashboard. The starter
tile is built from what the object actually holds: a time-series table
gets a PromQL sum over its series key; a table with a timestamp column
(typed, or epoch milliseconds in a plain integer — a schema-less store
has both) gets a count per hour; anything else gets a top-N group-by on
its first repeating text column. The board says what it built from, and
the tile is an ordinary saved query, so the person who knows the table
fixes it in ten seconds.
- Fields on the Schema tab lists what the rows carry, and lets anyone
write down what each one means. skaidb is schema-less, so the list comes
from a sample rather than a declaration — and that is exactly why the
note matters: it is often the only place source_type is ever
explained.
- Recent sits above the tree: the objects you opened, per user, kept in
_bi so they follow you to another browser. An alphabetical tree is the
right default and the wrong answer for someone who works on four tables
out of six hundred.
- Pivot turns a GROUP BY a, b result into a crosstab: one dimension
down the side, the other across the top, the measure in the cells. The
engine has no PIVOT and needs none — that query already returns exactly
these triples, so this is presentation. It is careful about three things:
an absent combination renders as a gap, not a zero ("did not occur"
and "summed to nothing" are different facts), only the widest 40 column
values are shown and the rest are counted, not dropped silently, and
a truncated result says so loudly, because a partial answer reshaped
into a grid looks complete.
- Drill down by clicking a bar, point or slice: the rows behind that
bucket appear under the chart, with the statement that produced them
shown above — a filtered result nobody can inspect is a magic trick, not
a tool. The statement is derived, not guessed: the one that drew the
chart is parsed, its grouped dimension is pinned to the clicked value,
and its aggregates are dropped. Grouping by an alias resolves back to the
expression it names, so a time_bucket chart filters on the bucket and
not on a column that does not exist. A result with no GROUP BY has no
dimension to pin and says so; the rows on screen already are the rows.
"Open in the editor" puts the derived statement in a new tab.
- Data also names any table the last pull could not fetch. A cycle
does not stop at the first failure, so "the mirror pulled" and "the
mirror is complete" are separate facts and the page shows both.
- Data is the page to open when someone says a number looks stale:
what this node mirrors, the pull interval, and — per table — when it was
last fully swept and how far through its data the pull has reached.
Those are two different clocks. A table whose newest row is months old
is not a stale mirror; it is a quiet table.
- The layout is fluid. Type, spacing, the sidebar, the editor and the
chart are sized in viewport units with floors and ceilings rather than
fixed pixels, so a 13-inch laptop is not the same screen as a 4K
monitor with the same 190-pixel editor on it. Three breakpoints do the
rest: below 1100px the chrome gives back its padding; below 860px the
table tree stops taking width from the editor and becomes an overlay
with its fold rail always reachable; below 560px rows grow to touch
size. Nothing scrolls sideways at any size, and no control ends up off
the edge — crates/skaidb-server/tests/bi_responsive.py measures that
at sixteen viewports from 360px to 4K, in four states (plain, builder
open, a result with 4KB cells, sidebar folded), and exits non-zero if
any of them is wrong. A dashboard steps twelve columns → six → three →
one as the window narrows, restating the saved placement in each
column count so a tile pinned to column nine never invents a
thirteenth, and tiles redraw from the rows already in hand when the
window changes size rather than re-querying the node. Sizes follow the
browser's own font-size preference, since the scale is written in
rem.
- Writes are refused, with the reason. A BI node reads a mirror;
changes go to the cluster.
- Load control: bi.max_concurrent_queries (default 4) bounds
concurrent statements. Past it a request waits a second and then gets
429 with a retry hint, so a dashboard storm degrades visibly instead
of dragging every tile down. bi.scan_row_budget replaces the node's
storage.scan_row_budget for BI statements in either direction — a BI
node is a dedicated node, and a wider row budget is the point of having
one, paid in engine time on its own mirror pulls — while
bi.scan_byte_budget can only tighten the node's byte ceiling, which
is what keeps a wide scan from materializing more than the node holds.
A statement that exceeds one is refused by name: the message says
bi.scan_row_budget when the [bi] budget is what bound it, so the
knob to raise is the one in the error, and the response carries the
knob and the rows examined as data. The app turns that into the remedy
that fits: a time-series ts picked without a bucket gets a one-click
bucket, a builder query with no filter is offered one, and the knob is
named for an admin. Grouped statements over row tables with
count/sum/avg/min/max stream one row per group and are not bounded by
the row budget at all; the refusal is for raw gathers — an unbucketed
time column on a time-series table, a count(*) over one, or a
distinct count.
- BI traffic is tagged via = bi, so SHOW QUERIES, the slow log and the
audit log separate it from driver traffic. KILL QUERY works on it.
The query builder
Builder is the third language in the editor. Instead of a statement
you pick: a source (a table in the current database, or a model), the
columns to group by, the measures to compute, filters, a
sort and a limit. The pane on the left lists what the source
offers — every column of a table, with numeric ones also offered as
sum and avg, and timestamps bucketable to a minute, an hour or a day;
a model's declared dimensions and metrics. The statement the picks
compile to appears in the editor beneath, read-only, and runs, charts,
exports, saves and becomes a tile like any other.
Nothing picked is ever syntax. The server builds the statement from the
picks as an AST: a column has to be an identifier, a filter value becomes
a literal, and the text is parsed back and checked against the picks
before it is returned. A filter value of x' OR 1=1 -- filters on that
string. A {{parameter}} is refused in a filter — the builder takes
values; the SQL tab takes parameters.
Types are detected, not only read. The palette shows what each
column is, judged from its values where the catalog says too little:
an integer in the epoch-millisecond range is epoch ms, an ISO date
stored as text is iso date, a number stored as text is number as
text. Picking such a column applies the conversion that makes it what
it is, so an epoch-millisecond column buckets and charts as time. A
time-series table is read from its fixed shape — series-key columns are
labels, ts is the time, every field is a measure — and ts arrives
bucketed by the hour.
Column type conversion. Every chip on a raw table carries a type
select: as is, number, integer, text, timestamp, bool. It compiles to
the engine's to_* conversion — sum(to_float(price)),
time_bucket(1h, to_timestamp(created_ms)), to_bool(active) = true
— applied before bucketing and before aggregation, and a filter value
is coerced to the same type so the comparison is the one you meant. A
model field carries its own type in its expression and takes no cast.
Time-series sources compile to the shape the engine serves from
partial aggregates without a scan: a bucketed ts, label dimensions,
and a count/sum/avg/min/max of a field. count(*) over a time-series
table reads every raw sample and is not offered; count a field.
A saved builder query stores its picks, not only their text. Reopening it
restores the builder; Edit as SQL lets the picks go and keeps the
statement, one way — a saved query edited as SQL is a SQL query from then
on. The Saved panel marks built queries with ◆. A builder tab's draft
carries its picks, so it comes back as a builder tab.
Models
A model is a named set of definitions over a base table and its
joins: dimensions (expressions to group by — lower(country) as
region) and metrics (expressions that aggregate — sum(total) -
sum(cost) as margin). A metric must aggregate and a dimension must not;
neither may read another table through a subquery, name a window, take a
parameter, or call a function that writes. The Models panel lists
them; + model defines one; a table's object page offers Model it,
which derives a model from the table in one step — every column a
dimension, each numeric column summed and averaged, and a row count — for
someone to rename and prune.
A model is what gives a number one definition. revenue means the same
thing on every board built from the model, and changing the definition
changes every board: when a model or one of its fields is saved, every
saved query built on it is recompiled and stored again, so a tile that
reads the stored statement gets the new meaning without being touched. A
change that would break a dependent — a field renamed out from under a
query that picks it — is refused, naming the queries, and deleting a
field or a model something is built on is refused the same way. Breaking
a dashboard on somebody else's screen at its next refresh is what the
refusal exists to prevent.
Models are private to their author unless marked shared or filed in a shared folder. Listing is not a gate: a model over a table you cannot read is not shown, and a shared model is compiled as you — if you may not read its base table, you cannot run a query built on it. A model's page shows its definition, its fields, and what is built on it; the lineage of a table shows the models over it, and the graph draws a model as a hop between the table and the queries built on it, keeping the direct table-to-query edge that carries the columns column impact walks.
Operating it
\biinskaidbshprints where the app is served, per node, or says none is deployed./statuson a BI node reports it; on a member, each entry ofwitnesses[]carriesrolesandbi_url.- The web UI badges a BI node as
<cluster>.bi.<alias>, and its witness card carries the endpoint. - Switching
[bi] enabledoff retires the endpoint on the next heartbeat — within onewitness.interval_secs— with no admin step.
Checking a deployment
crates/skaidb-server/tests/bi_smoke.py drives the real page in headless
Chrome against a running BI node: it signs in, runs a statement, saves it,
reopens it from the Saved panel, draws every chart type, confirms a draft
reached the cluster, and fails on any uncaught error. It cleans up the
rows it makes.
python3 crates/skaidb-server/tests/bi_smoke.py https://bi.example.net:7443 alice hunter2
It exists because the failures that matter here are invisible to the Rust
suite. A style attribute dropped by the content-security policy leaves
an element on the page and unpainted; the smoke test reads the computed
fill of a bar instead of trusting that the element exists. By default it
charts the node's own node_stats, so it is safe to run against a live
node; pass a statement of your own as the fourth argument.
crates/skaidb-server/tests/bi_storm.py is the load half, and it needs no
browser. It builds a dashboard of many tiles over a smaller set of saved
queries — so tiles share statements and the cache and the coalescing are
both exercised — gives the board a refresh cadence so the node's own
scheduler works it too, then drives concurrent viewers at
/bi/api/query the way an opening dashboard does.
python3 crates/skaidb-server/tests/bi_storm.py https://bi.example.net:7443 alice hunter2
It reports throughput, p50/p95/p99, what share of answers came from the
cache, and how many requests the admission gate turned away — a 429
share is the gate WORKING, so it is reported against a ceiling rather
than failed. What it does fail on is a 5xx, a statement whose row count
changes between runs, a cache hit rate of zero (the cache never worked),
or a p99 past the threshold you pass. Like the smoke test it targets
node_stats by default and removes everything it made, so it is safe
against a live node — though its own rows land in _bi.runs, so numbers
are comparable within a run rather than across many.
Parameters
A saved query can take parameters. Write {{name}} in the statement and
declare each one with a type — ts, text, number or list:
SELECT region, sum(total) FROM orders
WHERE ts BETWEEN {{from}} AND {{to}} AND region = {{region}}
GROUP BY region
The editor grows a filter chip per parameter as you type it, and the save
bar asks for the types — the one moment somebody is there to answer. Until
then the type is read off the value in the box, so WHERE n > {{floor}}
with -1 typed compares numbers rather than binding '-1' as text and
quietly matching nothing. A saved query never guesses: it carries its own
declarations.
Values are bound, never pasted. Each placeholder becomes a ?, the
statement is parsed once, and the values are substituted into the parsed
statement as literals. A value can therefore never become syntax, whatever
anybody types into a filter box. A list is the one shape that changes
the statement rather than a literal — IN ({{ids}}) with three values
becomes IN (?, ?, ?) — and that expansion is driven by the count, never
by the strings.
A {{name}} inside quotes is refused rather than substituted: '{{x}}'
would bind nothing and match the literal text, which is precisely the
silent wrong answer the types exist to prevent.
A ts takes milliseconds or a relative expression — now, now-7d,
now+1h — resolved when the statement RUNS. That is what makes a saved
dashboard mean "the last seven days" rather than the seven days that
ended when it was saved.
Dropdowns. A declaration may name where its values come from: a static list, or a saved query returning one column. The chip becomes a dropdown over them. The options query runs as the viewer, so a dropdown can never show a value its reader may not select for themselves, and the chosen value is still bound — a dropdown is a convenience over the same binding, not a second path into the statement. A value that is not in the list is still accepted and still bound, because the list may have changed since the dashboard was saved.
On a dashboard the filters belong to the board: the bar is the union of what its tiles declare, so one time range drives every statement that takes one, and a tile that does not declare a parameter ignores it.
Tile layout
Tiles sit on a twelve-column grid — twelve divides by two, three and four, so halves, thirds and quarters all land on whole columns. Drag a tile onto another to move it there; the width control sets how many columns it spans, in the sizes worth having rather than by the column.
The server owns the arrangement. Every drag and resize sends an intent, the whole board is renormalised, and the browser redraws from the answer — so a change that displaced three other tiles shows all four moving, and two people editing the same board cannot save half of each other's work. Normalisation never moves a tile upward: a gap left deliberately survives a save. Only the newest change is drawn, because two in flight at once return in whatever order the network chooses and the older answer describes a board that no longer exists.
Placement is carried by CSS classes rather than inline geometry — the
content-security policy drops a style attribute, so a grid positioned
that way would pile every tile into column one, unpainted, with nothing
reported. Below 900px the board becomes a single column: the saved columns
and heights are released, because a quarter-width tile on a phone is not a
smaller version of the same chart.
Folders
Saved queries can be filed in folders, nested a few levels deep. A shared folder shows its saved queries to the people it is shared with, read-only; drafts stay private to their author even inside one, because a half-written statement is not a publication.
Folders organise; grants authorize. Moving a query between folders changes nothing about who may RUN it — a folder confers only the ability to see that the query is there. Conflating the two would turn a file tree into an access-control system nobody can audit, so it is deliberately not one: the grants decide, as they do everywhere else.
Deleting a folder that still holds queries is refused rather than cascading, and a folder cannot be moved inside its own descendant — a tree with a loop hangs every reader that walks it.
The query profile
Profile runs the statement again under EXPLAIN ANALYZE and shows
where the work went: the plan as steps from the access path up to the
result, each with the rows behind it, and a line naming what to look at
and the likely fix — an index, a narrower filter, a partition key.
It is honest about what it measures. The engine reports statement totals, not per-operator timings, so each number says whose it is rather than implying a breakdown that does not exist. On a cluster a scattered query's time is the coordinator's share, and the profile says so where that applies — a profile presenting one member's time as the whole is a lie the reader cannot detect.
Profiling runs the statement, so it takes a SELECT, and it goes through
the same gate, budgets and RBAC as any other query: it costs what the
original cost, and must not have a cheaper way in.
Exports
CSV writes a file a spreadsheet opens correctly: a UTF-8 byte-order
mark (without it Excel on Windows reads the file in the system codepage
and mangles every non-ASCII cell), CRLF records, RFC-4180 quoting, and a
leading =, +, - or @ neutralised so a cell cannot arrive as a
formula — numbers excepted, since -42 is a number. NULL and the empty
string are written differently, so a reader can still tell them apart.
JSON writes the same envelope the app already speaks.
The export runs the STATEMENT rather than serialising the grid, bounded by
bi.export_max_rows instead of bi.max_result_rows — the number that
suits a screen and the number that suits a file are not the same, and
tying them together makes a large export impossible rather than slow. It
goes through the same runner as a query, so the admission gate, the
budgets, the parameter binding and the RBAC are the ones a query already
faces. The response carries X-Skaidb-Rows and X-Skaidb-Truncated: a
silently short file is the failure that matters, and a filename is the
one thing people rename.
Scheduled queries and alerts
A saved query can carry a schedule, and optionally an alert: a condition over its result and somewhere to send it.
[[bi.destinations]]
name = "ops"
kind = "webhook" # or "slack"
url = "https://hooks.example.net/…"
The URL lives in the node's config rather than in _bi, and that is the
point: _bi is mirrored and readable by every BI user, so a webhook URL
stored there would be a credential sitting on a node analysts read. An
alert names a destination; only the operator can see what is behind
the name. The app lists the names and never the URLs.
A cadence is EVERY 5m or CRON '0 8 * * *' with a timezone. Timezones
are fixed offsets — UTC or +HH:MM — not named zones: skaidb
embeds no timezone database, and a schedule that silently ran an hour off
twice a year would be worse than one that refuses the spelling. The
shortest cadence is 60 seconds.
A condition is one of three, deliberately narrow — anything richer, the query itself expresses better:
| condition | fires when |
|---|---|
no_rows |
the query returned nothing |
any_rows |
it returned anything (the query is the predicate) |
| a cell comparison | the first row's named column crosses a number |
A cell condition over a result with no such column, or no rows, does not fire: "the number I watch is missing" is a different fact from "it crossed a line", and firing on it would send somebody hunting for a breach that never happened.
Only a change of state is sent, firing and resolved alike — a recovery is news, and an alert whose result flaps would otherwise notify sixty times an hour. Every send is recorded with its outcome, including failures, because an alert that quietly stopped being delivered is worse than one that never fired and only the history tells them apart.
Each run happens on this node, as the schedule's owner, through the same admission gate and budgets as a person's query — so a scheduled query can never read more than the person who saved it. It bypasses the result cache: an alert evaluating a cached answer cannot see the transition it exists to catch.
Deploying alongside witnesses
A BI node rolls exactly like a witness: same package, same unit, same readiness probe. It is a witness, so everything in CLUSTERING.md about table selection, grace periods and tombstone retention applies unchanged — including that witness table selection is not access control: what the BI node mirrors is what its users can read.
Size it for the mirror, not for the cluster: storage.memory_target
should match the machine (cgroup-aware, so a container gets its own cap).