Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.
People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.
Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.
Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.
Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
363 lines
14 KiB
Go
363 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"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"; drives the registry-status badge color, not a health check
|
|
// Configured and MissingKeysText carry the SAME configuration-readiness
|
|
// signal the Integrations list shows (configurationReadiness) — the
|
|
// required-key resolution the settings page performs. Configured is
|
|
// vacuously true for a provider with no required configuration.
|
|
Configured bool
|
|
MissingKeysText string
|
|
}
|
|
|
|
// 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)
|
|
|
|
joined, joinedErr := h.IdentityQ.CountPersonsJoinedLast30Days(ctx)
|
|
h.logCountErr("persons joined in 30 days", joinedErr)
|
|
|
|
teams, teamsErr := h.OrgQ.CountTeamOrganizations(ctx)
|
|
h.logCountErr("team organizations", teamsErr)
|
|
|
|
subCounts, subCountsErr := h.BillingQ.CountLiveSubscriptionsByStatus(ctx)
|
|
h.logCountErr("live subscriptions", subCountsErr)
|
|
|
|
recurringRows, recurringErr := h.BillingQ.SumMonthlyRecurringByCurrency(ctx)
|
|
h.logCountErr("monthly recurring", recurringErr)
|
|
recurring := make([]moneyBucket, 0, len(recurringRows))
|
|
for _, r := range recurringRows {
|
|
recurring = append(recurring, moneyBucket{currency: r.Currency, cents: r.MonthlyCents})
|
|
}
|
|
|
|
openInvoices, openInvoicesErr := h.BillingQ.CountOpenInvoices(ctx)
|
|
h.logCountErr("open invoices", openInvoicesErr)
|
|
|
|
outstandingRows, outstandingErr := h.BillingQ.SumOpenInvoiceBalanceByCurrency(ctx)
|
|
h.logCountErr("open invoice balance", outstandingErr)
|
|
outstanding := make([]moneyBucket, 0, len(outstandingRows))
|
|
for _, r := range outstandingRows {
|
|
outstanding = append(outstanding, moneyBucket{currency: r.Currency, cents: r.OutstandingCents})
|
|
}
|
|
|
|
grants, grantsErr := h.EntitlementsQ.CountDeliveringOperatorGrants(ctx)
|
|
h.logCountErr("delivering operator grants", grantsErr)
|
|
|
|
defaults, defaultsErr := h.EntitlementsQ.CountDeliveringDefaultGrants(ctx)
|
|
h.logCountErr("delivering default grants", defaultsErr)
|
|
|
|
products, productsErr := h.BillingQ.CountPublishedProducts(ctx)
|
|
h.logCountErr("published products", productsErr)
|
|
|
|
data.Stats = []OverviewStat{
|
|
// Links to the People directory (operator-people-directory: "the
|
|
// landing surface's People metric tile SHALL link to the
|
|
// directory") so the count it advertises has a one-click answer.
|
|
newOverviewStat("People", personsErr, persons, peopleCaption(joined, joinedErr), "/operator/persons"),
|
|
// Personal orgs exist one-per-member by structural convention; a
|
|
// count including them tracks the People tile and reads as an error.
|
|
newOverviewStat("Team organizations", teamsErr, teams, "Personal orgs excluded", "/operator/organizations"),
|
|
// The billing pair reads together: what recurs (commitment), then
|
|
// what is currently outstanding (receivables).
|
|
newMoneyStat("Monthly recurring", recurringErr, recurring, recurringCaption(subCounts.ActiveCount, subCounts.TrialingCount, subCountsErr, len(recurring)), "/operator/billing/subscriptions"),
|
|
newOverviewStat("Open invoices", openInvoicesErr, openInvoices, openInvoicesCaption(outstanding, outstandingErr), "/operator/billing/invoices"),
|
|
newOverviewStat("Delivering grants", grantsErr, grants, deliveringGrantsCaption(defaults, defaultsErr), "/operator/grants"),
|
|
newOverviewStat("Catalog products", productsErr, products, "Published; drafts and retired products excluded", "/operator/products"),
|
|
}
|
|
|
|
data.Providers = h.loadOverviewProviders(ctx)
|
|
data.Queue = h.loadOverviewQueue(ctx)
|
|
return data
|
|
}
|
|
|
|
// deliveringGrantsCaption completes the headline: the tile counts the
|
|
// grants an operator deliberately issued (the actionable set), and the
|
|
// caption carries the signup-default mass conferral mints one-per-org —
|
|
// excluded above because it would drown the number. "Plus" makes the
|
|
// exclusion arithmetic explicit. The ledger-vs-delivery definition of
|
|
// "delivering" lives with the queries (CountDeliveringOperatorGrants).
|
|
func deliveringGrantsCaption(defaults int64, err error) string {
|
|
if err != nil {
|
|
return "Signup defaults excluded"
|
|
}
|
|
return "Plus " + formatCount(defaults) + " signup " + pluralize(defaults, "default", "defaults")
|
|
}
|
|
|
|
// peopleCaption prefers velocity over a restatement of what a person record
|
|
// is; the census is already the headline above it.
|
|
func peopleCaption(joined int64, err error) string {
|
|
if err != nil {
|
|
return "Active person records; find one with the search above"
|
|
}
|
|
return formatCount(joined) + " joined in the last 30 days"
|
|
}
|
|
|
|
// recurringCaption pairs the money headline with the subscriptions behind
|
|
// it. Trialing subscriptions contribute no money (they are not paying yet)
|
|
// but stay visible here so the pipeline is never invisible. otherBuckets
|
|
// counts the currency buckets the headline could not show; conversion is
|
|
// out of scope (issues.md).
|
|
func recurringCaption(active, trialing int64, err error, buckets int) string {
|
|
if err != nil {
|
|
return "Active subscriptions"
|
|
}
|
|
c := formatCount(active) + " active " + pluralize(active, "subscription", "subscriptions")
|
|
if trialing > 0 {
|
|
c += "; " + formatCount(trialing) + " trialing"
|
|
}
|
|
if buckets > 1 {
|
|
c += "; plus " + formatCount(int64(buckets-1)) + " more " +
|
|
pluralize(int64(buckets-1), "currency", "currencies")
|
|
}
|
|
return c
|
|
}
|
|
|
|
// openInvoicesCaption leads with the outstanding balance when it is
|
|
// available; with no open invoices (or a failed sum) it falls back to
|
|
// stating the filter.
|
|
func openInvoicesCaption(outstanding []moneyBucket, err error) string {
|
|
const filter = "issued and awaiting payment"
|
|
if err != nil || len(outstanding) == 0 {
|
|
return "Issued and awaiting payment"
|
|
}
|
|
top, others := largestBucket(outstanding)
|
|
c := formatMoney(top.cents, top.currency) + " outstanding; " + filter
|
|
if others > 0 {
|
|
c += "; more in other currencies"
|
|
}
|
|
return c
|
|
}
|
|
|
|
// moneyBucket is one currency's summed amount. Money never crosses
|
|
// currencies on this surface: the largest bucket is displayed and the rest
|
|
// are acknowledged, because converting needs a rate source this deployment
|
|
// does not have (issues.md).
|
|
type moneyBucket struct {
|
|
currency string
|
|
cents int64
|
|
}
|
|
|
|
// largestBucket returns the biggest bucket and how many others exist. The
|
|
// input is ORDER BY-ed descending by both backing queries, but sorting here
|
|
// too keeps the helper safe to reuse.
|
|
func largestBucket(buckets []moneyBucket) (moneyBucket, int) {
|
|
top := buckets[0]
|
|
for _, b := range buckets[1:] {
|
|
if b.cents > top.cents {
|
|
top = b
|
|
}
|
|
}
|
|
return top, len(buckets) - 1
|
|
}
|
|
|
|
// newMoneyStat builds the recurring-money tile. An empty bucket list with a
|
|
// nil error is a real zero (no active subscriptions), rendered as "$0" only
|
|
// when a currency is unknowable — there is nothing to prefer — so it falls
|
|
// back to the bare digit.
|
|
func newMoneyStat(label string, err error, buckets []moneyBucket, caption, href string) OverviewStat {
|
|
stat := OverviewStat{Label: label, Caption: caption, Href: href, Available: err == nil}
|
|
if err != nil {
|
|
return stat
|
|
}
|
|
if len(buckets) == 0 {
|
|
stat.Value = "0"
|
|
return stat
|
|
}
|
|
// formatMoney is the package's shared renderer (member_products.go), so
|
|
// the operator headline and member-facing prices read identically.
|
|
top, _ := largestBucket(buckets)
|
|
stat.Value = formatMoney(top.cents, top.currency)
|
|
return stat
|
|
}
|
|
|
|
// 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
|
|
}
|
|
configured, missing := configurationReadiness(h.IntegrationConfigs, p.Slug)
|
|
rows = append(rows, OverviewProvider{
|
|
Slug: p.Slug,
|
|
DisplayName: p.DisplayName,
|
|
Kind: p.ProviderKind,
|
|
Status: p.Status,
|
|
SurfacePath: surface,
|
|
Healthy: p.Status == "active",
|
|
Configured: configured,
|
|
MissingKeysText: strings.Join(missing, ", "),
|
|
})
|
|
}
|
|
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,
|
|
}
|
|
}
|