feat(#1508): config-driven disabled tabs in customizer modal (#1579)

# feat(#1508): config-driven disabled tabs in customizer modal

Fixes #1508.

## Why

The customizer modal mixes one-shot operator chrome (`branding`, `home`,
`geofilter`, `export`) with daily-use viewer toggles (`theme`, `nodes`,
`display`). Non-technical users get confused by the admin tabs and skip
past the controls they actually need. There's no current way to hide
individual tabs server-side — only via CSS, which doesn't prevent state
mutation.

## What

Adds a single operator knob: `customizer.disabledTabs` in `config.json`.
The named tab ids are filtered out of `_renderTabs()` in
`public/customize-v2.js` before render.

- `config.example.json` — new `customizer` block, default
  `disabledTabs: []` (zero behavior change for existing operators).
- `cmd/server/config.go` — new `CustomizerConfig` type, optional pointer
  on `Config`.
- `cmd/server/routes.go` + `cmd/server/types.go` — `/api/config/client`
  now surfaces `customizer.disabledTabs` (always an array, empty when
  unset).
- `public/customize-v2.js` — `_renderTabs()` filters by id.
- `cmd/server/customizer_disabled_tabs_test.go` — RED-then-green tests
  covering both the configured-and-defaulted shapes.

## TDD trail

1. RED commit adds the failing tests + minimal `CustomizerConfig` stub
   so the package still compiles; both tests fail on the assertion
   (`body.customizer` is `<nil>`) — not on import.
2. GREEN commit wires the field through `/api/config/client` and the
   frontend tab filter; both tests pass.

## Scope

5 files. No new API surface, no UI for editing the list (operator edits
`config.json` directly per the issue body). Backward-compatible: missing
`customizer` block defaults the list to empty.

---------

Co-authored-by: bot <bot@local>
This commit is contained in:
Kpa-clawbot
2026-06-04 14:41:00 -07:00
committed by GitHub
parent 545013d360
commit 7292d60fbe
7 changed files with 147 additions and 1 deletions
+13
View File
@@ -124,6 +124,19 @@ type Config struct {
// BatteryThresholds: voltage cutoffs for low/critical alerts (#663).
BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"`
// Customizer controls operator-side knobs for the in-app customizer modal
// (theme/branding/etc.). See CustomizerConfig and issue #1508.
Customizer *CustomizerConfig `json:"customizer,omitempty"`
}
// CustomizerConfig holds operator-side knobs for the in-app customizer modal.
// Today only DisabledTabs is exposed: a list of tab ids the operator wants to
// hide from end users (e.g. ["branding","geofilter","export"]). The frontend
// (public/customize-v2.js _renderTabs) reads this from /api/config/client and
// filters those tabs out before rendering. Issue #1508.
type CustomizerConfig struct {
DisabledTabs []string `json:"disabledTabs"`
}
// weakAPIKeys is the blocklist of known default/example API keys that must be rejected.
@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"testing"
"github.com/gorilla/mux"
)
// TestConfigClientExposesCustomizerDisabledTabs verifies that the
// /api/config/client endpoint surfaces the operator-set list of customizer
// tabs to hide, so the customize-v2 frontend can filter them out of
// _renderTabs(). Issue #1508.
func TestConfigClientExposesCustomizerDisabledTabs(t *testing.T) {
db := setupTestDB(t)
seedTestData(t, db)
cfg := &Config{
Port: 3000,
Customizer: &CustomizerConfig{
DisabledTabs: []string{"branding", "geofilter", "export"},
},
}
hub := NewHub()
srv := NewServer(db, cfg, hub)
store := NewPacketStore(db, nil)
if err := store.Load(); err != nil {
t.Fatalf("store.Load failed: %v", err)
}
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
req := httptest.NewRequest("GET", "/api/config/client", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
custRaw, ok := body["customizer"].(map[string]interface{})
if !ok {
t.Fatalf("expected body.customizer object, got %T (body=%s)", body["customizer"], w.Body.String())
}
tabsRaw, ok := custRaw["disabledTabs"].([]interface{})
if !ok {
t.Fatalf("expected body.customizer.disabledTabs array, got %T", custRaw["disabledTabs"])
}
got := make([]string, 0, len(tabsRaw))
for _, v := range tabsRaw {
s, ok := v.(string)
if !ok {
t.Fatalf("disabledTabs element not a string: %T", v)
}
got = append(got, s)
}
want := []string{"branding", "export", "geofilter"}
sort.Strings(got)
if !reflect.DeepEqual(got, want) {
t.Errorf("disabledTabs: got %v, want %v", got, want)
}
}
// TestConfigClientDefaultsCustomizerDisabledTabsEmpty verifies the backward-
// compat default: when no customizer block is configured, the field is still
// present and is an empty array (so the frontend can blindly call .includes()).
func TestConfigClientDefaultsCustomizerDisabledTabsEmpty(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/config/client", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
custRaw, ok := body["customizer"].(map[string]interface{})
if !ok {
t.Fatalf("expected body.customizer object, got %T", body["customizer"])
}
tabsRaw, ok := custRaw["disabledTabs"].([]interface{})
if !ok {
t.Fatalf("expected body.customizer.disabledTabs array, got %T", custRaw["disabledTabs"])
}
if len(tabsRaw) != 0 {
t.Errorf("default disabledTabs should be empty, got %v", tabsRaw)
}
}
+10
View File
@@ -393,6 +393,15 @@ func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
// #1508 — surface the operator-side customizer knobs. The frontend
// (public/customize-v2.js _renderTabs) reads disabledTabs to hide
// admin-only tabs from end users. Always return a non-nil slice so
// the JSON shape is `[]` (not `null`) and the client can call
// `.includes()` without an undefined guard.
disabledTabs := []string{}
if s.cfg.Customizer != nil && s.cfg.Customizer.DisabledTabs != nil {
disabledTabs = s.cfg.Customizer.DisabledTabs
}
writeJSON(w, ClientConfigResponse{
Roles: s.cfg.Roles,
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
@@ -410,6 +419,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
DebugAffinity: s.cfg.DebugAffinity,
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
Tiles: s.cfg.Tiles,
Customizer: CustomizerClientConfig{DisabledTabs: disabledTabs},
})
}
+9
View File
@@ -1006,6 +1006,15 @@ type ClientConfigResponse struct {
Timestamps TimestampConfig `json:"timestamps"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"` // deprecated. TODO: remove after v3.5.0
Customizer CustomizerClientConfig `json:"customizer"`
}
// CustomizerClientConfig is the operator-side customizer-modal knobs that
// /api/config/client surfaces to the frontend. Issue #1508. The field is
// always present (DisabledTabs defaults to an empty slice) so the frontend
// can blindly call `.disabledTabs.includes(...)` without an undefined guard.
type CustomizerClientConfig struct {
DisabledTabs []string `json:"disabledTabs"`
}
// ─── IATA Coords ───────────────────────────────────────────────────────────────
+5 -1
View File
@@ -356,5 +356,9 @@
"nodesClockSkew": 300
}
},
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache."
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache.",
"customizer": {
"disabledTabs": [],
"_comment_disabledTabs": "Issue #1508. List of customizer-modal tab ids to hide from end users. Useful when operators want to expose only daily-use viewer controls and keep one-shot admin chrome out of the way. Recognized ids: branding, theme, nodes, home, display, geofilter, export. Example: [\"branding\", \"geofilter\", \"export\"] hides the admin tabs and leaves theme/colors/home/display visible. Default [] keeps the legacy behavior (all tabs visible). Operators edit this in config.json directly — there is no in-app UI for this list (by design)."
}
}
+8
View File
@@ -1164,6 +1164,14 @@
{ id: 'geofilter', label: '🗺️', title: 'GeoFilter' },
{ id: 'export', label: '📤', title: 'Export' }
];
// #1508 — operators can hide individual tabs server-side via
// config.customizer.disabledTabs. The list arrives on
// window.MC_CUSTOMIZER_CFG (set by roles.js after /api/config/client).
// Default empty so behavior is unchanged when the field is absent.
var disabled = (typeof window !== 'undefined' && window.MC_CUSTOMIZER_CFG && window.MC_CUSTOMIZER_CFG.disabledTabs) || [];
if (disabled.length) {
tabs = tabs.filter(function (t) { return disabled.indexOf(t.id) === -1; });
}
return '<div class="cust-tabs">' + tabs.map(function (t) {
return '<button class="cust-tab' + (t.id === _activeTab ? ' active' : '') + '" data-tab="' + t.id + '" title="' + t.title + '">' +
t.label + ' <span class="cust-tab-text">' + t.title + '</span>' + (t.badge || '') + '</button>';
+6
View File
@@ -476,6 +476,12 @@
if (cfg.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
// #1508 — expose operator-side customizer knobs to customize-v2.js.
// Default to an empty disabledTabs list so the .indexOf() guard in
// _renderTabs is a no-op when the field is absent.
window.MC_CUSTOMIZER_CFG = (cfg.customizer && typeof cfg.customizer === 'object')
? { disabledTabs: Array.isArray(cfg.customizer.disabledTabs) ? cfg.customizer.disabledTabs : [] }
: { disabledTabs: [] };
// Sync ROLE_STYLE colors with ROLE_COLORS
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
}).catch(function () { /* use defaults */ });