Documentation
¶
Overview ¶
templ: version: v0.3.1020
The health dashboard mounts github.com/larsartmann/go-health-dashboard under `tq serve` (2026-09-16 adoption): a live severity-grouped view of derived queue health at /health, plus the conventional JSON probes (/healthz, /readyz, /startupz). The checks are the store-backed subset of `tq doctor` — database, worker heartbeats, expired leases, DLQ depth — computed from the same queue.Store projection the dashboard already reads, never from process state.
templ: version: v0.3.1020
Package webui serves a read-only, live-updating web dashboard over the task queue: a pure projection consumer of the store's facts and task views (ADR-0003). One journal tailer fans out coalesced change notifications; each connected browser receives server-rendered HTML fragments over SSE and swaps them into the page by container id.
Viewpoint — Consumer: a human operator in a browser; Concern: live task state rendered as coalesced SSE fragment swaps; Non-goals: queue mutation (the worst failure is a stale dashboard, never journal corruption) and persistence — it renders projections, it owns none.
Cross-package map (lifecycle × concern): docs/architecture-understanding/2026-10-23_lifecycle-concern-grid.md
Index ¶
- Constants
- Variables
- func Board(data DashboardData) templ.Component
- func DeadLetterTable(data DashboardData) templ.Component
- func FactFeed(data DashboardData) templ.Component
- func FilterBar(data DashboardData) templ.Component
- func MetricsRow(data DashboardData) templ.Component
- func Page(data DashboardData) templ.Component
- func RunAgentFeed(ctx context.Context, socket string, feed *AgentFeed, log *slog.Logger) error
- func StatusCards(data DashboardData) templ.Component
- func TaskDetail(data DashboardData, t task.Task, facts []journalFactView, ...) templ.Component
- func TaskDetailPage(data DashboardData, detail taskDetailData) templ.Component
- func TaskNotFoundPage(id string) templ.Component
- func TaskTable(data DashboardData) templ.Component
- type AgentEvent
- type AgentFeed
- type BoardBandGroup
- type BoardColumn
- type BudgetView
- type Config
- type DashboardData
- type FilterState
- type Hub
- type ProjectSummary
- type ProjectionPump
- type Server
Constants ¶
const ( HealthDashboardPath = "/health" HealthSSEPath = "/health/sse" HealthSDKPath = "/health/datastar.js" HealthFaviconPath = "/health/favicon.svg" HealthLivenessPath = "/healthz" HealthReadinessPath = "/readyz" HealthStartupPath = "/startupz" )
Health route paths, mounted by Handler and asserted by the tests.
const ( DefaultAddr = "127.0.0.1:8090" DefaultPoll = 500 * time.Millisecond DefaultHeartbeat = 15 * time.Second // BannerPrefix and BannerReadOnlySuffix define the serve startup // banner's exact shape — shared by the CLI print and the e2e/smoke // parsers, which nearly broke twice over string drift (00:21 e3). BannerPrefix = "tq: dashboard on http://" BannerReadOnlySuffix = " (read-only)" BannerWritesSuffix = " (writes ENABLED: cancel/rescue from the UI)" // BannerFoldFollowerSuffix marks the demoted serve: the projection // fold lock went to another folder, so this dashboard's model is // frozen at mount (see MarkFoldFollower). BannerFoldFollowerSuffix = " (read-only; STALE FOLD: fold lock held by another serve — this dashboard will not update)" )
Default configuration values.
Variables ¶
var ErrTokenRequiredOnLAN = errors.New(
"webui: refusing to serve on non-loopback addr without a token " +
"(the dashboard exposes task payloads and error tails; " +
"bind 127.0.0.1 or pass --auth-token / $TQ_SERVE_TOKEN)",
)
ErrTokenRequiredOnLAN is returned by Config.Validate and Server.Run when the address binds beyond the loopback interface without an AuthToken. The dashboard is read-only by construction (ADR-0003) but renders every task payload and error tail, so non-loopback binds are default-deny.
Functions ¶
func Board ¶
func Board(data DashboardData) templ.Component
Board renders the kanban projection (inner content of #frag-table when the filter selects the board): one column per lifecycle status — the queue's state machine made visible, cards newest first. Columns are read-only projections, not drag-and-drop lanes: moving a card is queue mutation and waits for the --allow-writes gate (ADR-0003 Phase D).
func DeadLetterTable ¶
func DeadLetterTable(data DashboardData) templ.Component
DeadLetterTable renders the dead-letter queue (inner content of #frag-dlq).
func FactFeed ¶
func FactFeed(data DashboardData) templ.Component
FactFeed renders the live fact tail (inner content of #frag-feed): the journal itself, terminal-style. Newest facts land at the bottom. Each line's task id links to the detail page — a thin custom variant of the library Scrollback (which renders plain text lines only); the classes mirror its markup so the visual language stays one.
func FilterBar ¶
func FilterBar(data DashboardData) templ.Component
FilterBar renders the search/filter form (inner content of #frag-filters).
func MetricsRow ¶
func MetricsRow(data DashboardData) templ.Component
MetricsRow renders the overview's two pure-SVG charts (M19): the fact rate sparkline (facts per 5-minute slice of the last hour) and the completion histogram (time-to-complete: queue wait + run, honestly labeled). Both are server-rendered SVG — no JavaScript, CSP-safe.
func Page ¶
func Page(data DashboardData) templ.Component
Page is the full dashboard page (ADR-0003): a live read-only projection of the journal. Fragments are server-rendered into the #frag-* containers so the page works without JS; SSE then replaces innerHTML.
func RunAgentFeed ¶ added in v0.4.1
RunAgentFeed is the L2 collector: poll the crush server's workspace list, attach an event stream to every new workspace, and stop attaching to gone ones (their attach loops exit on 404). One goroutine per active workspace; the whole collector stops with ctx. A missing server is a warn-and-retry (the pool may start it later; tq serve outlives pools).
func StatusCards ¶
func StatusCards(data DashboardData) templ.Component
StatusCards renders the overview stat band (inner content of #frag-stats). The four active states carry tone icons and link to their filtered view; cancelled and total stay quiet (no icon). Marker classes (card-pending, …) are kept as stable hooks for tests and CSS.
func TaskDetail ¶
func TaskDetail(data DashboardData, t task.Task, facts []journalFactView, prov priorityProvenanceView) templ.Component
TaskDetail renders the per-task detail page body. The two live regions sit in #frag-detail / #frag-timeline containers so the /task/{id}/events stream can swap them over SSE exactly like the dashboard fragments. The payload section sits BETWEEN them, deliberately static: a payload is immutable after enqueue, so an open prompt or raw pane survives every live swap, and the SSE stream only re-renders what can actually change.
func TaskDetailPage ¶
func TaskDetailPage(data DashboardData, detail taskDetailData) templ.Component
TaskDetailPage is the per-task detail page.
func TaskNotFoundPage ¶
TaskNotFoundPage is the styled 404 for /task/{id}: an unknown id keeps the dashboard chrome and a way back instead of a bare error page.
func TaskTable ¶
func TaskTable(data DashboardData) templ.Component
TaskTable renders the live task table (inner content of #frag-table).
Types ¶
type AgentEvent ¶ added in v0.4.1
type AgentEvent struct {
Workspace string `json:"workspace"`
Kind string `json:"kind"`
Line string `json:"line"`
}
AgentEvent is one live agent line for the L2 pane: a distilled frame from a routed run's workspace event stream (tool calls, tool results, session lifecycle), labeled with the workspace so the pane can group output when several agents run at once. Read-only observability (ADR-0003 posture): the feed never influences dispatch.
type AgentFeed ¶ added in v0.4.1
type AgentFeed struct {
// contains filtered or unexported fields
}
AgentFeed fans out live agent events to pane subscribers. Unlike the hub (payload-less ticks driving full snapshots), events carry their payload: the pane is an append-only log, so replay is impossible and every event is its own snapshot.
func NewAgentFeed ¶ added in v0.4.1
func NewAgentFeed() *AgentFeed
NewAgentFeed creates a ready-to-use feed.
func (*AgentFeed) Subscribe ¶ added in v0.4.1
func (f *AgentFeed) Subscribe() <-chan AgentEvent
Subscribe returns a channel receiving live agent events.
func (*AgentFeed) Unsubscribe ¶ added in v0.4.1
func (f *AgentFeed) Unsubscribe(ch <-chan AgentEvent)
Unsubscribe removes a subscriber channel.
type BoardBandGroup ¶ added in v0.3.1
BoardBandGroup is one priority band's slice of a board column's cards (ADR-0015): the band as the board's secondary grouping under the status lane, newest-first within the band.
type BoardColumn ¶
BoardColumn is one lifecycle column of the board view: the true count of tasks in this status (under the filter's project/query scope) plus the newest boardColumnLimit cards. Truncated is how many older tasks the column omits (the "+N older" link opens them in the table view).
type BudgetView ¶
BudgetView is the daily agent-spend projection: enqueued today vs the operator-set cap. Nil in the snapshot when no cap is configured.
func (BudgetView) Tone ¶
func (b BudgetView) Tone() display.StatTone
Tone picks the card's semantic color: green under 75%, amber under the cap, red at/over it.
type Config ¶
type Config struct {
// Addr is the TCP address to bind. Default 127.0.0.1:8090.
Addr string
// Poll is the journal tail interval. Default 500ms.
Poll time.Duration
// Heartbeat is the SSE keepalive interval. Default 15s.
Heartbeat time.Duration
// Version is the application version stamped into every health
// response and the dashboard's Version stat card. Empty leaves the
// card on the dashboard's "unknown" fallback — cmd/tq always passes
// the ldflags-injected main.version, so only in-package test
// constructors see the fallback.
Version string
// RequestLog enables per-request access logging (method, path, status,
// duration) via slog at Info level. Off by default.
RequestLog bool
// DailyBudget mirrors the agent pool's --daily-budget so the dashboard
// can show the spend card (tasks enqueued today vs cap). 0 hides the
// card — the cap is an operator decision, not queue state.
DailyBudget int
// AuthToken, when set, requires every request (pages, API, SSE, static)
// to present the token via an Authorization: Bearer header or a `token`
// query parameter. Validate refuses non-loopback binds without it.
AuthToken string
// AllowWrites opts in to admin actions from the dashboard (cancel
// pending/running tasks, rescue dead-lettered ones). ADR-0003's default
// is a read-only projection; writes are a conscious operator decision
// guarded by per-form CSRF tokens (withCSRF) and, on non-loopback
// binds, by the auth token. Off by default.
AllowWrites bool
// ReadModelPath, when set, moves the aggregate read side and the live
// notifications onto an ADR-0019 S3 metaengine read model: the
// projection at this path folds the journal (readmodel.PathFor
// derives it beside the queue db) and its Watcher replaces the hand
// tailer→hub fan-out. Status counts (nowband, /api/stats) read the
// collection; the task table, board, detail pages and fact feeds stay
// store-backed — the planned table does not carry lease owner,
// NotBefore or payload, which those row-rich views render. Empty =
// the store-backed defaults (off). Setting it requires Pump: this
// package never opens the projection itself (the composition root
// owns that engine — the single-opener seam).
ReadModelPath string
// Pump is the injected live-projection runtime consumed when
// ReadModelPath is set: the folded model plus its managed lifecycle
// (host start, teardown on Run's return). cmd/tq's serve wiring
// composes it so webui stays a leaf of the composition root rather
// than its peer. Nil with ReadModelPath set fails Validate; with
// ReadModelPath empty the hand tailer runs regardless.
Pump ProjectionPump
// DLQBackoff, when > 0, turns on the poisoned-repo DLQ-backoff
// surface: each snapshot folds the journal per repo (the shared
// dlqbackoff derivation behind `tq pool-health`'s dlqBackoffUntil
// JSON field and `tq dlq`'s poisoned rows) and the dashboard renders
// the repos currently in backoff — a nowband chip plus the panel
// above the dead-letter queue. 0 keeps the surface off (no journal
// read, nothing rendered), mirroring pool-health's --dlq-backoff.
DLQBackoff time.Duration
// CrushServer, when set, enables the L2 live agent pane: Run starts
// the workspace-event collector against the pool's shared crush
// server (unix socket; the same flag agent-pool takes), and
// /agents/live streams distilled tool events to the pane. Empty =
// the pane is off and /agents/live serves 503 (routed runs are an
// L1 opt-in; local runs produce no workspace events).
CrushServer string
}
Config controls the dashboard server.
type DashboardData ¶
type DashboardData struct {
Counts map[task.Status]int
Total int
Tasks []task.Task
Dead []task.Task
Facts []journal.Fact
Filter FilterState
Now time.Time
Projects []ProjectSummary
Page int
TotalPages int
MatchTotal int
Budget *BudgetView
// Board holds the per-status columns when the filter selects the board
// view; nil on the table view (which uses Tasks/Page/TotalPages).
Board []BoardColumn
// JournalSeq is the journal watermark (highest fact seq) — the resume
// point every SSE/bridge consumer carries.
JournalSeq int64
// FactBuckets counts facts per equal slice of the last hour (the
// activity sparkline's series).
FactBuckets []float64
// CompleteMinutes is the time-to-complete (queue wait + run) of recent
// completed tasks in minutes, newest first (the completion histogram's
// raw data).
CompleteMinutes []float64
// Reviews holds the parsed verdict of every COMPLETED review task on
// the visible page (keyed by task id) — the agent-review loop made
// visible: an approve/request-changes badge in the table and findings
// on the detail page. Absent when the page shows no finished reviews.
Reviews map[string]executor.ReviewResult
// Parked is the rate-limit-parked count (pending with not_before in
// the future) — the "11 tasks parked until 19:40" one-glance segment
// in the nowband meta (16-00 report f44; the stats payload already
// carries the same number).
Parked int
// BudgetParked counts tasks parked by the claim-time budget gate:
// pending-with-not_before tasks whose latest task.requeued fact in
// the last 24h carries the budget class — the "the pool is idle ON
// PURPOSE (money)" lamp next to the parked segment. The 24h window
// is the badge's honest scope, not a live park state.
BudgetParked int
// Stranded counts tasks that can never run: pending with a dependency
// that is terminally not-completed (dead or cancelled). The claim
// gate skips them silently forever — the one stuck-middle state the
// operator can otherwise only find by reading dep graphs. Derived
// from existing reads (no stranded fact type; that contract addition
// awaits a demonstrated need), M14.
Stranded int
// LoopSuspects counts tasks whose claim count exceeds
// queue.ClaimAnomalyThreshold — the churn lamp in the nowband meta
// (the P1 class: one task out-claming its budget for days). tq stats
// lists the suspects; the webui only counts them.
LoopSuspects int
// SessionsOpened / SessionsClosed count the interactive-session
// lifecycle facts (session.opened / session.closed) — the session
// volume one-glance segment in the nowband meta (03-28 §f20).
SessionsOpened int
SessionsClosed int
// SessionsOpen is the begun-but-never-closed count — the lamp next to
// the volume segment (03-28 §f9/f20). Read-only observation: closing
// stays an explicit operator ruling.
SessionsOpen int
// AllowWrites mirrors Config.AllowWrites: the templates render the
// admin affordances (cancel/stop/rescue forms) only when writes are
// enabled server-side.
AllowWrites bool
// Statuses holds the parsed outcome of every COMPLETED status task on
// the visible page (keyed by task id) — the done-prompt loop made
// visible: a report badge in the table and the report path + next-item
// count on the detail page. Absent when the page shows no finished
// status reports.
Statuses map[string]executor.StatusResult
// Prioritizes holds the parsed outcome of every COMPLETED prioritize
// task on the visible page (keyed by task id) — the AI batch scorer
// made visible: a verdict-count badge in the table and the verdict
// count + derived session usage on the detail page. Nil when the page
// shows no finished scorer run.
Prioritizes map[string]executor.PrioritizeResult
// AgentResults holds the parsed outcome of every COMPLETED agent task
// on the visible page (keyed by task id) — the run's derived outcome
// (footer commits) + derived session usage made visible: a commit-count
// badge in the table and the result card on the detail page. Nil when
// the page shows no finished agent run.
AgentResults map[string]executor.AgentResult
// DLQBackoff holds each repo currently in poisoned-repo DLQ backoff
// (repo → backoff deadline): the dashboard's rendering of the same
// fold behind `tq pool-health`'s dlqBackoffUntil JSON field and `tq
// dlq`'s poisoned rows. Nil when the surface is off
// (Config.DLQBackoff == 0) or no repo is standing in backoff.
DLQBackoff map[string]time.Time
}
DashboardData is the full projection snapshot one burst renders from.
type FilterState ¶
type FilterState struct {
Project string
Status task.Status
Query string
Page int
// Sort selects the task-table ordering: "", "age-asc", "age-desc",
// "priority-asc", "priority-desc", "attempts-asc", "attempts-desc".
// Empty keeps the severity order (dead, running, pending, ...).
Sort string
// View picks the task projection: viewTable (default) or viewBoard.
// Board drops Status (columns ARE the statuses) and ignores Sort/Page.
View string
// Band bounds the listing to one ADR-0015 band (backlog | hot |
// machine); empty means all bands.
Band string
// Evidence filters the DLQ listing to dead tasks whose failure evidence
// matches this comma-separated term spec with !-negation — the same
// semantics as `tq dlq --evidence-stage`
// (executor.MatchesEvidenceStageSet, including the
// gate-env/gate-dead/gate-slow error-text aliases); empty means all
// dead tasks.
Evidence string
}
FilterState is the URL-carried view filter (?project=&status=&q=). Page rides in the URL (?page=N) but is deliberately not part of QueryString: filter chips always reset to page 1.
func (FilterState) Empty ¶
func (f FilterState) Empty() bool
Empty reports whether no filter is active.
func (FilterState) QueryString ¶
func (f FilterState) QueryString() string
QueryString renders the filter as URL query parameters. Values are URL-escaped: a project or query containing &/=/space/% must round-trip through filterHref → parseFilter byte-identical (round-13 T5 armor).
type Hub ¶
type Hub struct {
// contains filtered or unexported fields
}
Hub fans out change notifications to all connected SSE clients. Notifications carry no payload — every one triggers a server-side re-render and a fresh full snapshot to each client (projection, not client state). The event ID is the journal watermark so browsers resume with a meaningful Last-Event-ID.
func (*Hub) Notify ¶
Notify broadcasts a change notification to every subscriber. seq is the journal watermark the notification represents.
func (*Hub) Unsubscribe ¶
Unsubscribe removes a subscriber channel.
type ProjectSummary ¶
ProjectSummary aggregates one project's live counts for the overview chips. Total is the project's task count across ALL statuses — the chip's headline number (R/P/D stay the operational breakdown).
type ProjectionPump ¶ added in v0.3.4
type ProjectionPump interface {
Model() *readmodel.Model
Run(ctx context.Context, notify func(cursor int64)) error
}
ProjectionPump is the live-projection surface the server consumes on the read-model path: the folded model backing the aggregate reads, driven by Run until the context is done. Run owns the pump's full lifecycle — start the managed host, fold the journal, notify with the applied journal cursor on every wake (the server's hub burst-coalesces exactly like the hand tailer's batch), and tear the runtime down before returning, so callers can drop their model references on defer.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is the read-only live dashboard. Create with New, then call Run.
func New ¶
New creates a Server over the given store. The store is read-only from this package's perspective: only Get, List and Facts are called.
func (*Server) Handler ¶
Handler returns the dashboard's HTTP routes:
GET / dashboard page
GET /project/{name} dashboard pinned to one project
GET /task/{id} per-task detail page
GET /task/{id}/events per-task SSE stream (detail fragments + resume)
GET /api/events SSE stream (dashboard fragments + resume)
GET /api/stats JSON status counts
GET /api/facts JSON journal cursor (?after=SEQ&limit=N)
The go-health-dashboard surface (healthBindings) mounts on top: /health, /health/sse, /health/datastar.js, /health/favicon.svg, and the JSON probes /healthz, /readyz, /startupz — all read-only, all behind the same auth gate; the /health page runs the dashboard's own CSP (health.go).
With Config.AllowWrites the admin routes are added on top:
POST /task/{id}/cancel withdraw a pending task / request a running stop
POST /task/{id}/rescue re-queue a dead-lettered task (fresh attempts)
Every response carries strict security headers (securityHeaders); the default dashboard is read-only by construction, writes are opt-in and CSRF-guarded (withCSRF).
func (*Server) MarkFoldFollower ¶ added in v0.4.0
func (s *Server) MarkFoldFollower()
MarkFoldFollower demotes this serve's staleness contract: the projection runtime lost the fold-lock election (Host nil), so the mounted model never folds again — the dashboard's SSE stream will not fire and its reads stay frozen at mount. The health surface carries the follower detail so an operator cannot silently trust a stale dashboard.