The anatomy of /dashboard/info-items/{id}: its five sections, its partial
templates, and their swap targets. Split out of PAGES.md when that
file passed its context budget at 12.8k tokens - this screen alone was half of
it, and nobody working on Domains or RepSpecs needs to load it. PAGES.md keeps
the page and route inventory, including every route listed here; this document
is what the screen is.
Sibling docs: UI.md shared mechanics · HTMX.md · COMPONENTS.md Alpine catalogue · STYLE.md tokens and component classes.
GET /dashboard/info-items/{id} - 5-section vertical-scroll hub page (info_items/detail.html):
Above section 1, the stored-document notice (archiver#164, PAGES.md § Home) when the
item's watch_spec or rep_fields fails its current schema - a linked item's bag is also
judged for the org.title/org.acronym it may not carry. Rendered from the page load, not
from any section's partial, so a fix in the Watcher or Fields section clears it on reload.
-
Overview -
.entity-cardheader (canonical detail-screen pattern, #81):.eyebrow"Information Item" kicker →<h1 class="entity-card__title" id="info-item-heading" tabindex="-1">name → copyable ULID (sharedcopyablemacro) → domain badge linking to/dashboard/domains/{name}, or a muted "No primary source" when unbound, then anarchived(badge--warning) badge while the item's watch is archived (archiver#361 - watch-only, so nothing else on the page locks; Restore is in section 3);.detail-gridwith description, owner (if set), created_at. Watcher status is deliberately absent here - the status and its controls live in section 3 (#62).Organization (archiver#306) -
info_items/_org_row.html(#ii-org), a full-width.detail-gridcell: the linked Power Map org with worded notices, anorgComboboxtype-ahead, Link / Unlink throughlink_org. Contract: ORG_ROW.md. Notified by (#ii-notified-by, archiver#363) - read-only: NOTIFICATIONS.md. -
Information Sources -
x-data="{swapOpen:false}"wrapper around adata-tableof activeinfo_item_sourcesbindings (columns: URL, Domain, Spec, Bound, Actions); the first row carries a brand left-border marking it primary. The Spec column summarises the InfoSource's primarysource_specsentry (_format_spec_summary, e.g.css · 2 specs) out ofspec_summary_by_source_id, computed by the detail route - the spec belongs here, not in the Watcher section (#62). The Actions cell on the primary row holds a "Swap primary" / "Cancel" toggle (@click="swapOpen=!swapOpen") that revealsinfo_items/_swap_primary.htmlinside<div x-show="swapOpen" x-cloak>; with no active bindings that panel (id="swap-panel") renders unconditionally, titled "Add primary source". Its "author new source" form posts withhx-target-422="#swap-error"and succeeds with 204 +HX-Redirect; the "bind by ID"<details>sub-form does the same against#swap-by-id-error. -
Watcher - a plain
<h2>heading. It used to be a deeplink ("Watcher ↗") built fromWATCHER_PUBLIC_BASE_URL+item.watcher_item_id(#62); archiver#142 retired it, because announcements never hand Watcher's primary key back and Watcher exposes no lookup byinfo_item_id, so there is no per-item URL left to build. Body:<div id="watcher-section">loaded async viahx-trigger="load"+hx-get="…/watcher-section"+hx-swap="outerHTML"; the root element also carrieshx-trigger="watcherUpdated from:body"so the panel self-refreshes whenever an action fires that event. Templateinfo_items/_watcher_section.html.The panel renders from local state with zero SDK calls (archiver#151): the
watch_statuscache (Archiver's tail ofinfo.watch-status),item.watch_spec/watch_active/ generations, and the latestsource_revisions.captured_atof the active binding, composed bysrc/dashboard/watch_panel.py(build_watch_context). Announceability decides the state before the cache row is consulted - an active binding whose source carries non-emptysource_specs, passed intobuild_watch_contextasis_announceable. A status row can outlive the fact it describes (an item dropped from the announced set is tombstoned to Watcher, but the cached row lingers until the status stream catches up, or forever if it never does), and reading the row first would renderwatchingfor an item nothing is watching. archiver#142 moved this offwatcher_item_id: announcements never populate that column, so the old key would have reportednot_watchingfor every item - an inversion rather than a failure. Four states:not_watching(outside the announced set - no button, since watching is a consequence rather than an action; the copy names what closes the gap: "bind an active source with extraction specs"),no_status("NO STATUS YET" - announced but Watcher has never reported; distinct from paused and from healthy, and what every item shows until watcher#264 publishes),watching, anddegraded(only an action failure renders it - the local read path cannot degrade).no_statusrenders the status row alone - every provenance field is empty there by construction - and carries no pause toggle:applied_activeis None, so "Pause" and "Resume" would both be guesses at the button's own effect.The
watchingbody is three zones (archiver#181). A.watch-panel__headeranchoring the pause/resume toggle to the panel's top right inside corner, out of flow so it costs no vertical space; then two authored.detail-rowgrids, stepping 3 -> 2 -> 1 columns as the panel narrows; then.watch-panel__fields, the editable fields one per row. The rows are authored rather than auto-filled because the grouping is the point - an operator asks "healthy, how often, when next" before asking anything else:Row Fields 1 - status at a glance Health, Cadence, Next due 2 - provenance Last attempted, Last observed, Announcement Every cell renders unconditionally, falling back to a muted
-: an authored row that reflows as fields come and go is an auto-filled grid with extra steps. Health is the health badge (open vocabulary -"ok"is the only healthy value; anything else, known or unknown, renders verbatim onbadge--danger) plus the Paused and pending badges below. Cadence leads with the appliedapplied_intervaland falls back to the announced one, carrying an "announced ..."badge--dangerwhen they diverge. Announced means resolved (archiver#364): the item's temporal profile's stored result (archiver#365, with aprofilebadge in the cell,watch.cadence_tier), else its own interval, else its host's default cadence, throughresolve_interval, the function the announcement is built with. Before that the panel read the storedwatch_spec, so an inheriting item showed no cadence and could never flag drift; it answers how often it is actually running, where the editable row below answers what was asked for, which is why both exist. Next due is derived (last_attempt_at+ effective interval), overdue intext-danger. Announcement is the drift line - "gen N announced / M applied" with a checkmark when in sync, adrift, 40m-style badge when applied lags (danger past the 15-minute threshold, aged frominfo_items.announced_at), or an "ahead of registry" badge when applied exceeds announced, which is an anomaly rather than health.Last changedleft the panel in archiver#181. The two authored rows hold six fields and there is no seventh slot; Revision History further down this same screen already carries everycaptured_at, so the value left the summary rather than the screen.A Paused badge shows when
applied_activeis false, and a "Pause/Resume pending" badge when the item's desired state differs from what Watcher reports applied. Desired is the announcedactive, notwatch_active(archiver#360). On a suspended host (an inactive or archived Domain) it isfalse. Otherwise a pause that Watcher applied would read as a resume that never lands.build_watch_contexttakeshost(the announcing source's Domain row, orNone) as a required argument, likeis_announceable, and derives bothsuspended_byand the resolved cadence from it. While the host is suspended, both partials replace Pause/Resume with "Paused: host is suspended": a toggle would write awatch_activethe wire ignores until the host resumes.no_statuscarries "Announced paused: host is suspended." in the same case.The SDK actions are gone (archiver#142). "Begin Watching", "Check now", and "Re-sync" each rode an outbound HTTP call, along with the 409-adoption recovery and the stale-link reconcile behind them. Nothing replaces them individually: reconciliation is level-triggered off
info.registry, so there is no per-item push to retry, no remote id to adopt or clear, and no provisioning gesture to repeat. While paused the toggle reads "Resume".The control plane is local as of archiver#158. Pause/resume and cadence are
UPDATEs oninfo_itemsplus aninfo.registryannouncement - no SDK call - and Watcher reconciles them off the stream. Two consequences on this panel. First, the cadence editor (info_items/_cadence_editor.html) posts towatch-cadence; archiver#181 made it a view/edit row on the Domains pattern (#176) - a.field-row__readoutof the item's own cadence label beside a row-level Edit, flipping to theform-select--smofsrc/dashboard/cadence.py's vocabulary beside Cancel + Save, toggled by the genericeditableFieldAlpine component (COMPONENTS.md). It was a bare select plus a "Set" button, the one editable field on the dashboard that committed without an explicit edit gesture. With no interval of its own, the readout and the blank option say what the item inherits (archiver#364): "Inherit: Every 6 hours (domain)", or "Inherit: Watcher's default", which deliberately leaves Watcher's constant unnamed so Archiver doesn't mirror it. Unlike the notes row it does carry the inlinedisplay:noneFOUC hint, because there is no no-JS save path to strand:watch-cadenceanswers with a bare fragment rather than a 303, and the form only ever posted over HTMX. The editor did not exist at all before #158, because post-registration cadence was display-only while the live value was Watcher's. It renders in both announced states -watchingandno_status- because a freshly registered item sits inno_statusuntil Watcher's first status frame, which is exactly when an operator wants to revise the cadence they just picked.not_watchinghas no announceable source by definition anddegradedshows an error, so neither carries it. Second, both policy affordances - pause/resume and the cadence editor - are gated onhas_active_source: an active binding whose source has non-emptysource_specs-announceable_specs_clause, the SQL form of the one rule_collect_full_setand the announce service apply (archiver#167). Mutating policy on an item that cannot announce live used to emit a tombstone and burn a generation - which read as drift while the panel keyed on the Watcher link, and since archiver#142 (not_watchingcarries no drift) is wire churn only. Since #167 the routes enforce the gate too, and the announce service no longer tombstones on a policy write. Since archiver#142 that same value is the panel's state key, so the gate and the state agree by construction rather than by coincidence. Third, archive is Archiver's own state (archiver#361), reversing #158. #158 dropped Watcher's archived-item guard because Archiver then modelled no per-item archived state and treated archive as Watcher-local mechanism; the design of record (D5) since moved archive, restore and delete here.info_items.archived_atis orthogonal towatch_active: an archived item announcesactive=false, and restore returns it to its own pause state. A toggle posted on an archived item (a stale tab, a script) is still accepted and stores intent - Watcher's 409 is not ported - but the panel no longer offers one (below).The watch lifecycle control (
info_items/_watch_lifecycle.html, archiver#361). On an archived item thewatchingheader holds Restore in place of Pause/Resume - a toggle would write awatch_activethe wire ignores until restore - and the Health cell shows anArchived(badge--warning) badge in place of Paused. Desired is the announcedfalse, so Watcher's applied pause never reads as "Resume pending". Archived takes the header even on a suspended host, because Restore is what the item can offer. Otherwise Archive watch (btn--secondary; not bare "Archive", which reads as the archive.org replication this page also offers) sits at the foot of the fields.no_statusandnot_watchingcarry the same control on one line - "Archived · Restore", or Archive watch - because archive stores state on any item and announces only what it can (#167);degradedcarries none. Both are full-page POST→303 (SCREENS.md § Allowed variant): the Overview badge moves too. The status strip replaces its toggle with "Archived · Restore" the same way.The Temporal profile row (
info_items/_temporal_profile.html, archiver#365): below the cadence editor, on its gate and in the same two states. Readout: the summary and stored result fromsrc/dashboard/temporal_profile_form.py(pure), or "No profile". Edit (Add without one) shows the type's own dates, one rules input, the post-action and Active in.field-row__controls; several controls, so an inlinex-data(editing, type) instead ofeditableField, and Cancel isform.reset(). Delete (hx-confirm) only with a profile. Contracts: INFO_ITEM_ACTIONS.md. -
Revision History -
data-tableof the last 50source_revisionscaptured across the item's InfoSource bindings - the active primary plus previous primaries (deactivatedinfo_item_sourcesrows, preserved as succession history) - orderedcaptured_at desc, count in the heading. Columns: Fingerprint (linked to revision detail), Source (the InfoSource URL, linked to source detail - present because an item accumulates successive sources over its life), Captured (UTC), Cache (.status-pill--cached/expired/missing). The timeline is a query over bindings, not over theinfo_item_source_revisionspin table, which was dropped in archiver#101. The route computesrevisions+rev_sources_by_id, the latter covering deactivated previous primaries that the active-onlysources_by_idmisses. -
Replication (
<section id="replication" aria-label="Replication">; the id is the section's deep link - keep it). Renamed from "Replicator" in archiver#307. Three blocks, each its own swap target (HTMX.md § Blocks that share a section): the assignments table, Fields, and the Add a spec picker (archiver#308, replacing the ULID form). Each block's render sites all spread its builder (SCREENS.md, #219).-
Fields -
info_items/_rep_fields.html(#ii-rep-fields, heading#ii-rep-fields-heading, archiver#307). Replaced the textarea and its flat-key chips (refused by v1). One row per raw key the specs require - assigned specs ∪ the picker's selection - so the spec requiresorg.title_slugand the operator typesorg.title(a requiredacronym_or_title[_slug]asks for bothacronymandtitle). Each row: the key and the specs requiring it; a raw-value input; Renders as, the derived value fromeffective_rep_fields(archiver#303); and a source badge - stored, override (a stored derived key, usually a_slug; its removable input sits under the first row it feeds, so the composite's two rows share one), from Power Map (read-only; the linked org'spm_organizationssnapshot, archiver#304 - a JSON save carrying one is refused, naming the org), slugs to nothing (empty_slug_reason's wording beneath, archiver#312), or missing. Badges are per row, so an empty slug shows even while another row is missing (the gate reports missing keys first). View model:src/dashboard/rep_fields_block.py(pure).The picker's selection (
?selected_spec=<id>, archiver#308) is named above the form with Clear, and the block carries it - re-fetch URL, a hiddenselected_specin both forms, the 409's Save and movehx-vals- so saves and re-fetches keep it; an unknown or since-assigned id is no selection. Select'sfocus=truefocuses the first input the spec still needs.The readout is live. A hidden element re-sends the unsaved inputs to
GET …/rep-fields/readoutoninput(300ms debounce) andfields-changed(a removed row); each row's status cell (_rep_fields_status.html,#rf-status-<ns>-<key>) comes back out of band, slugged by co-core.info_item.nameoffers a one-click suggestion: the item name minus a leading"<acronym> - "(the bag'sorg.acronym, else a leading all-caps token). Other fields - stored keys no row claims, as editablenamespace.key/ value pairs with Remove and Add field (repFieldsForm, COMPONENTS.md); a non-string value - and a storednullor"", since blank means "not set" - round-trips as JSON (field_type=json), so2024stays a number, text typed over it is kept as text, and an unedited save changes nothing. A pre-#302 flat key shows here so the save can refuse it by name.Save posts parallel
field_key/field_value/field_typelists; "Edit as JSON" (a<details>) postsrep_fields. Both go throughset_rep_fields, the gate behindPUT /info-items/{id}/rep-fields(archiver#302), and target#rep-fields-flash(aria-live="polite") withhx-target-422/hx-target-409, without whichhtmx-errors.jstoasts a bare "request failed". 200 re-renders the block from the stored bag (HX-Retarget: #ii-rep-fields+HX-Reswap: outerHTML), focuses its heading, toasts, and firesreplicationChanged; 422 (invalid JSON, an unbuildable form, a non-v1 bag, or one breaking an active assignment - one line per RepSpec and key) and 409 (the bag moves an active assignment's path, each before → after as the picker renders it, with Save and move re-sending the exact bag it warned about, not whatever the form holds by then) land in the flash, leaving the input on screen. The block re-fetches itself (GET …/rep-fields, no focus move) onreplicationChanged[detail.source!=='fields'] from:body. -
Replication Specs -
info_items/_rep_spec_assignments.html(#ii-rep-spec-assignments, heading#ii-rep-spec-heading): activeinfo_item_rep_specsassignments. Rows (_rep_spec_row.html): Spec, Provider, Activated, Replication, Public URL, Actions. Both row actions - deactivate and Replicate now (confirm- andhx-disabled-elt-guarded) - re-render the whole section and focus the heading, because each destroys the clicked button. Deactivate also firesreplicationChanged({"source": "assignments"}): the Fields rows come from the active assignments. Replicate now fires nothing - an occasion changes nothing a sibling renders, and a re-fetch would discard unsaved Fields input. The section re-fetches on a sibling'sreplicationChanged([detail.source!=='assignments']; Assign adds a row) through a hidden element inside the wrapper, syncedclosest #ii-rep-spec-assignments:droplike a row action, so the wrapper's attributes stay the poll's (archiver#308).It re-reads itself while a command is open (archiver#212): closure arrives on
content.artifactsa bus round trip (~800ms) after issuance, so the POST's swap is always too early.GET …/rep-spec-assignmentsreturns the same partial; the wrapper carrieshx-get+hx-trigger="every 2s"+hx-swap="outerHTML"only while some latest command isrequested, rendered from the samelatest_commandsthe badges are, so the swap that lands a terminal state stops the polling. Bounded at two minutes (POLL_WINDOW; the reaper's six hours is too long for a forgotten tab), then arole="status"line says one is still open. A poll rendersswapped=False: no focus move. The poll always yields to an action (archiver#220):hx-sync="this:abort"+hx-disinherit="hx-sync"on the wrapper,hx-sync="closest #ii-rep-spec-assignments:drop"on each action - HTMX.md § A section that polls and takes actions.Replication renders the
replication_statemacro over the assignment's latestreplication_commandsrow: state badge, the producer'sreasonfor a failure or Archiver's local one for a skip (detailon thetitle),command_id, and when it closed. Skips are shown because a refusal living only in a log line reads "not replicated yet" forever.public_urlis read-only (archiver#171;PATCH …/public-urlretired): #170 gave it an automated writer, so an inline edit was silently clobbered by the next occasion. -
Add a spec -
info_items/_rep_spec_picker.html(#ii-rep-spec-picker, heading#ii-rep-spec-picker-heading, archiver#308). Every RepSpec not actively assigned, by name. Readiness is the gateassign_rep_specrefuses on (check_bag_against_spec): Ready, Needs (the raw keys to enter, as Fields names them), Can't render (the gate's reason verbatim, archiver#312, or a bag off the v1 shape), Invalid (validate_rep_spec's first error, #334), or Unavailable (UNWRITABLE_PROVIDERS,src/dashboard/providers.py, #202). Path renders againstpreview_occasion(the move 409's too, #333): Replicate now's revision, else a labelled example (zeros, HTML); an unready spec shows its template. View model:src/dashboard/rep_spec_picker.py(pure).Select (Needs / Can't render) swaps Fields with the spec's keys. Assign is disabled unless Ready; the server re-checks through
assign_rep_specand refuses an unwritable provider itself. Success answers with the picker (heading focused), toasts, firesreplicationChanged({"source": "picker"}), and prompts Replicate latest revision now? with the row action's button (synced#ii-rep-spec-assignments:dropby id, as it sits outside the table) and Not now - or, with no revision, says it will replicate on the next. A refusal (#301's, #334's, an unknown spec, an unwritable provider) is a 200 with#picker-refusal(role="alert"), firing nothing. New spec ↗ links to/dashboard/rep-specs/new, under the empty state when there are no specs. Re-fetches itself (GET …/rep-spec-picker, no focus move) onreplicationChanged[detail.source!=='picker'].
-
Partial templates under info_items/:
| Template | Swap target (outerHTML) |
States |
|---|---|---|
_rep_spec_assignments.html |
#ii-rep-spec-assignments |
idle / polling (poll.active) / stalled (poll.stalled) |
_rep_spec_row.html |
- (included only; both its actions swap #ii-rep-spec-assignments) |
via replication_state: none, requested, complete, failed, abandoned, skipped |
_swap_primary.html |
#swap-panel |
- |
_watcher_status.html |
#watcher-status-strip |
not_watching, no_status, degraded, watching |
_watcher_section.html |
#watcher-section |
not_watching, no_status, degraded, watching |
_cadence_editor.html |
- (included, hx-swap="none") |
view / edit (editableField) |
_temporal_profile.html |
- (included, hx-swap="none") |
none / profile; view / edit (inline x-data) |
_org_row.html |
#ii-org (Link / Unlink's 200 via HX-Retarget) |
not linked / linked; view / edit (ORG_ROW.md) |
_rep_fields.html |
#ii-rep-fields (the re-fetch; a save's 200 via HX-Retarget) |
rows / no rows; per row via _rep_fields_status.html |
_rep_fields_status.html |
#rf-status-<ns>-<key> (included; out of band from the readout) |
stored, override, from_power_map, slugs_to_nothing, missing |
_rep_fields_readout.html |
- (hx-swap="none"; out-of-band status cells only) |
- |
_move_flash.html |
#rep-fields-flash, #ii-org-flash (innerHTML; a write's 409/422/503) |
move to confirm (moves, occasion, lead, confirm) / refused (message, problems) |
_rep_spec_picker.html |
#ii-rep-spec-picker (re-fetch, Assign) |
entries / all assigned / none; assigned prompt / refusal |
Each root element carries its own id, so it survives the swap that replaces it;
_watcher_section.html's root additionally carries hx-trigger="watcherUpdated from:body" (the Replication blocks' triggers are in section 5; the assignments
poll is polled, not event-driven, because the fact arrives on the bus in the
server process, which holds no connection to the browser). Both Watcher partials take the
context keys state, item_id, watch (the build_watch_context dict -
health, ages, cadence, next-due, drift), has_active_source, and
error_message (degraded only), and both render the badges and toggle
described in section 3. There is no not_configured state and no Watcher client
to configure: #151 made the render local, #142 removed the client outright.
degraded is likewise not a Watcher condition - the only path into it is a
local write failing, which is why its copy names the write and not the service.
_swap_primary.html is a full-width card - no max-width, so it spans the
bindings table - titled "Swap primary source" or "Add primary source" depending
on whether iis_rows is non-empty. It holds a URL input (id="swap-url",
.form-input), a source_specs textarea (id="swap-specs", .form-textarea), a
Preview HTMX button (.btn--secondary, hx-include="#swap-url,#swap-specs")
targeting #swap-preview, the submit to swap-primary-source (.btn--primary),
and an advanced <details> for swap-primary-by-id (.form-input field +
.btn--secondary Bind button).
The shared Watcher-POST contract, the routes retired with the SDK, and the replication outcome rule: INFO_ITEM_ACTIONS.md.