Skip to content

Web UI at /ui, secure-by-default authentication, remote read hints fix - #51

Merged
fungiboletus merged 54 commits into
mainfrom
frontend-good-enough
Oct 5, 2026
Merged

fungiboletus merged 54 commits into
mainfrom
frontend-good-enough

Conversation

@fungiboletus

Copy link
Copy Markdown
Member

What this adds

1. A web UI, served by SensApp under /ui/

A React single-page app in frontend/, built to static files that SensApp serves itself (/ redirects to it, the container image ships the build, no separate container).

  • Explorer: pick a metric, pick series (one colour each, grouped by label dimension), draw them over a time window zoomed by dragging. Step and aggregation selectors, five chart styles, a log scale, booleans drawn, and the whole state kept in the address.
  • Load Data: snippets to copy (Python SDK with a PEP 723 header for uv, Telegraf, Prometheus, curl) built from the state of the explorer.
  • Credentials: makes tokens, with the command line to do the same.
  • Light/dark theme from the logo, favicon, one variable Roboto file. The files are public and carry a CSP (own origin only, no framing), nosniff and no-cache.
  • SENSAPP_UI_ENABLED / SENSAPP_UI_DIR; a missing build is a warning, never a failure. See docs/FRONTEND.md.
  • frontend/openapi.json is generated from the server, and a test fails when it is stale.

2. Secure-by-default authentication ⚠️ breaking

SensApp no longer runs open unless told to.

SENSAPP_JWT_SECRET SENSAPP_AUTH_DISABLED Listens on Result
set – any JWT authentication
unset unset loopback a secret is made for the run, an admin token and a /ui/#token=… link are printed
unset unset any other address (the container image) refuses to start
unset true any every endpoint open (explicit opt-out)
  • New admin scope. POST /api/v1/admin/tokens lets an admin token make tokens (duration capped, never makes admin tokens; only the CLI can). sensapp generate-secret, generate-token --sensor for names with commas.
  • Tokens stay stateless: jti, iss/aud, kid; secret rotation with SENSAPP_JWT_PREVIOUS_SECRETS (which is also how tokens are revoked); the InfluxDB Authorization: Token scheme is accepted; the token subject is in the request logs.
  • Chart (made secret, existingSecret, previousSecrets, disabled), Dockerfile, compose files, perf/live scripts, the notebook and CI follow the new default. CI gains a step that checks the image refuses to start open.
  • Listing and revoking single tokens is deliberately left out: ideas/token-registry-and-revocation.md.

3. Remote read hints fix

fix: remote read hints: only aggregate when Prometheus gets the right answer, with the reasoning in docs/ and done/remote-read-hints-semantics.md. The live Prometheus harness now runs v3.15.0, and has a case for it.

Housekeeping

Frontend dependencies refreshed (Vite 8, Vitest 5, ESLint 10), CI Node 22 → 24, a flaky clock-dependent test fixed. Task files are in done/, follow-ups in ideas/.

Before merging

  • README.md (human-owned, so not touched here) still says "SensApp supports optional JWT authentication. By default, all endpoints are open." (line 119) and needs a rewrite along the lines of the table above. Suggested wording: "SensApp does not run open by default. Without SENSAPP_JWT_SECRET it listens on localhost only, with a secret made for the run and a token printed at startup; on any other address it refuses to start. Set SENSAPP_JWT_SECRET (sensapp generate-secret), or SENSAPP_AUTH_DISABLED=true to opt out. See docs/JWT_AUTH.md."
  • Anyone running the image or a non-loopback instance without a secret must set SENSAPP_JWT_SECRET or SENSAPP_AUTH_DISABLED=true on upgrade.

Verified

  • cargo clippy --all-targets --tests, cargo fmt --check: clean.
  • cargo test: 276 unit, 311 integration, 2 doc tests on SQLite; 276 / 301 / 2 on PostgreSQL (--no-default-features --features postgres).
  • Frontend: eslint, tsc, 261 tests (1 skipped), production build.
  • By hand earlier on the branch: the real binary in the three startup modes, the UI in a browser against a local SensApp (the printed link signs in, a made token is limited as asked), helm lint / helm template with the four auth setups.

Not verified here

  • The Helm lookup that keeps the made secret across upgrades (no cluster available).
  • The new CI step that the image refuses to start (only runs in CI; its command was replayed with the binary).
  • TimescaleDB, DuckDB, ClickHouse and the rest of the backend matrix: left to CI, none of this depends on them.

🤖 Generated with Claude Code

fungiboletus and others added 30 commits October 4, 2026 12:22
… 10)

TypeScript stays on 5.9: typescript-eslint does not support 7 yet.
Drop echarts-for-react (unmaintained, imports an undeclared tslib) for a small
wrapper on echarts core with only the line chart, which also halves the echarts
chunk. Drop the unused react-query devtools and switch to @vitejs/plugin-react.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Docker build gets a Node stage and the image ships the static files in
/usr/share/sensapp/ui. SENSAPP_UI_ENABLED (default true) and SENSAPP_UI_DIR
control it; / redirects to /ui/ when it is served. A missing build is a warning,
not a startup failure. The files are public, with a CSP, nosniff and no-cache.

The frontend is built for the /ui/ base, with a real favicon and Latin-only
Roboto (51 assets down to 9).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The UI has no login: tokens come from sensapp generate-token. On a 401 (missing,
invalid or expired token) or a 403 (a token without the read scope) a dialog shows
the answer of the server and asks for a JWT. The token is sent as a Bearer token,
kept in sessionStorage only, and the refused queries are asked again. The header
shows who is signed in, with Sign out, which forgets the token and the data.

One unwrap() turns the generated client's returned errors into ApiError with the
status, instead of four copies of the same check. Authentication errors are not
retried.

Checked against a live SensApp with JWT: no token, invalid token, write-only token
(403), read token, sign out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…f the server

The generated client had drifted: step, aggregation, limit and simplify, the
availability and last-sample endpoints and the delete routes were all missing.
A test compares the document with the server's and says how to update both
(UPDATE_OPENAPI=1, then npm run openapi-ts). The client is regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- The server refuses more than 100 000 raw samples of a series: past about 2.8 hours
  the chart asks for a round step (at most 2 000 buckets) averaged, only for numeric
  series (aggregation on a string series is a 500, see ideas/), and says so in the
  chart header.
- The series list says when the server cut it at 256 series.
- Small screens: the page scrolls and the panels keep a usable height, the header
  fits, the date range wraps.
- One Loading and one ErrorAlert replace the copies in the two tables, the invalid
  CSS and the dead useSeriesData hook are gone, the series checkboxes are labelled.
- CI uses Node 24 like the image, and the container smoke test checks the UI.
- docs/FRONTEND.md, ideas for what was left out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…aining labels

- The series list has Previous/Next on the bookmark of the server instead of
  stopping at 256 series; the selection is kept from page to page. /series now
  documents limit and bookmark, the client is regenerated.
- Dark mode: the daisyUI dark theme was declared but never selected. It is now,
  and echarts uses its dark theme and follows the OS.
- No more text in the chart header about averaging, and a shorter token dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Auto (the range decides), Raw, or a fixed step, with avg, min, max, sum, count, first
or last, like the window period and aggregate function of Influx and the resolution
of Prometheus. Both are part of the query key, so the chart asks again when they
change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…yles and a log scale

- metric, series, range, step, aggregation and style are query parameters, defaults
  left out, replacing the history entry. A series is named by its uuid, its name,
  labels and type come from its first sample; the lookups are queries, so a shared
  link on a server with a token asks for it and resumes.
- Booleans are drawn as a 0/1 step line on an axis of their own, read raw.
- Line, step, area, stacked and bars, and a log scale, built by a tested function.
- The preset of the range shows as pressed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
It wrote its data up to the current ten seconds, and failed when that was
a multiple of ten minutes (or the ten seconds before): the row of the
coarse archive that ends with the last update is complete, and holds the
average of its ten minutes, as it should. The end is now in the middle
of an interval, and the boundary case has a test of its own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ht answer

Prometheus evaluates the query again on the samples of a remote read, so buckets
are only a valid answer for the mergeable *_over_time functions with a range that
is a whole number of steps. Count, the aggregation operators (sum, avg, ...) and
windows shorter than the step answered wrongly (sum(x) gave 36004 instead of 576,
count_over_time gave 1 instead of 60); they now read the raw samples.

A live test compares 20 queries evaluated by a real Prometheus with the raw
samples; the harness now runs Prometheus v3.8.0 (PROMETHEUS_IMAGE overrides it).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…in two formats

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Prometheus 2 is not supported. v3.15.0 (latest) and v3.8.0 pass too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e, bounded metric names

The series list already says how many are selected, and a server that is down shows the error of
the request. A metric name is truncated at a fair width (the full name on hover), and a name that
exists with two types is two rows with their own keys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A preset such as 1h is re-resolved every minute while the tab is on screen and as soon as it
comes back. The chart keeps what it shows while the data of the moved range loads. Typed dates
do not move.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
keepPreviousData does nothing with useQueries (a new key is a new observer, the previous commit's
test caught it): the chart keeps the last data of each series itself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…egend

The series list has a column per label dimension and the uuid in small, last. The type is a column
only when the series of a name are not all of one type (the server groups metrics by name and type).
A selected series shows its color, which it keeps for as long as it is selected, and the chart has no
legend of its own. A metric of at most 8 series (the palette) is selected whole, once, when it is
chosen; strings are left out as they are not drawn. The chart merges a new option instead of
replacing everything, so a series comes and goes with an animation and the zoom stays. The colors
are the dataviz palette, validated in both themes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…m the logo

The header shows the logo (white on a dark page, without the name on a narrow screen), the favicon
is the box and the horn of it. The primary color is the navy of the logo, the neutral one the grey of
its dish, in a light and a dark theme of our own. The type badges of the metrics were all grey: the
server sends Float and String, and the colors were chosen on float and string. The metric names are
cut at the width that the other columns leave, so the table does not scroll sideways.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed, what is left

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… by dragging on it

The echarts zoom (slider, wheel, toolbox) zoomed into points that were cut for the window before,
and nothing said how to get back. The window of the explorer is now the only one: a drag on the
chart sets it and the data is read again at the step that fits (raw samples when zoomed in), and the
bar below the chart has back, earlier, zoom out and later next to the presets, the dates, the step
and the aggregation. The history keeps the windows the user left (not what the clock did to a
preset). While the new data loads the chart keeps the old points and a thin bar says it, where a
line of text moved the chart. Long series names no longer leave the tooltip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… said once, colored checkboxes

The series are sorted by the first label column, then the next ones, numbers inside values as numbers
(node-2 before node-10); a click on a header sorts by it, again reverses it. The server pages by
creation, so the sort is of the page on screen. The selector placeholder is made of two labels of the
first series. A label that every series has the same (the org and bucket the InfluxDB importer adds on
purpose, they are part of the identity of a series) is said once above the list and left out of the
names in the chart, which keeps the tooltip short. The checkbox of a selected series has its color
instead of a dot beside it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
More than 8 series no longer repeat a color: the first 8 are the validated palette, the 16 after
them are the farthest from the ones before under normal and color-blind vision (the dark theme
needs lighter steps than the validated band for that). Past 8 a color does not say which line is
which, so hovering a row of the list makes its line stand out and fades the others. The header of
the list has a checkbox that selects, or unselects, what is on screen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, the importer labels

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…der in both themes

The page and the header were capped at 1280 px and the chart was 260 px high whatever the window:
they follow the window now. The hint under the chart is gone. daisyUI's outline buttons and badges
were drawn in the full text color (white frames on the dark theme): buttons and fields share a border
that is a hint of the text color, the buttons that are on are tinted with the primary color, labels
are washes without frames, and the scrollbars are thin and quiet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…st opens on the metric

The controls of the chart (window, presets, both dates on one line, step, aggregation) and the filters
and the counts of the two lists are in the row of their title, which gives the room back to the chart
and the lists: the footer of the series list is gone, its count and its pager are in the header. A
metric has a radio button like a series has a checkbox, and a page that opens on a metric scrolls the
list to it, once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s, "Data Explorer" in Sora

The page had twice the space at its sides than between the cards. The ghost buttons and the quiet
ones had two hovers. The tagline of the header says Data Explorer, set in capitals in a display face.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n SDK, curl)

The generators behind the Code dialog, as plain functions with their tests. The code loads what the explorer
shows: the selected series (uuid, named in a comment), the window (relative to now for a preset in Python), the
step and the aggregation, the series a step cannot average read as they are. A token is only ever a placeholder
(`SENSAPP_TOKEN`). Names and labels are quoted or kept on one line, so a label cannot add a line of code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
fungiboletus and others added 24 commits October 5, 2026 12:18
…Brains Mono on a dark ground

The header has a Code button before API Docs. It opens a dialog with two tabs, Python and curl, coloured by
highlight.js (core, Python and Bash only), in JetBrains Mono, dark in both themes, with a copy button that also
works on a page served by plain http. The dialog, the highlighter and the font load when it is first opened
(13.6 kB gzipped, nothing for a page that never opens it). Checked live: the snippets of the dialog were run
against a SensApp, with and without JWT.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… selector of the series list

The Python snippet starts with an inline script block (`# /// script`) so that `uv run script.py` installs the
SDK from GitHub by itself; the `uv pip install` command stays next to it.

The selector box of the series list moves to the selection store. When one is typed, the code asks the server
for the series that match it (`list_series(metric=, selector=)` in Python, `curl -G --data-urlencode` into `jq`
and a loop in the shell) instead of listing uuids, and a checkbox of the dialog goes back to the checked series.
A step is only asked for the numbers among series whose types are not known when the code is written.

Checked live: `uv run` with only the block, selector snippets against a SensApp (mixed types, with a step), and a
hostile selector, metric and address in a real shell with curl stubbed: no command ran.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ing sideways

CSS only: pre-wrap, and a token with no space (a uuid, an address) breaks where it must. The copied text is the
same. Looked at on desktop and at 375 px, both tabs: nothing scrolls sideways.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eus and curl snippets to copy

The header gets two tabs, Data Explorer and Load Data (/ui/load, loaded on demand, the way in ?via=).
The snippets take the address of the server, read the token from SENSAPP_TOKEN when it asks for one,
and were all run against a real SensApp, with and without a JWT secret.

Telegraf sends 'Authorization: Token', SensApp reads 'Bearer': the snippet overrides the header, and
ideas/influxdb-token-authorization-scheme.md notes the server-side fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed tabs and the text beside the code

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d says less

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Without SENSAPP_JWT_SECRET, a loopback address now makes a secret for the run and
prints a 24 hour token with a UI link, any other address refuses to start, and
SENSAPP_AUTH_DISABLED=true is the explicit way to run open. `sensapp generate-secret`
prints a random 48 character secret.

Breaking: a container or server without a secret must now choose.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n the logs

Tokens get a jti, iss and aud (checked), and a kid naming the secret that signed
them, so SENSAPP_JWT_PREVIOUS_SECRETS can rotate the secret: the new one signs,
the old ones only verify, and dropping one revokes its tokens. The new `admin`
scope (mint tokens, nothing else) is never implied by another scope.

`Authorization: Token <jwt>` is accepted like Bearer, for InfluxDB clients and
Telegraf. The validated subject and token id are recorded in the request log span.
The CLI and the future HTTP endpoint share TokenRequest, which checks the subject,
scopes, sensors and duration in one place.

Breaking: tokens made by hand need iss and aud set to `sensapp`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Needs the admin scope, which gives nothing else. The endpoint never makes an admin
token (those take the secret, through `sensapp generate-token`), caps the duration
at SENSAPP_TOKEN_MAX_DURATION_SECONDS (one year by default), answers with
Cache-Control: no-store, is a 404 when authentication is disabled, and logs who made
which token, without the token. SensApp keeps no token: nothing is listed or revoked,
rotating the secret is how.

The OpenAPI document of the frontend and its generated client are regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…igns in

An admin token gets a form (name, scopes, sensors to click in, duration), then the
token is shown once with a Copy button and the export line the Load Data code reads.
Without an admin token the tab says how to make one, and says so when the server
is open. The catalog is not read with an admin token, which cannot read: its refusal
would ask for a token again.

The link a local SensApp prints (/ui/#token=...) signs the UI in and the fragment
is taken out of the address. A refused mutation opens the sign-in dialog like a
refused query does. Telegraf's snippet uses its own `token`, since SensApp reads
the Token scheme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Helm chart makes a random secret on the first install and keeps it across
upgrades (auth.jwtSecret, auth.existingSecret, auth.previousSecrets and
auth.disabled choose otherwise), and NOTES.txt says how to make an admin token.
The demos, the live and perf scripts, the CI smoke job and the quickstart notebook
set SENSAPP_AUTH_DISABLED=true explicitly, and a CI step checks that the image
refuses to start without authentication.

docs/JWT_AUTH.md is rewritten (startup modes, admin scope and endpoint, rotation and
revocation, logs); CONFIGURATION, FRONTEND (Credentials tab, token in the link) and
the chart README follow. Listing and revoking single tokens is left in ideas/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… document

Sensor names of a token are kept exactly as given (they were trimmed), so a name with
a comma or a space around it works through the endpoint, and the new repeatable
`sensapp generate-token --sensor <name>` takes one name as it is. `--sensors` still
splits on commas.

The OpenAPI document declares the bearer token as a security scheme, and each
protected operation names its scope (read, write, delete, admin); /prometheus/metrics
is public and takes a read token for the latest samples. A test keeps the
declarations in step with the routes. The frontend client is regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mmand line, and a token section in every way

The Credentials page loses its card and takes the whole width as Load Data does. The
validity comes before the scopes, the sensors are chips (a name is kept as typed, so a
comma or a space works), and a Command line section shows the `sensapp generate-token`
command of what the form says, quoted for the shell, which also covers what the endpoint
cannot do: an admin token. "Make it here" is the button for an admin token, and tells
how to get one otherwise.

Load Data starts every way with the same "A token" section (where the code finds the
token, how to make one, a link to Credentials) when the server asks for a token, instead of
comments scattered in the code and a paragraph at the top.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The command suggested for an admin token (sign-in dialog of Credentials, the chart
notes, the docs) is now `--scope read,admin`: the admin scope reads nothing, so a token
with only it signed in and the explorer asked for another token. The scopes stay separate
on the server, and the Credentials page reads the catalog when the token can read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…the others

They were the neutral grey of daisyUI, unlike the checkbox of the series table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the button that makes it

The name comes from the words of the names of the containers of Docker.
Make it here comes before the command line, and the token is shown below
the button, not at the top of the page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… together, before its details

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rests on the border

The tabs now run the full height of the bar so the line under the current one sits on the border
of the header, a short divider replaces the full-height one, and Sign in / Sign out are the height
of the Code and API Docs buttons. On a narrow screen the tabs scroll instead of widening the page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…om of the header

The tab strip was centered in the bar instead of stretched, so the line floated above the border.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…bled or not, and the arrows are of one width

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@fungiboletus
fungiboletus merged commit 98c3751 into main Oct 5, 2026
17 checks passed
@fungiboletus
fungiboletus deleted the frontend-good-enough branch October 5, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant