Implement the ux-first-run change: a state-derived setup checklist on /operator/setup with a landing region that recedes once required steps are done, and empty states that distinguish blocked from empty across operator and member surfaces. Also add production deployment and environment reference docs, plus a config-key completeness test.
423 lines
26 KiB
HTML
423 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<!-- Deployment identity: the configured deployment-name (default
|
|
"Member Console") renders identically here and on the member
|
|
mastheads, so the console tells one story about who it is. The
|
|
default is itself brand-neutral, which keeps a stranger's first
|
|
screenshot of this general-purpose OSS project deployment-neutral
|
|
too. -->
|
|
<title>Operator overview - {{ deploymentName }}</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">
|
|
<meta name="ia-position" content="{{ .IAPosition }}">
|
|
|
|
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
|
|
<link href="/static/bootstrap.css" rel="stylesheet">
|
|
<script defer src="/static/bootstrap.bundle.js"></script>
|
|
<script defer src="/static/htmx.min.js"></script>
|
|
<script defer src="/static/error-handler.js"></script>
|
|
<script defer src="/static/success-toast.js"></script>
|
|
<script defer src="/static/grant-toggle.js"></script>
|
|
<script defer src="/static/grant-valid-until-tz.js"></script>
|
|
<script defer src="/static/confirm-action-modal.js"></script>
|
|
<script defer src="/static/sortable.min.js"></script>
|
|
<script defer src="/static/sortable-reorder.js"></script>
|
|
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
|
<link rel="manifest" href="/static/site.webmanifest">
|
|
|
|
<link href="/static/app.css" rel="stylesheet">
|
|
</head>
|
|
|
|
<body class="d-flex flex-column vh-100"
|
|
hx-boost="true"
|
|
hx-headers='{"X-CSRF-Token": "{{ .CSRFToken }}"}'>
|
|
<!-- Top navbar — cross-app navigation; visible on all breakpoints so the
|
|
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="/">{{ deploymentName }}</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>
|
|
</button>
|
|
<div class="collapse navbar-collapse" id="navbarNav">
|
|
<ul class="navbar-nav ms-auto">
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/">Dashboard</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/products">Products</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link active" href="/operator" aria-current="page">Operator</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="{{ .KeycloakAccountURL }}" target="_blank" hx-boost="false">Identity and Access</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link text-danger" href="/logout" hx-boost="false">Logout</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="d-flex flex-column flex-lg-row flex-grow-1">
|
|
<!-- Operator IA sidebar — runtime → catalog → integration order,
|
|
flat list, no visible group headings. Every entry points at a
|
|
real /operator/<capability> route under hx-boost; sub-views
|
|
(e.g. billing sub-pills) render inside the page. -->
|
|
<aside class="app-sidebar operator-ia-sidebar bg-light d-none d-lg-flex flex-column flex-shrink-0 h-lg-100 border-end">
|
|
<ul class="nav flex-column p-3">
|
|
<!-- Runtime -->
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "" }}active{{ end }}" href="/operator">Overview</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "organizations" }}active{{ end }}" href="/operator/organizations">Organizations</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "grants" }}active{{ end }}" href="/operator/grants">Grants</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if or (eq .ActiveCapability "billing-accounts") (eq .ActiveCapability "billing-subscriptions") (eq .ActiveCapability "billing-invoices") (eq .ActiveCapability "billing-payments") }}active{{ end }}" href="/operator/billing/accounts">Billing</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "domains" }}active{{ end }}" href="/operator/domains">Domains</a></li>
|
|
<!-- Catalog -->
|
|
<li class="nav-item operator-ia-group-start"><a class="nav-link {{ if eq .ActiveCapability "org-types" }}active{{ end }}" href="/operator/org-types">Org Types</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "products" }}active{{ end }}" href="/operator/products">Products</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "entitlement-sets" }}active{{ end }}" href="/operator/entitlement-sets">Entitlement Sets</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "plan-ladders" }}active{{ end }}" href="/operator/plan-ladders">Plan Ladders</a></li>
|
|
<li class="nav-item"><a class="nav-link {{ if eq .ActiveCapability "plan-topology" }}active{{ end }}" href="/operator/plan-topology">Plan Topology</a></li>
|
|
<!-- Integration — dedicated section: a labeled "Integrations"
|
|
home, with the registry-driven provisioning providers
|
|
listed (indented) beneath it. -->
|
|
<li class="nav-item operator-ia-group-start"><a class="nav-link {{ if eq .ActiveCapability "integrations" }}active{{ end }}" href="/operator/integrations">Integrations</a></li>
|
|
{{ range $p := .IntegrationProviders }}
|
|
<li class="nav-item"><a class="nav-link ps-4 {{ if eq $.CurrentPath $p.SurfacePath }}active{{ end }}" href="{{ $p.SurfacePath }}">{{ $p.DisplayName }}</a></li>
|
|
{{ end }}
|
|
</ul>
|
|
</aside>
|
|
|
|
<!-- Main content — single swap target for hx-boost navigations. The
|
|
toast container and confirm modal are siblings (outside this region)
|
|
so they survive boosts per docs/design-system.md §3. The inner
|
|
container-xxl caps content width at ~1320px so wide screens don't
|
|
stretch text to the viewport edge. -->
|
|
<main id="operator-main" class="flex-fill p-4">
|
|
<div class="container-xxl px-0">
|
|
<!-- #operator-body is the HTMX swap target for every mutation
|
|
form in the operator panel. Forms hx-post to /partials/...
|
|
endpoints that respond with the bare page partial; HTMX
|
|
swaps that fragment into #operator-body, preserving the
|
|
#operator-main + container-xxl wrappers. See
|
|
docs/operator-ux-conventions.md §2 (HTMX request shape). -->
|
|
<div id="operator-body">
|
|
{{ if .BodyTemplate }}
|
|
<!-- MPA page handler set a body partial; dispatch via the
|
|
renderBody template func (html/template's built-in template
|
|
action requires a string literal, not a dynamic name). -->
|
|
{{ renderBody .BodyTemplate .BodyData }}
|
|
{{ else }}
|
|
<!-- ============================================================
|
|
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.
|
|
|
|
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.
|
|
</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
|
|
through the same X-CSRF-Token header path every other
|
|
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.
|
|
|
|
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 autocomplete="off" hx-post="/operator/lookup" hx-target="#lookup-result" hx-swap="innerHTML">
|
|
<div class="input-group input-group-lg">
|
|
<input autocomplete="off" 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 px-4">Look up</button>
|
|
</div>
|
|
</form>
|
|
<div id="lookup-result">
|
|
{{ template "operator_lookup_result.html" . }}
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Setup checklist region (ux-first-run). Renders ahead of the
|
|
metric tiles ONLY while a REQUIRED step is incomplete, and
|
|
stops once every required step is complete even if
|
|
conditional steps remain — RequiredIncomplete is the single
|
|
predicate that decides both this and /operator/setup's own
|
|
content, so the two can never disagree. -->
|
|
{{ if .Setup.RequiredIncomplete }}
|
|
{{ template "operator_setup.html" .Setup }}
|
|
{{ end }}
|
|
|
|
<!-- 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 text-decoration-none text-body" href="{{ .Href }}">
|
|
{{ else }}
|
|
<div class="card h-100">
|
|
{{ 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">—</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="card">
|
|
<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">
|
|
{{ 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">—</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 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>
|
|
<span class="d-flex flex-column align-items-end gap-1 flex-shrink-0">
|
|
<span class="badge {{ if .Healthy }}text-bg-success{{ else }}text-bg-warning{{ end }}"
|
|
title="Registry status: an operator-managed record, not a live health check">{{ .Status }}</span>
|
|
{{ if not .Configured }}
|
|
<span class="badge text-bg-light border" {{ if .MissingKeysText }}title="Missing: {{ .MissingKeysText }}"{{ end }}>Not configured</span>
|
|
{{ end }}
|
|
</span>
|
|
</li>
|
|
{{ end }}
|
|
</ul>
|
|
{{ else }}
|
|
<p class="small text-body-secondary mb-0">No integrations registered.</p>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h3 class="h6 mb-3">Delivery queue</h3>
|
|
<p class="small text-body-secondary mb-3">
|
|
Tracks Stripe's queued outbound work only; other integrations dispatch directly and are not reflected here, so an empty or draining queue says nothing about their state.
|
|
</p>
|
|
{{ 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>
|
|
</main>
|
|
</div>
|
|
<!-- Toast Container — sibling of <main> so toasts survive hx-boost
|
|
navigations and partial swaps (see docs/design-system.md §3). -->
|
|
<div class="toast-container">
|
|
<div id="errorToast" class="toast align-items-center text-bg-danger border-0" role="alert" aria-live="assertive"
|
|
aria-atomic="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body" id="errorToastBody">
|
|
An error occurred. Please try again.
|
|
</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
|
|
aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
<div id="successToast" class="toast align-items-center text-bg-success border-0" role="status"
|
|
aria-live="polite" aria-atomic="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body" id="successToastBody">
|
|
Done.
|
|
</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
|
|
aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Shared confirmation modal (driven by data-action-* attrs on triggers).
|
|
Sibling of <main> so the modal markup survives boosted navigations. -->
|
|
<div class="modal fade" id="confirmActionModal" tabindex="-1" aria-labelledby="confirmActionModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 class="modal-title h5" id="confirmActionModalLabel">Confirm</h2>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<form autocomplete="off" id="confirmActionForm"
|
|
hx-swap="innerHTML"
|
|
hx-disabled-elt="find button[type=submit]"
|
|
hx-indicator="find .htmx-indicator">
|
|
<div class="modal-body">
|
|
<p id="confirmActionBody" class="mb-0"></p>
|
|
<div id="confirmActionHiddenFields"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-danger" id="confirmActionSubmit">
|
|
<span class="htmx-indicator spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
|
<span id="confirmActionLabel">Confirm</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</body>
|
|
|
|
</html>
|