Web UI
InfraSigns ships an embedded web UI — a read-mostly dashboard over the same store that backs your digests, incidents, and reports, plus an add-source wizard that probes a Prometheus live and generates config for you. It is experimental and off by default.
Unauthenticated (self-hosted)
In the default self-hosted single-tenant mode the web UI has no login.
Every /app page and the wizard's probe endpoints are unauthenticated —
restrict /app at the network layer (VPN, reverse proxy, ingress auth). This
is the same posture as the read-only API endpoints, and it matters doubly
here because the wizard's verify/preview steps let anyone who can reach
/app make the server issue bounded read-only GETs against a URL of their
choosing. Setting a provider's client id
(auth.github.client_id or auth.google.client_id)
switches on cloud sign-in (the hosted / multi-tenant mode), which gates the
/app pages behind a login session and requires a live session for the
SSRF-adjacent endpoints (/app/sources/{verify,preview,config}, the
live-metric .../series feed, and /app/welcome) — closing the anonymous
abuse surface to authenticated tenants. The verify/preview probes are also
egress-gated in the hosted mode (a private/loopback/metadata dial is refused),
so the residual is narrower — a signed-in tenant can still name any source or
probe any public URL, since these routes carry no per-org source
resolution yet — and is deferred to the config-edit work; keep network
controls in front of a hosted deployment regardless. Note one deliberate
asymmetry: revoking a member cuts their access to org data immediately (it is
re-checked every request), but they retain the session-only endpoints above
until their session expires.
Enabling
The UI has no port of its own — it shares the single HTTP server on
server.port (default 8080). With ui.enabled: true, open
http://<host>:8080/app.
What you see
Everything under /app is served under an implicit project slug so links are
stable from day one: /app redirects to /app/default, and the pages live at
/app/default/.... (Multi-project support is a cloud-era feature; self-hosted
runs a single project.)
Dashboard
The landing page (/app/default) is an at-a-glance health view:
- Source health grid — one card per Prometheus source, worst-first (failing → stale → waiting → fresh, then the muted over-limit and removed cards), each judged against the digest cadence. Cards for still-configured sources link to their detail page.
- Attention banner — a one-line summary when any source is failing.
- Latest digest — the most recent delivered digest, or a placeholder naming the next scheduled run when the archive is empty.
- Recent incidents — the last few incident episodes, each linking to its detail page.
- Health checks — the latest verdict per configured
natural-language check, hidden when you have none. The badge is
the verdict of the last run; a row also reads not notified when that
verdict is not a
passand no notification has carried it — a check inmonitormode, which never announces anything; a failing check still short of itsfor_runsstreak; or a send that did not go out and will be retried on the next run. The card does not know which of the three it is, so it says only that nobody has been told. A passing row never carries the marker: nothing is wrong on it to be told about.
In self-hosted mode, when api.token
is configured and the report archive is genuinely empty (first run, or
everything aged out via retention), the dashboard offers a Run a digest now
button. You paste your API token and the UI calls the existing authenticated
POST /api/digest/trigger?wait=true on your behalf — the UI never triggers
billable LLM work unauthenticated. (In cloud sign-in mode the manual trigger is
disabled — it would run against the operator's data, not your organization — so
this button never appears; your organization's digests run automatically on their
schedule.)
Sources
The Sources page lists the same health cards as the dashboard grid and auto-refreshes them in place. Each still-configured source links to a detail page:
- current health state and the Prometheus URL;
- live metric charts for each configured query (or the node_exporter
defaults), fetched from that source's Prometheus on view — a range selector
(1h/6h/24h, deep-linkable via
?window=), per-instance lines with a legend, and dashedwarn/critthreshold lines. A slow or unreachable Prometheus degrades to a per-chart message, never a broken page. Turn the charts (and their/seriesdata endpoint) off withui.charts.enabled: falseto spare Prometheus the per-view queries; - the configured query catalog (name, PromQL, golden signal,
warn/critthresholds), or a note that node_exporter defaults apply when you've set none; - a merged timeline of everything that happened to the source — journaled
health transitions (fetch failures and recoveries), generated reports
(deep-linked into the reports archive), and attributed
incidents (deep-linked to
incident detail). A chip filters the timeline to
health,reports, orincidents.
Sources that appear in health history but are no longer in your config (removed sources) render as non-linkable cards — there is no detail page for a source that isn't configured.
In cloud mode, a source configured beyond your plan's source limit is no longer collected (only the first N sources by config order are monitored, where N is your plan's limit). Its card reads over-limit — a muted "not monitored on your plan" state with an Upgrade to monitor link. It is kept in your configuration and is not counted toward the failing badge; upgrading resumes monitoring with no reconfiguration. See Account and organizations.
Reports archive
The Reports page is the searchable archive of every delivered digest, trends
report, and post-deploy verification. Filter by type, source, and month. Expand
a row to see the headline, body, any structured claims and evidence, the
per-channel delivery receipt (e.g. telegram ✓ · slack ✗), and a
feedback control. A claim grounded in one engine finding carries a
Show evidence panel with the series the analysis saw, when that series has
points to chart — since #323 on a critical cycle's report (the incident
summary, filed under digest and badged CRITICAL) as well as an ordinary
digest, where before a critical cycle stored no evidence at all. The
Incidents archive below is a different view — inbound alert
episodes — and is unaffected. The archive is paged: 25 reports at a time,
newest first, with a Load more control that appends the next page in place
(the filters travel with it). Without JavaScript the same control is an ordinary
link that navigates to the next page — that view is a window of the archive
(it says so, and links back to the newest page); there is no "previous page"
link, so use the filters or go back to the newest to move the other way.
Incidents
The Incidents page is the full archive of incident episodes recorded by the webhook receiver. Filter by state (firing/resolved), source, attributed source, severity, and month. Each row opens a detail page with the full episode: name, origin, severity, state, start/resolve times and duration, the incident summary, per-channel delivery receipts, and — when the alert was attributed to a configured source — a link back to that source's detail page.
Marking an episode resolved by hand
An episode leaves the firing state only when a matching resolved
notification arrives, and incidents.retention_days prunes resolved episodes
only. So an alert whose sender will never send that resolution — a rule
deleted while it fired, a test alert you curl'd at the receiver, an Alertmanager
route with send_resolved: false, an exporter you retired — stays "still firing"
forever, on the dashboard card, in the digest and on every source timeline.
The detail page of an open episode carries a Mark resolved button for exactly that. It closes the episode as of now and records it as history, with every other field carried over unchanged: severity, the label the sender reported, the summary, the delivery receipts and the original start time. It rewrites nothing about what the episode was.
Whether it is safe to press on an episode you are not sure about depends on your sender, and the button's own hint says so.
- A sender that repeats a firing alert re-opens the episode with its original
start time, and the resolution you recorded stays in the archive beside it — so
the cost of a wrong click is a stretch of quiet, not a hidden alert. One caveat
on how long that stretch is: a manual close does not clear the receiver's
dedupe entry for the alert (only a resolved
delivery does), so a repeat arriving within
webhook.dedupe_window(default5m) of the last unsuppressed one is still suppressed and re-opens nothing. With arepeat_intervalwell above the window — Alertmanager ships4h— that never applies; a sender repeating faster than the window is the one that waits. - A sender that notifies only when its state changes does not repeat at all. A CloudWatch alarm is the shipped example: nothing re-opens the episode until the alarm clears and fires again, so a wrong click there does hide a live alarm. Prefer fixing the sender.
Closing an episode changes its internal id, so the detail page you were on stops
existing — going back to it gives a 404 — and you land on the incidents list
instead. That list is ordered by when each episode started, 50 rows to a page,
so an old episode reappears at its original position rather than at the top, and
nothing on the page confirms the close. Filter by firing to check that the
episode is gone from the open set.
Any member of the organization can use it (unlike removing a source, which is
owner-only — that changes what the deployment monitors; this changes the recorded
state of one observation). In self-hosted mode it is an unauthenticated write
like the rest of /app, the same posture as the report feedback control below —
one more reason to keep /app behind network controls.
Severity is shown as the canonical tier InfraSigns read the alert at — critical,
warning or info. When the sender reported a different label, that label is
shown beside the badge as muted text, introduced by sender reported:, on both
the list rows and the detail page: warning sender reported: p1 means the episode
was delivered at the warning tier and the alert rule said p1. The row names your
vocabulary; it does not diagnose it. p1 sits there identically whether a
severity_aliases entry put
it at warning deliberately or the fallback that catches every label no table covers
put it there by default — and the two are the same picture with very different
consequences.
To tell them apart, and to fix the second: incidents exist only for alerts pushed
to the webhook receiver, and the only thing that ever names an
unmapped label is the receiver's WARN line
(unrecognized severity labels rank as warning), which is throttled to one line
per payload format per 24h — so a second fleet pushing the same format has its
labels silenced for the window. The un-throttled
infrasigns_alerts_unrecognized_severity_total{path="webhook"} counter is the
durable half, but it counts requests and its only attributes are path and
source (the payload format, not the sender), so it says that something
unmapped arrived and never which label it was. The Settings Unmapped severity labels your
sources send card does not cover these rows — it reports what a collection
cycle saw, and nothing pushed to the receiver ever reaches it, so an empty card
there is not an all-clear for a push-only fleet. The table to edit is the
process-level severity_aliases: block in the config file: the receiver is
process-global and reads the operator's table, not a hosted organization's, so
editing the alias table in Settings changes no incident row.
Nothing extra is shown when the label and the tier agree, including when they
differ only in case, so a fleet already writing critical/warning/info sees the
same row it always did. An empty qualifier is four different facts and the page
cannot tell you which: the sender reported no label (an alert rule that set no
severity:, or any CloudWatch alarm — those carry no severity field at all, so the
receiver derives the tier from the alarm state); a label was reported but was
built only of whitespace, control characters or zero-width characters, so nothing
survived the receiver's neutralization; the label matched the tier; or the
episode was recorded before this shipped, since nothing is backfilled. That last
one matters while upgrading: an older p1 episode and a warning-writing one look
identical in the same list.
Two other surfaces show incidents and deliberately do not carry the qualifier: the dashboard's Recent incidents card and a source's detail-page timeline. Both are compact activity feeds; open the episode to see the reported label.
Like the reports archive the list is paged (50 episodes at a time, Load more for the next page). Episodes are ordered by when they started — the only moment that never moves, so an episode that resolves while you are reading stays where it was instead of jumping over your position. (Episodes that started in the same instant — one alert group — are ordered by an internal id that does change on resolution, so one of them resolving mid-read can still shift by a position within that group.) The dashboard's Recent incidents card still orders by last activity, so a long-running episode that is still firing stays on top there — and so does an episode that resolves long after it started, which the archive keeps at its start position.
Notifications
The Notifications page shows your notify configuration: which channels
(Telegram, Slack, email, PagerDuty) are configured, with secrets redacted, and
which feeds each one receives (digest, trends, incidents, deploys, checks) —
PagerDuty is paged by incidents alone, which means inbound alert groups and
health-check transitions. In self-hosted it is read-only (the config file is the
source of truth); in cloud sign-in mode an organization owner can edit its
Telegram, Slack, and email channels here (see below — PagerDuty has no edit
form). It reflects config, not
a live probe — for live channel health, see
/readyz in Observability.
In cloud sign-in mode the page reads your organization's own channels from its stored configuration per request, so each tenant sees only its own delivery targets (never the operator's). A failed read shows an error rather than a false "no channels configured". Self-hosted reflects the config file.
The feeds list is likewise per-organization in cloud mode, mirroring the
Settings page's honored/operator-level split. The feeds the per-organization
worker actually runs — digest, trends, and health checks — appear as active or
off. The two operator-level feeds — incident alerts (the alert webhook is
process-global) and deploy verification (deploys run process-wide) — are not
per-organization yet, so a feed you configured is shown muted as operator-level
with a note that it isn't delivered to your channels, and a feed you didn't
configure is omitted (you are never shown a CTA to set a process-level knob you
can't touch). As each becomes per-organization, its feed moves back into the active
list. PagerDuty is paged by incidents, and exactly one per-organization feed is one: a
health-check transition, which pages on failure and clears on recovery. So a
configured per-org PagerDuty channel carries a scope row naming what actually
reaches it, and the card is shown muted with a reason when nothing can — an
organization with no checks, or whose checks are all monitor, has a channel
that cannot fire, and the page says so rather than implying delivery. Whether the
channel is muted is therefore a property of your configuration, not of the mode
you run. Digests, trends and deploy verdicts are suppressed there as everywhere,
and inbound alert groups still page at the operator level.
Unlike the other channels it has no edit form: a routing key is operator-seeded, and there is no tenant write path for one (#284 removed the form when the channel was inert; what changed since is that it can deliver, not that a tenant can set it).
Cloud owners can edit the Telegram, email, and Slack channels directly from this page. For Telegram: set or rotate the bot token and chat ID, or remove the channel — a blank token keeps the stored one (so you can update the chat ID without re-entering the token); the stored token is never shown. For email: set the SMTP host, port (defaults to 587), From address, and recipients (one per line or comma-separated); the SMTP user and password are optional (leave both blank for an unauthenticated relay), and a blank password keeps the stored one when a user is set. The stored password is never shown. For Slack: pick the transport — an incoming webhook URL, or the Web API (a bot token plus a channel ID, which enables threaded incident updates); switching transport drops the other one's stored credentials, and a blank secret keeps the stored one within the same transport. The stored webhook URL and bot token are never shown. (PagerDuty has no per-org edit form — its routing key is operator-seeded, as described above.) Removing a channel clears its credentials from your organization. Notifications are editable only once your organization has added a source (the stored configuration requires one), so add a source first. Self-hosted edits its config file directly.
On the Free plan Slack is not delivered: a Slack channel configured on a paid plan or Team trial stays in place, but the worker suppresses its delivery once the plan lapses to Free (re-upgrading resumes it — nothing is rewritten). Telegram and email are unaffected. See Billing.
Account and organizations (cloud)
In cloud mode (a provider client_id set) the top bar carries the signed-in
chrome that self-hosted has no need for:
- The account menu shows your identity (name and email) and a Log out
action — a same-origin POST that revokes the session server-side and clears the
cookie, returning you to
/login. - An organization switcher lists the orgs you belong to; picking one re-POSTs
through
/auth/select-org, which re-checks your membership before switching (a revoked membership cannot be switched into). The switch list appears only when you belong to more than one org. - New organization (
/app/org/new) creates an org you own and switches you into it. The URL slug is derived from the name automatically. -
Members (
/app/org/members) lists the current org's team and — for an owner — lets you invite teammates by email, revoke pending invites, change a member's role (owner ⇄ member), and remove a member. Any member may view the roster; every mutation is owner-only (a non-owner request is refused). Inviting a teammate is capped at the org's plan seat limit (a member or a live pending invite each count as a seat); adding a source is capped likewise (#33). -
Billing (
/app/billing) shows the org's current plan, trial days remaining, and usage against the plan's honored limits (sources, members, Slack, the daily LLM-call ceiling, and the history-retention cap — Free 7 days / Solo 90 / Team 365; report, timeline, and source-health history is pruned to the smaller of your configuredretention_daysand this tier cap). A new org starts on a 14-day trial that becomes the Free plan when it ends. When Stripe is configured, owners get Upgrade (Solo / Team) and Manage billing actions (Stripe Checkout and the Customer Portal). A lapse to Free suppresses Slack delivery and stops monitoring any sources beyond the plan limit (their cards read over-limit), but keeps your configuration — upgrading resumes both.
Self-hosted renders none of these — there is no login, and the single implicit org needs no switcher.
Inviting a teammate
An owner enters a teammate's email on the Members page; InfraSigns creates a
single-use, 7-day invite and shows a shareable link (there is no email
delivery yet — copy the link and send it yourself). The teammate opens
/invite/{token}, signs in with GitHub or Google, and joins only if their
OAuth-verified email matches the invited address — a leaked link cannot be
redeemed by a different account, and no membership is ever granted to an
unverified identity. Re-inviting the same address refreshes the link (the old
one stops working); an invite is consumed the moment it is accepted.
Roles and removing members
Each member is an owner or a member. Owners can manage the team; members have read-only access to the roster. From the Members page an owner can:
- Promote a member to owner or demote an owner to member (the role toggle on each row). You can step down to a plain member yourself — this is how ownership is handed off: promote a teammate, then step down. Because only owners can invite, demoting an owner (or stepping down) also revokes any pending invites they had created.
- Remove a member. Removal takes effect immediately — a removed member's next request drops them back to the organization picker — and any invites they had created for the org are revoked with them.
An organization must always keep at least one owner: the last owner cannot be removed or demoted (promote someone else first). You cannot remove yourself here; step down and ask another owner. A removed member keeps any membership they hold in other organizations — only this org's membership ends.
Settings
Reached from the account menu in the top bar, Settings is a read-only projection of the running configuration — LLM provider and model, schedules, the severity-alias entry count, retention windows, ingress and feature toggles, server and storage — with the API key and database DSN redacted. It's a quick way to confirm what the process actually loaded without shelling into the container.
In cloud sign-in mode the page instead reflects your organization's own
stored configuration. It shows the settings the per-organization worker actually
honors — LLM, schedules, health checks, alert severity, and retention (report, timeline, and
source-health history are each pruned to the smaller of your organization's own
retention_days and your plan's per-tier retention cap, so the value shown is the
effective one the worker prunes at — a configured value above your tier renders as
the tier cap, not the raw number) — as active. A value your config carries that the worker does
not yet apply per organization is shown only when you set it, muted, under a note
that explains why it is inert — never as an active setting. Three kinds are
distinguished, each with its own note so none falsely implies a state it doesn't
have: the operator-level features that still run process-wide (the alert
webhook and deploy verification) carry an "applied at the operator level" note;
the features the hosted service does not run at all (the manual digest trigger
and the Telegram/Slack Q&A bots, which would otherwise read the operator's data
rather than yours) carry a distinct "the hosted service doesn't run these yet"
note; and incident-history retention — the one retention knob not applied per
organization, because incidents arrive through the process-global webhook receiver
so your organization has no incident rows — gets its own note explaining the value
isn't applied (rather than sitting under the operator-level note, where its number
could suggest your incidents are being pruned). Server and storage (which an
organization config never carries) are omitted. As each remaining feature becomes
per-organization, its row moves into the active sections.
An Alert severity section reports how many labels your
severity_aliases table
maps, and a Severity aliases card below the sections lists every mapping for
every member — an owner additionally edits it a row at a time. That
editor is the page's one write affordance: the intro says "read-only" to every
reader who doesn't get it, and stops saying so to the one who does; a member
reading the table is still reading a read-only page. Without the editor the card
is a plain two-column Label / Severity table — the headers are what keep
which way a mapping runs reachable to a screen reader — and its intro names the three
severities a label can map onto, so an organization with no mappings still learns
the range. An editor row is a label your alert rules write on the left and the
severity InfraSigns should read it as on the right; saving replaces the whole
table and leaves the rest of your
configuration untouched, and clearing every row removes the table. A new row's
severity starts unset rather than pre-picked, because this table escalates —
mapping p1: critical means a p1 alert now opens an incident and pages at
critical urgency — so the tier is always a deliberate choice. Two rows naming the
same label (in any case, with any padding) are refused rather than one quietly
winning; every other rule — a blank label, a severity outside
info/warning/critical, an attempt to redefine one of those three — comes
back from the same validator a self-hosted YAML file passes through, so the UI and
the file cannot disagree about what a valid table is. Two further limits apply to
what a hosted organization can store: at most 200 mappings, and a label of at most
500 runes (#366). The editor sends the whole table on every save, so that submission
is size-capped too — but since #370 the cap is sized to hold any table the store will
accept, so a valid table is never too large to submit. A table stored outside this
editor that runs over the 200-mapping limit still posts: it comes back naming the
limit and asking you to remove rows, and it renders in full so you can. A Clear
all button wipes every row in one step for a table you want to rebuild, and each row
also has its own delete — so a table too large to save can be shrunk until what
remains will. Like every other
organization-config write, the save is version-checked: if your configuration
changed in another session, it is refused with a reload prompt rather than
overwriting. A save takes effect on your next collection cycle and rewrites no
history. The card appears only once the organization has a stored configuration,
which in practice means once it has added a source. Self-hosted the card never
appears — the config file is the only way to change the table there — though the
Alert severity count is shown, since reporting what the process loaded is what
that page is for.
Neither of those tells you whether the table matches your fleet: an entry
reads back just as convincingly when it is a typo (p-1 where your rules write
p1) as when it works. An Unmapped severity labels your sources send card answers that
(#358), above the severity-alias card and visible to every member, not just an owner — it is
information, not a write surface. Per source, it lists the labels that source's
most recent collection cycle carried and no alias maps, each with how many alerts
carried it in that cycle, loudest first. Both lists are bounded and both say so
rather than presenting a handful as the whole: when a source sent more distinct
labels than fit, the card reports how many of how many it is showing, and when more
than twenty sources have unmapped labels it reports that too. Read it against the
table: a label you mapped is gone from the list, a label you mistyped is still on
it.
Everyone who can see the card can make that comparison: both it and the table it is read against render for every member (#363), and only changing the table is owner-only. Self-hosted both are there too — the card on the page, the table in your config file.
The card is careful about what silence means, because that is the whole point of
having it. A source is reported as recognized only when its last cycle actually
assessed something — at least one alert carrying a severity: label, all of
them mapped. A source whose last cycle carried no alert with a severity label
gets a separate, deliberately neutral line: that covers both a quiet cycle and a
fleet whose rules omit severity: entirely, and neither tells you anything about
your vocabulary. (The second case matters more than it sounds — alerts with no
severity label are read as warnings by the same fallback this card exists to
expose, so counting them as "recognized" would have been a green light on exactly
the fleet that needs fixing.) A source is absent from the card rather than
counted as either when there is no reading to show at all: no cycle of its has
ever scraped successfully, or you have removed it from your config, or retention
has reaped its last reading. A source that scrapes fine but never has an alert
firing is not absent — it has a reading, and that reading lands in the neutral
line above. And if the reading itself cannot be loaded, the card says so and shows
no counts — an empty list and a failed read are never rendered the same way.
Two limits are worth knowing. The counts are one cycle's snapshot, not a running total, so a number falls when the alerts stop firing. And an alert pushed to the alert webhook is not attributed to a source, so its labels never reach this card — for a push-only fleet an empty card is not evidence that its vocabulary is understood (#350).
Add-source wizard
The wizard turns "which queries should I even scrape?" into a guided flow. It's
available two ways: as the first-run onboarding page (/app/welcome, a
standalone add → verify → channels → digest walkthrough) and as Add source
inside the app shell (/app/default/sources/new, the add → verify steps only).
It's driven by three server endpoints, all pure read-only probes or computation:
- Verify (
POST /app/sources/verify) — you enter a Prometheus base URL; the server issues two fixed read-only GETs (/api/v1/query?query=upand/api/v1/alerts) and reports back aggregate counts: metrics found, targets up, active alerts, and the scrape jobs it discovered with a suggested golden-signal query count for each recognized exporter (node_exporter, postgres_exporter, blackbox, kube-state-metrics, cAdvisor, Redis, MySQL, NGINX). Response bodies from your Prometheus are never echoed back. - Preview (
POST /app/sources/preview) — runs a bounded 24-hour range fetch for the selected jobs through the deterministic analysis engine and shows the real findings it would surface (anomalies, threshold breaches) — no LLM involved, so a preview costs nothing and needs no API key. It reports how many queries ran, failed, or were skipped so an "all quiet" result is never an overclaim. - Config (
POST /app/sources/config) — pure computation, no network: generates a complete ready-to-runconfig.yaml, or just thesources:snippet, wired with the catalog's golden-signal queries for the jobs you selected.
In self-hosted mode, adding the source is config-file driven: you copy the generated config and restart. The wizard makes the decision — what to scrape and what "healthy" looks like — cheap and correct; it doesn't mutate a running config.
In cloud sign-in mode the wizard instead offers Add to organization
(POST /app/sources/add), which persists the source directly into your
organization's stored configuration — no file to edit, no restart. It is
owner-only (a non-owner request is refused) and appends: the new source
is added to your existing sources: list, leaving every other setting (other
sources, notification channels, LLM provider, schedules) untouched. The write is
optimistically versioned, so if the config changed in another session you are
asked to reload rather than silently overwriting it. Once saved, the source
starts being monitored within a few minutes and appears in your Sources list
right away: in cloud mode the sources list, the per-source detail page, the
settings source index, the incident attribution link, and the reports source
filter are all read per request from your organization's stored configuration,
so each tenant sees only its own sources. (The live metric charts on a source's
detail page are not shown for a cloud organization's sources yet — they query the
process collectors, so they are suppressed until per-tenant series resolution
lands, #34; the rest of the detail page — status, query catalog, timeline —
renders normally.)
Cloud sources (CloudWatch, DigitalOcean, Hetzner) are also selectable. They
take a different shape: you pick a region and IAM role (CloudWatch) or resource
types (DigitalOcean/Hetzner) instead of a URL. In self-hosted copy-paste mode the
token is emitted as a ${…_TOKEN} environment placeholder — you never type a
credential into the wizard. When saving to an organization, DigitalOcean and
Hetzner collect the token in the wizard and store it in the organization's
configuration (a shared multi-tenant worker has no per-organization environment,
so the secret must live with the config). CloudWatch in a cloud organization
takes a region, a cross-account IAM role_arn, and optional namespaces; the
wizard shows your organization id as the STS External ID to pin in the role's
trust policy (and a ready-made trust-policy snippet), so the worker can assume the
role via sts:AssumeRole scoped to your organization alone (see
configuration). No AWS keys are stored — the
role is assumed, not held. There is no live verify/preview step for a cloud
source: a live probe would need a credential on the verify endpoint and an
outbound authenticated call to the provider (a denial-of-wallet lever), so a
cloud source is generated-and-applied and verifies on its first collection. Verify and
Preview are Prometheus-only.
A Prometheus or Loki source can also carry an optional bearer token for an
authenticated backend
(Grafana Cloud, Dash0, Mimir, VictoriaMetrics) (#251). When saving to an
organization you type the token and it is stored with the config (a shared worker
has no per-organization environment); in self-hosted copy-paste mode the token is
a commented ${PROM_TOKEN} placeholder you uncomment and set in the environment.
Entering a token requires an https URL and skips the live probe — an
unauthenticated probe would 401 the authenticated endpoint, and handing the token
to the verify endpoint is the same denial-of-wallet lever the cloud sources avoid,
so a token-bearing Prometheus verifies on its first collection too. On the
source-detail page an owner can rotate the token (blank keeps the current one).
Healthcheck and Loki sources are also selectable. A healthcheck
source is a repeatable list of HTTP endpoints to probe — each takes a URL and
a down-alert severity (both required) plus an optional method (GET/HEAD),
expected status and timeout; add and remove rows as needed. The severity starts
unset rather than pre-picked, for the reason the severity-alias editor above
gives — it is the tier a down endpoint pages at — and Continue stays disabled
until every row you filled has one. A Loki source takes the
Loki API base URL plus a repeatable list of LogQL metric queries (each a name
and a LogQL expression that must return a numeric vector/matrix, e.g.
sum(rate({app="api"} |= "error" [5m]))), with optional signal/warn/crit/unit
engine hints per query. It also collects an optional tenant ID (sent as the
X-Scope-OrgID header for a multi-tenant Loki) and an optional repeatable list of
log queries — raw LogQL log selectors (name + selector + an optional
per-query line limit, e.g. {app="api"} |= "error") whose recent lines are
attached to the LLM summary as context on a critical cycle; scope these away from
streams carrying secrets/PII. Like the cloud sources, neither has a live verify/preview
step — a healthcheck has nothing to probe before it is saved (and probing
arbitrary URLs from the unauthenticated wizard would be an SSRF/denial-of-wallet
lever), and a Loki source is verified on its first collection. The wizard
validates the endpoints/queries against the same contract the config loader
enforces — plus, for endpoints, the explicit-severity rule above, which is the
one place the form is stricter than the file — so a generated config always
loads.
Editing a source (cloud)
A source's detail page carries an owner-only Edit source form
(POST /app/sources/edit) that updates the source in place. Like the save and
remove actions it is owner-only and optimistically versioned, and it
leaves every other setting — and every other source — untouched. The source
name and type are immutable (the name is the identity that ties together its
health, incidents, reports, and schedules; a rename is a deliberate remove +
add). What you can change:
- Prometheus / Loki: the URL. The configured queries — and, for Loki, the
tenant_idandlog_queries— are preserved as-is; to change which metrics (or LogQL / log queries) are collected, remove and re-add the source. - DigitalOcean / Hetzner: the monitored resource selection, and the API token. Leave the token field blank to keep the current one; enter a new value to rotate it. The stored token is never shown back on the page.
- Healthcheck: the list of probed endpoints (url, method, expected
status, timeout, down-alert severity), via a repeatable editor mirroring the
add-source wizard's. Saving replaces the endpoints with the rows you submit
(the same validation the config loader enforces, plus the wizard's
explicit-severity rule), so removing every row is rejected — a healthcheck
source must keep at least one endpoint. A source stored before that rule opens
its editor showing
critical, the tier already in force, so a URL-only edit is never blocked on picking one. - CloudWatch: the region, IAM
role_arn, and namespaces. The form shows your organization id as the STS external ID (with a ready-made trust-policy snippet) in case you re-pointrole_arnat a different role or account.role_arnis shown and edited directly — it is an identifier, not a secret, so unlike an API token it is not a blank-means-keep field. A source configured with an explicitmetrics:list (config-only, not offered in the UI) keeps its metrics unchanged; the namespace checkboxes are hidden for it, since the collector honorsmetrics:and ignores namespaces.
Editing a source does not change which reports or checks run, so it needs no schedule changes; the lease worker picks up the new source configuration on its next cycle. Self-hosted is unchanged: you edit a source by editing the config file.
Removing a source (cloud)
A source's detail page carries an owner-only Remove source action
(POST /app/sources/remove) that drops the source from the organization's
stored configuration. Like the save, it is owner-only, optimistically
versioned (a concurrent edit is caught, not overwritten), and leaves every
other setting untouched; the lease worker stops monitoring the source on its next
cycle. Two removals are refused: an organization must always monitor at least
one source, so removing the last one is blocked (the button is disabled and
explains why), and a source still referenced by a check or a deploy
verification can't be removed until those are repointed. Past reports and
incidents for a removed source are kept; if the source had already collected, it
stays visible in the sources list as a history-only card (health records
outlive a config removal). That card reads removed — a neutral, muted state
showing its last known outcome as frozen history. It is not counted as a
live problem: a source removed while it was failing does not keep the sidebar
failing badge red, and a removed card never shows a live "next collection"
line (the collector is gone). Self-hosted is unchanged: you remove a source by
editing the config file, and a dropped source's card reads removed the same
way. The underlying health record is kept, so the card shows the last known
outcome. Two independent bounds stop a removed card from lingering forever
(#218): the web UI hides a removed card once its last record is more than 30
days old, and the opt-in source_health.retention_days prunes the frozen row
from storage (a live source keeps its row fresh every cycle, so only removed
sources are reaped). With retention off (the default) the card still ages out of
the view at 30 days. The Unmapped severity labels your sources send card (#358) is
bounded for the same reason, though by a different predicate: its readings are pruned
by the same knob, and its view-side bound is membership rather than age — a source
absent from your current config is not listed, and does not count toward its
all-clear, however recent its last reading and whether or not retention is set.
SSRF posture
The verify and preview probes take a URL from the caller, so they
are deliberately constrained: http/https only, redirects refused, fixed API
paths appended (the caller can't choose the path), response size bounded, and
only aggregate numbers returned — never response content. The endpoints also
require Content-Type: application/json, which forces a CORS preflight and
blocks cross-origin simple-POST abuse.
In a hosted (multi-tenant) deployment the probes are gated two ways: they
require a live login session (so the surface is authenticated-tenant-only, never
anonymous), and the dial routes through the same egress (SSRF) gate as a tenant
Prometheus URL — a destination that resolves to a private, loopback, link-local
or cloud-metadata (169.254.169.254) address is refused at connect time (after
DNS, so it is DNS-rebinding-safe). A signed-in tenant therefore cannot turn these
probes into an internal-network / metadata-exfil proxy. The residual is that a
tenant can still probe an arbitrary public URL and spend preview compute —
bounded, not eliminated (per-tenant source-name resolution is deferred to #34);
denial-of-wallet caps live on the endpoints themselves. The egress gate engages
on the same signal the runtime uses — a per-organization source config — so the
wizard and the collector agree on which URLs are trusted.
In self-hosted mode the probes are not egress-gated — the operator's own
YAML is trusted, so probing an internal Prometheus on a private IP is the intended
behavior, and enabling login (an access floor) does not change that. Without login
the probes are also unauthenticated like the rest of /app. None of the above
makes /app safe to expose publicly in self-hosted mode (see the warning above)
— it bounds the blast radius, it doesn't authenticate the caller.
One consequence of the paged archives (#295) worth stating plainly: paging makes
the whole report and incident history reachable through the UI, where the previous
hard 500-row cap left older rows unreachable there. (The per-source timeline is
unchanged — it still shows its most recent 100 entries with a truncation note.)
Each page is one bounded query. One view can still issue more than one: a deep
link (#r-…) to a report that is not on the first page makes the page walk
forward for it — at most 20 further page requests, stopping at the first error or
as soon as you interact. So on an unauthenticated self-hosted /app the full
archive (LLM summaries, delivery receipts) is walkable page by page, and a single
crafted link costs a bounded handful of queries. Another reason to keep /app
behind network controls.
Feedback
Each report row carries a thumbs-up / thumbs-down control with an optional note.
Votes are stored against the report and survive a reload, so you can tell the
team (and, later, tune the prompts) which digests were actually useful. In
self-hosted mode this is an unauthenticated write like the rest of /app
(another reason to keep it off the open internet); in the hosted mode it is
org-scoped behind the login session, like the other report/incident pages.
Chrome
- Theme — light/dark toggle in the top bar; your choice is remembered and applied before first paint (no flash), falling back to your OS preference.
- Sidebar — a collapsible vertical nav (Dashboard, Sources, Reports, Incidents, Notifications); the Sources entry carries a red badge with the failing-source count.
- LLM budget — the top bar shows today's LLM call count against the configured daily cap.
- Strict CSP — every page is served with a strict Content-Security-Policy:
script-srccarries a per-request nonce with nounsafe-inlineand nounsafe-eval(style-srckeepsunsafe-inlinefor inline style attributes), and all assets are vendored and embedded, so the UI makes no third-party requests.