hub: merge the customer edit page into the Edit tab (v0.48.0 edit-a, part 2)

- Settings tab renamed Edit; embeds config_form_body (.ConfigForm via the
  builder) + Controller Update + Geo + a new Danger zone card holding the
  relocated Block/Unblock/Delete forms (endpoints + confirm() unchanged).
  All cards are SIBLINGS after </form> — never nested in the config form.
- Customer Info header loses the Edit link and Block/Delete forms; only the
  config-less Create Config action stays.
- GET /configs/{id}/edit is a 302 to /customers/{id}#tab=edit; tabs JS gains
  the settings→edit legacy-hash alias.
- Post-action redirects land back on their tab: update/block/unblock/
  offsite-reissue/offsite-freeze/pbsdr-reissue → #tab=edit, regen-password
  → #tab=setup; delete unchanged (/configs).
- handleConfigUpdate gains the server-side twin of the form's required
  fields; the error path re-renders the STANDALONE page with the SUBMITTED
  overrides (B3 red-proof: nil overrides → typed values reset → test FAILS;
  header red-proof: restored header buttons → count=2 → test FAILS; both run).
- Tests: Group A (panel surface, sibling forms, header cleaned by COUNT),
  Group B (B1 302, B2 create unchanged, B3 typed-values, B4/B5 anchor table).
  Amended pins: customer_tabs_test settings→edit; pbsdr_test postUpdate now
  supplies the required fields + FormRendersState asserts the embedded render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZc5w5jDhFLv6qDC32KN5v
This commit is contained in:
2026-07-12 17:33:07 +02:00
parent e74014730a
commit 2e03de1e0c
6 changed files with 384 additions and 40 deletions
+33 -9
View File
@@ -333,6 +333,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
// (1 today, N for a later HA cluster). Each entry is the hostDetailData view-model map
// the shared host_detail_body sub-template renders.
Hosts []map[string]interface{}
// ConfigForm (v0.48.0 edit-a): the embedded config form's view model for the Edit tab —
// the same configFormData the standalone chrome renders. Zero-valued (and never rendered)
// when the customer has no config.
ConfigForm configFormView
}
pendingSet := make(map[string]bool, len(pendingTails))
@@ -415,6 +420,13 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Hosts: hostViews,
}
// Edit tab (v0.48.0 edit-a): embed the config form. nil overrides → the builder parses the
// STORED ConfigJSON (the read path; submitted-value preservation is the standalone error
// re-render's job).
if cfg != nil {
data.ConfigForm = s.configFormData(r, false, cfg, nil, "")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil {
s.logger.Printf("[ERROR] Template render: %v", err)
@@ -538,15 +550,16 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther)
}
// handleConfigEditForm shows the edit form for a customer config.
// handleConfigEditForm — the standalone edit page merged into the customer page's Edit tab
// (v0.48.0 edit-a); old links and bookmarks land on the tab. POST /configs/{id}/edit stays the
// real mutation endpoint (the embedded form posts to it).
func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) {
cfg, err := s.store.GetCustomerConfig(customerID)
if err != nil || cfg == nil {
http.NotFound(w, r)
return
}
s.renderConfigForm(w, r, false, cfg, nil, "")
http.Redirect(w, r, "/customers/"+customerID+"#tab=edit", http.StatusFound)
}
// handleConfigUpdate processes the edit form submission.
@@ -565,6 +578,17 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name"))
cfg.Domain = strings.TrimSpace(r.FormValue("domain"))
cfg.Email = strings.TrimSpace(r.FormValue("email"))
// Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is
// the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is
// lost; runs BEFORE provisioning so an invalid submit never touches Hetzner/ep0.
if cfg.CustomerName == "" || cfg.Domain == "" {
var submitted map[string]interface{}
_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
s.renderConfigForm(w, r, false, cfg, submitted, "Display Name and Domain are required.")
return
}
cfg.ConfigJSON = buildConfigJSON(r)
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
@@ -588,7 +612,7 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
}
s.logger.Printf("[INFO] Customer config updated: %s", customerID)
http.Redirect(w, r, "/customers/"+customerID+"?flash=updated", http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", http.StatusSeeOther)
}
// handleOffsiteReissue (F4) resets the customer's offsite credential and stores a fresh one-time password —
@@ -632,7 +656,7 @@ func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, cu
return
}
s.logger.Printf("[INFO] offsite credentials re-issued for %s (fresh one-time password stored; ConfigVersion bumped)", customerID)
http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued", http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued#tab=edit", http.StatusSeeOther)
}
// handleOffsiteFreeze (SLICE 4) freezes/unfreezes the customer's shared sub-account (readonly) — an
@@ -672,7 +696,7 @@ func (s *Server) handleOffsiteFreeze(w http.ResponseWriter, r *http.Request, cus
if !frozen {
flash = "offsite_unfrozen"
}
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash+"#tab=edit", http.StatusSeeOther)
}
// handleConfigDelete deletes a customer config.
@@ -726,7 +750,7 @@ func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Reques
}
s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID)
http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated", http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated#tab=setup", http.StatusSeeOther)
}
// handleBlockCustomer sets a customer's status to "blocked".
@@ -742,7 +766,7 @@ func (s *Server) handleBlockCustomer(w http.ResponseWriter, r *http.Request, cus
return
}
s.logger.Printf("[INFO] Customer blocked: %s", customerID)
http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked", http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked#tab=edit", http.StatusSeeOther)
}
// handleUnblockCustomer sets a customer's status back to "active".
@@ -758,7 +782,7 @@ func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, c
return
}
s.logger.Printf("[INFO] Customer unblocked: %s", customerID)
http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked", http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked#tab=edit", http.StatusSeeOther)
}
// countBoxesBelowFloor counts reporting boxes whose EFFECTIVE floor (per-customer override else the
+288
View File
@@ -0,0 +1,288 @@
package web
// v0.48.0 edit-a — the standalone customer edit page merged into the customer page's Edit tab
// (config_form_body embedded; Block/Delete relocated to a danger zone) plus the redirect-anchor
// contract of the surrounding POST actions. Groups A + B of the task's test plan.
import (
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// editPanel slices the rendered customer page down to the Edit tab's panel (from its opening div
// to the next panel) so containment assertions are panel-scoped, not page-scoped.
func editPanel(t *testing.T, html string) string {
t.Helper()
start := strings.Index(html, `tab-panel" data-tab="edit"`)
if start < 0 {
t.Fatal("Edit tab panel missing")
}
end := strings.Index(html[start:], `tab-panel" data-tab="backup"`)
if end < 0 {
t.Fatal("Backup panel (the Edit panel's terminator) missing")
}
return html[start : start+end]
}
// Group A — the Edit tab renders the full mutation surface and the header is cleaned.
func TestCustomerEditTab_RendersMutationSurface(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", Email: "a@acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`,
}); err != nil {
t.Fatal(err)
}
if err := st.SaveReport("c1", []byte(tabsTestReportJSON)); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "c1")
panel := editPanel(t, html)
// The panel carries the config form + the three sibling cards.
for _, want := range []string{
`action="/configs/c1/edit"`, // the embedded config form
"Controller Update",
"Geo-korlátozás",
"Danger zone",
`action="/customers/c1/block"`,
`action="/configs/c1/delete"`,
`name="_csrf"`, // CSRF field inside the form
} {
if !strings.Contains(panel, want) {
t.Errorf("Edit panel missing %q", want)
}
}
// Stored override values populate the embedded form (values come from Config/ConfigJSON).
if !strings.Contains(panel, `value="stored-tunnel-token"`) {
t.Error("embedded form does not render the stored cf_tunnel_token override")
}
// The named trap: the floor/geo/danger forms must be SIBLINGS after </form>, never nested
// inside the config <form> (invalid HTML; breaks the formaction sub-buttons). Between the
// config form's opening tag and its first </form> no other <form may open.
formStart := strings.Index(panel, `action="/configs/c1/edit"`)
formEnd := strings.Index(panel[formStart:], "</form>")
if formEnd < 0 {
t.Fatal("config form never closes")
}
if inner := panel[formStart : formStart+formEnd]; strings.Contains(inner, "<form") {
t.Error("a form is NESTED inside the config <form> — floor/geo/danger must be siblings")
}
// Header cleaned: the Edit link is gone, and Block/Delete exist EXACTLY once on the whole
// page (in the Edit panel). RED-PROOF: restoring the old header buttons makes the counts 2
// and this FAILS — a naive Contains would not catch the duplicate danger zone.
if strings.Contains(html, `href="/configs/c1/edit"`) {
t.Error("header still links to the retired standalone edit page")
}
if got := strings.Count(html, `action="/customers/c1/block"`); got != 1 {
t.Errorf("Block form count = %d, want exactly 1 (in the danger zone)", got)
}
if got := strings.Count(html, `action="/configs/c1/delete"`); got != 1 {
t.Errorf("Delete form count = %d, want exactly 1 (in the danger zone)", got)
}
}
// A blocked customer's danger zone offers Unblock instead of Block (the old header conditional).
func TestCustomerEditTab_BlockedShowsUnblock(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "cb", CustomerName: "Blocked", Domain: "b.hu",
RetrievalPassword: "pw", APIKey: "k",
}); err != nil {
t.Fatal(err)
}
// SaveCustomerConfig does not persist Status — blocking is its own state transition.
if err := st.SetCustomerConfigStatus("cb", "blocked"); err != nil {
t.Fatal(err)
}
html := renderCustomerPage(t, s, "cb")
panel := editPanel(t, html)
if !strings.Contains(panel, `action="/customers/cb/unblock"`) {
t.Error("blocked customer's danger zone missing the Unblock form")
}
if strings.Contains(html, `action="/customers/cb/block"`) {
t.Error("blocked customer must not offer Block")
}
}
// Group B1 — the standalone GET edit page 302s to the Edit tab.
func TestConfigEditForm_RedirectsToEditTab(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
}); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/c1/edit", nil), "c1")
if rr.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rr.Code)
}
if loc := rr.Header().Get("Location"); loc != "/customers/c1#tab=edit" {
t.Errorf("Location = %q, want /customers/c1#tab=edit", loc)
}
// Unknown customer still 404s (no open redirect for garbage ids).
rr = httptest.NewRecorder()
s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/nope/edit", nil), "nope")
if rr.Code != http.StatusNotFound {
t.Errorf("unknown customer edit = %d, want 404", rr.Code)
}
}
// Group B2 — the create flow (IsNew) still renders standalone chrome around the SAME shared
// body sub-template, and the create round-trip is unchanged.
func TestConfigNewForm_StandaloneUnchanged(t *testing.T) {
s, st := newTestServer(t)
rr := httptest.NewRecorder()
s.handleConfigNewForm(rr, httptest.NewRequest("GET", "/configs/new", nil))
if rr.Code != 200 {
t.Fatalf("new form status = %d", rr.Code)
}
html := rr.Body.String()
for _, want := range []string{"Add Customer", `action="/configs/new"`, `id="customer_id"`, "Create Configuration"} {
if !strings.Contains(html, want) {
t.Errorf("new form missing %q", want)
}
}
form := url.Values{"customer_id": {"newcust"}, "customer_name": {"New"}, "domain": {"new.hu"}}
req := httptest.NewRequest("POST", "/configs/new", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
s.handleConfigCreate(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("create = %d, want 303: %s", rr.Code, rr.Body.String())
}
if loc := rr.Header().Get("Location"); loc != "/customers/newcust?flash=created" {
t.Errorf("create Location = %q", loc)
}
if cfg, _ := st.GetCustomerConfig("newcust"); cfg == nil {
t.Error("created config not stored")
}
}
// Group B3 — THE load-bearing correctness test: a validation error re-renders the STANDALONE
// form with the SUBMITTED values preserved (overrides pass-through), never the stored ones.
// RED-PROOF (run once during v0.48.0): passing nil overrides in handleConfigUpdate's error path
// makes the builder fall back to the STORED ConfigJSON → "typed-tunnel-token" disappears and
// "stored-tunnel-token" renders → this test FAILS.
func TestConfigUpdate_ValidationErrorPreservesTypedValues(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active",
ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`,
}); err != nil {
t.Fatal(err)
}
// Invalid: customer_name cleared; the operator also typed a NEW tunnel token.
rr := httptest.NewRecorder()
s.handleConfigUpdate(rr, postForm("/configs/c1/edit",
"customer_name=&domain=acme.hu&cf_tunnel_token=typed-tunnel-token"), "c1")
if rr.Code != 200 {
t.Fatalf("validation error = %d, want 200 (standalone re-render)", rr.Code)
}
html := rr.Body.String()
if !strings.Contains(html, "Display Name and Domain are required.") {
t.Error("error message missing from the re-render")
}
if !strings.Contains(html, `value="typed-tunnel-token"`) {
t.Error("SUBMITTED value lost on the error re-render (values reset to stored)")
}
if strings.Contains(html, "stored-tunnel-token") {
t.Error("stored value rendered instead of the submitted one")
}
// Nothing was saved.
cfg, _ := st.GetCustomerConfig("c1")
if cfg.CustomerName != "Acme" || !strings.Contains(cfg.ConfigJSON, "stored-tunnel-token") {
t.Error("invalid submit mutated the stored config")
}
}
// Group B4/B5 — redirect anchors, table-driven over the handlers whose buttons live on the
// customer page: post-action lands back on the tab the action came from.
func TestCustomerActions_RedirectAnchors(t *testing.T) {
newProvisioned := func(t *testing.T) (*Server, *store.Store) {
s, st := newTestServer(t)
s.SetOffsiteProvisioner(&offsite.Provisioner{
API: hetznerapi.NewFake(), Store: st, Scanner: webTestScanner{},
PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0),
})
cfg := &store.CustomerConfig{
CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}",
}
// Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze
// find exactly one labelled sub-account.
r := postForm("/configs/c1/edit", "offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50")
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
t.Fatalf("provision: %v", err)
}
if err := st.SaveCustomerConfig(cfg); err != nil {
t.Fatal(err)
}
return s, st
}
cases := []struct {
name string
invoke func(t *testing.T, s *Server, rr *httptest.ResponseRecorder)
blocked bool // seed Status=blocked (for unblock)
wantLoc string
}{
{"update", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleConfigUpdate(rr, postForm("/configs/c1/edit", "customer_name=Acme&domain=acme.hu"), "c1")
}, false, "/customers/c1?flash=updated#tab=edit"},
{"block", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleBlockCustomer(rr, postForm("/customers/c1/block", ""), "c1")
}, false, "/customers/c1?flash=blocked#tab=edit"},
{"unblock", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleUnblockCustomer(rr, postForm("/customers/c1/unblock", ""), "c1")
}, true, "/customers/c1?flash=unblocked#tab=edit"},
{"offsite-reissue", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleOffsiteReissue(rr, postForm("/configs/c1/offsite-reissue", ""), "c1")
}, false, "/customers/c1?flash=offsite_reissued#tab=edit"},
{"offsite-freeze", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleOffsiteFreeze(rr, postForm("/configs/c1/offsite-freeze", ""), "c1", true)
}, false, "/customers/c1?flash=offsite_frozen#tab=edit"},
{"regen-password lands on Setup (its card lives there)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleConfigRegenPassword(rr, postForm("/configs/c1/regen-password", ""), "c1")
}, false, "/customers/c1?flash=password_regenerated#tab=setup"},
{"delete stays anchor-free (leaves the page)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
s.handleConfigDelete(rr, postForm("/configs/c1/delete", ""), "c1")
}, false, "/configs?flash=deleted"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, st := newProvisioned(t)
if tc.blocked {
if err := st.SetCustomerConfigStatus("c1", "blocked"); err != nil {
t.Fatal(err)
}
}
rr := httptest.NewRecorder()
tc.invoke(t, s, rr)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303: %s", rr.Code, rr.Body.String())
}
if loc := rr.Header().Get("Location"); loc != tc.wantLoc {
t.Errorf("Location = %q, want %q", loc, tc.wantLoc)
}
})
}
}
+2 -1
View File
@@ -51,8 +51,9 @@ func TestTemplates_CustomerTabs(t *testing.T) {
html := renderCustomerPage(t, s, "acme")
// The tab nav renders all 8 tabs (hash links — plain anchors without JS).
// v0.48.0 edit-a: "settings" became "edit" (the standalone config form merged in).
for _, tab := range []string{
"overview", "applications", "setup", "settings", "backup", "events", "notifications", "host",
"overview", "applications", "setup", "edit", "backup", "events", "notifications", "host",
} {
if !strings.Contains(html, `href="#tab=`+tab+`"`) {
t.Errorf("tab nav missing tab %q", tab)
+1 -1
View File
@@ -266,7 +266,7 @@ func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, cust
return
}
s.logger.Printf("[INFO] pbsdr credentials re-issued for %s (host %s; fresh consume-once secret stored)", customerID, host.HostID)
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued", http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued#tab=edit", http.StatusSeeOther)
}
// pbsDRView is the config form's render model for the PBS DR section.
+12 -2
View File
@@ -90,6 +90,14 @@ func newPBSDRServer(t *testing.T, fake *fakeTenancy) (*Server, *store.Store, *by
// postUpdate drives the REAL handler pipeline (handleConfigUpdate → applyPBSDR → save).
func postUpdate(t *testing.T, s *Server, form url.Values) *httptest.ResponseRecorder {
t.Helper()
// v0.48.0 edit-a: the update handler now enforces the form's required fields server-side —
// supply them like every real submission does (the browser form marks both `required`).
if form.Get("customer_name") == "" {
form.Set("customer_name", "Peti")
}
if form.Get("domain") == "" {
form.Set("domain", "peti.example")
}
req := httptest.NewRequest("POST", "/configs/peti", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
@@ -347,9 +355,11 @@ func TestPBSDR_FormRendersState(t *testing.T) {
s, _, _ := newPBSDRServer(t, fake)
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
req := httptest.NewRequest("GET", "/configs/peti/edit", nil)
// v0.48.0 edit-a: the standalone GET edit page is a redirect now — the form renders embedded
// in the customer page's Edit tab (the same config_form_body sub-template), so assert there.
req := httptest.NewRequest("GET", "/customers/peti", nil)
rr := httptest.NewRecorder()
s.handleConfigEditForm(rr, req, "peti")
s.handleCustomerUnified(rr, req, "peti")
out := rr.Body.String()
if !strings.Contains(out, `name="pbsdr_enabled" checked`) {
t.Error("enabled checkbox not checked after provisioning")
@@ -89,7 +89,7 @@
<a href="#tab=overview" data-tab="overview" class="active">Overview</a>
<a href="#tab=applications" data-tab="applications">Applications</a>
<a href="#tab=setup" data-tab="setup">Setup</a>
<a href="#tab=settings" data-tab="settings">Settings</a>
<a href="#tab=edit" data-tab="edit">Edit</a>
<a href="#tab=backup" data-tab="backup">Backup &amp; DR</a>
<a href="#tab=events" data-tab="events">Events{{with mapGet .EventCounts "error"}}<span class="tab-badge">{{.}}</span>{{end}}</a>
<a href="#tab=notifications" data-tab="notifications">Notifications</a>
@@ -104,26 +104,9 @@
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h2>Customer Info</h2>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{{if .HasConfig}}
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Unblock</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Block</button>
</form>
{{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
{{.CSRFField}}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{{else}}
{{/* v0.48.0 edit-a: Edit/Block/Delete moved to the Edit tab (form + danger
zone); only the config-less bootstrap action stays in the header. */}}
{{if not .HasConfig}}
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Create Config</button>
@@ -539,8 +522,17 @@
</div>
<!-- ═══ Settings ═══ -->
<div class="tab-panel" data-tab="settings">
<!-- ═══ Edit ═══ (v0.48.0 edit-a: the standalone config form embedded via the shared
config_form_body sub-template, plus the relocated Block/Delete danger zone. The
Controller Update / Geo / Danger zone cards are SIBLINGS after </form> — nesting
them inside the config <form> is invalid HTML and breaks the formaction sub-buttons. -->
<div class="tab-panel" data-tab="edit">
{{if .HasConfig}}
{{template "config_form_body" .ConfigForm}}
{{else}}
<section class="card"><p class="text-muted">No managed config yet — create one with the Create Config button above.</p></section>
{{end}}
{{if .HasReports}}
<!-- Controller Update -->
@@ -596,9 +588,8 @@
</form>
<p class="text-muted" style="margin-top: 0.75em; font-size: 0.8em;">
Controller updates are agent-driven (the version floor above) and config is delivered by the
box pulling it on a config change — the hub never connects into the box. Edit the config via
the <strong>Edit</strong> button (Overview tab); the controller re-pulls and restarts on its
next report.
box pulling it on a config change — the hub never connects into the box. Edit the config in
the form above; the controller re-pulls and restarts on its next report.
</p>
</section>
@@ -673,6 +664,34 @@
<section class="card"><p class="text-muted">Controller update and geo-restriction settings appear once the first report arrives.</p></section>
{{end}}
{{if .HasConfig}}
<!-- Danger zone (v0.48.0 edit-a): the Block/Delete forms relocated verbatim from the
Customer Info header — endpoints and confirm() handlers unchanged. -->
<section class="card">
<h2>Danger zone</h2>
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted); deleting removes the managed configuration permanently.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-sm">Unblock</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
{{.CSRFField}}
<button type="submit" class="btn btn-outline btn-sm">Block</button>
</form>
{{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
{{.CSRFField}}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</section>
{{end}}
</div>
<!-- ═══ Backup & DR ═══ -->
@@ -1049,7 +1068,9 @@
panels.forEach(function(p) { known[p.getAttribute('data-tab')] = true; });
function currentTab() {
var m = (location.hash || '').match(/^#tab=([a-z-]+)$/);
return (m && known[m[1]]) ? m[1] : 'overview';
var t = m ? m[1] : '';
if (t === 'settings') t = 'edit'; // legacy alias: the Settings tab became Edit (v0.48.0)
return known[t] ? t : 'overview';
}
function activate() {
var tab = currentTab();