Files
meshcore-analyzer/cmd/server/config.go
T
Kpa-clawbot ca2c3d6c79 feat(1488): customize marker stroke (color, width, opacity) (#1494)
## Summary

Reporter (@EldoonNemar in #1488) found the new white marker stroke
overwhelming with hundreds of nodes on screen. This PR exposes the
stroke through CSS vars + a customizer panel so operators can dial
color/width/opacity (or remove it) without code edits.

**Scope:** ship stroke customization only. The reporter also asked for
the old glow-style highlight ring as an alternative — that's a separate
visual feature that needs design discussion, so it's deferred to a
follow-up issue.

## Changes

- **`public/style.css`** `:root` declares `--mc-marker-stroke-color` /
`--mc-marker-stroke-width` / `--mc-marker-stroke-opacity` with sensible
defaults (white, 1, 1) that match current behavior.
- **`public/roles.js`** `makeRoleMarkerSVG` — replaced the 6 baked
`stroke="#fff" stroke-width="1"` literals with a single shared
`strokeAttr` referencing the CSS vars. One source of truth for all role
shapes.
- **`public/map.js`** `makeMarkerIcon` — same migration. The observer
star overlay keeps its narrow 0.8 width but routes color + opacity
through the same vars.
- **`public/live.js`** `addNodeMarker` fallback SVG — same migration.
- **`public/customize-v2.js`** — new `markerStroke` object section
(color/width/opacity) with validation, `applyCSS` writes, three controls
on the Colors tab → "Marker Stroke" panel (color picker + width slider
0–4 + opacity slider 0–100%). Optimistic CSS-var writes on the `input`
event so markers repaint live as the operator drags.
- **`cmd/server/{config,types,routes}.go`** — `ThemeFile` / `Config` /
`ThemeResponse` pick up `MarkerStroke` so `theme.json` and `config.json`
can ship server-side defaults. Defaults mirror the `:root` CSS values so
no breaking change for current operators.
- **`config.example.json`** — documented `markerStroke` section with
usage hint.

## TDD

- **Red commit** `92183f95` — `test-issue-1488-marker-stroke-vars.js` (5
sections, 18 assertions); failed 14/18 before implementation.
- **Green commit** `ce39637e` — implementation; same test now passes
18/18.
- Existing `#1438` (marker CSS-var migration) and `#1293` (marker
shapes) regression tests still pass.
- Go tests (`cmd/server/...`) all green.

## CDP validation

Synthetic page with 600 markers, three blocks proving CSS-var control
works end-to-end:

| Block | Stroke setting | Computed `getComputedStyle().stroke` / width
/ opacity |
| --- | --- | --- |
| Default | `var(--mc-marker-stroke-color)` (no override) |
`rgba(255,255,255,0.85)` / `1px` / `1` |
| Tuned | inline `--mc-marker-stroke-*` (operator override) |
`rgb(255,255,255)` / `0.5px` / `0.3` |
| Cyan | inline `--mc-marker-stroke-*` (branding/CB) | `rgb(0,229,255)`
/ `2px` / `1` |

Same SVG source, three different rendered strokes — that's the whole
point. Runtime `documentElement.style.setProperty(...)` (which is
exactly what the customizer slider's `input` handler does) repaints
mounted markers without reload. CDP screenshot attached to the
implementation note.

## Hot-deploy

Frontend + Go binary changes. Safe to hot-deploy frontend files
(`public/*.js`, `public/style.css`) via the standard staging path; Go
binary update needs a container restart.

## Defer

Glow highlight ring (the second half of #1488) — separate follow-up
issue. This PR delivers the immediately-useful, smaller deliverable.

Partial fix for #1488 (stroke customization shipped; glow ring deferred
to a follow-up issue).

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-05-29 14:31:36 +00:00

663 lines
22 KiB
Go

package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/meshcore-analyzer/dbconfig"
"github.com/meshcore-analyzer/geofilter"
)
// AreaEntry defines a geographic area by polygon or bounding box.
type AreaEntry struct {
Label string `json:"label"`
Polygon [][2]float64 `json:"polygon,omitempty"`
LatMin *float64 `json:"latMin,omitempty"`
LatMax *float64 `json:"latMax,omitempty"`
LonMin *float64 `json:"lonMin,omitempty"`
LonMax *float64 `json:"lonMax,omitempty"`
}
// Config mirrors the Node.js config.json structure (read-only fields).
type Config struct {
Port int `json:"port"`
APIKey string `json:"apiKey"`
DBPath string `json:"dbPath"`
// NodeBlacklist is a list of public keys to exclude from all API responses.
// Blacklisted nodes are hidden from node lists, search, detail, map, and stats.
// Use this to filter out trolls, nodes with offensive names, or nodes
// reporting deliberately false data (e.g. wrong GPS position) that the
// operator refuses to fix.
NodeBlacklist []string `json:"nodeBlacklist"`
// blacklistSetCached is the lazily-built set version of NodeBlacklist.
blacklistSetCached map[string]bool
blacklistOnce sync.Once
Branding map[string]interface{} `json:"branding"`
Theme map[string]interface{} `json:"theme"`
ThemeDark map[string]interface{} `json:"themeDark"`
NodeColors map[string]interface{} `json:"nodeColors"`
TypeColors map[string]interface{} `json:"typeColors"`
Home map[string]interface{} `json:"home"`
// #1488 — marker stroke (outline) settings. Operators dial color, width
// and opacity to soften the default white outline when hundreds of
// nodes feel overwhelming. Frontend reads these as CSS vars; see
// public/customize-v2.js applyCSS markerStroke block.
MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"`
MapDefaults struct {
Center []float64 `json:"center"`
Zoom int `json:"zoom"`
} `json:"mapDefaults"`
Regions map[string]string `json:"regions"`
Roles map[string]interface{} `json:"roles"`
HealthThresholds *HealthThresholds `json:"healthThresholds"`
Tiles map[string]interface{} `json:"tiles"`
SnrThresholds map[string]interface{} `json:"snrThresholds"`
DistThresholds map[string]interface{} `json:"distThresholds"`
MaxHopDist *float64 `json:"maxHopDist"`
Limits map[string]interface{} `json:"limits"`
PerfSlowMs *int `json:"perfSlowMs"`
WsReconnectMs *int `json:"wsReconnectMs"`
CacheInvalidMs *int `json:"cacheInvalidateMs"`
ExternalUrls map[string]interface{} `json:"externalUrls"`
LiveMap struct {
PropagationBufferMs int `json:"propagationBufferMs"`
} `json:"liveMap"`
CacheTTL map[string]interface{} `json:"cacheTTL"`
Retention *RetentionConfig `json:"retention,omitempty"`
DB *DBConfig `json:"db,omitempty"`
PacketStore *PacketStoreConfig `json:"packetStore,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
Areas map[string]AreaEntry `json:"areas,omitempty"`
Timestamps *TimestampConfig `json:"timestamps,omitempty"`
// CORSAllowedOrigins is the list of origins permitted to make cross-origin
// requests. When empty (default), no Access-Control-* headers are sent,
// so browsers enforce same-origin policy. Set to ["*"] to allow all origins.
CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"`
DebugAffinity bool `json:"debugAffinity,omitempty"`
// MapDarkTileProvider selects the default dark-mode basemap provider for
// new visitors. The client may override per-browser via the customizer
// (persisted to localStorage). Allowed values: "carto-dark" (default),
// "esri-darkgray-labels", "voyager-inverted", "positron-inverted". See
// public/map-tile-providers.js for the registry. #1420.
MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"`
// ObserverBlacklist is a list of observer public keys to exclude from API
// responses (defense in depth — ingestor drops at ingest, server filters
// any that slipped through from a prior unblocked window).
ObserverBlacklist []string `json:"observerBlacklist,omitempty"`
// obsBlacklistSetCached is the lazily-built set version of ObserverBlacklist.
obsBlacklistSetCached map[string]bool
obsBlacklistOnce sync.Once
Compression *CompressionConfig `json:"compression,omitempty"`
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
// Observers cache settings (#1481 P0-3 / #1483).
ObserversCache *ObserversCacheConfig `json:"observersCache,omitempty"`
// Analytics steady-state background recompute (issue #1240).
Analytics *AnalyticsConfig `json:"analytics,omitempty"`
// BatteryThresholds: voltage cutoffs for low/critical alerts (#663).
BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"`
}
// weakAPIKeys is the blocklist of known default/example API keys that must be rejected.
var weakAPIKeys = map[string]bool{
"your-secret-api-key-here": true,
"change-me": true,
"example": true,
"test": true,
"password": true,
"admin": true,
"apikey": true,
"api-key": true,
"secret": true,
"default": true,
}
// IsWeakAPIKey returns true if the key is in the blocklist or shorter than 16 characters.
func IsWeakAPIKey(key string) bool {
if key == "" {
return false // empty is handled separately (endpoints disabled)
}
if weakAPIKeys[strings.ToLower(key)] {
return true
}
if len(key) < 16 {
return true
}
return false
}
// CompressionConfig controls HTTP gzip and WebSocket permessage-deflate compression.
// Both are disabled by default — enable only when the upstream proxy does not already compress.
type CompressionConfig struct {
GZip bool `json:"gzip"`
Websocket bool `json:"websocket"`
// Level is the gzip compression level (1=BestSpeed … 9=BestCompression).
// 0 / out-of-range means "use compress/gzip.DefaultCompression".
Level int `json:"level,omitempty"`
// MinSizeBytes is an advisory minimum response size below which gzip
// would not pay off. Currently informational — kept here so operators
// can express intent and so future small-body fast-paths can use it.
MinSizeBytes int `json:"minSizeBytes,omitempty"`
// ContentTypes overrides the default compressible-MIME allow-list. When
// empty, a conservative default (application/json, text/html, text/css,
// application/javascript, text/plain, image/svg+xml, application/xml)
// is used. Already-compressed types (image/*, video/*, application/zip,
// application/x-gzip, …) are always skipped.
ContentTypes []string `json:"contentTypes,omitempty"`
}
// GZipEnabled returns true when HTTP gzip compression is explicitly enabled.
func (c *Config) GZipEnabled() bool {
return c.Compression != nil && c.Compression.GZip
}
// WSCompressionEnabled returns true when WebSocket permessage-deflate is explicitly enabled.
func (c *Config) WSCompressionEnabled() bool {
return c.Compression != nil && c.Compression.Websocket
}
// ResolvedPathConfig controls async backfill behavior.
type ResolvedPathConfig struct {
BackfillHours int `json:"backfillHours"` // how far back (hours) to scan for NULL resolved_path (default 24)
}
// NeighborGraphConfig controls neighbor edge pruning.
type NeighborGraphConfig struct {
MaxAgeDays int `json:"maxAgeDays"` // edges older than this are pruned (default 5)
MaxEdgeKm float64 `json:"maxEdgeKm"` // geo-implausibility threshold (km); 0 = default 500; negative disables (#1228)
// CacheRecomputeIntervalSeconds: cadence for the background
// recomputer that rebuilds the default-shape neighbor-graph
// response (#1481 P0-1). 0/missing = default 300 (5 min).
// Lower = fresher data, more CPU per minute. #1483.
CacheRecomputeIntervalSeconds int `json:"cacheRecomputeIntervalSeconds,omitempty"`
}
// ObserversCacheConfig controls the /api/observers default-shape cache.
// #1481 P0-3 / #1483.
type ObserversCacheConfig struct {
// TTLSeconds: how long the cached default-shape /api/observers
// response is served before a singleflight-collapsed refill.
// 0/missing = default 30. Lower = fresher data, more SQL pressure.
TTLSeconds int `json:"ttlSeconds,omitempty"`
}
// PacketStoreConfig controls in-memory packet store limits.
type PacketStoreConfig struct {
RetentionHours float64 `json:"retentionHours"` // max age of packets in hours (0 = unlimited)
MaxMemoryMB int `json:"maxMemoryMB"` // hard memory ceiling in MB (0 = unlimited)
MaxResolvedPubkeyIndexEntries int `json:"maxResolvedPubkeyIndexEntries"` // warning threshold for index size (0 = 5M default)
HotStartupHours float64 `json:"hotStartupHours"` // load only this many hours synchronously; 0 = disabled
}
// GeoFilterConfig is an alias for the shared geofilter.Config type.
type GeoFilterConfig = geofilter.Config
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
ObserverDays int `json:"observerDays"`
PacketDays int `json:"packetDays"`
MetricsDays int `json:"metricsDays"`
}
// DBConfig is the shared SQLite vacuum/maintenance config (#919, #921).
type DBConfig = dbconfig.DBConfig
// IncrementalVacuumPages returns the configured pages per vacuum or 1024 default.
func (c *Config) IncrementalVacuumPages() int {
if c.DB != nil && c.DB.IncrementalVacuumPages > 0 {
return c.DB.IncrementalVacuumPages
}
return 1024
}
// MetricsRetentionDays returns configured metrics retention or 30 days default.
func (c *Config) MetricsRetentionDays() int {
if c.Retention != nil && c.Retention.MetricsDays > 0 {
return c.Retention.MetricsDays
}
return 30
}
// BackfillHours returns configured backfill window or 24h default.
func (c *Config) BackfillHours() int {
if c.ResolvedPath != nil && c.ResolvedPath.BackfillHours > 0 {
return c.ResolvedPath.BackfillHours
}
return 24
}
// NeighborMaxAgeDays returns configured max edge age or 30 days default.
func (c *Config) NeighborMaxAgeDays() int {
if c.NeighborGraph != nil && c.NeighborGraph.MaxAgeDays > 0 {
return c.NeighborGraph.MaxAgeDays
}
return 5
}
// NeighborMaxEdgeKm returns the geo-implausibility threshold in km.
// 0 (unset) → DefaultMaxEdgeKm (500). Negative → 0 (filter disabled).
// See issue #1228.
func (c *Config) NeighborMaxEdgeKm() float64 {
if c == nil || c.NeighborGraph == nil || c.NeighborGraph.MaxEdgeKm == 0 {
return DefaultMaxEdgeKm
}
if c.NeighborGraph.MaxEdgeKm < 0 {
return 0
}
return c.NeighborGraph.MaxEdgeKm
}
type TimestampConfig struct {
DefaultMode string `json:"defaultMode"` // "ago" | "absolute"
Timezone string `json:"timezone"` // "local" | "utc"
FormatPreset string `json:"formatPreset"` // "iso" | "iso-seconds" | "locale"
CustomFormat string `json:"customFormat"` // freeform, only used when AllowCustomFormat=true
AllowCustomFormat bool `json:"allowCustomFormat"` // admin gate
}
func defaultTimestampConfig() TimestampConfig {
return TimestampConfig{
DefaultMode: "ago",
Timezone: "local",
FormatPreset: "iso",
CustomFormat: "",
AllowCustomFormat: false,
}
}
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.
func (c *Config) NodeDaysOrDefault() int {
if c.Retention != nil && c.Retention.NodeDays > 0 {
return c.Retention.NodeDays
}
return 7
}
// ObserverDaysOrDefault returns the configured retention.observerDays or 14 if not set.
// A value of -1 means observers are never removed.
func (c *Config) ObserverDaysOrDefault() int {
if c.Retention != nil && c.Retention.ObserverDays != 0 {
return c.Retention.ObserverDays
}
return 14
}
type HealthThresholds struct {
InfraDegradedHours float64 `json:"infraDegradedHours"`
InfraSilentHours float64 `json:"infraSilentHours"`
NodeDegradedHours float64 `json:"nodeDegradedHours"`
NodeSilentHours float64 `json:"nodeSilentHours"`
// RelayActiveHours: how recent a path-hop appearance must be for a
// repeater to be considered "actively relaying" vs only "alive
// (advert-only)". See issue #662. Defaults to 24h.
RelayActiveHours float64 `json:"relayActiveHours"`
}
// ThemeFile mirrors theme.json overlay.
type ThemeFile struct {
Branding map[string]interface{} `json:"branding"`
Theme map[string]interface{} `json:"theme"`
ThemeDark map[string]interface{} `json:"themeDark"`
NodeColors map[string]interface{} `json:"nodeColors"`
TypeColors map[string]interface{} `json:"typeColors"`
Home map[string]interface{} `json:"home"`
// #1488 — marker stroke overlay from theme.json.
MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"`
}
func LoadConfig(baseDirs ...string) (*Config, error) {
if len(baseDirs) == 0 {
baseDirs = []string{"."}
}
paths := make([]string, 0)
for _, d := range baseDirs {
paths = append(paths, filepath.Join(d, "config.json"))
paths = append(paths, filepath.Join(d, "data", "config.json"))
}
cfg := &Config{Port: 3000}
for _, p := range paths {
data, err := os.ReadFile(p)
if err != nil {
continue
}
if err := json.Unmarshal(data, cfg); err != nil {
continue
}
cfg.NormalizeTimestampConfig()
return cfg, nil
}
cfg.NormalizeTimestampConfig()
return cfg, nil // defaults
}
func LoadTheme(baseDirs ...string) *ThemeFile {
if len(baseDirs) == 0 {
baseDirs = []string{"."}
}
for _, d := range baseDirs {
for _, name := range []string{"theme.json"} {
p := filepath.Join(d, name)
data, err := os.ReadFile(p)
if err != nil {
p = filepath.Join(d, "data", name)
data, err = os.ReadFile(p)
if err != nil {
continue
}
}
var t ThemeFile
if json.Unmarshal(data, &t) == nil {
return &t
}
}
}
return &ThemeFile{}
}
func (c *Config) GetHealthThresholds() HealthThresholds {
h := HealthThresholds{
InfraDegradedHours: 24,
InfraSilentHours: 72,
NodeDegradedHours: 1,
NodeSilentHours: 24,
RelayActiveHours: 24,
}
if c.HealthThresholds != nil {
if c.HealthThresholds.InfraDegradedHours > 0 {
h.InfraDegradedHours = c.HealthThresholds.InfraDegradedHours
}
if c.HealthThresholds.InfraSilentHours > 0 {
h.InfraSilentHours = c.HealthThresholds.InfraSilentHours
}
if c.HealthThresholds.NodeDegradedHours > 0 {
h.NodeDegradedHours = c.HealthThresholds.NodeDegradedHours
}
if c.HealthThresholds.NodeSilentHours > 0 {
h.NodeSilentHours = c.HealthThresholds.NodeSilentHours
}
if c.HealthThresholds.RelayActiveHours > 0 {
h.RelayActiveHours = c.HealthThresholds.RelayActiveHours
}
}
return h
}
// GetHealthMs returns degraded/silent thresholds in ms for a given role.
func (h HealthThresholds) GetHealthMs(role string) (degradedMs, silentMs int) {
const hourMs = 3600000
if role == "repeater" || role == "room" {
return int(h.InfraDegradedHours * hourMs), int(h.InfraSilentHours * hourMs)
}
return int(h.NodeDegradedHours * hourMs), int(h.NodeSilentHours * hourMs)
}
// ToClientMs returns the thresholds as ms for the frontend.
func (h HealthThresholds) ToClientMs() map[string]int {
const hourMs = 3600000
return map[string]int{
"infraDegradedMs": int(h.InfraDegradedHours * hourMs),
"infraSilentMs": int(h.InfraSilentHours * hourMs),
"nodeDegradedMs": int(h.NodeDegradedHours * hourMs),
"nodeSilentMs": int(h.NodeSilentHours * hourMs),
}
}
func (c *Config) ResolveDBPath(baseDir string) string {
if c.DBPath != "" {
return c.DBPath
}
if v := os.Getenv("DB_PATH"); v != "" {
return v
}
return filepath.Join(baseDir, "data", "meshcore.db")
}
func (c *Config) NormalizeTimestampConfig() {
defaults := defaultTimestampConfig()
if c.Timestamps == nil {
log.Printf("[config] timestamps not configured - using defaults (ago/local/iso)")
c.Timestamps = &defaults
return
}
origMode := c.Timestamps.DefaultMode
mode := strings.ToLower(strings.TrimSpace(origMode))
switch mode {
case "ago", "absolute":
c.Timestamps.DefaultMode = mode
default:
log.Printf("[config] warning: timestamps.defaultMode=%q is invalid, using %q", origMode, defaults.DefaultMode)
c.Timestamps.DefaultMode = defaults.DefaultMode
}
origTimezone := c.Timestamps.Timezone
timezone := strings.ToLower(strings.TrimSpace(origTimezone))
switch timezone {
case "local", "utc":
c.Timestamps.Timezone = timezone
default:
log.Printf("[config] warning: timestamps.timezone=%q is invalid, using %q", origTimezone, defaults.Timezone)
c.Timestamps.Timezone = defaults.Timezone
}
origPreset := c.Timestamps.FormatPreset
formatPreset := strings.ToLower(strings.TrimSpace(origPreset))
switch formatPreset {
case "iso", "iso-seconds", "locale":
c.Timestamps.FormatPreset = formatPreset
default:
log.Printf("[config] warning: timestamps.formatPreset=%q is invalid, using %q", origPreset, defaults.FormatPreset)
c.Timestamps.FormatPreset = defaults.FormatPreset
}
}
func (c *Config) GetTimestampConfig() TimestampConfig {
if c == nil || c.Timestamps == nil {
return defaultTimestampConfig()
}
return *c.Timestamps
}
func (c *Config) PropagationBufferMs() int {
if c.LiveMap.PropagationBufferMs > 0 {
return c.LiveMap.PropagationBufferMs
}
return 5000
}
// blacklistSet lazily builds and caches the nodeBlacklist as a set for O(1) lookups.
// Uses sync.Once to eliminate the data race on first concurrent access.
func (c *Config) blacklistSet() map[string]bool {
c.blacklistOnce.Do(func() {
if len(c.NodeBlacklist) == 0 {
return
}
m := make(map[string]bool, len(c.NodeBlacklist))
for _, pk := range c.NodeBlacklist {
trimmed := strings.ToLower(strings.TrimSpace(pk))
if trimmed != "" {
m[trimmed] = true
}
}
c.blacklistSetCached = m
})
return c.blacklistSetCached
}
// IsBlacklisted returns true if the given public key is in the nodeBlacklist.
func (c *Config) IsBlacklisted(pubkey string) bool {
if c == nil || len(c.NodeBlacklist) == 0 {
return false
}
return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))]
}
// SaveGeoFilter writes the geo_filter section back to config.json on disk.
// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
var configPath string
for _, p := range []string{
filepath.Join(configDir, "config.json"),
filepath.Join(configDir, "data", "config.json"),
} {
if _, err := os.Stat(p); err == nil {
configPath = p
break
}
}
if configPath == "" {
return fmt.Errorf("config.json not found in %s", configDir)
}
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
// Parse as a raw map so non-struct fields (_comment, etc.) are preserved.
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parse config: %w", err)
}
if gf == nil || len(gf.Polygon) == 0 {
delete(raw, "geo_filter")
} else {
// Round-trip through JSON to get a plain interface{} value.
b, _ := json.Marshal(gf)
var v interface{}
_ = json.Unmarshal(b, &v)
raw["geo_filter"] = v
}
out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
out = append(out, '\n')
// Atomic write: temp file + rename.
tmp := configPath + ".tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := os.Rename(tmp, configPath); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename config: %w", err)
}
return nil
}
// obsBlacklistSet lazily builds and caches the observerBlacklist as a set for O(1) lookups.
func (c *Config) obsBlacklistSet() map[string]bool {
c.obsBlacklistOnce.Do(func() {
if len(c.ObserverBlacklist) == 0 {
return
}
m := make(map[string]bool, len(c.ObserverBlacklist))
for _, pk := range c.ObserverBlacklist {
trimmed := strings.ToLower(strings.TrimSpace(pk))
if trimmed != "" {
m[trimmed] = true
}
}
c.obsBlacklistSetCached = m
})
return c.obsBlacklistSetCached
}
// IsObserverBlacklisted returns true if the given observer ID is in the observerBlacklist.
func (c *Config) IsObserverBlacklisted(id string) bool {
if c == nil || len(c.ObserverBlacklist) == 0 {
return false
}
return c.obsBlacklistSet()[strings.ToLower(strings.TrimSpace(id))]
}
// AnalyticsConfig controls steady-state background recompute of
// analytics endpoints (issue #1240).
//
// DefaultIntervalSeconds applies to every endpoint that does not have
// an explicit per-endpoint override in RecomputeIntervalSeconds. The
// project default is 300 (5 minutes): the operator's guiding principle
// is "serving slightly stale data quickly is better than real-time
// data slowly." Lower values give fresher data at higher CPU cost.
//
// RecomputeIntervalSeconds keys (all optional):
// topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew
type AnalyticsConfig struct {
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
}
// AnalyticsDefaultRecomputeInterval returns the configured default
// recompute interval, or 5 minutes if unset/invalid.
func (c *Config) AnalyticsDefaultRecomputeInterval() time.Duration {
if c != nil && c.Analytics != nil && c.Analytics.DefaultIntervalSeconds > 0 {
return time.Duration(c.Analytics.DefaultIntervalSeconds) * time.Second
}
return 5 * time.Minute
}
// AnalyticsRecomputeIntervals returns the per-endpoint override map.
// Returns the zero value (all defaults) if the analytics block is
// absent or empty.
func (c *Config) AnalyticsRecomputeIntervals() AnalyticsRecomputeIntervals {
out := AnalyticsRecomputeIntervals{}
if c == nil || c.Analytics == nil || c.Analytics.RecomputeIntervalSeconds == nil {
return out
}
get := func(key string) time.Duration {
v, ok := c.Analytics.RecomputeIntervalSeconds[key]
if !ok || v <= 0 {
return 0
}
return time.Duration(v) * time.Second
}
out.Topology = get("topology")
out.RF = get("rf")
out.Distance = get("distance")
out.Channels = get("channels")
out.HashCollisions = get("hashCollisions")
out.HashSizes = get("hashSizes")
out.Roles = get("roles")
out.ObserversClockSkew = get("observersClockSkew")
out.NodesClockSkew = get("nodesClockSkew")
return out
}