The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
423 lines
15 KiB
Go
423 lines
15 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
)
|
|
|
|
// The Rules section's rendering (spec entitlement-set-management "The Rules
|
|
// table is the rule form", "One tray under the table carries the batch and
|
|
// its commit"; design D1, D10, D11, D13 of staged-rule-changes). The table is
|
|
// the form's body: the handler binds the declaration to the page's state,
|
|
// renders the table with that view in scope so its cells call RowField and
|
|
// RowAction, and hands the fragment back as the form's body.
|
|
//
|
|
// Every sentence the tray reads lives in
|
|
// operator_entitlement_set_rules_tray.html; this file carries only the facts
|
|
// it renders from, already formatted, so the copy stays greppable in one
|
|
// file.
|
|
|
|
// EntitlementSetRulesTable is the body fragment's context: the bound form,
|
|
// whose RowField and RowAction the body's controls call, and the rows to
|
|
// draw: the add form's row while Add rule holds it open, above the table and
|
|
// outside it, then the two groups inside the one table, the numeric rules
|
|
// under Limit and the boolean rules under Boolean (design D8, D10).
|
|
type EntitlementSetRulesTable struct {
|
|
Form forms.FormView
|
|
New *RuleViewModel
|
|
Limits []RuleViewModel
|
|
Booleans []RuleViewModel
|
|
}
|
|
|
|
// EntitlementSetRuleEditing is one editing row's context for the row part
|
|
// the table template defines: the form the row's controls come from and the
|
|
// row itself.
|
|
type EntitlementSetRuleEditing struct {
|
|
Form forms.FormView
|
|
Row RuleViewModel
|
|
}
|
|
|
|
// Editing pairs a row with the form for the editing-row part.
|
|
func (t EntitlementSetRulesTable) Editing(row RuleViewModel) EntitlementSetRuleEditing {
|
|
return EntitlementSetRuleEditing{Form: t.Form, Row: row}
|
|
}
|
|
|
|
// EntitlementSetRuleTrayView is the tray's own lines: the population once,
|
|
// the path the commit will take when the population is above the cap, and
|
|
// the rule type that reaches no pool.
|
|
type EntitlementSetRuleTrayView struct {
|
|
PoolCount int64
|
|
OrgCount int64
|
|
Pools string
|
|
Orgs string
|
|
// AboveCap says the commit will go on recomputing after it returns.
|
|
AboveCap bool
|
|
// ReachesNoPool is a quota or credit rule in the batch: the materializer
|
|
// folds neither, so no pool moves.
|
|
ReachesNoPool bool
|
|
}
|
|
|
|
// EntitlementSetRuleLineView is one staged delta's narrative on its tray
|
|
// line: the ledger's own sentences, without the History line the tray does
|
|
// not repeat.
|
|
type EntitlementSetRuleLineView struct {
|
|
Verb string
|
|
Boolean bool
|
|
Label string
|
|
|
|
LimitBefore string
|
|
LimitAfter string
|
|
LimitChanged bool
|
|
|
|
PerUnit bool
|
|
PerUnitBefore bool
|
|
PerUnitChanged bool
|
|
|
|
// PolicyBefore and PolicyAfter are the policies as the sentence says
|
|
// them ("clamp", "force reduce"), not as stored.
|
|
PolicyBefore string
|
|
PolicyAfter string
|
|
PolicyChanged bool
|
|
|
|
// Unchanged is a carried edit that moves nothing. Staging refuses such a
|
|
// row, so only a crafted batch carries one; its line says so and the
|
|
// commit refuses it with "Already applied.".
|
|
Unchanged bool
|
|
}
|
|
|
|
// RuleActionURL is where the section header's Add rule opener posts: the
|
|
// rules form's own row-action route, which it reaches from outside the form.
|
|
func (d EntitlementSetRulesData) RuleActionURL() string {
|
|
return entitlementSetRulesAction(d.EntitlementSet.SetID)
|
|
}
|
|
|
|
// entitlementSetRulesFormSelector is the form's id as a CSS selector, for the
|
|
// Add rule opener's hx-include: the declaration's name carries dots, which a
|
|
// selector would otherwise read as class separators.
|
|
func entitlementSetRulesFormSelector() string {
|
|
return "#" + strings.ReplaceAll("form-"+entitlementSetRulesFormName, ".", `\.`)
|
|
}
|
|
|
|
// entitlementSetRulesData builds the Rules section's whole view model from
|
|
// one render's state: the rows in their three shapes, the bound form with the
|
|
// table as its body, and the drain's two counts.
|
|
func (h *OperatorPartialsHandler) entitlementSetRulesData(rr entitlementSetRulesRender) EntitlementSetRulesData {
|
|
if rr.errs == nil {
|
|
rr.errs = forms.NewErrors()
|
|
}
|
|
if rr.open == nil {
|
|
rr.open = map[string]forms.RowBinding{}
|
|
}
|
|
newRow, limits, booleans := entitlementSetRuleRows(rr)
|
|
rows := make([]RuleViewModel, 0, len(limits)+len(booleans)+1)
|
|
if newRow != nil {
|
|
rows = append(rows, *newRow)
|
|
}
|
|
rows = append(append(rows, limits...), booleans...)
|
|
data := EntitlementSetRulesData{
|
|
EntitlementSet: rr.state.Set,
|
|
Rules: rows,
|
|
ResourceKeys: rr.state.Available,
|
|
PendingPools: rr.state.PendingPools,
|
|
FailedPools: rr.state.FailedPools,
|
|
}
|
|
data.Form = h.entitlementSetRulesFormView(rr, EntitlementSetRulesTable{New: newRow, Limits: limits, Booleans: booleans})
|
|
return data
|
|
}
|
|
|
|
// entitlementSetRulesFormView binds the declaration to this render's state
|
|
// and fills the form's body with the table. The body is rendered after the
|
|
// bind, because its cells call RowField on the view it sits inside.
|
|
func (h *OperatorPartialsHandler) entitlementSetRulesFormView(rr entitlementSetRulesRender, table EntitlementSetRulesTable) forms.FormView {
|
|
spec := entitlementSetRulesSpecFor(rr.state.SetID)
|
|
spec.CommitAction = entitlementSetRulesApplyAction(rr.state.SetID)
|
|
|
|
mode := forms.ModeRecord
|
|
if rr.refused() {
|
|
mode = forms.ModeSubmission
|
|
}
|
|
values := forms.NewValues()
|
|
values.Set("note", rr.note)
|
|
|
|
binding := forms.Binding{
|
|
Mode: mode,
|
|
Action: entitlementSetRulesAction(rr.state.SetID),
|
|
Values: values,
|
|
Errors: rr.errs,
|
|
Options: map[string][]forms.Option{
|
|
"resource_key": entitlementSetRuleKeyOptions(rr.state.Available),
|
|
"tier_reduction_policy": entitlementSetRulePolicyOptions(""),
|
|
},
|
|
Rows: entitlementSetRuleOpenRows(rr),
|
|
Staged: h.entitlementSetRuleStagedDeltas(rr),
|
|
Autofocus: rr.focus,
|
|
// The commit applies the batch, so it re-renders the operator body
|
|
// as every other commit on this page does; a row action re-renders
|
|
// the Rules section alone (design D2). The body's contents are
|
|
// swapped, never the container: the response is what #operator-body
|
|
// holds, and an outerHTML swap would destroy the element every
|
|
// later request on this page targets.
|
|
CommitTarget: "#operator-body",
|
|
CommitSwap: "innerHTML",
|
|
Target: entitlementSetRulesTarget,
|
|
}
|
|
if len(binding.Staged) > 0 {
|
|
binding.Message = h.Templates.Fragment("operator_entitlement_set_rules_tray.html", h.entitlementSetRuleTray(rr))
|
|
}
|
|
view := spec.Bind(binding)
|
|
table.Form = view
|
|
view.Body = h.Templates.Fragment("operator_entitlement_set_rules_table.html", table)
|
|
return view
|
|
}
|
|
|
|
// entitlementSetRuleOpenRows gives each open editor the policy options its
|
|
// own rule allows: the two acting values, plus the stored value when it is a
|
|
// dormant one, so the row rewrites a stored promise only when the operator
|
|
// picks another (design D7). A new row offers the acting two.
|
|
func entitlementSetRuleOpenRows(rr entitlementSetRulesRender) map[string]forms.RowBinding {
|
|
rows := make(map[string]forms.RowBinding, len(rr.open))
|
|
for instance, rb := range rr.open {
|
|
stored := ""
|
|
if rule, ok := rr.state.ruleFor(instance); ok {
|
|
stored = rule.TierReductionPolicy
|
|
}
|
|
rb.Options = map[string][]forms.Option{
|
|
"tier_reduction_policy": entitlementSetRulePolicyOptions(stored),
|
|
}
|
|
rows[instance] = rb
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// entitlementSetRuleStagedDeltas turns the validated batch into the library's
|
|
// own staged values: the hidden fields it writes, the line the tray renders
|
|
// and the refusal on that line. A line carries no control of its own: the
|
|
// policy is a field of the row, so the tray states and undoes only.
|
|
func (h *OperatorPartialsHandler) entitlementSetRuleStagedDeltas(rr entitlementSetRulesRender) []forms.StagedDelta {
|
|
if len(rr.staged) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]forms.StagedDelta, 0, len(rr.staged))
|
|
for _, d := range rr.staged {
|
|
out = append(out, forms.StagedDelta{
|
|
Verb: d.Verb,
|
|
Key: d.Key,
|
|
Values: d.Values,
|
|
Line: h.Templates.Fragment("entitlementSetRuleLine", entitlementSetRuleLine(d)),
|
|
Error: d.Error,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// entitlementSetRuleLine is one delta's narrative facts.
|
|
func entitlementSetRuleLine(d entitlementSetRuleDelta) EntitlementSetRuleLineView {
|
|
view := EntitlementSetRuleLineView{
|
|
Verb: d.Verb,
|
|
Boolean: d.Boolean,
|
|
Label: d.Label,
|
|
LimitAfter: d.limit(),
|
|
PerUnit: d.perUnit(),
|
|
PolicyAfter: entitlementSetRulePolicyWord(d.policy()),
|
|
}
|
|
if d.Stored != nil {
|
|
view.LimitBefore = storedLimit(*d.Stored)
|
|
view.PerUnitBefore = d.Stored.ResourcePerUnit.Valid && d.Stored.ResourcePerUnit.Bool
|
|
view.PolicyBefore = entitlementSetRulePolicyWord(d.Stored.TierReductionPolicy)
|
|
}
|
|
view.LimitChanged = view.LimitBefore != view.LimitAfter
|
|
view.PerUnitChanged = view.PerUnitBefore != view.PerUnit
|
|
view.PolicyChanged = view.PolicyBefore != view.PolicyAfter
|
|
if d.Verb == entitlementSetRuleVerbRemove {
|
|
// A removal states the rule as it stands, not as it was proposed.
|
|
view.LimitAfter = view.LimitBefore
|
|
}
|
|
view.Unchanged = d.Verb == entitlementSetRuleVerbEdit &&
|
|
!view.LimitChanged && !view.PerUnitChanged && !view.PolicyChanged
|
|
return view
|
|
}
|
|
|
|
// entitlementSetRuleTray reads the carrying population once, which is the one
|
|
// fact the tray states about the pools; it runs no dry run, because nothing
|
|
// the tray renders depends on one (design D5).
|
|
func (h *OperatorPartialsHandler) entitlementSetRuleTray(rr entitlementSetRulesRender) EntitlementSetRuleTrayView {
|
|
view := EntitlementSetRuleTrayView{}
|
|
for _, d := range rr.staged {
|
|
if d.ReachesNoPool {
|
|
view.ReachesNoPool = true
|
|
}
|
|
}
|
|
counts, err := h.EntitlementsQ.CountPoolsCarryingSet(rr.context(), rr.state.SetID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to count the pools carrying the set", slog.Any("error", err), slog.String("set_id", rr.state.SetID))
|
|
return view
|
|
}
|
|
view.PoolCount = counts.PoolCount
|
|
view.OrgCount = counts.OrgCount
|
|
view.Pools = formatCount(counts.PoolCount)
|
|
view.Orgs = formatCount(counts.OrgCount)
|
|
view.AboveCap = counts.PoolCount > entitlements.SyncCap()
|
|
return view
|
|
}
|
|
|
|
// entitlementSetRuleRows draws the rows: the add form's row while Add rule
|
|
// holds it open, which stays above the table whatever key it names, and the
|
|
// two groups, the numeric rules under Limit and the boolean rules under
|
|
// Boolean (design D10). In each group the staged additions come first, then
|
|
// the stored rules in the query's order (design D8).
|
|
func entitlementSetRuleRows(rr entitlementSetRulesRender) (newRow *RuleViewModel, limits, booleans []RuleViewModel) {
|
|
place := func(row RuleViewModel) {
|
|
if row.RuleType == "boolean" {
|
|
booleans = append(booleans, row)
|
|
return
|
|
}
|
|
limits = append(limits, row)
|
|
}
|
|
|
|
if rb, open := rr.open[entitlementSetRulesNewRow]; open {
|
|
kind := rb.Values.String("resource_key_kind")
|
|
newRow = &RuleViewModel{
|
|
Instance: entitlementSetRulesNewRow,
|
|
RuleType: "limit",
|
|
IsNew: true,
|
|
Editing: true,
|
|
Numeric: kind == entitlementSetRuleNumericKind,
|
|
}
|
|
if kind == "boolean" {
|
|
newRow.RuleType = "boolean"
|
|
}
|
|
}
|
|
|
|
staged := map[string]entitlementSetRuleDelta{}
|
|
for _, d := range rr.staged {
|
|
if d.Verb != entitlementSetRuleVerbAdd {
|
|
staged[d.RuleID] = d
|
|
continue
|
|
}
|
|
row := RuleViewModel{
|
|
Instance: entitlementSetRulesNewRow,
|
|
ResourceKey: d.ResourceKey,
|
|
DisplayName: d.Label,
|
|
RuleType: "limit",
|
|
IsNew: true,
|
|
Staged: true,
|
|
Verb: d.Verb,
|
|
DeltaKey: d.Key,
|
|
}
|
|
if row.DisplayName == row.ResourceKey {
|
|
row.DisplayName = ""
|
|
}
|
|
if d.Boolean {
|
|
row.RuleType = "boolean"
|
|
} else {
|
|
row.NewLimit = d.limit()
|
|
row.NewPerUnit = perUnitLabel(d.perUnit())
|
|
row.NewPolicy = entitlementSetRulePolicyLabel(d.policy())
|
|
}
|
|
place(row)
|
|
}
|
|
|
|
for _, rule := range rr.state.Rules {
|
|
row := RuleViewModel{
|
|
RuleID: rule.RuleID,
|
|
Instance: rule.RuleID,
|
|
RuleType: rule.RuleType,
|
|
ResourceKey: rule.ResourceKey.String,
|
|
DisplayName: rr.state.label(rule.ResourceKey.String),
|
|
ResourceValue: rule.ResourceValue.Int64,
|
|
ResourcePerUnit: rule.ResourcePerUnit.Valid && rule.ResourcePerUnit.Bool,
|
|
StackingPolicy: rule.StackingPolicy.String,
|
|
IsActive: rule.IsActive,
|
|
}
|
|
if rule.RuleType != "boolean" {
|
|
row.Policy = entitlementSetRulePolicyLabel(rule.TierReductionPolicy)
|
|
}
|
|
if row.DisplayName == row.ResourceKey {
|
|
row.DisplayName = ""
|
|
}
|
|
switch d, isStaged := staged[rule.RuleID]; {
|
|
case isStaged:
|
|
row.Staged = true
|
|
row.Verb = d.Verb
|
|
row.DeltaKey = d.Key
|
|
row.Removed = d.Verb == entitlementSetRuleVerbRemove
|
|
stageRuleRowValues(&row, d, rule)
|
|
default:
|
|
if rb, open := rr.open[rule.RuleID]; open {
|
|
row.Editing = true
|
|
row.Numeric = rb.Values.String("resource_key_kind") == entitlementSetRuleNumericKind
|
|
}
|
|
}
|
|
place(row)
|
|
}
|
|
return newRow, limits, booleans
|
|
}
|
|
|
|
// splitRuleRows sorts rows already drawn into the two groups by kind, for a
|
|
// caller that holds a flat list (a render test's fixture).
|
|
func splitRuleRows(rows []RuleViewModel) (limits, booleans []RuleViewModel) {
|
|
for _, row := range rows {
|
|
if row.RuleType == "boolean" {
|
|
booleans = append(booleans, row)
|
|
continue
|
|
}
|
|
limits = append(limits, row)
|
|
}
|
|
return limits, booleans
|
|
}
|
|
|
|
// stageRuleRowValues fills a staged row's struck old values and its new ones.
|
|
// A removal strikes the resource name instead and keeps the rule's values as
|
|
// they stand.
|
|
func stageRuleRowValues(row *RuleViewModel, d entitlementSetRuleDelta, rule entitlements.EntitlementSetRule) {
|
|
if rule.RuleType == "boolean" {
|
|
return
|
|
}
|
|
stored := storedLimit(rule)
|
|
storedPerUnit := rule.ResourcePerUnit.Valid && rule.ResourcePerUnit.Bool
|
|
storedPolicy := entitlementSetRulePolicyLabel(rule.TierReductionPolicy)
|
|
if d.Verb == entitlementSetRuleVerbRemove {
|
|
row.NewLimit = stored
|
|
row.NewPerUnit = perUnitLabel(storedPerUnit)
|
|
row.NewPolicy = storedPolicy
|
|
return
|
|
}
|
|
row.NewLimit = d.limit()
|
|
row.NewPerUnit = perUnitLabel(d.perUnit())
|
|
row.NewPolicy = entitlementSetRulePolicyLabel(d.policy())
|
|
if row.NewLimit != stored {
|
|
row.OldLimit = stored
|
|
}
|
|
if d.perUnit() != storedPerUnit {
|
|
row.OldPerUnit = perUnitLabel(storedPerUnit)
|
|
}
|
|
if row.NewPolicy != storedPolicy {
|
|
row.OldPolicy = storedPolicy
|
|
}
|
|
}
|
|
|
|
func perUnitLabel(on bool) string {
|
|
if on {
|
|
return "Yes"
|
|
}
|
|
return "No"
|
|
}
|
|
|
|
// renderEntitlementSetRulesSection answers a row action: the Rules section
|
|
// whole, from the submitted state, at 200 or at 422 when something was
|
|
// refused.
|
|
func (h *OperatorPartialsHandler) renderEntitlementSetRulesSection(w http.ResponseWriter, rr entitlementSetRulesRender) {
|
|
data := h.entitlementSetRulesData(rr)
|
|
if rr.refused() {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
}
|
|
h.Templates.Render(w, "operator_entitlement_set_rules.html", data)
|
|
}
|