mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-29 13:21:38 +00:00
# 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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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 */ });
|
||||
|
||||
Reference in New Issue
Block a user