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).
Every write route does refuse a cross-origin browser request
(details), so a hostile page cannot drive a visitor's
browser into muting your fleet; that is not authentication, and it does not
stop a caller who can reach /app directly. 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. All five also resolve an
organization, and three of them — the two that dial an address you type,
plus the config-snippet generator, which judges the credential without dialling
— also resolve a role inside it. A request acting as a TENANT dials through the egress gate, so a
private/loopback/metadata address is refused. A request acting as this
deployment's own organization skips that gate only if it is an owner of
it, because that operator's private Prometheus is exactly what the product
exists to scrape and an owner is who runs this deployment. A plain member
of the same organization is egress-gated like a tenant, and is not handed a
resolved ${VAR} either — the same owner floor governs whether a ${…} in a
submitted credential is expanded against the process environment before this
server dials or judges it.
Such a member keeps the wizard. Saving a source is owner-only, but the other
deliverable — the config.yaml snippet — is a real ending: a configuration
file is a property of the deployment, not of anyone's standing in it, so a
reader who can reach that file can finish the walk. The floor is about what
this server dials, not about what it prints. Where no sign-in is configured
there is no role to ask and the single caller is the operator, so nothing
changes for a plain self-hosted install.
A refused probe now says which boundary it hit rather than answering both with one sentence. A tenant reads that the address has no route from the hosted service and that running InfraSigns inside their own network is the way to monitor it; a member of this deployment's own organization reads that only an owner may have InfraSigns dial an address that is not on the public internet, that we could not confirm them as an owner, and to ask an owner — deliberately saying nothing about where InfraSigns is hosted, because that reader is refused the same way on a hosted deployment and on a self-hosted one.
That split exists because membership and role are different guarantees. Nobody
joins this organization without the operator — its FIRST member can only be
admitted by infrasigns org invite --org default, which needs a shell on the
server — but every later member is invited from the Members page by an owner,
at whatever role the owner picked, so membership alone says only "an owner let
this person in", never "this person runs the host". Read ownership of
default as equivalent to server access when you grant it
(Where a source lives). The residual
for a tenant is narrower but real — they can still probe any public URL —
so keep network controls in front of a hosted deployment regardless. The
.../series feed goes further and refuses a tenant outright: it queries the
process's own collectors, so it answers only for this deployment's own
provisioned sources; per-tenant series resolution is still deferred. Removing a member cuts
their access to org data immediately (it is re-checked every request) and now
also deletes their sessions, so the session-only endpoints above go with it;
a demotion leaves the session alive, which is correct — it removes authority,
and authority is re-read per request.
Who may sign in at all is a separate question, and the hosted answer is invite-only: completing OAuth is not enough — the identity must already belong to a member of some organization, or its verified email must hold a live invite, or the callback refuses it before any write. That is always on in cloud mode, with no key to relax it, and it is what bounds the population the paragraph above calls "authenticated tenants". Self-hosted mounts no login routes at all, so none of this applies there.
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/ — on a self-hosted deployment the root redirects to the
dashboard, so you do not have to remember a path. (With cloud sign-in configured
the root is mode-dependent; a signed-in visitor still lands on
the dashboard in one hop.)
What you see
The pages themselves live under /app, 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.)
What the ROOT path answers depends on the mode:
- Self-hosted —
/redirects to/app/default, always. The instance root is the operator's own dashboard; there is nothing else to show there. - Cloud (sign-in configured) — a visitor who already has a live session is
redirected to
/app/defaultexactly as before, in one hop, whether or not they currently have an organization selected (/appthen routes them onward to the organization picker). A visitor with no session gets the public landing page instead of being bounced into the sign-in wall.
Only the root path itself does any of this. Any other path this app does not
serve still answers 404, so a typo does not silently land you on the
dashboard — and / answers 405 to POST and other write methods, because it
is registered under GET. With ui.enabled: false no UI route is mounted at all
and / is a 404 like everything else.
Public pages
Two pages are readable without signing in. Both belong to the public site —
the face a deployment shows to someone who has not signed in yet — which is opt-in
under ui.public_site and off by default. They are not gated in the same way, and
the difference matters if you are reading route behaviour off this table:
| Path | Mounted | What it is |
|---|---|---|
/ |
always | The landing page, for an anonymous visitor of a deployment that sets ui.public_site: what InfraSigns is, what it does today, a sign-in button, and — beside it — the two things worth knowing before spending an invitation (anything the service dials by URL must answer over the public internet, while a DigitalOcean, Hetzner or CloudWatch source needs nothing opened; and every delivery channel is one the reader supplies — their own Telegram bot, SMTP server or Slack app) plus the fact that access is by invitation and that there is no sign-up. Since #496 it also carries the access-request form. A visitor who already has a session never sees any of it. Without ui.public_site the same route is mounted and redirects to the dashboard instead — which is what a self-hosted deployment does, and what a deployment with sign-in configured does too until it asks for the public site. |
/pricing |
ui.public_site and a billing client |
A comparison of what each plan carries: sources, health checks, member seats, history retention, LLM calls per day, and whether Slack notifications are included. Both conditions are required: the page is an offer, and an offer a visitor has no way to accept is a commercial claim that applies to nobody. The billing client is compiled in only by the ee build, so on the public image this route never mounts, whatever billing.secret_key says. |
POST /access-request |
ui.public_site |
The landing form's write. See below. |
So GET / is registered everywhere — it is the landing PAGE that is opt-in,
not the route — while /pricing answers 404 unless ui.public_site is set
AND a billing client is wired. The key alone is not enough, and that is
deliberate: the page's whole content is the hosted subscription tiers, so on a
deployment that sells nothing every row would be a limit that applies to nobody.
The billing client is proprietary and compiled in only by the ee build, so on
the public image this page is absent by construction — setting
billing.secret_key there changes nothing, because nothing reads it.
ui.public_site is in turn refused unless
sign-in is configured, for a related reason:
without a sign-in there is no anonymous visitor to show a landing page to, because
/ already answers with this deployment's own dashboard.
/pricing quotes no prices and sells nothing. InfraSigns is in closed beta,
pricing is not announced, there is no checkout on that page, and a plan is
assigned when the operator creates an organization (infrasigns org create
--plan); changing one is not self-service yet, which is what the signed-in
Billing page says too. What it does carry is the real limits: every number on it
is read from the same tier table the cloud worker truncates each organization
against and the quota gates enforce (internal/plan), so the page cannot drift
from the limits it describes. One tier field is deliberately absent — the
dead-man's-switch (WatchdogEnabled) is not enforced by any code today, so no
tier's value for it changes what an organization gets, and a public row for it
would advertise behaviour nothing honours.
Some rows carry a qualifier, and where it sits says what kind of qualifier it is.
Above the table are the notes about LLM calls per day, because each changes
how that figure is to be read before the reader reaches it: the row is a ceiling on
spending rather than a reserved allowance — the scheduled reports and the health
checks draw on one daily budget with a share of it held back for the reports, so
the calls a check can use are fewer than that row shows — and where the operator
has set llm.max_fleet_calls_per_day, a service-wide daily limit applies on top of
the same row. (A third paragraph sits in that same alert and is not a qualifier at
all: it is the beta note, which renders on every deployment.) The other rows are
entitlements the product resolves to rather than caps it merely refuses to
exceed: sources and checks are truncated to the tier value, retention resolves to
it, and the seat gate stops exactly at it.
Below the table are the notes that each explain a single row, in the table's
own row order. Sources counts sources — one item under sources: — and how
much one source covers is not the same for every kind: one Prometheus or Loki
endpoint; one DigitalOcean account or one Hetzner Cloud project, with every
resource of the kinds you pick; one AWS account in one region for CloudWatch;
or a whole set of HTTP URLs probed together. So a second Prometheus or Loki
endpoint, a second account or project, or a second CloudWatch region each needs a
source of its own, while up to 50 probed URLs fit in one source without needing
another — which is exactly what a bare count cannot say, and which the wizard
otherwise tells you only after you have already picked a type. (The page says
source rather than entry: its reader is choosing a tier and has no config file,
so the word has to be defined by the row label above it rather than by a YAML list
they have never seen.) The 50 is internal/web's own form cap
(configgen.maxEndpoints) and not the store's
config.HealthcheckMaxEndpoints of 200: the
page is mounted only where billing is wired, so its reader is a hosted prospect who
will configure through the wizard and the source-edit card, and 50 is the only
endpoint limit either of those will let them reach. Quoting 200 told a prospect with
120 URLs that one source held them.
A member seat is held by a current member or by an invitation that has been sent
and has not expired, which is why that row is labelled seats.
Neither of those two is gated, and the question is not the one the LLM notes answer.
Those render only where their figure exists; both rows here print a tier integer
everywhere, so neither sentence can be left pointing at nothing. The sources note
does name kinds, and one of them may be unavailable — on a deployment that has
declared no aws.worker_principal_arn a
tenant cannot add a CloudWatch source at all, because the wizard tile is disabled
and the save is refused. It is named here anyway, deliberately: a public page
describes the product, and what is available on a particular deployment is the
wizard's job, where the disabled tile and its explanation already say so at the
moment a reader is about to act. The landing page names CloudWatch in the same voice
for the same reason. Gating this paragraph instead would leave the same claim
standing on the page a stranger actually reaches — which is not hypothetical: an
earlier round did gate it, and the deployment that argument was written for serves
no /pricing at all while serving the landing page. Do not re-gate it on the
deployment's AWS identity.
Both pages follow the deployment. With llm.provider on none or mock nothing
is billable, so LLM calls per day reads "Not used by this service" and neither
ceiling paragraph is rendered; the landing page's health-checks card drops the line
about a run spending one call a day, which is a claim about a budget a non-billable
provider does not keep. With llm.provider: none the Health
checks row reads "Not evaluated by this service" on every tier and the landing
page drops the card altogether, because checksRunUnder is a property
of the service — with no model no organization on any tier has a check evaluated.
(mock counts as a provider for checks and not for billing, which is why the two
answers differ.)
Both pages pick their language from the request's Accept-Language header,
matched against the deployment's supported locales and falling back to the
configured default (locale:) when nothing matches. An Accept-Language longer
than 512 bytes (web.maxAcceptLanguage) is ignored and the default is used — no
browser sends one a fraction that long. This applies to these two pages only: every signed-in page
keeps using the acting organization's configured locale, so a browser header never
overrides a setting an owner made.
In cloud mode / declares Vary: Cookie, Accept-Language and
Cache-Control: no-store, and /pricing declares Vary: Accept-Language and
Cache-Control: private. The self-hosted / declares neither: it negotiates
nothing and reads no session, it is one redirect. private rather than nothing on
/pricing because the response carries a per-request CSP nonce, and a 200 with no
Cache-Control is heuristically cacheable — a shared cache would otherwise hand
one visitor's nonce to the next; the visitor's own browser cache is unaffected.
The signed-in Billing page is a different surface and stays where it is: it
shows your plan, your usage meters and the upgrade path, which is what an
"upgrade to add more" link inside the app should lead to. /pricing is anonymous
and does not know who is asking.
The access-request form
Sign-in is invite-only and unconditional, and every organization and every
invitation comes from the operator's shell (infrasigns org create,
infrasigns org invite). Until #496 the landing page said so and offered nothing
to do about it, which left the visitor the page exists for — somebody sent a link
but not an invitation — with no way to ask. The form is that way. There is
deliberately no address to write to anywhere on the page: an address on a
public page is harvested, and there was no intake channel behind one.
The form takes an email address and nothing else. Submitting it records the address and grants nothing — it is a request, not a sign-up, and the page says so.
What the visitor is told, and what they are not:
- The same answer either way. A first ask and a repeat render an identical page, byte for byte modulo the per-request CSP nonce every render stamps. The form is anonymous and reachable by anyone, so an answer that differed would tell a stranger whether an address is already on the list.
- No lookup of any kind runs before the write — not against the request list, not against memberships, not against invitations. A member who submits the form gets the same neutral page as anybody else and you see a row you can ignore. The cross-reference is in the CLI, where the caller is you.
- No timeline, and no delivery. The copy promises no response time, no queue
position and no date, and the success sentence does not say an invitation will
arrive either — nothing in the product sends anything to these addresses. You
read the list and decide, and you pass on the link
org inviteprints. - A failed write says so. If the row could not be stored the page reports that nothing was saved rather than painting a success — there is no retry behind the form, so a false confirmation is a person who never hears back and never learns why.
- A refusal lands where the problem is. A message about the ADDRESS marks the field, is announced before the field's own hint and privacy line, and keeps what was typed. A message about the SUBMISSION — the rate limit, a store failure, a body too large to read — renders as a page-level notice with the control left alone, because the address may be perfectly good. The submission cap and the address bound are different numbers and the page says so: a body too large to read names the whole-form limit, because nothing was parsed and no field can be named; the address bound stays on the field's own hint, which renders on every visit.
The address is bounded at 254 bytes (RFC 5321's path maximum, the same bound
org invite applies) and must be a bare address — no display name, no angle
brackets — so that what is stored can match an address a provider verified. Two
spellings that differ only in ASCII case or in surrounding whitespace are ONE
request; a non-ASCII homoglyph is a different address, for the same reason
sign-in folds ASCII only. The form's input is
type="email", whose HTML5 validity is ASCII-only, so a browser will not submit
вася@пример.рф even though the server accepts it — the hint therefore states
the byte bound and does not advertise other alphabets.
Rate limit. The route runs behind a single token bucket — 60 submissions an
hour with a burst of 10, a constant with no config key. Per process, not per
service: two replicas are two buckets, so 120
submissions an hour get through and each process's gauge reports only its own.
Nothing in the tree pins that count where this route runs — the Helm chart
refuses a replicaCount above 1, but it cannot express a hosted deployment at
all. Read the limit as what it is: it is not a per-client limit, and it
cannot be. This application reads no client address anywhere, and the hosted
deployment sits behind a Cloudflare proxy with the origin's firewall admitting
only Cloudflare ranges, so every request arrives from one of a handful of edge
addresses and a per-IP bucket there would be a global one under a false name.
That last one is a precondition, not a property of this code: a hosted
deployment that let traffic reach the origin directly would make the argument
false without a line here changing. A refused submission answers 429
with Retry-After and a neutral message that says nothing about the caller,
because nothing about the bucket is a fact about them. The residual is worse than
a window: a poster submitting one well-formed address a minute matches the refill
rate exactly, so the bucket never recovers and every genuine visitor is refused
for as long as it continues — on the only intake channel there is. What makes
that visible is infrasigns_access_request_tokens_available (see
observability) — an observable gauge read
from the bucket itself on every scrape, so it reports the held-shut state
whether or not anybody is being refused. Alert on it staying near zero for
longer than the bucket takes to refill; a real burst of interest drains it once
and climbs back at one token a minute. The sibling counter
infrasigns_access_request_submissions_total{status="throttled"} is not the
alert: it counts submissions the bucket turned away, and the poster holding it
empty is being accepted, so it is flat in exactly this scenario. Read it beside the gauge
for what the condition has cost. The remedy for a sustained one is at your edge,
not in this process — the rate is a compile-time constant, a restart refills
only the burst, and remove does not touch the bucket. Refusals are deliberately not logged — an anonymous caller decides how
often one happens, so a per-request log line is disk they control. The
per-address upsert bounds the damage in the other direction: N submissions of one
address cost one row. Nothing prunes the table, so a poster spending the budget
on distinct addresses can still fill your default list page; remove takes one
address at a time today.
Reading the list. There is no web surface for it, by decision: every /app
page is scoped to an organization and an access request belongs to none, so a page
would mean inventing an operator-authorization concept this app does not have. Use
the shell you already need for org create:
infrasigns access-requests list # oldest first, with a STATUS column
infrasigns access-requests remove --email …
list shows, per address, when it first asked, when it last asked, how many times,
and whether the identity tables already know it — member (already in), invited
(a live, unexpired invitation is out) or new. That column is the one thing the
web form may not answer and the shell may: it stops you inviting somebody who is
already a member or minting a second invitation over a live one.
remove is both the "I have acted on this" path and the way to erase a row. It
is your command, not a promise the form makes: the privacy line beside the
field says the address is stored only so an invitation can be sent to it and is
used for nothing else, and deliberately claims no deletion — nothing erases a row
when a request is acted on, and the page carries no address and no off-origin
link, so a visitor has no channel to ask through. Erasure is something you do
because you decided to, and this is how.
It reports honestly when nothing matched — an address that never asked is a miss,
not a failure — so removing twice says so the second time.
Dashboard
The landing page (/app/default) is an at-a-glance health view:
- Silenced-notifications note — leads the page whenever a maintenance window is open, naming when paging resumes and how much has been held back. It comes first deliberately: it changes how everything below it should be read, because a quiet dashboard under an open window is not a healthy one. It names the scope when the window covers individual sources rather than the whole organization. A cut window list ORDINARILY no longer hides this note or its numbers: an active window ordinarily cannot be among the rows a capped read drops (see Maintenance windows for why), so what the note claims is ordinarily accurate, truncated read or not. The one exception is the same one that section names — an organization already carrying more active windows than the cap the moment it shipped — and for that organization the note falls back to an explicit "cannot tell" wording rather than a precise time or count. If the read FAILED outright, no note is drawn at all — a failed read cannot support "you are muted" any more than "you are not", so the Notifications section reports the failure and the dashboard says nothing; do not read a quiet dashboard during a database outage as proof of either.
- 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 the hosted service every row additionally reads paused — the service's shared model limit while the operator's fleet-wide daily LLM ceiling is reached (#472): no check anywhere records a verdict until midnight UTC, so every badge on the card is the last one from before the pause. That marker is on the row rather than on the card because it is the badge that misleads, and it can appear beside any of the others — it is not about this check, or even about your organization. Every fleet-pause line on every page carries the same precondition, stated here once: it appears only where your organization's own allowance was not already refusing that call itself. Where your own cap — or, for a health check, the cap minus the reserve held back for reports — is what stopped it, the pages say nothing about the shared ceiling, because naming it there would tell you nothing is wrong with your configuration while the limit that actually binds is the one you can act on.
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. (The button is offered only to a member of this deployment's own
organization. The trigger runs that organization's digest — never a tenant's — so
where sign-in is configured a tenant does not see it, and their 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; - the log context source, when the source has one — which other source's log
lines ground its critical incidents
(
log_source). This is the only place the setting is visible in a deployment with no sign-in. Wherever sources can also be stored, it is annotated when it cannot currently deliver: (cannot provide logs) if the target no longer qualifies — for a hosted organization that is also why your configuration is being refused, while for the deployment's own organization the target may live inconfig.yamland the process says so in its log instead — or (not monitored on your plan) if it is a valid target your plan's source limit leaves unmonitored, which upgrading resumes; - 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.
A source your config file provisions is marked From config file — on its card, on its detail page beside the endpoint, and in the Settings source index — and its detail page carries a note saying what that means: it is monitored like any other source, and it can only be changed in that file. Such a source is offered no edit form and no remove control anywhere in the UI. The write endpoints refuse it by origin as well, so the withheld buttons are not the gate. Only this deployment's own organization ever sees one; a hosted tenant's sources all live in its stored configuration.
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.
The Add source control on the Sources page header and on the dashboard is offered disabled for either of two reasons, and each carries the sentence the save itself would answer with. The control is disabled rather than hidden so someone who has just met a boundary is not left wondering where the button went, and the add-source wizard states the same thing and does not open, since it is reachable by bookmark and by the browser's back button.
- This reader cannot finish the walk. Neither of the wizard's two endings is open to them: the save is owner-only, and there is no configuration file that is theirs to paste a snippet into. Exactly one reader is in that state — a non-owner member of a hosted organization, and nobody else. A member of this deployment's own organization is not: the snippet is an ending they can finish, so they keep the walk. The sentence is the save's own — Only organization owners can add sources. — and there is no upgrade link beside it: a role is not a plan. On the add-source page the same state additionally names who to ask and links to the Members page, which lists them and which any member may open; the disabled control in the two page headers carries the sentence alone, because the wizard page is where a reader has somewhere to go next.
This arm fails closed: a role that cannot be read at that moment is read as
"not an owner". The save does not answer the same way — there an unreadable role
is a 500, not a refusal, so a retry can still succeed. That is why the sentence
names who may add a source rather than telling the reader they are not one: it is
a claim about the capability, which stays true on the degrade, where a verdict
about the reader would not.
- The organization is at its plan's source limit. The sentence is the quota
refusal ("you've reached your plan's limit of N sources") with an Upgrade to
add more link beside it — the same billing route the over-limit card offers.
This arm is a display of the plan, so it fails open: if the subscription
cannot be read at that moment the control keeps working and the save answers for
itself. Cloud only; self-hosted enforces no plan limits.
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.
A claim ending in a † is one whose text InfraSigns shortened before the
report was delivered; a footnote under the card says so. It is the archive's copy
of the mark the delivered message carries — see
Shortened text — and it exists because an
ellipsis alone cannot tell you whether the model wrote one or this product removed
words. Two limits worth knowing:
- Reports delivered before this shipped carry no mark either way. Nothing recorded whether their claims were shortened, so an unmarked old claim means "unknown", not "complete".
- Only the structured claim rows carry it. A report that renders its prose body instead (the deploy verifications, and any digest whose summarizer produced no claims) shows no marks, because a free-form body has no per-line structure a mark could point at.
Change since the previous digest
An expanded digest row opens with a short section saying what moved between the previous digest for that same source and this one — the "do I need to read this one" answer, which is why it sits above the summary rather than below it. It reports three things, each in its own sentence: the verdict, how many episodes were still firing, and how many alerts the cycle saw.
The still-firing line names both numbers and claims no direction, deliberately: "Still firing: 5 in the previous digest, 0 in this one.", never "fell from 5 to 0". That count is a floor — the read behind it stops at the longest-running episodes and leaves out episodes nothing has re-asserted — so a fleet whose Alertmanager goes quiet overnight can push five open episodes below the line and produce a 0 with nothing resolved. Comparing two floors cannot tell you the fleet improved, so the sentence compares the two reports, which is what the archive knows. The alert count is a snapshot rather than a floor — it is neither truncated nor filtered — so that line does say rose or fell.
It is computed when the page is read, from the archive itself. Nothing is stored, so no report is rewritten by it and a report generated before this shipped gets the section too.
Six things worth knowing, because each of them is a case where the section says less than you might expect it to:
- Only digest rows have it. A trends report is already a period analysis, so "since the last one" would duplicate its own subject; and a deploy verification is an event, not a cadence, so "the previous one" is a different question. Both badge by report type rather than by a health tier, and this section would put a tier back on them.
- The comparison ignores the filters. Narrow the archive to one month and the first digest in that month still compares against the digest before it, which is outside what you are looking at. The section names the date it compared against precisely so this is visible rather than silent.
- A missing predecessor is reported as such, never as "no change". The wording
is "no earlier digest for this source is retained" — deliberately, because
the row may simply be the first, or the older one may have been pruned by
reports.retention_days. Nothing distinguishes those two from the archive, so the sentence does not claim the one it cannot check. - Digests older than the verdict record cannot be compared on verdict. The three verdict columns arrived together and without a backfill, so a report delivered before they existed carries none — and because the still-firing count is one of those three, it goes with them. The section names which of the two rows is missing them, says both dimensions are affected rather than leaving you to infer it from the column layout, and compares the alert count, which every report has always recorded.
- The summary needs every dimension to be comparable, and it speaks about the two reports rather than the interval between them: "Both digests report the same — CRITICAL." If the verdict could not be compared, it is withheld even when the alert counts match — a claim about the whole comparison must not be made from part of it. You get the disclaimer, and the alert line whenever the two counts are not both zero.
The wording is deliberate and it is the same caution as the still-firing line.
Two reports agreeing is a fact about the reports; it is not a fact about the
window between them, because the numbers they agree on can both be blind at once
— a source that stopped re-asserting produces two identical zeros while episodes
pile up, and the healthy verdict itself is derived from that same zero.
- A dimension that is zero on both sides says nothing at all. For still-firing
this matches every surface — the badge and the meta line both omit the term at
zero, because a zero invites you to look for something that was never there. For
alerts it matches the badge, which drops the term at zero; the meta line prints
0 alert(s) regardless, so the delta staying quiet simply avoids repeating it. A
count that moved to or from zero still gets its sentence; that is the news.
HEALTHY, DEGRADED and CRITICAL appear here in the same un-localized form the
row's own badge uses, so one value never reads two ways on one screen. And if
either report's summary was written from a template rather than by the model — a
budget cap, or a provider failure — a muted note says so, because two summaries of
different kinds are not straightforwardly comparable.
Each row's badge is the verdict the report was DELIVERED with, recorded on the row rather than re-derived from the alert count stored beside it (#418) — so a digest sent as "1 still firing" reads DEGRADED · 1 still firing here too, and the count of still-open episodes also appears on the row's meta line beside the cycle's own alert count (they are different facts: one is what was live when the cycle ran, the other is what was still open and unconfirmed).
The full vocabulary, one value per branch of the badge decider:
| Badge | When |
|---|---|
| HEALTHY | a digest whose verdict asserted health |
| DEGRADED | a digest whose verdict was warning-tier |
| CRITICAL | a digest whose verdict was critical-tier |
| NO HEALTH CLAIM | a digest whose verdict asserts nothing about health either way |
| NO VERDICT RECORDED | a report generated before the verdict column existed |
| Trends report | any weekly trends report — the badge names the shape, not a health verdict |
| Deploy verification | a post-deploy verification that passed |
| Deploy REGRESSION | a verification that FAILED — the deploy made something worse |
| Deploy verification — could not verify | a verification that could not run at all |
CRITICAL and DEGRADED additionally carry the counts when there are any —
DEGRADED · 2 alert(s) · 1 still firing — and omit each term at zero rather
than printing a 0 that invites you to look for alerts that were never there.
Three of those rows deserve a note. Deploy verification — could not verify is new with the verdict column: such a run used to be indistinguishable from one that passed, so the archive said the deploy verified fine when the verification had failed to complete. And NO HEALTH CLAIM you are unlikely to meet — no shipped producer writes a non-health info verdict for a digest today; it is what the badge would say rather than guessing green if one ever did. NO VERDICT RECORDED is the pre-column state: the archive genuinely does not know what those reports said, no backfill can recover it, and a green all-clear the row never recorded is exactly the defect this closes.
The same badge, the same words and the same neutral fallback are used by the dashboard's latest-digest card. The source-detail timeline reads the same decider for its dot, which has no green: a critical row is red and a degraded one amber, the two states with no reading to give — no verdict recorded, and a verification that could not run — are a distinct grey, and everything else takes the neutral accent.
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.
Durations the archive is not measuring
Some archived episodes have an end that no notification about them reported. When a resolved notification arrives with no start of its own — a CloudWatch alarm returning to OK carries only the moment it cleared — it is read as the end of the alert's most recent recorded episode, if that episode ended within the last 7 days and has not already absorbed one (how the merge works). The alert stopped firing somewhere between the two recorded moments, and the archive cannot know where.
So those rows say "lasted at most 2d" rather than "lasted 2d", on the dashboard card, the archive rows and the source timeline alike; the detail page adds why, under the duration. Everything else on the row is the episode's own: its start, its severity and the label its sender reported are unchanged by the merge, and only the end, the summary and the delivery receipts come from the resolution.
An episode's end is borrowed at most once, so a run of alerts whose firings never reached the database leaves a second shape beside it: a resolution that finds no episode to merge into is written as its own row, keyed at its own moment, whose recorded start and end are therefore the same instant. Those rows read "no duration recorded".
That wording states the fact and stops there, because the archive cannot tell which of several rows it is looking at. A resolution whose firing never arrived is one. A sender reporting a start at or after its own end is another — both closers floor the recorded end at the start, so it lands in the same shape. So is an episode you closed by hand whose start was already in the future. And an episode recorded before this behaviour shipped can have an end earlier than its start, which is why the note compares the two moments rather than calling them equal: the page shows both timestamps right beside it.
Neither wording is an error state. Both mark a number the archive is not asserting, so a reader can discount those rows instead of reading a fiction as a measurement.
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.
When such an alarm eventually does clear, its notification carries only the moment it cleared — no start time — so it has nothing to identify the episode by. It is now read as the end of the episode you closed by hand, provided the alarm clears within 7 days of the click: the archived episode keeps its severity, the label the sender reported and its real start, and takes the alarm's own clear time, the alarm's summary and the delivery receipts of that notification. Note that the button's "rewrites nothing" above is about the button; this later merge does move the recorded end time, the summary and the receipts, because the sender's account of the end is the newer one. Past 7 days the clear is recorded as a separate row instead — the safe direction, since an episode that ended a week ago was probably not this one. This happens once per archived episode: if the alarm goes on to clear again without a firing in between, that later clear gets its own row rather than stretching this one further (why).
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 source per 24h — so a fleet stays audible even while another one is inside
its own cooldown. The un-throttled
infrasigns_alerts_unrecognized_severity_total{path="webhook"} counter is the
durable half, but its only attributes are path and source (the configured
source name the alert attributes to, falling back to the payload format when
nothing does — #350), so
it says which fleet something unmapped arrived from 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 is persisted for 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. Each channel card carries a Feeds row answering that
question for itself, and it is a delivery claim rather than an echo of the
feeds: key: what the channel is routed to, intersected with what the channel
can carry at all and with what this deployment (in cloud mode, your organization)
actually produces. So a channel routed at deploys on a deployment with deploy
verification switched off names nothing — the card mutes instead of promising it.
Every feed is reserved for a channel that receives the whole vocabulary, which
is why PagerDuty, carrying two of the five, never prints it. See
Feed routing. Below the cards is a separate list
of the feeds produced — by this deployment, or in cloud mode by your
organization — and whether each one is active at all; it is not a per-channel
claim, and a feed active there still reaches only the channels routed to it.
In self-hosted the page 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, and choose — from the feeds a tenant's
own runtime honours — which of them reach any of the four channels, including
PagerDuty, whose card is routing-only (see below). It reflects config, not
a live probe — for live channel health, see
/readyz in Observability.
A card is shown muted, with the reason spelled out and its own remedy, when
nothing can reach it. More than one reason can hold at once, and the page lists
each: a channel routed to no feed at all (feeds: []); a channel routed to a
non-empty set none of whose feeds is produced here — the note points at the
routing control in cloud, and at the feeds: key or the switch that turns the
producer on in self-hosted, because only one of those remedies exists for each
reader; a Slack channel on a plan that does not include Slack, whose note names
the Billing page for an organization owner and no page at all for anyone else,
because the upgrade controls there are owner-gated while the link to them is not;
PagerDuty with nothing left that can page it; and — in cloud only — a channel emptied by the
operator's fleet-wide daily LLM ceiling, the one mute with no remedy for either
reader, described under the feeds list below. On PagerDuty the second of those is worded
differently, because there the miss is a capability rather than a
configuration: a pager carries incident alerts and health checks only, so the note
says that instead of naming a producer to switch on.
One further reason is prior to every reason above rather than independent of
them, so it replaces their notes instead of joining them — all but the plan gate,
which is a per-request subscription read applied after the card is built and so
still contributes its own: the section is present
but incomplete, so no delivery channel was built from it at all — for example
a Telegram token with no chat_id, a chat_id with no token, a webhook_url
that is not an http/https URL naming a remote host, or an email section
naming a sender and recipients with no smtp_host. Routing is a property of a channel that exists, so on this
card "routed to no feed" would name a control that is not what stops delivery —
and, on a render that does not carry the routing controls, one that is not on the
screen either. The card is shown rather than dropped, because a value you stored
must not disappear from the page that reports it — and where it is the only
channel, dropping it would leave an empty state asking you to add a block you had
already written. Its note points at the config file in self-hosted, where
serve's startup log has already said what that channel needs, and at the
channel's own settings in cloud, where there is no such line: the
per-org runtime builds its channels without passing through that startup
disclosure. The note does not name the missing field itself — that rule belongs
to the channel's config type, and the startup log is its one home. A
muted card drops its Feeds row rather than printing a delivery claim beside a
badge saying the channel is inert; what you configured — the redacted key, the
default severity — stays on screen either way.
Every one of those reasons is written for the reader in front of it. Where a note's remedy is a control this page carries, an organization owner is told to use it, and everyone else — a member, or an owner whose role read failed, which hides the edit cards rather than failing the page — is told instead that an organization owner can change it. The wording is not politeness: the routing controls live inside the edit cards, so on a render that does not carry them the instruction would name something that is not on the screen. Two of the reasons read the same for both readers, deliberately: the incomplete note already names the role rather than commanding anyone, and the PagerDuty one names no control at all. The self-hosted operator, who has the config file and no controls, gets a third wording pointing at the key.
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. While the operator's fleet-wide daily LLM ceiling is reached — and your own
allowance is not what is refusing your checks, per the precondition above
(#472) — the health
checks row reads as paused rather than as active, and says the shared service
limit is the reason and that nothing is recorded or sent until midnight UTC. It
prints no count while it holds — the number it usually carries is a delivery
claim, and nothing is being delivered — and it offers no remedy, because no
control of yours reaches a service-wide ceiling. Since it is not active, checks
also drops out of what this deployment produces for as long as the pause lasts,
so a channel card that could receive nothing else is muted meanwhile; that
includes a PagerDuty card, whose only per-organization feed is the health-check
transition. Such a card's note names the shared ceiling, not your configuration:
a card the pause alone emptied would otherwise read as though no check of yours
alerts, or as though the fix were to route it elsewhere. A card that delivers
nothing either way keeps its own reason instead — the ceiling is not why a
channel routed to no feed is silent. 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's Feeds row names what actually reaches
it — the intersection of what can page this channel at all with what you routed
to it — and the card is shown muted with a reason when nothing can. Three
reasons are possible and they have different remedies: nothing is reachable (an
organization with no checks, or whose checks are all monitor, has a channel that
cannot fire); something is reachable and your routing excludes it; or the channel
is routed to no feed at all. A fourth is the fleet pause above, and it is the one
with no remedy at all — it names the shared ceiling and the reset, and it replaces
whichever of the three would otherwise have applied only when lifting the ceiling
would put something back on the card. The last of those and the first can appear together
— a channel routed to no feed in an organization with no alerting check carries
both notes, the routing one first, because a tenant told only about reachability
would fix that and still hear nothing. 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 credential form: the 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). What an owner does get is a routing-only card, saved on its own. It offers the feeds that can both page PagerDuty and run for an organization, which today is one: health-check transitions. Incident alerts can page a pager, but they run at the operator level, so there is no box for them — routing a tenant channel to a feed its own runtime never produces would be a control that cannot change delivery. The card appears only once a routing key has been seeded for the organization, since routing a channel that does not exist would store a choice nobody honours. The key is not shown there, is not editable and cannot be removed; switching PagerDuty off entirely is still the operator un-seeding the key.
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
credential form — its routing key is operator-seeded, as described above — only
the routing card.) Each of the three forms also carries a Feeds this channel
receives checkbox group, saved with the rest of that channel's fields: leave
every box ticked for the default, or untick some to narrow the channel. Unticking
all of them is the feeds: [] state — the channel keeps its credentials and
stops delivering, and its card renders muted saying so. The group offers the three
feeds a tenant's own runtime honours — digest, trends and health
checks; incident alerts and deploy verification run at the operator level, so
they are not offered here. If your operator seeded one of those two on a channel
anyway, it stays: saving preserves a stored feed this form cannot show, and the
form says which one rather than letting a save quietly narrow it. Removing a channel clears its
credentials from your organization, and takes its routing with it, so re-adding it
later starts at the default rather than inheriting a selection you cannot see.
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 higher plan stays in place, but the worker suppresses its delivery once the plan drops to Free (re-upgrading resumes it — nothing is rewritten). The card says so: it renders muted, pointing at Billing, rather than looking like a live channel. Saving the Slack form on such a plan changes nothing, and it answers on the card — the page comes back in your own language with what you submitted still in the fields and the Billing remedy beside it, instead of replacing itself with a plain-text refusal. Removing the channel is not gated, so you can still clear it. Telegram and email are unaffected. See Billing.
Maintenance windows
The page ends with a maintenance windows section: silence notifications while
you do planned work, without losing the record. Pick a scope (one configured
source, or the whole organization), a duration, and give a reason; the window
appears in the table below with how many notifications it has swallowed so far,
and you can end it early. It also lapses on its own — there is no open-ended
window. The rules the window obeys, including which notifications each scope
reaches and why a resolution is never withheld, are in
Maintenance windows; this page is one of
its two surfaces, the other being the self-hosted /api/maintenance endpoints.
The section renders in both modes and for any member of the organization
— unlike the channel edit forms above it, which are cloud-and-owner-only. That is
the same line the manual incident close draws: an owner gate guards actions that
change your configuration permanently, and a window changes who gets woken for
a bounded period and then heals itself. In self-hosted mode /app is
unauthenticated, so the residual is the same one the whole app carries — anyone
who can reach it can open a window, exactly as they can already close an episode
or vote on a report. Restrict it at the network layer.
The UI offers a fixed set of durations (30 minutes to 24 hours) rather than free
text, which removes the "seconds where minutes were meant" mistake by
construction; the API keeps the full one-minute-to-30-day range for the cases
that need it. Windows opened here are recorded as web, or web:<your email>
when you are signed in — self-hosted /app cannot name a person, so it does not
claim one.
While any window is open the dashboard carries a note saying so, with the time paging resumes and the running suppressed count. That note is the point of the feature as much as the silence is: without it a muted dashboard and a healthy one are the same picture. If the window read fails, no note is rendered and the section says the read failed — "nothing is muted" is not a claim a failed read can make.
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. - There is no create-organization page. On a hosted instance organizations
are created by the operator, with
infrasigns org create, and you join one by accepting an invite. Nothing in the UI mints an org.
That is a deliberate bound rather than a missing feature. The hosted service runs every organization's summaries on the operator's LLM key and counts the daily allowance per organization, with no fleet-wide cap — so a page that let one admitted user create organizations would let them multiply the operator's daily spend at will. It also keeps admission meaningful: sign-in is invite-only, and an account whose last membership is removed can no longer mint an organization of its own to stay a member of something.
If you reach the org picker belonging to no organization, it says so and points
you at the only thing that works — asking an owner to invite you. Signing in
does not normally take you there: an admitted identity with no membership got
in on an invite, and the callback routes it straight to that invite's landing.
The empty picker is what you see if you then navigate to /app without
accepting, if you sign in on a deep link to some other page, or if your last
membership is removed.
- 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, any trial days remaining, and usage against the plan's honored limits (sources, members, health checks, Slack, the daily LLM-call cap, 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). The LLM line shows today's calls against the cap the worker actually enforces — your tier's ceiling, narrowed to your configuredmax_calls_per_dayif you set one below it, the same number the Settings page shows, so the two pages agree. If either the usage or your configuration cannot be read, the line falls back to the cap alone rather than painting a "0 of N" that would read as "nothing spent today"; and when the service runs no model at all (the operator is onllm.provider: none), the line says so instead of quoting a number, because an allowance that nothing can spend is not a limit. Under that line a note says that part of the day's allowance is held for your scheduled reports: a health check is refused once the day's total — reports included — reaches the cap minus that reserve, so the number that actually bounds your checks is not the cap. The note names that figure whenever the enforced cap itself could be resolved. A second hint line appears under the same row while the operator's fleet-wide daily ceiling is reached and your own daily allowance still has room (#472): no call is made for any organization until midnight UTC. It is a separate sentence rather than a change to the meter, because nothing it reports was charged to you — the numbers above it stay exactly as true, and the allowance they show still has the room it says. Where the meter instead reads its cap of its cap, your own allowance is what refused the call and the line does not appear: the meter is the statement there. That is the other answer to "why was my call refused with the meter unspent"; before #472 the reserve was the only one. The line carries no figure and no action, because the ceiling's size is the operator's capacity and no control of yours reaches it. An organization is seeded byinfrasigns org create, on the plan that command was given (Team by default) and active with no expiry, so the trial line reads nothing and nothing lapses on a date; atrialingsubscription written some other way still does, and that is what the trial line is for. When Stripe is configured, owners get Upgrade (Solo / Team) and Manage billing actions (Stripe Checkout and the Customer Portal). Dropping to Free suppresses Slack delivery, stops monitoring any sources beyond the plan limit (their cards read over-limit) and stops running any health checks beyond it (Free runs none; the Settings page names the remainder) — but keeps your configuration, so upgrading resumes all three with nothing to re-enter. The health-check line counts the checks you have stored against the number your plan covers, except where a ratio would not be a measurement: on a tier that covers none (Free) it says the feature is not included rather than drawing a full bar over "0 of 0", and where the service runs no model it says that instead, since nothing is evaluating a check on any plan. See Health checks for the per-tier numbers.
Self-hosted renders none of these — there is no login, and the single implicit org needs no switcher.
Inviting a teammate
On a hosted instance an invite is not one way in, it is the way in: sign-in is invite-only, so an address nobody has invited and that belongs to no organization is refused at the callback, before anything is written (details).
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.
The invite also decides where they LAND: an invited newcomer who signs in at the
bare /login — never having opened the link — is still admitted, and is sent to
/invite/{token} rather than to an empty org-picker. The token in the return URL
is a convenience for that routing and is never what admits them; the decision
reads the provider-verified profile — the provider id it resolves membership on,
and the verified email an invite is bound to.
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 is an eviction. Their access to this organization ends immediately (membership is re-checked on every request), any invites they had created for it are revoked with them, and every session they hold is deleted — including sessions currently pointed at a different organization, because the active organization of a session is switchable, so leaving one alive would put the removal one click away from being undone. They are signed out everywhere and must sign in again; the invite-only gate then admits them on whatever membership they still have.
- Revoke a pending invite. The gate admits on the invited address and a session is created before the invite is accepted, so an invitee can already be signed in without holding any membership. Revoking therefore also signs out that address's live sessions — but only when the invite was still live and nothing else admits the address: no membership in any organization, and no other live invite naming it. A teammate who belongs somewhere keeps their sessions.
From the browser an organization must always keep at least one owner: the
last owner cannot be removed or demoted here (promote someone else first). The
one exception is the operator's infrasigns org member remove, which runs at a
shell against the database and is allowed to empty the owner set — see
Undoing an invite, and un-admitting a member.
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, and signing in again lands them where they still belong. A
demotion does not sign anyone out: it removes authority, and authority is
re-read on every request, so there is no stale power a session could carry.
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, with one deliberate exception: the LLM section shows
what the hosted service runs FOR you, because that is what the worker uses. The
provider and model there are the operator's, the daily call cap is the one the
worker enforces for your organization (your tier's allowance, narrowed to your
configured max_calls_per_day if you set one), and no API key is shown — you have none, and the
operator's is not yours to see. The section's caption says that usage counts
against your organization's daily limit — and, only where the operator has
configured a service-wide ceiling at all, against a further limit shared by the
whole service. With no ceiling configured, which is the default and what every
deployment predating the key has, the caption names your own limit alone rather
than asserting a second one that does not exist. While a configured shared
limit is reached for the day and your own allowance still has room, a muted
Service limit row is appended under the daily cap saying reports come from
the template renderer until midnight UTC, and saying explicitly that it is the
service-wide limit rather than your own
(#472). It quotes no
number: the ceiling's size is the operator's capacity. If the service is running
no model at all, the section says exactly that in one muted line instead of
naming a provider or a config key you cannot set. See
Who provides the model in the hosted service.
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. Two kinds are
distinguished, each with its own note so none falsely implies a state it doesn't
have: the operator-level features that run process-wide carry an "applied at
the operator level" note — the alert webhook and deploy verification, and since
#537 the manual digest
trigger and the Telegram/Slack Q&A bots as well, which the service does run but
against the operator's own organization, so your stored values for them are as
inert as the other two; 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). Before #537 the trigger and the bots carried a third note saying the
hosted service did not run them at all; it was retired with the suppression it
described. 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.
The Health checks section is the one honored section that can report a partial state, and it separates the reasons rather than folding them into one count. When the hosted service runs no model, it reads "N configured · not running: the service has no LLM provider" — your checks are stored and nothing is evaluating them, which is the operator's setting and not yours to change. When a model IS running and the service has not yet started running your checks, it says so and when it will: whether they run turns on facts outside your configuration, and the worker learns of a change when it next builds your organization's runtime, which is at worst one report cycle away. That note and the no-model one are never shown together — with no model there is nothing pending to schedule. When a model IS running but you have stored more checks than your plan covers, the ordinary configured/alerting summary is followed by a muted Plan limit row naming the remainder — "1 over-limit: this plan covers 3, so the first 3 in configuration order run — upgrade to run the rest." With no model the plan line is withheld: nothing is running for any reason, so quoting a tier ceiling would point you at an upgrade that changes nothing. A check inside that limit can still be stopped by a different ceiling — the plan's source limit — because the worker collects nothing from a source beyond it, so a second muted Source limit row names how many of your covered alerting checks that applies to: "1 alerting on an over-limit source: this plan doesn't monitor those sources, so those checks never run even though the check limit covers them — upgrade to monitor them." It carries a See plans link, because it is the only place either page names this loss when some of your alerting checks still run. The two rows count disjoint sets and either can appear alone.
A third muted row, Service limit, appears above both while the operator's fleet-wide daily LLM ceiling is reached — and your own allowance is not what is refusing your checks, per the precondition stated on the dashboard's health card above (#472) — "reached for today — no verdict is recorded and nothing is sent until midnight UTC." It differs from the two in kind, and that is why it sits first and carries no upgrade link: the plan rows name checks that never run, this one names every check — including the ones the summary line above just counted as alerting — not running right now. Those summary numbers are deliberately unchanged by it, because they describe what a runtime build keeps and that stays exactly as true while the ceiling holds; what changes is that the page now says so. No plan lifts an operator's ceiling, so there is nothing to link to. The Health checks card lower down repeats the fact in a standing note beside its own two, and can show it beside any of them.
That row counts alerting checks only, and the omission is deliberate: a monitor
check on an over-limit source also never runs, and no row on this page counts it. A
monitor check delivers nothing, so it cannot appear in a subtraction about delivery —
and that subtraction is what the row is for. Nothing else reports it either, and that
is worth stating plainly: the Health checks card below lists what you stored and
no run state at all, and the dashboard's card — which does mark a verdict the worker
no longer produces, whatever the check's mode — has a row only for a check that has
run at least once, so a check whose source was over the limit from the day you wrote
it has no row there either. A monitor check stopped that way is reported nowhere.
The Notifications page's Health checks feed row names the same states, in the vocabulary of delivery, and its count is what is actually delivered — this section's alerting count is what your plan's check limit covers, which is what the Plan limit row beside it explains. The difference between the two numbers is exactly the Source limit row above, which is why that row counts alerting checks and not every mode. That subtraction is a claim about the days the feed row prints a count at all: while the shared service ceiling is reached the row prints none, so there is nothing to subtract from and both pages carry the pause in words instead. See Health checks.
Below the sections, a Health checks card lists the organization's checks for every member — an owner additionally edits them, which is the page's second write affordance. A row is one assertion in plain language against one of your own sources, on a cron schedule; the source picker offers exactly the sources your organization has, and there is no per-check model field, because the hosted worker clears it (the provider is the operator's). Saving replaces the whole list, so a removed row is removed. The order of the rows is the order the plan cap covers them in, which is why the card keeps it rather than sorting. Without the editor the card is a plain Name / Source / Schedule / Mode / Severity / Assertion table — the headers are what keep a row's meaning reachable to a screen reader, and mode and severity are what say whether a failure pages anyone at all — and an organization whose plan includes no checks gets a sentence saying so plus an Upgrade link, never an editor whose every save would be refused. The Add button stops at the plan's ceiling and explains itself there; an organization already over its ceiling after a downgrade still sees every stored check, because this is the only surface that can remove them.
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 one of the page's two write affordances (the health-checks card below it
is the other): the intro says "read-only" to every reader who gets neither, and
stops saying so to a reader who gets either — it names the editable sections as a
class rather than listing them, so it does not go stale on the next one. 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 signalled under the source its labels attribute to (#350), but only as a counter and a log line — the receiver persists no per-source reading, so those labels never reach this card and for a push-only fleet an empty card is not evidence that its vocabulary is understood.
Add-source wizard
The wizard turns "which queries should I even scrape?" into a guided flow. It's
reached 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).
Neither is offered to a reader who can finish neither of the wizard's two endings.
That is a non-owner member of a hosted organization and nobody else: their save
is owner-only and there is no configuration file for a snippet to go into, so
before this they walked a multi-step form to a button that answered 403. They are
now told so on /app/default/sources/new instead of being given the form, and
/app/welcome redirects there. A member of this deployment's own organization
keeps the walk — the snippet is an ending they can finish — even though their save
would be refused and their probe is egress-gated.
In cloud mode the second one is additionally not offered to an organization
already at its plan's source limit: /app/default/sources/new states the limit
and links to billing instead of opening the form, so a bookmark or the back button
cannot walk you through a flow whose save is going to be refused — and a walk started there no
longer spends the verify and preview steps, which make live outbound requests, on
it. Those two endpoints are not organization-scoped, so a walk started from
onboarding still spends them.
Onboarding does not state the limit up front, and since
#533 that is a difference in
when you are told rather than in whether the limit holds. /app/welcome now has
an "add to organization" step of its own, and it posts to the same endpoint, which
refuses a save past the limit with the plan message. So an organization already at
its limit can walk both steps there and is turned away at the end — a wasted walk,
not a way around the limit, and not a dead end either: a refused save leaves
Continue offered beside it, so the rest of the walk stays reachable.
Self-hosted enforces no plan limits, so the wizard always opens there for a reader
who can finish it — and with sign-in configured that is anyone acting as this
deployment's own organization, at any role, because the config-file snippet is an
ending they can finish. What decides it is the organization the request acts as and
not the deployment: a second organization created with infrasigns org create has
no configuration file of its own, so a non-owner member of one is refused the walk
on a self-hosted install exactly as on the hosted service.
Where the walk ends. A save lands you on the new source's own page. The wizard does not build that address: the save endpoint returns it, so it is derived once from the name the server has already validated.
How many steps you get depends on the source type — and, for three of them, on whether you keep a configuration file. DigitalOcean, Hetzner and CloudWatch are added from the first step: nothing about them can be checked from here before they are stored, so there is nothing left on the second screen except the generated snippet. On the hosted service there is no file to paste that into, so the step is empty and the progress bar shows one step. If you edit a configuration file — every self-hosted deployment without sign-in, and, once sign-in is configured, any reader acting as this deployment's own organization at any role, since the file belongs to the deployment rather than to anyone's standing in it — the snippet is a real deliverable, so the bar keeps both steps and step 1 offers Show the config snippet as a way in. Prometheus, Loki and health-check sources keep the verify step for everyone: the first because the queries the wizard generates come from the jobs the probe discovers, the other two because their probe is a real reachability check, and adding first would store a source that may be unreachable.
The generated snippet — the config.yaml block with its Copy and Download
buttons — is shown on the Add source page only if you have a configuration
file. Self-hosted it is there — with login enabled, for anyone acting as this
deployment's own organization, at any role; on the hosted service it is not,
because there is no file to paste it into and the source is stored either way. Self-hosted, when the type is one of the three added from the first
step, Show the config snippet beside the add button opens the verify step and
generates it. First-run onboarding follows the same rule since
#533: the snippet is offered
to a reader who has a configuration file, and that page now has a save of its own,
so a hosted member gets the save and no snippet. Where a reader has both — a
self-hosted operator who enabled sign-in — the panel says what choosing the file
costs: a source your config.yaml declares is owned by the file from the next
start, so the UI shows it read-only.
What the form asks for, and what it does not
The wizard asks for the minimal set of attributes needed to add a source —
everything a source's own gate requires, plus its name. Anything else is
configured afterwards, on the source's own page in cloud mode, or in config.yaml
self-hosted, where the generated snippet carries a commented placeholder for each
optional key so it is discoverable rather than merely accepted.
Two controls moved out under that rule and are named here so their absence reads as moved, not withdrawn:
- CloudWatch namespaces — narrowing them lowers the AWS bill, and you cannot
tell which ones are dead weight until something has been collected. They are on
the source's edit card in cloud (with the cost reason beside them), and in the
generated
config.yamlas# namespaces: optional; omit to fetch all supported. - The log-context source (
log_source) — the picker is on the source's edit card in cloud, and the generated config carries# log_source: optional; the name of ANOTHER source in your own config. Note the difference from the line above it: an absentnamespacesmeans all, an absentlog_sourcemeans none.
Two optional Loki fields stay in the wizard as stated exceptions: tenant_id
and the raw-LogQL log_queries. Neither has an edit control anywhere yet, and
neither leaves a placeholder in the generated file, so removing them would hide a
capability rather than move it. They leave the wizard the day the edit card grows
controls for them.
Required fields carry an asterisk, explained once per step above the fields,
and the mark is rendered from the same predicate the Continue button reads —
there is no second list to keep in step. That predicate decides which CONTROLS are
marked, and it is the whole of what it decides: three gates cannot be expressed as
a mark on any one field. Two are properties of a row — every health-check
endpoint you submit needs a severity, and a Loki source needs at least one query
carrying both a name and an expression. The third is a property of a PAIR: a URL
that carries any credential must be https unless its host is loopback, which is
the server's own rule and is stated on the gate rather than only in the URL
field's description. A line beside a disabled Continue names whichever
condition is unmet.
Field hints open from a "?" button next to each label, as a floating panel over the form. Three gestures open one: pointing at the "?", tabbing to it, and pressing it — the press is what a touch screen has, where there is no pointing; with a mouse, pointing has already opened it and the press is what closes it again. Escape closes a hint without moving the pointer or the focus (WCAG 1.4.13).
The panel hangs from the label's own row and lands on the field below it, so opening a hint moves nothing on the form — the panel is out of the page's flow. What it costs is that it covers the field it explains, and usually the next control down, for as long as it is open. That is made harmless by how it closes rather than by letting presses through, and the two are different on each device:
- with a mouse, moving the pointer off the hint closes it, whatever opened it — hover, click, or a tap that left a stale hover behind. No click is spent, but the direction matters and the obvious one does not work: the field's whole box is under the panel, and the panel is part of the hint, so moving toward the field never leaves the hint. Move the pointer clear of the hint sideways or upward; the panel closes and the next click lands on the field.
- on a touch screen there is no pointer to move, and a tap on the covered field is a tap inside the panel — which never closes it. You tap somewhere else first; the tap after that reaches the field. The same is true of the field's own label while a hint is open.
What a dismissing press costs depends on the device, and it is charged only inside the wizard card. On a touch screen the first tap inside the wizard and outside an open panel only closes it and does not reach the control underneath — the second tap does. That costs a tap, and it buys the one thing a phone reader could not otherwise avoid: the DigitalOcean and Hetzner resource kinds group's panel covers its own first checkboxes, so clearing it by tapping an uncovered box used to tick a billable resource kind. With a mouse the press is not consumed — it closes the panel and does what it would have done, which is how every non-modal tooltip behaves, and a mouse has the cheaper way out anyway: move off the hint and the panel is gone before you press anything.
Anywhere outside the wizard — the sidebar, the theme toggle, a copy button — the press is never consumed, on any device: it closes the panel and reaches its target on the first press. Nothing out there can cost you what a stray press in the form costs, which is the whole reason the touch rule is paid inside it.
One qualification on the touch rule, measured rather than promised. A tap is only spent while the panel is on screen with nothing inside the hint focused. Tapping the "?" leaves the keyboard cursor on it, so a tap that goes straight from there onto another control moves the cursor — which closes the panel by itself, and your tap is not spent. Tapping the panel first (to read it, or to select its text) drops the cursor, and from then on the next tap inside the form is. So the tap you get back is the one after you have read the hint, which is the sequence the cost was priced for; going straight from the "?" to a control costs nothing.
One region behaves the same way on both: while a panel is open, a thin invisible strip lets the pointer walk from the "?" down to it (down the right edge of the source-type tiles, and across the label row of a field). A press there is not a press on the tile or the label underneath — it closes the panel and takes the strip with it, so the next press lands where you aimed.
A click inside the panel never closes it, on any device: that is what lets you select its text — a text drag ends in a click, and a rule that closed on any inside click would close the panel the moment you let go, and it is what lets you follow a documentation link in one. ("Inside" means inside the panel you can see, not inside its invisible approach strip — see above.) Only a click outside, a press on the "?" while the panel is showing, Escape, moving keyboard focus off the hint, or (with a mouse) the pointer leaving closes it. With a mouse that press is the FIRST one, because pointing at the "?" has already opened the panel — press again and it comes back. Moving focus is in that list for a reason: the panel covers its own field, so a hint left open while Tab moves into that field would hide the border, the value and the caret of the control you had just reached (WCAG 2.4.11). Tab back to the "?" and it opens again.
The hint text stays in the document and in the accessibility tree at all times —
only its visual rendering changes — so a screen reader announces the full
description when the field takes focus whether or not the hint is open, and reads
it in place when browsing the page. It is also the description of the "?" itself,
so tabbing to the button announces what it will show. Every hint now belongs to a
field, a fieldset heading or a source-type tile, so that announcement is a repeat
of the one the control itself carries — it is kept because a reader who arrives at
the "?" and not at the field should hear what it will show. (Four hints used to
belong to no control at all, which is why the button's description was the only
way they were announced. Those placements are gone; three of the four sentences
came back on real controls, and the fourth — the reminder to export DO_TOKEN or
HETZNER_TOKEN — is on the step that hands you the generated config, beside the
snippet that references it.)
Why the hint is clipped rather than removed while closed: a removed node leaves
the page's reading order, so a screen-reader user browsing the form would never
meet the text in place. It is NOT that removal would break the description — a
hidden node that is directly referenced by aria-describedby is still used for
it, measured in Chromium and specified by the accessibility-name algorithm. The
clip is kept because that exception is one engine's behaviour across a matrix we
do not test, where a clip needs an exception from nobody.
It's driven by three server endpoints, all pure read-only probes or computation:
- Verify (
POST /app/sources/verify) — a live check against what you just described. Three source types are checked, each with the credential you entered, so what the wizard tests is the endpoint your source will actually query: - Prometheus — two fixed read-only GETs (
/api/v1/query?query=upand/api/v1/alerts), reporting 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). - Loki — one GET for the stream label names visible to the
tenant_idyou configured, reported as a count. - Healthcheck — your own endpoints, with the method and
expect_statusyou configured, up to 10 per check (any beyond that are reported as a count, not silently skipped). Each is reported individually. One case is called out rather than counted as a failure: an endpoint that answers a redirect this preview will not follow. The live check follows a redirect anywhere and judges the final response; this preview follows one only while it stays on the host you entered and keepshttps, so for a hop that leaves that host — or that drops back to cleartext on it — the preview says so instead of reporting the endpoint as down. A redirect that stays put and keepshttpsis followed here too and judged on whatever it arrives at. If not one endpoint could be confirmed healthy — every one failed, every one redirected somewhere this check will not follow, or a mix — the step reports that as its own outcome rather than as a success: the check ran and confirmed nothing, which is not the same claim as "the source answered". It still hands you the config (see the gating note below).
Response bodies from your source are never echoed back — only counts, status
codes and a short reason. That holds for the credential's own machinery too:
an oauth2 token endpoint that answers with something other than a token —
the usual sign of a mistyped token_url — is reported by its HTTP status
alone, never by what it returned.
CloudWatch, DigitalOcean and Hetzner are not checked here: doing so would
mean a real call to the provider's API (and for CloudWatch an
sts:AssumeRole), so those verify on their first collection instead.
A Prometheus is the only type whose check gates the flow, because its generated config is built from the jobs the check discovers. A Loki or healthcheck source you authored yourself still hands you the config when the check fails — a healthcheck source is usually added because something is down.
Two credential shapes are refused by the check itself. An sigv4 block with
no access_key/secret_key pair, or one naming a role_arn, is refused in
every deployment, because both would sign with the server's AWS identity
rather than yours. An oauth2 block is checked only in the hosted service:
that check is the one thing on this endpoint that makes the server POST to a
second address supplied with the request, and only in the hosted deployment
is that dial held to public addresses by the egress gate. In both cases the
source still verifies on its first collection.
Self-hosted, one more thing differs between the check and your process
environment: a source using any of the other authentication
schemes dials directly, so
an HTTP_PROXY/HTTPS_PROXY does not apply to the check any more than it
applies to the collector that will scrape the source. A bare token source
honours the proxy in both places. The check and the runtime agree on purpose —
a check that proxied where the collector does not would go green on a path the
source never takes.
2. 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.
3. Config (POST /app/sources/config) — pure computation, no network:
generates a complete ready-to-run config.yaml, or just the sources:
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. This mode also offers the
log context source picker described under
Editing a source — the same list, so a new source can
be wired to your logs as you add it. It appears only once your organization has a
source that can supply logs, and never in self-hosted mode: the generated
config.yaml describes a single source, so a reference to a sibling could not
resolve in 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 and a cross-account IAM role_arn (namespaces are narrowed
afterwards, on the source's own page — see above); 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 naming the principal the
service's own worker runs as), so the worker can assume the role via
sts:AssumeRole scoped to your organization alone. The step links to the ordered
walkthrough — create the role, paste the policy, what the first cycle looks like,
when to narrow — at
Recommended setup (hosted). No AWS keys are stored — the
role is assumed, not held. This depends on the operator having declared an AWS
identity for the service
(aws.worker_principal_arn): on a hosted
deployment that has not, the CloudWatch tile is shown disabled with a sentence
saying so, and a cloudwatch source cannot be saved — there would be no principal
for your trust policy to name. 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.
Authenticating a Prometheus or Loki source
A Prometheus or Loki source can carry a credential, and the wizard collects it in both modes — self-hosted included, which is what #480 changed. Pick a scheme and fill its fields:
| Scheme | Fields | Who issues it |
|---|---|---|
| No authentication | — | an open endpoint |
| Bearer token | one masked field | VictoriaMetrics Cloud, Chronosphere, a token behind an ingress |
| Authorization header — a scheme other than Bearer | scheme token (optional, defaults to Bearer) + masked credentials |
any vendor whose docs print a word other than Bearer in front of the token — ApiKey, Token, DSN |
| Username and password (Basic) | username (optional) + masked password | Grafana Cloud metrics and logs, Last9, nginx-ingress/Traefik, Prometheus's own --web.config.file |
| Custom header | header name + masked value | Cloudflare Access, and any gateway reading its own header |
| OAuth2 (client credentials) | client id, masked secret, token URL, scopes, one optional extra parameter | Azure Monitor managed Prometheus — its resource= goes in that parameter |
| AWS SigV4 (Prometheus only) | region, access key id, masked secret key | Amazon Managed Service for Prometheus |
The Bearer token and Authorization header rows overlap on purpose, and the
second is the general form of the first: they write the same header.
Bearer token writes token:, the shorthand for the commonest case;
Authorization header writes an authorization: block, which is the same header
with the scheme word spelled out — so it is what you need when your vendor prints
something other than Bearer in front of the value. Leave its scheme token blank
and the two produce the same request. What neither of them is, is Custom
header: headers: may not set Authorization at all, so picking it for a value
that belongs in that header is refused rather than silently downgraded — the
refusal names token/authorization as what to use instead.
Every field the table calls masked is masked on screen. The access key id is
the one credential field that is not, and that is deliberate: it names an AWS
principal rather than authorising one, and it is what you read back to check you
pasted the right pair. It is treated as a secret everywhere else — masked out of
any error this form echoes back, and sealed alongside the secret key wherever
stored credentials are encrypted. Any credential requires an https URL unless
the address is a loopback one.
Where the secret goes differs by mode, and the self-hosted answer is the point.
Saving to an organization stores the value with the config — a shared worker has no
per-organization environment. In self-hosted copy-paste mode the value you type is
never written into the generated snippet: the snippet references a ${…}
environment variable derived from the source name (INFRASIGNS_<SOURCE>_PASSWORD
and friends) and shows the export line to set. The identifier half of a scheme —
username, client id, token URL, scopes, header name, region, access key id — IS
written literally, because that is what you read back to check you pasted the right
one.
Two things about those exports, both stated in the snippet itself:
- An unset variable expands to an empty value, not to an error.
config.Loadruns the expansion over the raw YAML and it has no way to fail, so what turns a forgottenexportinto a loud start-up failure is the required rule on the field it stood for — which every credential the wizard writes has, except the bearertoken. An emptytoken:is a valid unauthenticated source, so an unset bearer variable quietly drops the credential rather than failing; the snippet flags that line. (A SigV4 secret key is safe: only the secret is a reference, the access key id is literal, and the two are required together.) - Write each exported value as YAML would take it. The reference is substituted
into a single-quoted scalar in the raw file and nothing decodes it afterwards, so
a value containing
'must have each one doubled ('') or the config stops parsing at start-up.
Three shapes are configured in the config file rather than here (see
source authentication): client
certificates (mTLS) and a custom CA, because a PEM chain is not something a form
can guide; the OAuth2 service-account (jwt-bearer) grant used by Google
Managed Service for Prometheus, for the same reason and because it takes a
different secret from the client-credentials grant; and an AWS IAM role or the
host's own AWS credentials, which is not a usability question — in a hosted
deployment the shared worker would sign your source with the operator's AWS
identity, so the wizard collects a static key pair of your own and nothing else.
The wizard says so where you would look for it, and each note ends in a link to
the reference above, which is the home for the full answer. mTLS/custom CA and the
jwt-bearer grant share one hint on the Authentication heading — they are one
fact, that this form does not offer them — and the AWS one sits on the Access
key label, because that is the field its advice is about: an empty key pair is a
legitimate configuration in the file (it means "use the machine's own AWS
credentials"), and it is the one shape this form cannot write. Each note has a
hosted arm as well as a self-hosted one, since "set it in the config file" is not
advice a tenant of the hosted service can act on. (For one release the three notes
had no control to hang off and were deleted with the hostless placement they
needed; they came back on real controls in the same release.)
The credential is used by the live check on the next step, so what the wizard tests is the endpoint your source will actually query. Editing any credential field drops a green result rather than leaving it standing as a claim about a request made with something else. On the source-detail page an owner can rotate a stored bearer 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. Both ARE live-checked on the next step (see Verify
above), and neither check GATES the flow: a healthcheck source is usually added
precisely because something is down, and a Loki source's queries are authored here
rather than derived from the check. 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 stored source
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). Neither editing nor removing is offered for a source your config file
provisions: the file is what defines it and the next start would write your change
back out again, so the endpoint refuses it by origin.
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, the per-kind narrowing, 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.
Under Narrow which resources are monitored each resource kind gets its own
box: DigitalOcean narrows by tags (a resource is monitored only if it carries
every tag listed for its kind), Hetzner by labels (one key=value per
line, all of which must match). Leave a box empty to monitor that kind whole,
and clear a box to widen again. That is what lets one provider account become
several sources: add the account twice through the wizard, then narrow each —
droplets tagged production on one, staging on the other. A submission that
does not carry the field at all — a stale tab, a script that edits only the
token — keeps whatever is stored, so a partial edit can never silently widen
collection to your whole account.
Two things the boxes refuse, both loudly and in place: ,, = and !, which
are the separators of Hetzner's own label selector; and spaces or invisible
characters inside a value. Nothing verifies that a tag or label matches
anything — a narrowing that matches nothing yields a source that collects
nothing, visible only as an empty metric set.
Narrowing by ids is a config-file capability and is not offered here. A
stored ids list is shown on the page above and is preserved through every
edit made from this form; because an id list takes precedence and no listing is
performed, that kind's tags/labels box is replaced by a note saying so rather
than by a control whose value would be discarded.
- 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.
Each row also carries its own credential — basic_auth or an
Authorization header, the two being mutually exclusive. The stored secret is
never shown back: leaving the password or credentials field blank keeps it,
while the row still points at the same address on the same scheme. A
credential travels only with its own address — change an endpoint's URL and
you must enter that endpoint's credential again; a submission that changes the
URL while leaving the secret blank is refused on the endpoints control rather
than presenting a credential stored for one address to a different one. The row
says so before you submit: a hint under the credential fields carries the rule —
leave blank to keep the stored one — and the moment the URL stops matching,
that same line turns into a visible warning, The address changed, so the stored
credential no longer applies here — enter it again, or restore the address. The
secret inputs name the hint with aria-describedby, so it is a description read
out with the field rather than placeholder text that stops being painted on the
first keystroke. Switching a row between the two schemes leaves the new scheme's
secret field empty, because it is a different credential; picking No credential
is how one is removed — and it is the way out if you have forgotten a write-only
secret and cannot restore the address either. If a save is refused for some
other reason, a credential you typed is not kept: the card says so, and you
enter it again with your correction. Reordering the rows or deleting a neighbour
keeps every other row's stored credential, and removing a row moves keyboard
focus to the button that takes its place and announces which row went.
- Any type — the log context source (log_source): which of your other
sources supplies the log lines that ground this source's critical incidents
(see
Grounding an incident in another source's logs).
It is a picker over the organization's own log-capable sources, never free
text, so a typo cannot become a save error — and the source is never offered
itself, which the config loader refuses. The field appears only when there is
something to pick: add a Loki source with log_queries first, and it shows up
on every source's form. Choosing None clears the key, which is what the
runtime already means by an absent one: ground incidents in this source's own
logs, if it has any. Sources beyond your plan's source limit are not offered —
they are not collected, so their logs could never arrive. A stored value your
organization can no longer supply is still shown, selected and annotated with
the reason: (cannot provide logs) when the target was removed or lost its
log_queries — for a hosted organization that value is also why the stored
configuration is being refused, and this picker is where you clear it — or
(not monitored on your plan) when it is a valid target your plan simply does
not collect. On a source
that gathers logs of its own (a Loki source with log_queries), the form says
what the configuration reference
does: choosing another source replaces its own log capture rather than
adding to it. Whether the lines you get back are narrowed to the incident is the
target's business: one of its log_queries may carry a placeholder —
%INSTANCE%, %LABEL_url%, and the rest of the
vocabulary — which is filled from the labels of
the alert firing on this source, so the summary is grounded in the firing
instance's lines rather than in everything the log source logged; a log_query
without one runs source-wide, as before. Log queries are written when the Loki
source is added and are not editable on this form. The add-source wizard checks a
placeholder's spelling as you save and names anything it does not recognize
beside the query it is about.
- 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-point role_arn at a different role or account. If the
operator has declared no AWS identity for the service
(aws.worker_principal_arn), the
snippet is replaced by a note saying so — a policy naming nobody would be
worse than none — while the external ID itself stays, because it is a fact about
your organization and the role_arn hint sends you to it. The note also says
what that state means for you: nothing about how this source is collected
changed — with a role_arn set it is still collected, and with that field left
empty it is still skipped, exactly as before — only adding a CloudWatch source
is gated, and removing this one cannot be undone until your operator declares an
identity.
The form keeps working either way — a source you already have stays editable
and removable. role_arn
is 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 explicit
metrics: list (config-only, not offered in the UI) keeps its metrics unchanged;
the namespace checkboxes are hidden for it, since the collector honors metrics:
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 stored source
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, a deploy
verification, or another source's log_source can't be removed until those are
repointed. On the deployment's own organization that second refusal covers
log_source alone, and it is the removal's own check rather than the validator's:
that organization's stored configuration is deliberately not asserted against its
own cross-references when it is READ, because refusing it there would take every
source it holds — including the ones your config.yaml provisions — dark over one
dangling name. So nothing is refused at load and the removal owns the check. It names the source holding the reference, and what it asks you
to do depends on where that source lives — clear the key on its page, or, if
config.yaml declares it and there is no page, edit the file and restart. A
checks[].source written in config.yaml naming a source you
added through the UI stays invisible to it. Nothing in the browser reads that file, so removing such a
source succeeds and the process logs an ERROR about the check on its next
rebuild, which is the same signal every
other cross-reference between the two documents gets. That second refusal is now
claimed only when it is true: if the same refusal was already there before you
touched anything — a validation rule
tightened underneath settings that were accepted when they were written — the
message says so instead of blaming a reference, and names the section that needs
attention rather than the one you were editing (#369, #380). Every settings page
answers that case the same way, with one exception that is deliberate: a page
whose OWN section is the one at fault shows you the validator's message for it,
so the editor that can repair the setting is the one that tells you what to
repair. 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.
Cross-origin writes
Every state-changing route the UI mounts — the report vote, the manual episode resolve, the two maintenance-window actions, and in hosted mode the sign-out, org, member, billing, source and notification-channel writes, plus the public access-request submission — sits behind a cross-origin check, in both modes.
That last one is the first with no cookie behind it at all, and it is worth
naming separately. In hosted mode every other write here is additionally
protected by the session cookie's SameSite=Lax, which refuses a forged
cross-site POST before any of this runs; the access-request route is anonymous by
design, so for it this check is the only browser-forgery defence there is.
Self-hosted there is no cookie for SameSite to protect on any route, which is
why this check runs in both modes and is the sole layer in one of them.
It uses the browser's own Sec-Fetch-Site header (sent by every browser since
2023), falling back to comparing the request's Origin against its Host. A
request a browser makes from another site is refused with 403 and a plain-text
body, before the handler runs: nothing is written, nothing is logged against your
organization. There is no token in any form, and nothing to configure.
This matters most self-hosted, where there is no login and therefore no
session cookie whose SameSite=Lax would have refused the forgery: before this,
any page a browser visited could POST to a reachable /app and, since #407,
silence every page, digest and check alert for up to 24 hours. In hosted mode the
check is a second layer under the session cookie rather than a replacement for
it.
What it deliberately does not refuse:
- A request carrying neither header —
curl, a script, a test harness. No browser forges a request that looks like that, and refusing it would break every non-browser caller. So this is not an access control: the network-layer restriction above is still the boundary for self-hosted/app. - A pre-2023 browser behind a proxy that rewrites
Host— the fallback comparison then mismatches and the write is refused. That is the safe direction; current browsers never reach the comparison. If you hit it, the refusal body says which of the two checks fired. - An
httppage posting to thehttpsorigin of the same host — theHostheader carries no scheme. Use HSTS.
SSRF posture
The verify and preview probes take a URL from the caller, so they
are deliberately constrained: http/https only, a redirect followed only
while it stays on the host and port the caller named and never dropping from
https to http (at most ten hops), 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) — and because that gate runs per DIAL, each hop
of a followed redirect is refused on the same terms as the first. 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 — self-hosted, the top bar shows today's LLM call count against the configured daily cap. The badge is not rendered in cloud sign-in mode: the process budget it reads is the operator's, not yours. A hosted organization's own usage is on the Billing page.
- 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.