mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 19:18:25 +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: voltage cutoffs for low/critical alerts (#663).
|
||||||
BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"`
|
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.
|
// 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) {
|
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{
|
writeJSON(w, ClientConfigResponse{
|
||||||
Roles: s.cfg.Roles,
|
Roles: s.cfg.Roles,
|
||||||
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
|
HealthThresholds: s.cfg.GetHealthThresholds().ToClientMs(),
|
||||||
@@ -410,6 +419,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
|
|||||||
DebugAffinity: s.cfg.DebugAffinity,
|
DebugAffinity: s.cfg.DebugAffinity,
|
||||||
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
|
MapDarkTileProvider: s.cfg.MapDarkTileProvider,
|
||||||
Tiles: s.cfg.Tiles,
|
Tiles: s.cfg.Tiles,
|
||||||
|
Customizer: CustomizerClientConfig{DisabledTabs: disabledTabs},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1006,6 +1006,15 @@ type ClientConfigResponse struct {
|
|||||||
Timestamps TimestampConfig `json:"timestamps"`
|
Timestamps TimestampConfig `json:"timestamps"`
|
||||||
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
DebugAffinity bool `json:"debugAffinity,omitempty"`
|
||||||
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"` // deprecated. TODO: remove after v3.5.0
|
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 ───────────────────────────────────────────────────────────────
|
// ─── IATA Coords ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+5
-1
@@ -356,5 +356,9 @@
|
|||||||
"nodesClockSkew": 300
|
"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: 'geofilter', label: '🗺️', title: 'GeoFilter' },
|
||||||
{ id: 'export', label: '📤', title: 'Export' }
|
{ 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 '<div class="cust-tabs">' + tabs.map(function (t) {
|
||||||
return '<button class="cust-tab' + (t.id === _activeTab ? ' active' : '') + '" data-tab="' + t.id + '" title="' + t.title + '">' +
|
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>';
|
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.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
|
||||||
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
||||||
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
|
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
|
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||||||
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
|
// #1407 — both are now live getters; no manual sync needed. Kept as no-op for clarity.
|
||||||
}).catch(function () { /* use defaults */ });
|
}).catch(function () { /* use defaults */ });
|
||||||
|
|||||||
Reference in New Issue
Block a user