Rebuild the operator landing as a real overview

Replace the bare landing panel with a four-region overview: lookup, an
"At a glance" tile row, a unified activity timeline, and a System rail.

The tiles are chosen so each earns its place and each drills into a real
operator section, which makes the row double as navigation for an
operator who arrived without a destination. People carries no link
because there is no persons browse route by design -- persons are reached
by lookup. Grants and provisions share one tile: the headline counts
grants and the caption names the active provisions it was joined against,
so the ledger-versus-delivery distinction is visible on the page instead
of only in the SQL.

Every count degrades independently, like the activity feed already did. A
failed count renders an em dash and says so; it never renders zero, since
an operator reading "0 organizations" off a broken query would draw
exactly the wrong conclusion. This is the surface people reach for when
something is already wrong, so it has to render under partial failure.

The System rail reports every registered provider, not just the
provisioning ones the sidebar lists, and splits the outbox by what an
operator would do about each bucket -- only dead-lettered work, which has
exhausted its retries, gets the alarm.

Headings run H1 to H3 with no skipped levels; the H1-to-H6 pattern of the
older operator pages is deliberately not copied. The stat-tile hover and
focus affordance lives in app.css because Bootstrap cards are containers,
not controls, and it is suppressed under reduced-motion. No new JS, no
inline script, handler or style attribute.

The shell's title and brand no longer name a specific deployment: this is
general-purpose OSS and the operator surface is the first page a stranger
evaluating the project sees.

Covered by operator_overview_render_test.go, which renders the template
without a database and guards the outline, the CSP constraints, the
brand neutrality, the empty states, and the em-dash-not-zero rule.
This commit is contained in:
2026-07-25 15:44:36 -05:00
parent 56a743fb32
commit 4cec3868d4
6 changed files with 907 additions and 54 deletions
+24
View File
@@ -130,6 +130,30 @@ meter::-moz-meter-bar {
background-color: var(--bs-primary-bg-subtle) !important;
}
/* Operator overview stat tiles. The whole card is the link target, so it
* needs a hover/focus affordance that Bootstrap's .card does not provide —
* cards are containers, not controls. Lift + deepen the shadow on pointer
* hover and mirror it for keyboard focus so both routes read the same.
* `transform` is animated rather than `box-shadow` alone so the motion is
* composited; the whole effect is suppressed under reduced-motion. */
.stat-tile {
transition: transform .12s ease-out, box-shadow .12s ease-out;
}
.stat-tile:hover,
.stat-tile:focus-visible {
transform: translateY(-2px);
box-shadow: 0 .5rem 1rem rgba(var(--bs-body-color-rgb), .15) !important;
}
@media (prefers-reduced-motion: reduce) {
.stat-tile {
transition: none;
}
.stat-tile:hover,
.stat-tile:focus-visible {
transform: none;
}
}
/* Affected-population disclosure in preview panels (org-type default change,
* tier reorder/removal): the bucket line carries only the count; org names
* live behind a <details> and render one per row, scrolling past ~8 rows so
+205 -54
View File
@@ -2,8 +2,11 @@
<html lang="en">
<head>
<title>Operator - Wiki Cafe</title>
<meta name="description" content="Operator console for Wiki Cafe member management">
<!-- Brand-neutral: this is general-purpose OSS and the operator surface
is the page a stranger evaluating the project sees first, so it
names the software, not any one deployment. -->
<title>Operator overview - Member Console</title>
<meta name="description" content="Operator console for membership, entitlement and billing administration">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -36,7 +39,7 @@
sidebar can be repurposed for operator IA. -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">Wiki Cafe Member Console</a>
<a class="navbar-brand" href="/">Member Console</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@@ -112,14 +115,31 @@
action requires a string literal, not a dynamic name). -->
{{ renderBody .BodyTemplate .BodyData }}
{{ else }}
<!-- Curated landing surface. Per operator-composite-expansion D4,
"Recent activity" is one unified timeline (grants +
transitions + invoices + payments interleaved by timestamp)
rather than two domain-partitioned sections. Override the
default 20-row limit with ?limit=N (clamped to [1,100]). -->
<h1 class="mb-4">Operator</h1>
<!-- ============================================================
Curated landing surface — the operator's entry point.
Answers "what is this deployment doing right now?" coarsest
first: search, then counts, then system signals, then the
event timeline.
<!-- Lookup affordance — single typed input resolves to person
Heading contract: one H1 for the page, an H2 per region,
H3 for panels nested inside a region. No level is skipped.
(The older operator pages run H1→H6; that is not a pattern
to copy here.)
Per operator-composite-expansion D4, "Recent activity" is
one unified timeline (grants + transitions + invoices +
payments interleaved by timestamp) rather than two
domain-partitioned sections. Override the default 20-row
limit with ?limit=N (clamped to [1,100]).
============================================================ -->
<header class="mb-4">
<h1 class="h2 mb-1">Operator overview</h1>
<p class="text-muted mb-0">
Membership, delivery, catalog and integration state for this deployment, read live.
</p>
</header>
<!-- Region 1 — Lookup. Single typed input resolves to person
detail (email-exact or name-substring), org composite
(slug-exact), a disambiguation list (multi-person), or a
no-match notice. Uses HTMX (hx-post) so the request goes
@@ -127,14 +147,21 @@
operator form uses (native form POST hit a brittle
Origin-check path in gorilla CSRF). Unique matches return
HX-Redirect; multi/no-match return the result partial
swapped into #lookup-result. -->
<section class="mb-4">
swapped into #lookup-result.
The heading is visually hidden: the input's own placeholder
and button already label the region on screen, and a visible
"Find" heading above a search box is redundant. Screen
readers still get the landmark. -->
<section class="mb-5" aria-labelledby="overview-lookup-heading">
<h2 class="visually-hidden" id="overview-lookup-heading">Find a person or organization</h2>
<form hx-post="/operator/lookup" hx-target="#lookup-result" hx-swap="innerHTML">
<div class="input-group">
<input type="text" name="term" class="form-control"
<div class="input-group input-group-lg shadow-sm">
<input type="search" name="term" class="form-control form-control-lg"
aria-labelledby="overview-lookup-heading"
placeholder="Find a person or organization (email, name, or org slug)"
value="{{ .LookupTerm }}" autofocus>
<button type="submit" class="btn btn-primary">Look up</button>
<button type="submit" class="btn btn-primary px-4">Look up</button>
</div>
</form>
<div id="lookup-result">
@@ -142,50 +169,174 @@
</div>
</section>
<section class="mb-5">
<h2 class="h4 mb-3">Recent activity</h2>
{{ if eq (len .Activity) 0 }}
<div class="text-center py-4 border rounded">
<p class="text-muted mb-0">No recent activity.</p>
<!-- Region 2 — At a glance. One tile per capability; each tile
is a link into that capability's section, so this row doubles
as the primary navigation for operators who arrived here
without a specific destination in mind. A tile whose count
could not be loaded renders an em dash, never a zero. -->
<section class="mb-5" aria-labelledby="overview-glance-heading">
<h2 class="h6 text-uppercase text-body-secondary mb-3" id="overview-glance-heading">At a glance</h2>
{{ if .Overview.Stats }}
<div class="row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3">
{{ range .Overview.Stats }}
<div class="col">
{{ if .Href }}
<a class="card h-100 border-0 shadow-sm text-decoration-none text-body stat-tile" href="{{ .Href }}">
{{ else }}
<div class="card h-100 border-0 shadow-sm">
{{ end }}
<div class="card-body">
<p class="text-uppercase text-body-secondary small fw-semibold mb-1">{{ .Label }}</p>
<p class="display-6 fw-semibold lh-1 mb-2">
{{ if .Available }}{{ .Value }}{{ else }}<span class="text-body-secondary" title="This count could not be loaded">&mdash;</span>{{ end }}
</p>
<p class="small text-body-secondary mb-0">
{{ if .Available }}{{ .Caption }}{{ else }}Count unavailable{{ end }}
</p>
</div>
{{ if .Href }}
</a>
{{ else }}
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<div class="table-responsive">
<table class="table table-hover table-sm align-middle">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Organization</th>
<th>Actor</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{{ range .Activity }}
<tr>
<td><small class="text-nowrap">{{ .Timestamp }}</small></td>
<td>
{{ if eq .EventType "grant_issued" }}<span class="badge text-bg-primary">grant</span>
{{ else if eq .EventType "transition" }}<span class="badge text-bg-info">transition</span>
{{ else if eq .EventType "invoice_created" }}<span class="badge text-bg-secondary">invoice</span>
{{ else if eq .EventType "payment_received" }}<span class="badge text-bg-success">payment</span>
{{ else }}<span class="badge text-bg-light">{{ .EventType }}</span>{{ end }}
</td>
<td>
{{ if .OrgID }}<a href="{{ .LinkPath }}">{{ if .OrgName }}{{ .OrgName }}{{ else }}<code>{{ .OrgID }}</code>{{ end }}</a>{{ end }}
</td>
<td>
{{ if .PersonID }}<a href="/operator/persons/{{ .PersonID }}">{{ if .PersonName }}{{ .PersonName }}{{ else }}<code>{{ .PersonID }}</code>{{ end }}</a>
{{ else }}<small class="text-muted"></small>{{ end }}
</td>
<td><small>{{ .Summary }}</small></td>
</tr>
{{ end }}
</tbody>
</table>
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-4">
<p class="text-body-secondary mb-0">Counts are unavailable.</p>
</div>
</div>
{{ end }}
</section>
<!-- Regions 3 and 4 sit side by side on xl+ and stack below it:
the timeline is the wide, scannable half; System is the
narrow status rail. -->
<div class="row g-4">
<div class="col-12 col-xl-8">
<!-- Region 3 — Recent activity. -->
<section aria-labelledby="overview-activity-heading">
<h2 class="h6 text-uppercase text-body-secondary mb-3" id="overview-activity-heading">Recent activity</h2>
<div class="card border-0 shadow-sm">
{{ if eq (len .Activity) 0 }}
<div class="card-body text-center py-5">
<p class="mb-1">No activity recorded yet.</p>
<p class="small text-body-secondary mb-0">
Grants, plan transitions, invoices and payments appear here as they happen.
</p>
</div>
{{ else }}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr class="text-uppercase small text-body-secondary">
<th scope="col">When</th>
<th scope="col">Event</th>
<th scope="col">Organization</th>
<th scope="col">Actor</th>
<th scope="col">Summary</th>
</tr>
</thead>
<tbody>
{{ range .Activity }}
<tr>
<td><small class="text-nowrap text-body-secondary">{{ .Timestamp }}</small></td>
<td>
{{ if eq .EventType "grant_issued" }}<span class="badge text-bg-primary">grant</span>
{{ else if eq .EventType "transition" }}<span class="badge text-bg-info">transition</span>
{{ else if eq .EventType "invoice_created" }}<span class="badge text-bg-secondary">invoice</span>
{{ else if eq .EventType "payment_received" }}<span class="badge text-bg-success">payment</span>
{{ else }}<span class="badge text-bg-light">{{ .EventType }}</span>{{ end }}
</td>
<td>
{{ if .OrgID }}<a href="{{ .LinkPath }}">{{ if .OrgName }}{{ .OrgName }}{{ else }}<code>{{ .OrgID }}</code>{{ end }}</a>{{ end }}
</td>
<td>
{{ if .PersonID }}<a href="/operator/persons/{{ .PersonID }}">{{ if .PersonName }}{{ .PersonName }}{{ else }}<code>{{ .PersonID }}</code>{{ end }}</a>
{{ else }}<small class="text-body-secondary">&mdash;</small>{{ end }}
</td>
<td><small>{{ .Summary }}</small></td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
</div>
</section>
</div>
<div class="col-12 col-xl-4">
<!-- Region 4 — System. Registry-driven: every registered
integration is listed with its operational status,
and the outbox panel reports whether queued
integration work is draining. -->
<section aria-labelledby="overview-system-heading">
<h2 class="h6 text-uppercase text-body-secondary mb-3" id="overview-system-heading">System</h2>
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<h3 class="h6 mb-3">Integrations</h3>
{{ if .Overview.Providers }}
<ul class="list-unstyled mb-0">
{{ range .Overview.Providers }}
<li class="d-flex justify-content-between align-items-center gap-2 py-2 border-bottom">
<span class="text-truncate">
{{ if .SurfacePath }}<a href="{{ .SurfacePath }}">{{ .DisplayName }}</a>
{{ else }}{{ .DisplayName }}{{ end }}
<span class="d-block small text-body-secondary">{{ .Kind }}</span>
</span>
{{ if .Healthy }}<span class="badge text-bg-success flex-shrink-0">{{ .Status }}</span>
{{ else }}<span class="badge text-bg-warning flex-shrink-0">{{ .Status }}</span>{{ end }}
</li>
{{ end }}
</ul>
{{ else }}
<p class="small text-body-secondary mb-0">No integrations registered.</p>
{{ end }}
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<h3 class="h6 mb-3">Delivery queue</h3>
{{ if .Overview.Queue.Available }}
<!-- Term before description, matching the
label-then-value rhythm of the stat tiles
above and keeping the list well-formed. -->
<dl class="row row-cols-3 g-0 mb-0 text-center">
<div class="col">
<dt class="small fw-normal text-body-secondary">Pending</dt>
<dd class="fs-4 fw-semibold mb-0">{{ .Overview.Queue.Pending }}</dd>
</div>
<div class="col">
<dt class="small fw-normal text-body-secondary">Retrying</dt>
<dd class="fs-4 fw-semibold mb-0">{{ .Overview.Queue.Retrying }}</dd>
</div>
<div class="col">
<dt class="small fw-normal text-body-secondary">Dead-letter</dt>
<dd class="fs-4 fw-semibold mb-0 {{ if .Overview.Queue.NeedsAttention }}text-danger{{ end }}">{{ .Overview.Queue.DeadLetter }}</dd>
</div>
</dl>
{{ if .Overview.Queue.NeedsAttention }}
<p class="small text-danger mb-0 mt-3">
Dead-lettered work has exhausted its retries and needs an operator.
</p>
{{ else }}
<p class="small text-body-secondary mb-0 mt-3">
Queued integration work is draining normally.
</p>
{{ end }}
{{ else }}
<p class="small text-body-secondary mb-0">Queue health is unavailable.</p>
{{ end }}
</div>
</div>
</section>
</div>
</div>
{{ end }}
</div>
</div>
+18
View File
@@ -13,9 +13,11 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"github.com/spf13/viper"
@@ -31,6 +33,14 @@ type OperatorHandler struct {
EntitlementsQ entitlements.Querier
OrgQ organization.Querier
IdentityQ identity.Querier
// DomainsQ backs the landing surface's domain-claim tile. Optional: a
// deployment that wires no domains registry simply renders that tile
// unavailable rather than failing the page.
DomainsQ domains.Querier
// IntegrationQ backs the landing surface's System panel (registered
// providers + outbox health). Derived from Database in the constructor;
// a separate field so tests can inject a fake without a live database.
IntegrationQ integration.Querier
}
// OperatorHandlerConfig holds configuration for the operator handler
@@ -42,6 +52,7 @@ type OperatorHandlerConfig struct {
EntitlementsQ entitlements.Querier
OrgQ organization.Querier
IdentityQ identity.Querier
DomainsQ domains.Querier
}
// NewOperatorHandler creates a new OperatorHandler
@@ -77,6 +88,8 @@ func NewOperatorHandler(cfg OperatorHandlerConfig) (*OperatorHandler, error) {
EntitlementsQ: cfg.EntitlementsQ,
OrgQ: cfg.OrgQ,
IdentityQ: cfg.IdentityQ,
DomainsQ: cfg.DomainsQ,
IntegrationQ: integration.New(cfg.Database),
}, nil
}
@@ -153,6 +166,10 @@ type OperatorPageData struct {
// "Recent activity" section (operator-composite-expansion D4).
// Populated only on the landing branch; nil/empty on MPA pages.
Activity []ActivityEvent
// Overview holds the landing surface's "At a glance" counts and "System"
// signals. Populated only on the landing branch; the zero value renders
// the surface's empty states, which is what MPA pages get.
Overview OverviewData
// Lookup state for the landing-surface lookup affordance. LookupTerm
// retains the operator's input on re-render so they can edit and
// re-submit; LookupCandidates is populated on ambiguous matches;
@@ -297,6 +314,7 @@ func (h *OperatorHandler) renderLanding(w http.ResponseWriter, r *http.Request,
CurrentPath: r.URL.Path,
IntegrationProviders: loadIntegrationProvidersNav(ctx, h.Database, h.Logger),
IAPosition: "runtime:landing",
Overview: h.loadOverview(ctx),
Activity: h.loadRecentActivity(ctx, limit),
LookupTerm: lookupTerm,
LookupCandidates: candidates,
+272
View File
@@ -0,0 +1,272 @@
package server
import (
"context"
"errors"
"log/slog"
"strconv"
"strings"
)
// This file backs the "At a glance" and "System" regions of the operator
// landing surface (operator.html, the branch taken when BodyTemplate is
// empty). The landing surface answers one question — "what is this
// deployment doing right now?" — in three passes, coarsest first:
//
// 1. At a glance counts, one per capability, each a link into its section
// 2. System which integrations are registered and whether the
// delivery queue is draining
// 3. Recent activity the unified event timeline (loadRecentActivity, in
// operator.go)
//
// Every number here is a live read. Nothing is cached and nothing is
// precomputed at boot: an operator looking at this page is looking at the
// database as of this request.
// OverviewStat is one tile in the landing surface's "At a glance" row.
//
// Value is pre-formatted in Go rather than in the template because the
// grouping separator is a presentation decision and html/template has no
// number formatter; the template's job is layout only.
//
// Available distinguishes "counted zero" from "could not count". A failed
// count must never render as 0 — an operator reading 0 organizations off a
// broken query would draw exactly the wrong conclusion — so the template
// renders an em dash for unavailable tiles instead.
type OverviewStat struct {
Label string
Value string
Caption string // one line stating precisely what was counted
Href string // operator section this tile drills into; "" renders a static tile
Available bool
}
// OverviewProvider is one registered integration in the System panel,
// projected from the provider registry (core.providers) rather than
// hardcoded, so installing a conforming integration surfaces it here with
// no template edit.
type OverviewProvider struct {
Slug string
DisplayName string
Kind string
Status string
SurfacePath string // "" when the provider declares no operator surface
Healthy bool // status == "active"
}
// OverviewQueue is the integration outbox's health, split by what an
// operator would do about each bucket. Retrying rows are in-flight and need
// no action; DeadLetter rows have exhausted their retries and will not move
// without intervention, so the template gives that count the alarm styling.
type OverviewQueue struct {
Pending int64
Retrying int64
DeadLetter int64
Available bool
}
// NeedsAttention reports whether the delivery queue holds work that will not
// resolve on its own. Exposed as a method so the template asks a question
// instead of re-deriving the threshold.
func (q OverviewQueue) NeedsAttention() bool { return q.DeadLetter > 0 }
// OverviewData is everything the landing surface renders above the activity
// timeline.
type OverviewData struct {
Stats []OverviewStat
Providers []OverviewProvider
Queue OverviewQueue
}
// loadOverview assembles the landing surface's counts and system signals.
//
// Like loadRecentActivity, every source degrades independently: a failing
// query costs its own tile (rendered unavailable) and is logged, but never
// 500s the operator's entry point. The landing page is the surface an
// operator reaches for when something is already wrong, so it has to render
// under partial failure.
func (h *OperatorHandler) loadOverview(ctx context.Context) OverviewData {
data := OverviewData{}
persons, personsErr := h.IdentityQ.CountActivePersons(ctx)
h.logCountErr("active persons", personsErr)
orgs, orgsErr := h.OrgQ.CountActiveOrganizations(ctx)
h.logCountErr("active organizations", orgsErr)
subs, subsErr := h.BillingQ.CountLiveSubscriptions(ctx)
h.logCountErr("live subscriptions", subsErr)
grants, grantsErr := h.EntitlementsQ.CountDeliveringGrants(ctx)
h.logCountErr("delivering grants", grantsErr)
provisions, provisionsErr := h.EntitlementsQ.CountDeliveringProvisions(ctx)
h.logCountErr("delivering provisions", provisionsErr)
products, productsErr := h.BillingQ.CountPublishedProducts(ctx)
h.logCountErr("published products", productsErr)
domainsValue, domainsPending := int64(0), int64(0)
domainsErr := errDomainsRegistryAbsent
if h.DomainsQ != nil {
claims, err := h.DomainsQ.CountClaimsByLifecycle(ctx)
domainsErr = err
h.logCountErr("domain claims", err)
if err == nil {
domainsValue, domainsPending = claims.LiveCount, claims.PendingCount
}
}
data.Stats = []OverviewStat{
// No /operator/persons browse route exists by design (persons are
// reached by lookup, not by list), so this tile carries no Href and
// points the operator back at the search above it.
newOverviewStat("People", personsErr, persons, "Active person records — find one with the search above", ""),
newOverviewStat("Organizations", orgsErr, orgs, "Active organizations across all types", "/operator/organizations"),
newOverviewStat("Subscriptions", subsErr, subs, "Active or trialing — presently owed service", "/operator/billing/subscriptions"),
newOverviewStat("Delivering grants", grantsErr, grants, deliveringGrantsCaption(provisions, provisionsErr), "/operator/grants"),
newOverviewStat("Catalog products", productsErr, products, "Published — drafts and retired products excluded", "/operator/products"),
newOverviewStat("Domain claims", domainsErr, domainsValue, domainClaimsCaption(domainsPending), "/operator/domains"),
}
data.Providers = h.loadOverviewProviders(ctx)
data.Queue = h.loadOverviewQueue(ctx)
return data
}
// errDomainsRegistryAbsent marks the domain tile unavailable on a deployment
// that wired no domains registry. Distinct from a query failure only in
// intent — both render an em dash — but it keeps "not configured" out of the
// warning log.
var errDomainsRegistryAbsent = errors.New("operator overview: no domains registry configured")
// deliveringGrantsCaption explains the "Delivering grants" headline.
//
// The headline counts grants, but grants.status is an issuance ledger: it
// says what was written down, not what is being served. The authoritative
// current-delivery fact is core.pool_provisions.status, so the tile is only
// honest if the caption names the provisions the count was joined against.
// That is why the provision count is the caption rather than a tile of its
// own — it is the evidence for the number above it, not an independent
// statistic. When the provision count itself is unavailable the caption still
// states the join, so the tile never reads as a bare grants.status filter.
func deliveringGrantsCaption(provisions int64, err error) string {
if err != nil {
return "Grants with at least one active pool provision"
}
return "Backed by " + formatCount(provisions) + " active pool " +
pluralize(provisions, "provision", "provisions")
}
// domainClaimsCaption pairs the live-claim headline with the verification
// backlog behind it, since a growing pending count is the signal an operator
// acts on and it would be invisible if only live claims were shown.
func domainClaimsCaption(pending int64) string {
return "Verified and serving · " + formatCount(pending) + " awaiting verification"
}
// newOverviewStat builds a tile, folding the load error into the tile's own
// availability so a caller never has to branch on err at every call site.
func newOverviewStat(label string, err error, value int64, caption, href string) OverviewStat {
stat := OverviewStat{Label: label, Caption: caption, Href: href, Available: err == nil}
if err == nil {
stat.Value = formatCount(value)
}
return stat
}
func (h *OperatorHandler) logCountErr(what string, err error) {
if err != nil {
h.Logger.Warn("operator overview: count failed",
slog.String("metric", what), slog.Any("error", err))
}
}
// formatCount renders n with thin comma grouping ("12,480"). Written out
// rather than pulled from golang.org/x/text because the operator surface is
// English-only and a locale-aware formatter would be the only thing that
// dependency was used for.
func formatCount(n int64) string {
digits := strconv.FormatInt(n, 10)
sign := ""
if strings.HasPrefix(digits, "-") {
sign, digits = "-", digits[1:]
}
if len(digits) <= 3 {
return sign + digits
}
// Walk from the least-significant end, inserting a separator every third
// digit, then reverse once at the end.
var reversed strings.Builder
for i := 0; i < len(digits); i++ {
if i > 0 && i%3 == 0 {
reversed.WriteByte(',')
}
reversed.WriteByte(digits[len(digits)-1-i])
}
out := []byte(reversed.String())
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return sign + string(out)
}
// pluralize picks a noun form for n. Only the two irregular-free cases the
// overview needs; not a general inflector.
func pluralize(n int64, singular, plural string) string {
if n == 1 {
return singular
}
return plural
}
// loadOverviewProviders lists every registered integration — not just the
// provisioning ones the sidebar nav shows — because the System panel reports
// what is installed, and a billing or identity provider being down matters
// just as much as a provisioning one.
func (h *OperatorHandler) loadOverviewProviders(ctx context.Context) []OverviewProvider {
if h.IntegrationQ == nil {
return nil
}
providers, err := h.IntegrationQ.ListProviders(ctx)
if err != nil {
h.Logger.Warn("operator overview: list providers failed", slog.Any("error", err))
return nil
}
rows := make([]OverviewProvider, 0, len(providers))
for _, p := range providers {
surface := ""
if p.OperatorSurfacePath.Valid {
surface = p.OperatorSurfacePath.String
}
rows = append(rows, OverviewProvider{
Slug: p.Slug,
DisplayName: p.DisplayName,
Kind: p.ProviderKind,
Status: p.Status,
SurfacePath: surface,
Healthy: p.Status == "active",
})
}
return rows
}
// loadOverviewQueue probes the integration outbox. An unavailable result
// leaves Available false and the template omits the panel rather than
// printing three zeroes it cannot stand behind.
func (h *OperatorHandler) loadOverviewQueue(ctx context.Context) OverviewQueue {
if h.IntegrationQ == nil {
return OverviewQueue{}
}
counts, err := h.IntegrationQ.CountOutboxByStatus(ctx)
if err != nil {
h.Logger.Warn("operator overview: outbox health probe failed", slog.Any("error", err))
return OverviewQueue{}
}
return OverviewQueue{
Pending: counts.PendingCount,
Retrying: counts.FailedCount,
DeadLetter: counts.DeadLetterCount,
Available: true,
}
}
@@ -0,0 +1,386 @@
package server
import (
"bytes"
"errors"
"html/template"
"io/fs"
"regexp"
"strconv"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// Render coverage for the operator landing surface's overview regions ("At a
// glance" + "System"). Template-level only: it executes operator.html against
// hand-built OperatorPageData, so it needs no database and runs in every
// environment. The loader that fills that data (loadOverview) is exercised
// separately by the pure-function tests at the bottom of this file.
// overviewTemplate parses the operator template set the same way
// NewOperatorPartialsHandler does — partials first, then the shell — so this
// test fails on the same parse errors production would hit.
func overviewTemplate(t *testing.T) *template.Template {
t.Helper()
sub, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub partials: %v", err)
}
tmpl := template.New("operator").Funcs(template.FuncMap{
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
"routeURL": web.RouteURL,
"fieldErr": func(_, _, _, _ string, _, _ any) string { return "" },
"stripeEntityURL": func(string, string) string { return "" },
})
if tmpl, err = tmpl.ParseFS(partialsSub, "operator_*.html"); err != nil {
t.Fatalf("ParseFS partials: %v", err)
}
if tmpl, err = tmpl.ParseFS(sub, "operator.html"); err != nil {
t.Fatalf("ParseFS operator.html: %v", err)
}
return tmpl
}
func renderOperator(t *testing.T, data OperatorPageData) string {
t.Helper()
var buf bytes.Buffer
if err := overviewTemplate(t).ExecuteTemplate(&buf, "operator.html", data); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
return buf.String()
}
// landingRegion slices the rendered document down to the landing surface
// itself. The navbar, sidebar, toast container and confirmation modal are
// outside <main> (or outside #operator-body) and carry their own markup —
// including an <h5> in the modal — so assertions about this page's structure
// must not see them.
func landingRegion(t *testing.T, out string) string {
t.Helper()
const openTag = `<div id="operator-body">`
start := strings.Index(out, openTag)
if start < 0 {
t.Fatalf("rendered output has no #operator-body region")
}
end := strings.Index(out, "</main>")
if end < start {
t.Fatalf("rendered output has no </main> after #operator-body")
}
return out[start+len(openTag) : end]
}
// populatedOverview is a representative landing payload: every tile
// available, a mix of healthy and unhealthy integrations, and a draining
// queue.
func populatedOverview() OperatorPageData {
return OperatorPageData{
CSRFToken: "csrf",
IAPosition: "runtime:landing",
Overview: OverviewData{
Stats: []OverviewStat{
{Label: "People", Value: "1,204", Caption: "Active person records", Available: true},
{Label: "Organizations", Value: "312", Caption: "Active organizations", Href: "/operator/organizations", Available: true},
{Label: "Delivering grants", Value: "87", Caption: "Backed by 91 active pool provisions", Href: "/operator/grants", Available: true},
},
Providers: []OverviewProvider{
{Slug: "alpha", DisplayName: "Alpha", Kind: "provisioning", Status: "active", SurfacePath: "/operator/alpha-sites", Healthy: true},
{Slug: "beta", DisplayName: "Beta", Kind: "billing", Status: "disabled"},
},
Queue: OverviewQueue{Pending: 4, Retrying: 1, Available: true},
},
Activity: []ActivityEvent{{
EventType: "grant_issued",
Timestamp: "Jan 2, 2026 3:04 PM",
OrgID: "org-1",
OrgName: "Example Org",
Summary: "Issued Example Plan (promo)",
LinkPath: "/operator/organizations/org-1",
}},
}
}
func TestOperatorOverviewRendersStatTiles(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
for _, want := range []string{
"At a glance",
"People",
"1,204", // grouped value, formatted in Go
"Organizations", // tile label
`href="/operator/organizations"`, // tile drills into its section
"Delivering grants", // grants tile
"Backed by 91 active pool provisions", // caption names the provision join
`href="/operator/grants"`,
} {
if !strings.Contains(out, want) {
t.Errorf("landing surface missing %q", want)
}
}
// A tile with no Href must not render as a link — there is no persons
// browse route, so the People tile is static by design.
if strings.Contains(out, `href=""`) {
t.Errorf("hrefless stat tile rendered an empty link target")
}
}
// An unavailable count must render an em dash. Rendering 0 would be an
// actively misleading answer to "how many organizations are there".
func TestOperatorOverviewUnavailableStatIsNotZero(t *testing.T) {
data := OperatorPageData{
CSRFToken: "csrf",
Overview: OverviewData{Stats: []OverviewStat{
{Label: "Organizations", Caption: "Active organizations", Href: "/operator/organizations", Available: false},
}},
}
out := landingRegion(t, renderOperator(t, data))
if !strings.Contains(out, "&mdash;") {
t.Errorf("unavailable stat did not render an em dash")
}
if !strings.Contains(out, "Count unavailable") {
t.Errorf("unavailable stat did not explain itself")
}
// The tile still renders its label and link; only the number is withheld.
if !strings.Contains(out, "Organizations") {
t.Errorf("unavailable stat dropped its label")
}
if regexp.MustCompile(`display-6[^>]*>\s*0\s*<`).MatchString(out) {
t.Errorf("unavailable stat rendered a zero value")
}
}
func TestOperatorOverviewSystemPanel(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
for _, want := range []string{
"System",
"Integrations",
"Alpha",
`href="/operator/alpha-sites"`, // provider with a surface links to it
"provisioning", // provider kind is shown
`<span class="badge text-bg-success`, // healthy provider
"Beta",
`<span class="badge text-bg-warning`, // unhealthy provider
"Delivery queue",
"Pending",
"Retrying",
"Dead-letter",
"draining normally",
} {
if !strings.Contains(out, want) {
t.Errorf("system panel missing %q", want)
}
}
// A provider without a surface path must not become a dead link.
if strings.Contains(out, `href="">Beta</a>`) {
t.Errorf("surfaceless provider rendered an empty link")
}
}
// Dead-lettered outbox work is the one queue bucket nothing will clear on its
// own, so it must be called out rather than shown as another neutral number.
func TestOperatorOverviewDeadLetterRaisesAlarm(t *testing.T) {
data := populatedOverview()
data.Overview.Queue = OverviewQueue{Pending: 2, Retrying: 0, DeadLetter: 3, Available: true}
out := landingRegion(t, renderOperator(t, data))
if !strings.Contains(out, "text-danger") {
t.Errorf("dead-lettered work did not render with alarm styling")
}
if !strings.Contains(out, "exhausted its retries") {
t.Errorf("dead-letter state did not explain what an operator must do")
}
if strings.Contains(out, "draining normally") {
t.Errorf("dead-lettered queue still claimed to be draining normally")
}
}
// The landing surface is the first thing a fresh deployment shows, so every
// region needs a legible empty state rather than a blank panel.
func TestOperatorOverviewEmptyStates(t *testing.T) {
out := landingRegion(t, renderOperator(t, OperatorPageData{CSRFToken: "csrf"}))
for _, want := range []string{
"Counts are unavailable.",
"No activity recorded yet.",
"No integrations registered.",
"Queue health is unavailable.",
} {
if !strings.Contains(out, want) {
t.Errorf("empty landing surface missing %q", want)
}
}
// The lookup affordance is the one thing that must work on an empty
// deployment, so it renders regardless.
if !strings.Contains(out, "Find a person or organization") {
t.Errorf("empty landing surface dropped the lookup affordance")
}
}
// The landing surface is the page screenshotted for the project README, so
// its document outline has to be defensible: exactly one H1, no skipped
// levels. The older operator pages run H1 straight to H6; that pattern must
// not spread here.
func TestOperatorOverviewHeadingHierarchy(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
matches := regexp.MustCompile(`(?i)<h([1-6])[\s>]`).FindAllStringSubmatch(out, -1)
if len(matches) == 0 {
t.Fatalf("landing surface rendered no headings at all")
}
levels := make([]int, 0, len(matches))
for _, m := range matches {
n, err := strconv.Atoi(m[1])
if err != nil {
t.Fatalf("unparsable heading level %q", m[1])
}
levels = append(levels, n)
}
if levels[0] != 1 {
t.Errorf("first heading is h%d, want h1", levels[0])
}
h1Count := 0
for _, l := range levels {
if l == 1 {
h1Count++
}
}
if h1Count != 1 {
t.Errorf("found %d h1 elements, want exactly 1", h1Count)
}
for i := 1; i < len(levels); i++ {
if levels[i] > levels[i-1]+1 {
t.Errorf("heading level skips from h%d to h%d at position %d (levels: %v)",
levels[i-1], levels[i], i, levels)
}
}
// Guard the specific regression: no h4/h5/h6 on this surface at all.
for _, l := range levels {
if l > 3 {
t.Errorf("landing surface uses h%d; the overview outline stops at h3 (levels: %v)", l, levels)
}
}
}
// Strict CSP: nothing on this surface may rely on an inline script, an inline
// event handler, or a style attribute.
func TestOperatorOverviewIsCSPClean(t *testing.T) {
out := landingRegion(t, renderOperator(t, populatedOverview()))
if regexp.MustCompile(`(?i)<script(\s[^>]*)?>[^<]`).MatchString(out) {
t.Errorf("landing surface contains an inline <script> body")
}
if m := regexp.MustCompile(`(?i)\son(click|load|change|submit|input|focus|error)\s*=`).FindString(out); m != "" {
t.Errorf("landing surface contains an inline event handler: %q", strings.TrimSpace(m))
}
if regexp.MustCompile(`(?i)\sstyle\s*=\s*"`).MatchString(out) {
t.Errorf("landing surface contains an inline style attribute")
}
}
// Brand neutrality: this is general-purpose OSS and the operator surface is
// the page a stranger evaluating the project sees first.
func TestOperatorShellIsBrandNeutral(t *testing.T) {
out := renderOperator(t, populatedOverview())
for _, forbidden := range []string{"Wiki Cafe", "wiki.cafe", "WikiCafe"} {
if strings.Contains(out, forbidden) {
t.Errorf("operator shell names a specific deployment: %q", forbidden)
}
}
if !strings.Contains(out, "<title>Operator overview - Member Console</title>") {
t.Errorf("operator shell lost its brand-neutral title")
}
}
func TestFormatCount(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "0"},
{7, "7"},
{999, "999"},
{1000, "1,000"},
{1204, "1,204"},
{12480, "12,480"},
{999999, "999,999"},
{1000000, "1,000,000"},
{-4200, "-4,200"},
}
for _, tc := range cases {
if got := formatCount(tc.in); got != tc.want {
t.Errorf("formatCount(%d) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The "Delivering grants" caption is where the grants-ledger caveat is made
// visible to the operator: grants.status records issuance, pool_provisions
// .status records delivery, and the tile must say which one it counted.
func TestDeliveringGrantsCaptionNamesProvisionJoin(t *testing.T) {
cases := []struct {
name string
provisions int64
err error
want string
}{
{"plural", 91, nil, "Backed by 91 active pool provisions"},
{"singular", 1, nil, "Backed by 1 active pool provision"},
{"zero", 0, nil, "Backed by 0 active pool provisions"},
{"grouped", 12480, nil, "Backed by 12,480 active pool provisions"},
{"unavailable", 0, errors.New("boom"), "Grants with at least one active pool provision"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := deliveringGrantsCaption(tc.provisions, tc.err)
if got != tc.want {
t.Errorf("deliveringGrantsCaption(%d, %v) = %q, want %q", tc.provisions, tc.err, got, tc.want)
}
if !strings.Contains(strings.ToLower(got), "provision") {
t.Errorf("caption %q does not name the provision join the count relies on", got)
}
})
}
}
func TestDomainClaimsCaption(t *testing.T) {
if got, want := domainClaimsCaption(0), "Verified and serving · 0 awaiting verification"; got != want {
t.Errorf("domainClaimsCaption(0) = %q, want %q", got, want)
}
if got, want := domainClaimsCaption(1500), "Verified and serving · 1,500 awaiting verification"; got != want {
t.Errorf("domainClaimsCaption(1500) = %q, want %q", got, want)
}
}
func TestOverviewQueueNeedsAttention(t *testing.T) {
if (OverviewQueue{Pending: 99, Retrying: 12, Available: true}).NeedsAttention() {
t.Errorf("pending and retrying work must not raise an alarm on its own")
}
if !(OverviewQueue{DeadLetter: 1, Available: true}).NeedsAttention() {
t.Errorf("dead-lettered work must raise an alarm")
}
}
func TestNewOverviewStatWithholdsValueOnError(t *testing.T) {
ok := newOverviewStat("Organizations", nil, 42, "caption", "/operator/organizations")
if !ok.Available || ok.Value != "42" {
t.Errorf("successful stat = %+v, want available with value 42", ok)
}
bad := newOverviewStat("Organizations", errors.New("boom"), 42, "caption", "/operator/organizations")
if bad.Available {
t.Errorf("failed stat reported itself available")
}
if bad.Value != "" {
t.Errorf("failed stat carried a value %q; the template must have nothing to print", bad.Value)
}
}
+2
View File
@@ -328,6 +328,8 @@ func Start(ctx context.Context, cfg Config) error {
EntitlementsQ: cfg.EntitlementsQ,
OrgQ: cfg.OrgQ,
IdentityQ: cfg.IdentityQ,
// Backs the landing surface's domain-claim tile.
DomainsQ: cfg.DomainsQ,
})
if err != nil {
cfg.Logger.Error("failed to set up Operator handler", slog.Any("error", err))