Skip to content

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

ui:
  enabled: true   # serves the embedded UI under /app; off by default

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/default exactly as before, in one hop, whether or not they currently have an organization selected (/app then 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 invite prints.
  • 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 pass and no notification has carried it — a check in monitor mode, which never announces anything; a failing check still short of its for_runs streak; 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 dashed warn/crit threshold lines. A slow or unreachable Prometheus degrades to a per-chart message, never a broken page. Turn the charts (and their /series data endpoint) off with ui.charts.enabled: false to spare Prometheus the per-view queries;
  • the configured query catalog (name, PromQL, golden signal, warn/crit thresholds), 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 in config.yaml and 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, or incidents.

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 (default 5m) of the last unsuppressed one is still suppressed and re-opens nothing. With a repeat_interval well above the window — Alertmanager ships 4h — 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 configured retention_days and this tier cap). The LLM line shows today's calls against the cap the worker actually enforces — your tier's ceiling, narrowed to your configured max_calls_per_day if 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 on llm.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 by infrasigns 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; a trialing subscription 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.yaml as # 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 absent namespaces means all, an absent log_source means 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:

  1. 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:
  2. Prometheus — two fixed read-only GETs (/api/v1/query?query=up and /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).
  3. Loki — one GET for the stream label names visible to the tenant_id you configured, reported as a count.
  4. Healthcheck — your own endpoints, with the method and expect_status you 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 keeps https, 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 keeps https is 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.Load runs the expansion over the raw YAML and it has no way to fail, so what turns a forgotten export into a loud start-up failure is the required rule on the field it stood for — which every credential the wizard writes has, except the bearer token. An empty token: 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_id and log_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 credentialbasic_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 headercurl, 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 http page posting to the https origin of the same host — the Host header 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-src carries a per-request nonce with no unsafe-inline and no unsafe-eval (style-src keeps unsafe-inline for inline style attributes), and all assets are vendored and embedded, so the UI makes no third-party requests.