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.
367 lines
13 KiB
Go
367 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
)
|
|
|
|
// PlanTopologyData is the body data for operator_plan_topology.html — the
|
|
// operator-facing mirror of the member catalog. It conveys the whole plan
|
|
// topology at once: ladders as columns (ordered by sort_order), ranks as
|
|
// rows, the off-ladder add-on strip, the shared-product (M:N) reverse index,
|
|
// the per-org-type provisioning summary, and inline structural-validation
|
|
// health. It is read-only; every drill-in links into existing CRUD.
|
|
type PlanTopologyData struct {
|
|
Ladders []TopologyLadderColumn
|
|
Rows []TopologyRow
|
|
Addons []TopologyAddonViewModel
|
|
SharedProducts []SharedProductViewModel
|
|
OrgTypes []OrgTypeProvisionViewModel
|
|
Health PlanLadderValidationData
|
|
// NothingToValidate reports that no active pool-to-ladder attachments
|
|
// exist anywhere (AnyActiveLadderAttachment), so the structural checks
|
|
// in Health had nothing to range over — a fresh deployment, most often.
|
|
// The strip renders a neutral "nothing to validate yet" state instead of
|
|
// claiming healthy, since healthy is only honest when something was
|
|
// actually checked (ux-first-run spec: topology health strip).
|
|
NothingToValidate bool
|
|
Error string
|
|
}
|
|
|
|
// TopologyLadderColumn is one ladder rendered as a grid column. The header is a
|
|
// drag-to-reorder handle (SortableJS) plus a hidden ladder-ID input the reorder
|
|
// POST serializes in the dropped order.
|
|
type TopologyLadderColumn struct {
|
|
PlanLadderID string
|
|
LadderKey string
|
|
Name string
|
|
IsActive bool
|
|
ActiveAttachmentCount int64
|
|
}
|
|
|
|
// TopologyRow is one rank rendered as a grid row. Cells are aligned 1:1 with
|
|
// PlanTopologyData.Ladders order.
|
|
type TopologyRow struct {
|
|
Rank int32
|
|
Cells []TopologyCell
|
|
}
|
|
|
|
// TopologyCell is the (ladder, rank) intersection. Present=false renders as an
|
|
// empty/muted cell so ranks stay aligned across axes of differing depth.
|
|
type TopologyCell struct {
|
|
Present bool
|
|
ProductID string
|
|
ProductName string
|
|
Shared bool
|
|
LifecycleStatus string
|
|
LadderID string
|
|
}
|
|
|
|
// TopologyAddonViewModel is an off-ladder add-on (product_kind = 'addon').
|
|
type TopologyAddonViewModel struct {
|
|
ProductID string
|
|
Name string
|
|
Category string // display_category, "uncategorized" when blank (presentation only)
|
|
LifecycleStatus string
|
|
}
|
|
|
|
// SharedProductMembership is one (ladder, rank) membership of a shared product.
|
|
type SharedProductMembership struct {
|
|
LadderKey string
|
|
LadderName string
|
|
Rank int32
|
|
}
|
|
|
|
// SharedProductViewModel is a product that is a tier in more than one ladder,
|
|
// with all of its memberships — the reverse index of the M:N relationship.
|
|
type SharedProductViewModel struct {
|
|
ProductID string
|
|
ProductName string
|
|
Memberships []SharedProductMembership
|
|
}
|
|
|
|
// OrgTypeProvisionViewModel summarizes what a newly created org of this type is
|
|
// provisioned: the configured default resolved to its ladder + rank-0 product.
|
|
type OrgTypeProvisionViewModel struct {
|
|
OrgType string
|
|
DisplayName string
|
|
HasDefault bool
|
|
LadderName string
|
|
RankZeroProduct string
|
|
}
|
|
|
|
// GetPlanTopologyPage handles GET /operator/plan-topology — the read-only
|
|
// cross-ladder topology overview. Catalog-group MPA page rendered inside the
|
|
// operator.html shell.
|
|
func (h *OperatorPartialsHandler) GetPlanTopologyPage(w http.ResponseWriter, r *http.Request) {
|
|
bodyData := h.loadPlanTopologyData(r)
|
|
|
|
page := h.buildOperatorPageData(r)
|
|
page.IAPosition = "catalog:plan-topology"
|
|
page.ActiveCapability = "plan-topology"
|
|
page.BodyTemplate = "operator_plan_topology.html"
|
|
page.BodyData = bodyData
|
|
|
|
h.Templates.Render(w, "operator.html", page)
|
|
}
|
|
|
|
// loadPlanTopologyData hydrates the topology overview. All reads are read-only
|
|
// over existing schema; no mutation occurs here.
|
|
func (h *OperatorPartialsHandler) loadPlanTopologyData(r *http.Request) PlanTopologyData {
|
|
ctx := r.Context()
|
|
var data PlanTopologyData
|
|
|
|
ladders, err := h.BillingQ.ListPlanLadders(ctx)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list plan ladders", slog.Any("error", err))
|
|
data.Error = "Failed to load plan topology"
|
|
return data
|
|
}
|
|
|
|
// Shared products: the set of product IDs that are tiers in >1 ladder, plus
|
|
// the reverse index grouping each shared product's memberships. Rows arrive
|
|
// ordered by product name then ladder, so grouping preserves stable order.
|
|
sharedRows, err := h.BillingQ.ListSharedTierProducts(ctx)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list shared tier products", slog.Any("error", err))
|
|
}
|
|
sharedSet := make(map[string]bool, len(sharedRows))
|
|
sharedIndex := make(map[string]int) // product_id -> index into data.SharedProducts
|
|
for _, sr := range sharedRows {
|
|
sharedSet[sr.ProductID] = true
|
|
idx, ok := sharedIndex[sr.ProductID]
|
|
if !ok {
|
|
idx = len(data.SharedProducts)
|
|
sharedIndex[sr.ProductID] = idx
|
|
data.SharedProducts = append(data.SharedProducts, SharedProductViewModel{
|
|
ProductID: sr.ProductID,
|
|
ProductName: sr.ProductName,
|
|
})
|
|
}
|
|
data.SharedProducts[idx].Memberships = append(data.SharedProducts[idx].Memberships, SharedProductMembership{
|
|
LadderKey: sr.LadderKey,
|
|
LadderName: sr.LadderName,
|
|
Rank: sr.Rank,
|
|
})
|
|
}
|
|
|
|
// Columns (ladders) + per-ladder tier lookup keyed by rank. ListPlanLadders
|
|
// already orders by sort_order ASC, name ASC, so column order is the member
|
|
// catalog's axis order.
|
|
data.Ladders = make([]TopologyLadderColumn, len(ladders))
|
|
tiersByLadder := make([]map[int32]TopologyCell, len(ladders))
|
|
rankSet := make(map[int32]bool)
|
|
tieredProducts := make(map[string]bool)
|
|
for i, l := range ladders {
|
|
count, _ := h.EntitlementsQ.CountActiveAttachmentsByLadder(ctx, l.PlanLadderID)
|
|
data.Ladders[i] = TopologyLadderColumn{
|
|
PlanLadderID: l.PlanLadderID,
|
|
LadderKey: l.LadderKey,
|
|
Name: l.Name,
|
|
IsActive: l.IsActive,
|
|
ActiveAttachmentCount: count,
|
|
}
|
|
|
|
cells := make(map[int32]TopologyCell)
|
|
tiers, _ := h.BillingQ.ListTiersByLadderWithProducts(ctx, l.PlanLadderID)
|
|
for _, t := range tiers {
|
|
cells[t.Rank] = TopologyCell{
|
|
Present: true,
|
|
ProductID: t.ProductID,
|
|
ProductName: t.ProductName,
|
|
Shared: sharedSet[t.ProductID],
|
|
LifecycleStatus: t.LifecycleStatus,
|
|
LadderID: l.PlanLadderID,
|
|
}
|
|
rankSet[t.Rank] = true
|
|
tieredProducts[t.ProductID] = true
|
|
}
|
|
tiersByLadder[i] = cells
|
|
}
|
|
|
|
// Rows span the union of ranks present across all ladders, rendered with the
|
|
// highest rank on top and rank 0 at the bottom (member-catalog mirror:
|
|
// upgrades move up). Absent (ladder, rank) intersections render as empty
|
|
// cells so ranks stay aligned across axes of differing depth.
|
|
ranks := make([]int32, 0, len(rankSet))
|
|
for rank := range rankSet {
|
|
ranks = append(ranks, rank)
|
|
}
|
|
sort.Slice(ranks, func(a, b int) bool { return ranks[a] > ranks[b] })
|
|
|
|
data.Rows = make([]TopologyRow, len(ranks))
|
|
for ri, rank := range ranks {
|
|
cells := make([]TopologyCell, len(data.Ladders))
|
|
for ci := range data.Ladders {
|
|
if cell, ok := tiersByLadder[ci][rank]; ok {
|
|
cells[ci] = cell
|
|
}
|
|
}
|
|
data.Rows[ri] = TopologyRow{Rank: rank, Cells: cells}
|
|
}
|
|
|
|
// Off-ladder products, bucketed by display_category (presentation-only, Doc
|
|
// 41 Decision 136; a blank category lists as "uncategorized"). Every product
|
|
// not tiered on a ladder is shown with a lifecycle badge.
|
|
products, err := h.BillingQ.ListAllProducts(ctx)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list products", slog.Any("error", err))
|
|
} else {
|
|
for _, p := range products {
|
|
if tieredProducts[p.ProductID] {
|
|
continue
|
|
}
|
|
category := "uncategorized"
|
|
if p.DisplayCategory.Valid && p.DisplayCategory.String != "" {
|
|
category = p.DisplayCategory.String
|
|
}
|
|
data.Addons = append(data.Addons, TopologyAddonViewModel{
|
|
ProductID: p.ProductID,
|
|
Name: p.Name,
|
|
Category: category,
|
|
LifecycleStatus: p.LifecycleStatus,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Per-org-type provisioning summary: resolve each org type's configured
|
|
// default ladder to its rank-0 product ("new orgs get → ladder @ product").
|
|
rankZero, err := h.BillingQ.ListPlanLaddersWithRankZeroProduct(ctx)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list rank-zero products", slog.Any("error", err))
|
|
}
|
|
rankZeroByLadder := make(map[string]struct{ ladderName, productName string }, len(rankZero))
|
|
for _, rz := range rankZero {
|
|
// The query LEFT JOINs so rank-0-less ladders surface elsewhere; here
|
|
// a default that can't resolve to a product renders as no-default.
|
|
if !rz.ProductID.Valid {
|
|
continue
|
|
}
|
|
rankZeroByLadder[rz.PlanLadderID] = struct{ ladderName, productName string }{rz.LadderName, rz.RankZeroProductName.String}
|
|
}
|
|
if orgTypes, otErr := h.OrgQ.ListOrgTypes(ctx); otErr != nil {
|
|
h.Logger.Error("failed to list org types", slog.Any("error", otErr))
|
|
} else {
|
|
for _, ot := range orgTypes {
|
|
vm := OrgTypeProvisionViewModel{
|
|
OrgType: ot.OrgType,
|
|
DisplayName: ot.DisplayName,
|
|
}
|
|
if ot.DefaultPlanLadderID.Valid {
|
|
if rz, ok := rankZeroByLadder[ot.DefaultPlanLadderID.UUID.String()]; ok {
|
|
vm.HasDefault = true
|
|
vm.LadderName = rz.ladderName
|
|
vm.RankZeroProduct = rz.productName
|
|
}
|
|
}
|
|
data.OrgTypes = append(data.OrgTypes, vm)
|
|
}
|
|
}
|
|
|
|
// Structural-validation health — shared computation with the validation page.
|
|
data.Health = h.computePlanLadderValidation(ctx)
|
|
|
|
// Empty-validation-domain gate: claim healthy only when at least one
|
|
// active pool-to-ladder attachment existed for the checks above to
|
|
// cover. A query failure falls through to the normal healthy/issue
|
|
// branches (NothingToValidate stays false) rather than hiding the strip.
|
|
if anyActive, err := h.EntitlementsQ.AnyActiveLadderAttachment(ctx); err != nil {
|
|
h.Logger.Error("failed to check active ladder attachments", slog.Any("error", err))
|
|
} else {
|
|
data.NothingToValidate = !anyActive
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
// ReorderPlanLadders handles POST /partials/operator/plan-ladders/reorder — the
|
|
// one mutation permitted on the otherwise read-only topology overview. It sets
|
|
// the ladder display order from a drag-and-drop on the column headers, then
|
|
// re-renders the grid in place.
|
|
//
|
|
// The request carries the full ladder-ID order as repeated `ladder` form values
|
|
// (the hidden inputs in the header row, serialized by htmx in their new DOM
|
|
// order after the SortableJS drop). The server assigns each submitted ladder a
|
|
// contiguous sort_order by position (0..N-1) — so the result is deterministic
|
|
// even when ladders previously shared the default sort_order = 0 — validating
|
|
// every ID against the live ladder set and writing the changed rows in one
|
|
// transaction. Submitted IDs that don't exist are ignored.
|
|
func (h *OperatorPartialsHandler) ReorderPlanLadders(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderPlanTopologyBody(w, r, "", "Invalid reorder request.")
|
|
return
|
|
}
|
|
ordered := r.Form["ladder"] // ladder IDs in the new left-to-right order
|
|
if len(ordered) == 0 {
|
|
// Nothing submitted (e.g. a drag that changed nothing on a single-column
|
|
// grid): re-render unchanged, no writes, no toast.
|
|
h.renderPlanTopologyBody(w, r, "", "")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
existing, err := h.BillingQ.ListPlanLadders(ctx)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list plan ladders for reorder", slog.Any("error", err))
|
|
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
|
|
return
|
|
}
|
|
// Map each known ladder to its current sort_order so we can skip no-op writes.
|
|
current := make(map[string]int32, len(existing))
|
|
for _, l := range existing {
|
|
current[l.PlanLadderID] = l.SortOrder
|
|
}
|
|
|
|
tx, err := h.Database.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
h.Logger.Error("failed to begin reorder tx", slog.Any("error", err))
|
|
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
qtx := billing.New(tx)
|
|
|
|
// Assign a contiguous sort_order by submitted position, skipping unknown IDs
|
|
// (defensive against a stale page) and rows already at the right value.
|
|
pos := int32(0)
|
|
for _, id := range ordered {
|
|
old, known := current[id]
|
|
if !known {
|
|
continue
|
|
}
|
|
if old != pos {
|
|
if err := qtx.SetPlanLadderSortOrder(ctx, billing.SetPlanLadderSortOrderParams{
|
|
PlanLadderID: id,
|
|
SortOrder: pos,
|
|
}); err != nil {
|
|
h.Logger.Error("failed to set ladder sort_order", slog.Any("error", err))
|
|
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
|
|
return
|
|
}
|
|
}
|
|
pos++
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
h.Logger.Error("failed to commit reorder tx", slog.Any("error", err))
|
|
h.renderPlanTopologyBody(w, r, "", "Failed to reorder ladders.")
|
|
return
|
|
}
|
|
|
|
h.renderPlanTopologyBody(w, r, "Ladder order updated.", "")
|
|
}
|
|
|
|
// renderPlanTopologyBody re-renders the topology overview body into
|
|
// #operator-body after a reorder, mirroring the detail-body re-render helpers.
|
|
// Best-effort: a load error surfaces inline rather than aborting.
|
|
func (h *OperatorPartialsHandler) renderPlanTopologyBody(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
|
fireSuccessToast(w, success)
|
|
data := h.loadPlanTopologyData(r)
|
|
if errMsg != "" {
|
|
data.Error = errMsg
|
|
}
|
|
h.Templates.Render(w, "operator_plan_topology.html", data)
|
|
}
|