From 470533fd662269ed94df5d6bd9d0aa24c8af2867 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 06:12:25 -0400 Subject: [PATCH 001/109] =?UTF-8?q?feat(monitor):=20infra=20scaffold=20?= =?UTF-8?q?=E2=80=94=20Dockerfiles,=20conf/=20tree,=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.gitignore | 18 + .../backend/.air.toml | 29 + .../backend/.env.example | 33 + .../backend/.gitignore | 44 + .../backend/.golangci.yml | 113 + .../backend/cmd/api/main.go | 221 ++ .../backend/config.yaml | 53 + .../backend/go.mod | 69 + .../backend/go.sum | 165 ++ .../backend/internal/admin/handler.go | 208 ++ .../backend/internal/auth/dto.go | 62 + .../backend/internal/auth/entity.go | 47 + .../backend/internal/auth/handler.go | 316 ++ .../backend/internal/auth/jwt.go | 295 ++ .../backend/internal/auth/repository.go | 236 ++ .../backend/internal/auth/service.go | 411 +++ .../backend/internal/config/config.go | 302 ++ .../backend/internal/core/database.go | 145 + .../backend/internal/core/errors.go | 169 ++ .../backend/internal/core/redis.go | 63 + .../backend/internal/core/response.go | 120 + .../backend/internal/core/security.go | 218 ++ .../backend/internal/core/telemetry.go | 142 + .../backend/internal/core/validation.go | 42 + .../backend/internal/health/handler.go | 195 ++ .../backend/internal/middleware/auth.go | 189 ++ .../backend/internal/middleware/headers.go | 103 + .../backend/internal/middleware/logging.go | 99 + .../backend/internal/middleware/ratelimit.go | 375 +++ .../backend/internal/middleware/request_id.go | 40 + .../backend/internal/server/server.go | 108 + .../backend/internal/user/dto.go | 84 + .../backend/internal/user/entity.go | 40 + .../backend/internal/user/handler.go | 288 ++ .../backend/internal/user/repository.go | 289 ++ .../backend/internal/user/service.go | 256 ++ .../conf/docker/dev/go.docker | 19 + .../conf/docker/dev/vite.docker | 14 + .../conf/docker/prod/go.docker | 26 + .../conf/docker/prod/vite.docker | 26 + .../frontend/.dockerignore | 15 + .../frontend/.gitignore | 25 + .../frontend/.stylelintignore | 22 + .../frontend/biome.json | 94 + .../frontend/index.html | 44 + .../frontend/package.json | 50 + .../frontend/pnpm-lock.yaml | 2603 +++++++++++++++++ .../public/assets/android-chrome-192x192.png | Bin 0 -> 25807 bytes .../public/assets/android-chrome-512x512.png | Bin 0 -> 73395 bytes .../public/assets/apple-touch-icon.png | Bin 0 -> 22963 bytes .../frontend/public/assets/favicon-16x16.png | Bin 0 -> 746 bytes .../frontend/public/assets/favicon-32x32.png | Bin 0 -> 2032 bytes .../frontend/public/assets/favicon.ico | Bin 0 -> 15406 bytes .../frontend/public/assets/site.webmanifest | 1 + .../frontend/src/App.tsx | 36 + .../frontend/src/api/hooks/index.ts | 8 + .../frontend/src/api/hooks/useAdmin.ts | 219 ++ .../frontend/src/api/hooks/useAuth.ts | 243 ++ .../frontend/src/api/hooks/useUsers.ts | 138 + .../frontend/src/api/index.ts | 7 + .../frontend/src/api/types/auth.types.ts | 140 + .../frontend/src/api/types/index.ts | 7 + .../frontend/src/api/types/user.types.ts | 127 + .../frontend/src/components/index.tsx | 4 + .../frontend/src/config.ts | 116 + .../frontend/src/core/api/api.config.ts | 160 + .../frontend/src/core/api/errors.ts | 114 + .../frontend/src/core/api/index.ts | 8 + .../frontend/src/core/api/query.config.ts | 105 + .../frontend/src/core/app/protected-route.tsx | 47 + .../frontend/src/core/app/routers.tsx | 67 + .../frontend/src/core/app/shell.module.scss | 331 +++ .../frontend/src/core/app/shell.tsx | 160 + .../frontend/src/core/app/toast.module.scss | 67 + .../frontend/src/core/lib/auth.form.store.ts | 43 + .../frontend/src/core/lib/auth.store.ts | 105 + .../frontend/src/core/lib/index.ts | 8 + .../frontend/src/core/lib/shell.ui.store.ts | 63 + .../frontend/src/main.tsx | 15 + .../src/pages/admin/admin.module.scss | 468 +++ .../frontend/src/pages/admin/index.tsx | 415 +++ .../src/pages/dashboard/dashboard.module.scss | 152 + .../frontend/src/pages/dashboard/index.tsx | 90 + .../frontend/src/pages/landing/index.tsx | 97 + .../src/pages/landing/landing.module.scss | 161 + .../frontend/src/pages/login/index.tsx | 114 + .../src/pages/login/login.module.scss | 171 ++ .../frontend/src/pages/register/index.tsx | 164 ++ .../src/pages/register/register.module.scss | 171 ++ .../frontend/src/pages/settings/index.tsx | 95 + .../src/pages/settings/settings.module.scss | 109 + .../frontend/src/styles.scss | 29 + .../frontend/src/styles/_fonts.scss | 12 + .../frontend/src/styles/_index.scss | 8 + .../frontend/src/styles/_mixins.scss | 120 + .../frontend/src/styles/_reset.scss | 198 ++ .../frontend/src/styles/_tokens.scss | 168 ++ .../frontend/stylelint.config.js | 107 + .../frontend/tsconfig.app.json | 31 + .../frontend/tsconfig.json | 7 + .../frontend/tsconfig.node.json | 24 + .../frontend/vite.config.ts | 71 + 102 files changed, 13846 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-512x512.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-16x16.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon.ico create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/site.webmanifest create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore new file mode 100644 index 00000000..48cf6544 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore @@ -0,0 +1,18 @@ +# ©AngelaMos | 2026 +# .gitignore + +.env +.env.local +.env.*.local + +backend/tmp/ +backend/bin/ +backend/coverage.out +backend/coverage.html +backend/keys/ + +frontend/dist/ +frontend/node_modules/ +frontend/.vite/ + +docs/ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml new file mode 100644 index 00000000..5f03fe95 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.air.toml @@ -0,0 +1,29 @@ +# AngelaMos | 2026 +# .air.toml - Hot reload configuration + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "./tmp/main" +include_ext = ["go", "yaml", "yml"] +exclude_dir = ["tmp", "vendor", "bin", "keys", "migrations"] +exclude_regex = ["_test\\.go"] +delay = 1000 +stop_on_error = true +send_interrupt = true +kill_delay = 500 + +[log] +time = false + +[color] +main = "cyan" +watcher = "magenta" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example new file mode 100644 index 00000000..f542823a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.env.example @@ -0,0 +1,33 @@ +# AngelaMos | 2026 +# .env.example + +# Environment: development, staging, production +ENVIRONMENT=development + +# Server +HOST=0.0.0.0 +PORT=8080 + +# Database +DATABASE_URL=postgres://postgres:postgres@localhost:5432/app?sslmode=disable + +# Redis +REDIS_URL=redis://localhost:6379/0 + +# JWT +JWT_PRIVATE_KEY_PATH=keys/private.pem +JWT_PUBLIC_KEY_PATH=keys/public.pem +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=1m + +# OpenTelemetry +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=go-backend + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=text diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore new file mode 100644 index 00000000..3688c87b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.gitignore @@ -0,0 +1,44 @@ +# AngelaMos | 2026 +# .gitignore + +# Binaries +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test +*.test +coverage.out +coverage.html + +# Build +tmp/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Keys (sensitive) +keys/*.pem +keys/*.key + +# Vendor (if using) +vendor/ + +# Debug +__debug_bin* diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml new file mode 100644 index 00000000..679c3e2c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml @@ -0,0 +1,113 @@ +# AngelaMos | 2026 +# .golangci.yml + +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - gosec + - bodyclose + - nilerr + - errorlint + - exhaustive + - gocritic + - funlen + - gocognit + - dupl + - goconst + - ineffassign + - unused + - unconvert + - unparam + - testifylint + - fatcontext + + settings: + errcheck: + check-type-assertions: true + check-blank: true + + funlen: + lines: 100 + statements: 50 + + gocognit: + min-complexity: 20 + + govet: + enable-all: true + disable: + - fieldalignment + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-declaration + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + + staticcheck: + checks: + - all + + gosec: + excludes: + - G104 + + sloglint: + no-mixed-args: true + kv-only: true + context: all + +issues: + max-same-issues: 50 + exclude-dirs: + - vendor + - testdata + exclude-rules: + - path: _test\.go + linters: + - funlen + - dupl + - goconst + + +formatters: + enable: + - gci # Groups imports + - gofumpt # Whitespace + - golines # Vertical wrap + settings: + golines: + max-len: 80 + reformat-tags: true + goimports: + local-prefixes: + - github.com/carterperez-dev/templates/go-backend + gci: + sections: + - standard + - default + - prefix(github.com/carterperez-dev) + custom-order: true + gofumpt: + extra-rules: true diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go new file mode 100644 index 00000000..150d2b52 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -0,0 +1,221 @@ +// AngelaMos | 2026 +// main.go + +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/carterperez-dev/templates/go-backend/internal/admin" + "github.com/carterperez-dev/templates/go-backend/internal/auth" + "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/health" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" + "github.com/carterperez-dev/templates/go-backend/internal/server" + "github.com/carterperez-dev/templates/go-backend/internal/user" +) + +const ( + drainDelay = 5 * time.Second +) + +func main() { + configPath := flag.String("config", "config.yaml", "path to config file") + flag.Parse() + + if err := run(*configPath); err != nil { + slog.Error("application error", "error", err) + os.Exit(1) + } +} + +//nolint:funlen // bootstrap code is inherently verbose +func run(configPath string) error { + ctx, stop := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGTERM, + ) + defer stop() + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + + logger := setupLogger(cfg.Log) + slog.SetDefault(logger) + + logger.Info("starting application", + "name", cfg.App.Name, + "version", cfg.App.Version, + "environment", cfg.App.Environment, + ) + + var telemetry *core.Telemetry + if cfg.Otel.Enabled { + tel, telErr := core.NewTelemetry(ctx, cfg.Otel, cfg.App) + if telErr != nil { + logger.Warn("failed to initialize telemetry", "error", telErr) + } else { + telemetry = tel + logger.Info("OpenTelemetry tracer initialized", + "endpoint", cfg.Otel.Endpoint, + ) + } + } + + db, err := core.NewDatabase(ctx, cfg.Database) + if err != nil { + return err + } + logger.Info("database connected", + "max_open_conns", cfg.Database.MaxOpenConns, + "max_idle_conns", cfg.Database.MaxIdleConns, + ) + + redis, err := core.NewRedis(ctx, cfg.Redis) + if err != nil { + return err + } + logger.Info("redis connected", + "pool_size", cfg.Redis.PoolSize, + ) + + jwtManager, err := auth.NewJWTManager(cfg.JWT) + if err != nil { + return err + } + logger.Info("JWT manager initialized", + "algorithm", "ES256", + "key_id", jwtManager.GetKeyID(), + ) + + userRepo := user.NewRepository(db.DB) + userSvc := user.NewService(userRepo) + userHandler := user.NewHandler(userSvc) + + authRepo := auth.NewRepository(db.DB) + authSvc := auth.NewService(authRepo, jwtManager, userSvc, redis.Client) + authHandler := auth.NewHandler(authSvc) + + healthHandler := health.NewHandler(db, redis) + + adminHandler := admin.NewHandler(admin.HandlerConfig{ + DBStats: db.Stats, + RedisStats: redis.PoolStats, + DBPing: db.Ping, + RedisPing: redis.Ping, + }) + + srv := server.New(server.Config{ + ServerConfig: cfg.Server, + HealthHandler: healthHandler, + Logger: logger, + }) + + router := srv.Router() + + router.Use(middleware.RequestID) + router.Use(middleware.Logger(logger)) + router.Use( + middleware.NewRateLimiter(redis.Client, middleware.RateLimitConfig{ + Limit: middleware.PerMinute( + cfg.RateLimit.Requests, + cfg.RateLimit.Burst, + ), + FailOpen: true, + }).Handler, + ) + router.Use(middleware.SecurityHeaders(cfg.App.Environment == "production")) + router.Use(middleware.CORS(cfg.CORS)) + + healthHandler.RegisterRoutes(router) + + router.Get("/.well-known/jwks.json", jwtManager.GetJWKSHandler()) + + authenticator := middleware.Authenticator(jwtManager) + adminOnly := middleware.RequireAdmin + + router.Route("/v1", func(r chi.Router) { + authHandler.RegisterRoutes(r, authenticator) + + r.Post("/users", authHandler.Register) + + userHandler.RegisterRoutes(r, authenticator) + userHandler.RegisterAdminRoutes(r, authenticator, adminOnly) + adminHandler.RegisterRoutes(r, authenticator, adminOnly) + }) + + errChan := make(chan error, 1) + go func() { + errChan <- srv.Start() + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + logger.Info("shutdown signal received") + } + + shutdownCtx, cancel := context.WithTimeout( + context.Background(), + cfg.Server.ShutdownTimeout+drainDelay+5*time.Second, + ) + defer cancel() + + if err := srv.Shutdown(shutdownCtx, drainDelay); err != nil { + logger.Error("server shutdown error", "error", err) + } + + if telemetry != nil { + if err := telemetry.Shutdown(shutdownCtx); err != nil { + logger.Error("telemetry shutdown error", "error", err) + } + } + + if err := redis.Close(); err != nil { + logger.Error("redis close error", "error", err) + } + + if err := db.Close(); err != nil { + logger.Error("database close error", "error", err) + } + + logger.Info("application stopped") + return nil +} + +func setupLogger(cfg config.LogConfig) *slog.Logger { + var handler slog.Handler + + level := slog.LevelInfo + switch cfg.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + + opts := &slog.HandlerOptions{Level: level} + + if cfg.Format == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + + return slog.New(handler) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml new file mode 100644 index 00000000..01aa28f0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/config.yaml @@ -0,0 +1,53 @@ +# AngelaMos | 2026 +# config.yaml - Default configuration + +app: + name: "Go Backend Template" + version: "1.0.0" + +server: + host: "0.0.0.0" + port: 8080 + read_timeout: 30s + write_timeout: 30s + idle_timeout: 120s + shutdown_timeout: 15s + +database: + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h + conn_max_idle_time: 30m + +redis: + pool_size: 10 + min_idle_conns: 5 + +jwt: + access_token_expire: 15m + refresh_token_expire: 168h + issuer: "go-backend" + +rate_limit: + requests: 100 + window: 1m + burst: 20 + +cors: + allowed_origins: + - "http://localhost:3000" + - "http://localhost:3420" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "PATCH" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-Request-ID" + allow_credentials: true + max_age: 300 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod new file mode 100644 index 00000000..e5981e90 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -0,0 +1,69 @@ +module github.com/carterperez-dev/templates/go-backend + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-playground/validator/v10 v10.23.0 + github.com/go-redis/redis_rate/v10 v10.0.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/jmoiron/sqlx v1.4.0 + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/env v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.1.2 + github.com/lestrrat-go/jwx/v3 v3.0.12 + github.com/redis/go-redis/v9 v9.7.0 + go.opentelemetry.io/otel v1.33.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 + go.opentelemetry.io/otel/sdk v1.33.0 + go.opentelemetry.io/otel/trace v1.33.0 + golang.org/x/crypto v0.43.0 + golang.org/x/time v0.14.0 + google.golang.org/grpc v1.68.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/dsig v1.0.0 // indirect + github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/valyala/fastjson v1.6.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/net v0.45.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/protobuf v1.35.2 // indirect +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum new file mode 100644 index 00000000..0a9a9619 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -0,0 +1,165 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo= +github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= +github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= +github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= +github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go new file mode 100644 index 00000000..4f75ab22 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go @@ -0,0 +1,208 @@ +// AngelaMos | 2026 +// handler.go + +package admin + +import ( + "context" + "database/sql" + "net/http" + "runtime" + + "github.com/go-chi/chi/v5" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type AuthService interface { + InvalidateAllSessions(ctx context.Context) error +} + +type Handler struct { + dbStats func() sql.DBStats + redisStats func() *redis.PoolStats + redisPing func(ctx context.Context) error + dbPing func(ctx context.Context) error + authSvc AuthService +} + +type HandlerConfig struct { + DBStats func() sql.DBStats + RedisStats func() *redis.PoolStats + RedisPing func(ctx context.Context) error + DBPing func(ctx context.Context) error + AuthSvc AuthService +} + +func NewHandler(cfg HandlerConfig) *Handler { + return &Handler{ + dbStats: cfg.DBStats, + redisStats: cfg.RedisStats, + redisPing: cfg.RedisPing, + dbPing: cfg.DBPing, + authSvc: cfg.AuthSvc, + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator, adminOnly func(http.Handler) http.Handler, +) { + r.Route("/admin", func(r chi.Router) { + r.Use(authenticator) + r.Use(adminOnly) + + r.Get("/stats", h.GetSystemStats) + r.Get("/stats/db", h.GetDatabaseStats) + r.Get("/stats/redis", h.GetRedisStats) + r.Get("/stats/runtime", h.GetRuntimeStats) + }) +} + +func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + dbHealthy := true + if h.dbPing != nil { + if err := h.dbPing(ctx); err != nil { + dbHealthy = false + } + } + + redisHealthy := true + if h.redisPing != nil { + if err := h.redisPing(ctx); err != nil { + redisHealthy = false + } + } + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := SystemStatsResponse{ + Database: DatabaseStatus{ + Healthy: dbHealthy, + Stats: h.getDBStats(), + }, + Redis: RedisStatus{ + Healthy: redisHealthy, + Stats: h.getRedisStats(), + }, + Runtime: RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + }, + } + + core.OK(w, response) +} + +func (h *Handler) GetDatabaseStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getDBStats()) +} + +func (h *Handler) GetRedisStats(w http.ResponseWriter, r *http.Request) { + core.OK(w, h.getRedisStats()) +} + +func (h *Handler) GetRuntimeStats(w http.ResponseWriter, r *http.Request) { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + response := RuntimeStats{ + GoVersion: runtime.Version(), + NumGoroutine: runtime.NumGoroutine(), + NumCPU: runtime.NumCPU(), + MemAlloc: memStats.Alloc, + MemSys: memStats.Sys, + NumGC: memStats.NumGC, + } + + core.OK(w, response) +} + +func (h *Handler) getDBStats() *DBPoolStats { + if h.dbStats == nil { + return nil + } + + stats := h.dbStats() + return &DBPoolStats{ + MaxOpenConnections: stats.MaxOpenConnections, + OpenConnections: stats.OpenConnections, + InUse: stats.InUse, + Idle: stats.Idle, + WaitCount: stats.WaitCount, + WaitDuration: stats.WaitDuration.String(), + MaxIdleClosed: stats.MaxIdleClosed, + MaxIdleTimeClosed: stats.MaxIdleTimeClosed, + MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} + +func (h *Handler) getRedisStats() *RedisPoolStats { + if h.redisStats == nil { + return nil + } + + stats := h.redisStats() + return &RedisPoolStats{ + Hits: stats.Hits, + Misses: stats.Misses, + Timeouts: stats.Timeouts, + TotalConns: stats.TotalConns, + IdleConns: stats.IdleConns, + StaleConns: stats.StaleConns, + } +} + +type SystemStatsResponse struct { + Database DatabaseStatus `json:"database"` + Redis RedisStatus `json:"redis"` + Runtime RuntimeStats `json:"runtime"` +} + +type DatabaseStatus struct { + Healthy bool `json:"healthy"` + Stats *DBPoolStats `json:"stats,omitempty"` +} + +type RedisStatus struct { + Healthy bool `json:"healthy"` + Stats *RedisPoolStats `json:"stats,omitempty"` +} + +type DBPoolStats struct { + MaxOpenConnections int `json:"max_open_connections"` + OpenConnections int `json:"open_connections"` + InUse int `json:"in_use"` + Idle int `json:"idle"` + WaitCount int64 `json:"wait_count"` + WaitDuration string `json:"wait_duration"` + MaxIdleClosed int64 `json:"max_idle_closed"` + MaxIdleTimeClosed int64 `json:"max_idle_time_closed"` + MaxLifetimeClosed int64 `json:"max_lifetime_closed"` +} + +type RedisPoolStats struct { + Hits uint32 `json:"hits"` + Misses uint32 `json:"misses"` + Timeouts uint32 `json:"timeouts"` + TotalConns uint32 `json:"total_conns"` + IdleConns uint32 `json:"idle_conns"` + StaleConns uint32 `json:"stale_conns"` +} + +type RuntimeStats struct { + GoVersion string `json:"go_version"` + NumGoroutine int `json:"num_goroutine"` + NumCPU int `json:"num_cpu"` + MemAlloc uint64 `json:"mem_alloc_bytes"` + MemSys uint64 `json:"mem_sys_bytes"` + NumGC uint32 `json:"num_gc"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go new file mode 100644 index 00000000..9fc1cfa1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go @@ -0,0 +1,62 @@ +// AngelaMos | 2026 +// dto.go + +package auth + +import ( + "time" +) + +type LoginRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` +} + +type RegisterRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" validate:"required"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + ExpiresAt time.Time `json:"expires_at"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Tier string `json:"tier"` + CreatedAt time.Time `json:"created_at"` +} + +type AuthResponse struct { + User UserResponse `json:"user"` + Tokens TokenResponse `json:"tokens"` +} + +type SessionInfo struct { + ID string `json:"id"` + UserAgent string `json:"user_agent"` + IPAddress string `json:"ip_address"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type SessionsResponse struct { + Sessions []SessionInfo `json:"sessions"` +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8,max=128"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go new file mode 100644 index 00000000..8ce8a4e5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/entity.go @@ -0,0 +1,47 @@ +// AngelaMos | 2026 +// entity.go + +package auth + +import ( + "time" +) + +type RefreshToken struct { + ID string `db:"id"` + UserID string `db:"user_id"` + TokenHash string `db:"token_hash"` + FamilyID string `db:"family_id"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` + IsUsed bool `db:"is_used"` + UsedAt *time.Time `db:"used_at"` + RevokedAt *time.Time `db:"revoked_at"` + ReplacedByID *string `db:"replaced_by_id"` + UserAgent string `db:"user_agent"` + IPAddress string `db:"ip_address"` +} + +func (t *RefreshToken) IsExpired() bool { + return time.Now().After(t.ExpiresAt) +} + +func (t *RefreshToken) IsRevoked() bool { + return t.RevokedAt != nil +} + +func (t *RefreshToken) IsValid() bool { + return !t.IsExpired() && !t.IsRevoked() && !t.IsUsed +} + +func (t *RefreshToken) MarkAsUsed(replacedByID string) { + now := time.Now() + t.IsUsed = true + t.UsedAt = &now + t.ReplacedByID = &replacedByID +} + +func (t *RefreshToken) Revoke() { + now := time.Now() + t.RevokedAt = &now +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go new file mode 100644 index 00000000..16b3b6bd --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go @@ -0,0 +1,316 @@ +// AngelaMos | 2026 +// handler.go + +package auth + +import ( + "encoding/json" + "errors" + "net" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" +) + +type Handler struct { + service *Service + validator *validator.Validate +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/auth", func(r chi.Router) { + r.Post("/login", h.Login) + r.Post("/register", h.Register) + r.Post("/refresh", h.Refresh) + + r.Group(func(r chi.Router) { + r.Use(authenticator) + r.Get("/me", h.GetMe) + r.Post("/logout", h.Logout) + r.Post("/logout-all", h.LogoutAll) + r.Get("/sessions", h.GetSessions) + r.Delete("/sessions/{sessionID}", h.RevokeSession) + r.Post("/change-password", h.ChangePassword) + }) + }) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := extractIPAddress(r) + + resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress) + if err != nil { + if errors.Is(err, ErrInvalidCredentials) { + core.JSONError( + w, + core.UnauthorizedError("invalid email or password"), + ) + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, resp) +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := extractIPAddress(r) + + resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress) + if err != nil { + if errors.Is(err, ErrEmailExists) { + core.JSONError(w, core.DuplicateError("email")) + return + } + core.InternalServerError(w, err) + return + } + + core.Created(w, resp) +} + +func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + userAgent := r.UserAgent() + ipAddress := extractIPAddress(r) + + resp, err := h.service.Refresh( + r.Context(), + req.RefreshToken, + userAgent, + ipAddress, + ) + if err != nil { + if errors.Is(err, ErrTokenReuse) { + core.JSONError(w, core.NewAppError( + core.ErrTokenRevoked, + "security alert: token reuse detected, all sessions revoked", + http.StatusUnauthorized, + "TOKEN_REUSE_DETECTED", + )) + return + } + if errors.Is(err, core.ErrTokenExpired) { + core.JSONError(w, core.TokenExpiredError()) + return + } + if errors.Is(err, core.ErrTokenRevoked) { + core.JSONError(w, core.TokenRevokedError()) + return + } + if errors.Is(err, core.ErrTokenInvalid) { + core.JSONError(w, core.TokenInvalidError()) + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, resp) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.service.Logout(r.Context(), req.RefreshToken, userID); err != nil { + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "cannot revoke another user's token") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + if err := h.service.LogoutAll(r.Context(), userID); err != nil { + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetSessions(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + sessions, err := h.service.GetActiveSessions(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.OK(w, SessionsResponse{Sessions: sessions}) +} + +func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + sessionID := chi.URLParam(r, "sessionID") + if sessionID == "" { + core.BadRequest(w, "session ID required") + return + } + + if err := h.service.RevokeSession(r.Context(), userID, sessionID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "session") + return + } + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "cannot revoke another user's session") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + var req ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + if err := h.service.ChangePassword(r.Context(), userID, req.CurrentPassword, req.NewPassword); err != nil { + if errors.Is(err, ErrInvalidCredentials) { + core.JSONError( + w, + core.UnauthorizedError("current password is incorrect"), + ) + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + if userID == "" { + core.Unauthorized(w, "") + return + } + + user, err := h.service.GetCurrentUser(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, user) +} + +func extractIPAddress(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + return strings.TrimSpace(ips[len(ips)-1]) + } + + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + + return ip +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go new file mode 100644 index 00000000..026cf056 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go @@ -0,0 +1,295 @@ +// AngelaMos | 2026 +// jwt.go + +package auth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" + + "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" +) + +type JWTManager struct { + privateKey jwk.Key + publicKey jwk.Key + publicJWKS jwk.Set + config config.JWTConfig +} + +func NewJWTManager(cfg config.JWTConfig) (*JWTManager, error) { + privateKeyPEM, err := os.ReadFile(cfg.PrivateKeyPath) + if err != nil { + return nil, fmt.Errorf("read private key: %w", err) + } + + privateKey, err := jwk.ParseKey(privateKeyPEM, jwk.WithPEM(true)) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + if setErr := privateKey.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil { + return nil, fmt.Errorf("set algorithm: %w", setErr) + } + + keyID := uuid.New().String()[:8] + if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil { + return nil, fmt.Errorf("set key id: %w", setErr) + } + + publicKey, err := privateKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("derive public key: %w", err) + } + + if setErr := publicKey.Set(jwk.KeyUsageKey, "sig"); setErr != nil { + return nil, fmt.Errorf("set key usage: %w", setErr) + } + + publicJWKS := jwk.NewSet() + if addErr := publicJWKS.AddKey(publicKey); addErr != nil { + return nil, fmt.Errorf("add key to set: %w", addErr) + } + + return &JWTManager{ + privateKey: privateKey, + publicKey: publicKey, + publicJWKS: publicJWKS, + config: cfg, + }, nil +} + +func GenerateKeyPair(privateKeyPath, publicKeyPath string) error { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate key: %w", err) + } + + jwkPrivate, err := jwk.Import(privateKey) + if err != nil { + return fmt.Errorf("import private key: %w", err) + } + + keyID := uuid.New().String()[:8] + if setErr := jwkPrivate.Set(jwk.KeyIDKey, keyID); setErr != nil { + return fmt.Errorf("set key id: %w", setErr) + } + if setErr := jwkPrivate.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil { + return fmt.Errorf("set algorithm: %w", setErr) + } + + privatePEM, err := jwk.Pem(jwkPrivate) + if err != nil { + return fmt.Errorf("encode private key: %w", err) + } + + if writeErr := os.WriteFile(privateKeyPath, privatePEM, 0o600); writeErr != nil { + return fmt.Errorf("write private key: %w", writeErr) + } + + jwkPublic, err := jwkPrivate.PublicKey() + if err != nil { + return fmt.Errorf("derive public key: %w", err) + } + + publicPEM, err := jwk.Pem(jwkPublic) + if err != nil { + return fmt.Errorf("encode public key: %w", err) + } + + //nolint:gosec // G306: public key is intentionally world-readable + if writeErr := os.WriteFile(publicKeyPath, publicPEM, 0o644); writeErr != nil { + return fmt.Errorf("write public key: %w", writeErr) + } + + return nil +} + +type AccessTokenClaims struct { + UserID string `json:"sub"` + Role string `json:"role"` + Tier string `json:"tier"` + TokenVersion int `json:"token_version"` +} + +func (m *JWTManager) CreateAccessToken( + claims AccessTokenClaims, +) (string, error) { + now := time.Now() + + token, err := jwt.NewBuilder(). + JwtID(uuid.New().String()). + Issuer(m.config.Issuer). + Audience([]string{m.config.Audience}). + Subject(claims.UserID). + IssuedAt(now). + Expiration(now.Add(m.config.AccessTokenExpire)). + NotBefore(now). + Claim("role", claims.Role). + Claim("tier", claims.Tier). + Claim("token_version", claims.TokenVersion). + Claim("type", "access"). + Build() + if err != nil { + return "", fmt.Errorf("build token: %w", err) + } + + signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), m.privateKey)) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + + return string(signed), nil +} + +func (m *JWTManager) VerifyAccessToken( + ctx context.Context, + tokenString string, +) (*middleware.AccessTokenClaims, error) { + token, err := jwt.Parse( + []byte(tokenString), + jwt.WithKey(jwa.ES256(), m.publicKey), + jwt.WithValidate(true), + jwt.WithIssuer(m.config.Issuer), + jwt.WithAudience(m.config.Audience), + ) + if err != nil { + if isTokenExpiredError(err) { + return nil, fmt.Errorf("verify token: %w", core.ErrTokenExpired) + } + return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid) + } + + var tokenType string + if err := token.Get("type", &tokenType); err != nil || + tokenType != "access" { + return nil, fmt.Errorf( + "verify token: invalid token type: %w", + core.ErrTokenInvalid, + ) + } + + subject, ok := token.Subject() + if !ok || subject == "" { + return nil, fmt.Errorf( + "verify token: missing subject: %w", + core.ErrTokenInvalid, + ) + } + + var roleStr string + if err := token.Get("role", &roleStr); err != nil { + return nil, fmt.Errorf( + "verify token: missing role claim: %w", + core.ErrTokenInvalid, + ) + } + + var tierStr string + if err := token.Get("tier", &tierStr); err != nil { + return nil, fmt.Errorf( + "verify token: missing tier claim: %w", + core.ErrTokenInvalid, + ) + } + + var versionFloat float64 + if err := token.Get("token_version", &versionFloat); err != nil { + return nil, fmt.Errorf( + "verify token: missing token_version claim: %w", + core.ErrTokenInvalid, + ) + } + + return &middleware.AccessTokenClaims{ + UserID: subject, + Role: roleStr, + Tier: tierStr, + TokenVersion: int(versionFloat), + }, nil +} + +func isTokenExpiredError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "exp") && + strings.Contains(errStr, "not satisfied") +} + +func (m *JWTManager) GetJWKSHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + + if err := json.NewEncoder(w).Encode(m.publicJWKS); err != nil { + http.Error( + w, + "Internal Server Error", + http.StatusInternalServerError, + ) + return + } + } +} + +func (m *JWTManager) GetPublicKey() jwk.Key { + return m.publicKey +} + +func (m *JWTManager) GetKeyID() string { + var kid string + //nolint:errcheck // key ID always set during NewJWTManager init + _ = m.privateKey.Get(jwk.KeyIDKey, &kid) + return kid +} + +type RefreshTokenData struct { + Token string + Hash string + ExpiresAt time.Time + FamilyID string +} + +func (m *JWTManager) CreateRefreshToken( + userID, familyID string, +) (*RefreshTokenData, error) { + token, err := core.GenerateRefreshToken() + if err != nil { + return nil, fmt.Errorf("generate refresh token: %w", err) + } + + hash := core.HashToken(token) + expiresAt := time.Now().Add(m.config.RefreshTokenExpire) + + if familyID == "" { + familyID = uuid.New().String() + } + + return &RefreshTokenData{ + Token: token, + Hash: hash, + ExpiresAt: expiresAt, + FamilyID: familyID, + }, nil +} + +func (m *JWTManager) VerifyRefreshTokenHash(token, storedHash string) bool { + return core.CompareTokenHash(token, storedHash) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go new file mode 100644 index 00000000..2abb0360 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go @@ -0,0 +1,236 @@ +// AngelaMos | 2026 +// repository.go + +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type Repository interface { + Create(ctx context.Context, token *RefreshToken) error + FindByHash(ctx context.Context, tokenHash string) (*RefreshToken, error) + FindByID(ctx context.Context, id string) (*RefreshToken, error) + MarkAsUsed(ctx context.Context, id, replacedByID string) error + RevokeByID(ctx context.Context, id string) error + RevokeByFamilyID(ctx context.Context, familyID string) error + RevokeAllForUser(ctx context.Context, userID string) error + GetActiveSessionsForUser( + ctx context.Context, + userID string, + ) ([]RefreshToken, error) + DeleteExpired(ctx context.Context) (int64, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) Create(ctx context.Context, token *RefreshToken) error { + query := ` + INSERT INTO refresh_tokens ( + id, user_id, token_hash, family_id, expires_at, + user_agent, ip_address + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + RETURNING created_at` + + err := r.db.GetContext(ctx, &token.CreatedAt, query, + token.ID, + token.UserID, + token.TokenHash, + token.FamilyID, + token.ExpiresAt, + token.UserAgent, + token.IPAddress, + ) + if err != nil { + return fmt.Errorf("create refresh token: %w", err) + } + + return nil +} + +func (r *repository) FindByHash( + ctx context.Context, + tokenHash string, +) (*RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE token_hash = $1` + + var token RefreshToken + err := r.db.GetContext(ctx, &token, query, tokenHash) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("find refresh token: %w", err) + } + + return &token, nil +} + +func (r *repository) FindByID( + ctx context.Context, + id string, +) (*RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE id = $1` + + var token RefreshToken + err := r.db.GetContext(ctx, &token, query, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("find refresh token: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("find refresh token: %w", err) + } + + return &token, nil +} + +func (r *repository) MarkAsUsed( + ctx context.Context, + id, replacedByID string, +) error { + query := ` + UPDATE refresh_tokens + SET is_used = true, used_at = NOW(), replaced_by_id = $2 + WHERE id = $1 AND is_used = false` + + result, err := r.db.ExecContext(ctx, query, id, replacedByID) + if err != nil { + return fmt.Errorf("mark refresh token as used: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("mark refresh token as used: %w", err) + } + + if rows == 0 { + return fmt.Errorf("mark refresh token as used: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) RevokeByID(ctx context.Context, id string) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE id = $1 AND revoked_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + + if rows == 0 { + return fmt.Errorf("revoke refresh token: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) RevokeByFamilyID( + ctx context.Context, + familyID string, +) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE family_id = $1 AND revoked_at IS NULL` + + _, err := r.db.ExecContext(ctx, query, familyID) + if err != nil { + return fmt.Errorf("revoke token family: %w", err) + } + + return nil +} + +func (r *repository) RevokeAllForUser( + ctx context.Context, + userID string, +) error { + query := ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL` + + _, err := r.db.ExecContext(ctx, query, userID) + if err != nil { + return fmt.Errorf("revoke all user tokens: %w", err) + } + + return nil +} + +func (r *repository) GetActiveSessionsForUser( + ctx context.Context, + userID string, +) ([]RefreshToken, error) { + query := ` + SELECT + id, user_id, token_hash, family_id, expires_at, created_at, + is_used, used_at, revoked_at, replaced_by_id, user_agent, ip_address + FROM refresh_tokens + WHERE user_id = $1 + AND revoked_at IS NULL + AND is_used = false + AND expires_at > NOW() + ORDER BY created_at DESC` + + var tokens []RefreshToken + err := r.db.SelectContext(ctx, &tokens, query, userID) + if err != nil { + return nil, fmt.Errorf("get active sessions: %w", err) + } + + return tokens, nil +} + +func (r *repository) DeleteExpired(ctx context.Context) (int64, error) { + query := ` + DELETE FROM refresh_tokens + WHERE expires_at < $1` + + cutoff := time.Now().Add(-24 * time.Hour) + + result, err := r.db.ExecContext(ctx, query, cutoff) + if err != nil { + return 0, fmt.Errorf("delete expired tokens: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("delete expired tokens: %w", err) + } + + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go new file mode 100644 index 00000000..3629051c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go @@ -0,0 +1,411 @@ +// AngelaMos | 2026 +// service.go + +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrTokenReuse = errors.New("token reuse detected") + ErrEmailExists = errors.New("email already exists") +) + +type UserInfo struct { + ID string + Email string + Name string + PasswordHash string + Role string + Tier string + TokenVersion int +} + +type UserProvider interface { + GetByEmail(ctx context.Context, email string) (*UserInfo, error) + GetByID(ctx context.Context, id string) (*UserInfo, error) + Create( + ctx context.Context, + email, passwordHash, name string, + ) (*UserInfo, error) + IncrementTokenVersion(ctx context.Context, userID string) error + UpdatePassword(ctx context.Context, userID, passwordHash string) error +} + +type Service struct { + repo Repository + jwt *JWTManager + userProvider UserProvider + redis *redis.Client + blacklistTTL time.Duration +} + +func NewService( + repo Repository, + jwt *JWTManager, + userProvider UserProvider, + redisClient *redis.Client, +) *Service { + return &Service{ + repo: repo, + jwt: jwt, + userProvider: userProvider, + redis: redisClient, + blacklistTTL: 15 * time.Minute, + } +} + +func (s *Service) Login( + ctx context.Context, + req LoginRequest, + userAgent, ipAddress string, +) (*AuthResponse, error) { + user, err := s.userProvider.GetByEmail(ctx, req.Email) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + //nolint:errcheck // timing attack prevention - always verify to prevent enumeration + _, _, _ = core.VerifyPasswordTimingSafe(req.Password, nil) + return nil, ErrInvalidCredentials + } + return nil, fmt.Errorf("get user: %w", err) + } + + valid, newHash, err := core.VerifyPasswordTimingSafe( + req.Password, + &user.PasswordHash, + ) + if err != nil { + return nil, fmt.Errorf("verify password: %w", err) + } + + if !valid { + return nil, ErrInvalidCredentials + } + + if newHash != "" { + //nolint:errcheck // best-effort rehash upgrade + _ = s.userProvider.UpdatePassword(ctx, user.ID, newHash) + } + + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) +} + +func (s *Service) Register( + ctx context.Context, + req RegisterRequest, + userAgent, ipAddress string, +) (*AuthResponse, error) { + passwordHash, err := core.HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user, err := s.userProvider.Create(ctx, req.Email, passwordHash, req.Name) + if err != nil { + if errors.Is(err, core.ErrDuplicateKey) { + return nil, ErrEmailExists + } + return nil, fmt.Errorf("create user: %w", err) + } + + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) +} + +func (s *Service) Refresh( + ctx context.Context, + refreshToken, userAgent, ipAddress string, +) (*AuthResponse, error) { + tokenHash := core.HashToken(refreshToken) + + storedToken, err := s.repo.FindByHash(ctx, tokenHash) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil, fmt.Errorf("refresh: %w", core.ErrTokenInvalid) + } + return nil, fmt.Errorf("find token: %w", err) + } + + if storedToken.IsUsed { + //nolint:errcheck // security revocation continues regardless + _ = s.repo.RevokeByFamilyID(ctx, storedToken.FamilyID) + return nil, ErrTokenReuse + } + + if !storedToken.IsValid() { + if storedToken.IsRevoked() { + return nil, fmt.Errorf("refresh: %w", core.ErrTokenRevoked) + } + return nil, fmt.Errorf("refresh: %w", core.ErrTokenExpired) + } + + user, err := s.userProvider.GetByID(ctx, storedToken.UserID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + return s.createAuthResponse( + ctx, + user, + userAgent, + ipAddress, + storedToken.FamilyID, + &storedToken.ID, + ) +} + +func (s *Service) Logout( + ctx context.Context, + refreshToken, userID string, +) error { + tokenHash := core.HashToken(refreshToken) + + storedToken, err := s.repo.FindByHash(ctx, tokenHash) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil + } + return fmt.Errorf("find token: %w", err) + } + + if storedToken.UserID != userID { + return fmt.Errorf("logout: %w", core.ErrForbidden) + } + + if err := s.repo.RevokeByID(ctx, storedToken.ID); err != nil && + !errors.Is(err, core.ErrNotFound) { + return fmt.Errorf("revoke token: %w", err) + } + + return nil +} + +func (s *Service) LogoutAll(ctx context.Context, userID string) error { + if err := s.repo.RevokeAllForUser(ctx, userID); err != nil { + return fmt.Errorf("revoke all tokens: %w", err) + } + + if err := s.userProvider.IncrementTokenVersion(ctx, userID); err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + return nil +} + +func (s *Service) RevokeAccessToken( + ctx context.Context, + jti string, + expiresAt time.Time, +) error { + key := "blacklist:" + jti + ttl := time.Until(expiresAt) + + if ttl <= 0 { + return nil + } + + if err := s.redis.Set(ctx, key, "1", ttl).Err(); err != nil { + return fmt.Errorf("blacklist token: %w", err) + } + + return nil +} + +func (s *Service) IsAccessTokenBlacklisted( + ctx context.Context, + jti string, +) (bool, error) { + key := "blacklist:" + jti + + exists, err := s.redis.Exists(ctx, key).Result() + if err != nil { + return false, fmt.Errorf("check blacklist: %w", err) + } + + return exists > 0, nil +} + +func (s *Service) GetActiveSessions( + ctx context.Context, + userID string, +) ([]SessionInfo, error) { + tokens, err := s.repo.GetActiveSessionsForUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get sessions: %w", err) + } + + sessions := make([]SessionInfo, 0, len(tokens)) + for _, t := range tokens { + sessions = append(sessions, SessionInfo{ + ID: t.ID, + UserAgent: t.UserAgent, + IPAddress: t.IPAddress, + CreatedAt: t.CreatedAt, + ExpiresAt: t.ExpiresAt, + }) + } + + return sessions, nil +} + +func (s *Service) RevokeSession( + ctx context.Context, + userID, sessionID string, +) error { + token, err := s.repo.FindByID(ctx, sessionID) + if err != nil { + return fmt.Errorf("find session: %w", err) + } + + if token.UserID != userID { + return fmt.Errorf("revoke session: %w", core.ErrForbidden) + } + + if err := s.repo.RevokeByID(ctx, sessionID); err != nil { + return fmt.Errorf("revoke session: %w", err) + } + + return nil +} + +func (s *Service) ChangePassword( + ctx context.Context, + userID, currentPassword, newPassword string, +) error { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + valid, _, err := core.VerifyPasswordWithRehash( + currentPassword, + user.PasswordHash, + ) + if err != nil { + return fmt.Errorf("verify password: %w", err) + } + + if !valid { + return ErrInvalidCredentials + } + + newHash, err := core.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + if err := s.userProvider.UpdatePassword(ctx, userID, newHash); err != nil { + return fmt.Errorf("update password: %w", err) + } + + if err := s.LogoutAll(ctx, userID); err != nil { + return fmt.Errorf("logout all: %w", err) + } + + return nil +} + +func (s *Service) ValidateTokenVersion( + ctx context.Context, + userID string, + tokenVersion int, +) error { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + if tokenVersion < user.TokenVersion { + return fmt.Errorf("validate token version: %w", core.ErrTokenRevoked) + } + + return nil +} + +func (s *Service) GetCurrentUser( + ctx context.Context, + userID string, +) (*UserResponse, error) { + user, err := s.userProvider.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + return &UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + Tier: user.Tier, + }, nil +} + +func (s *Service) createAuthResponse( + ctx context.Context, + user *UserInfo, + userAgent, ipAddress, familyID string, + oldTokenID *string, +) (*AuthResponse, error) { + accessToken, err := s.jwt.CreateAccessToken(AccessTokenClaims{ + UserID: user.ID, + Role: user.Role, + Tier: user.Tier, + TokenVersion: user.TokenVersion, + }) + if err != nil { + return nil, fmt.Errorf("create access token: %w", err) + } + + refreshData, err := s.jwt.CreateRefreshToken(user.ID, familyID) + if err != nil { + return nil, fmt.Errorf("create refresh token: %w", err) + } + + newTokenID := uuid.New().String() + + refreshTokenEntity := &RefreshToken{ + ID: newTokenID, + UserID: user.ID, + TokenHash: refreshData.Hash, + FamilyID: refreshData.FamilyID, + ExpiresAt: refreshData.ExpiresAt, + UserAgent: userAgent, + IPAddress: ipAddress, + } + + if err := s.repo.Create(ctx, refreshTokenEntity); err != nil { + return nil, fmt.Errorf("store refresh token: %w", err) + } + + if oldTokenID != nil { + //nolint:errcheck // best-effort token chain tracking + _ = s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID) + } + + return &AuthResponse{ + User: UserResponse{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + Tier: user.Tier, + CreatedAt: time.Now(), + }, + Tokens: TokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshData.Token, + TokenType: "Bearer", + ExpiresIn: int(15 * time.Minute / time.Second), + ExpiresAt: time.Now().Add(15 * time.Minute), + }, + }, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go new file mode 100644 index 00000000..ff266e50 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -0,0 +1,302 @@ +// AngelaMos | 2026 +// config.go + +package config + +import ( + "fmt" + "sync" + "time" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +type Config struct { + App AppConfig `koanf:"app"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + Redis RedisConfig `koanf:"redis"` + JWT JWTConfig `koanf:"jwt"` + RateLimit RateLimitConfig `koanf:"rate_limit"` + CORS CORSConfig `koanf:"cors"` + Log LogConfig `koanf:"log"` + Otel OtelConfig `koanf:"otel"` +} + +type AppConfig struct { + Name string `koanf:"name"` + Version string `koanf:"version"` + Environment string `koanf:"environment"` +} + +type ServerConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` +} + +type DatabaseConfig struct { + URL string `koanf:"url"` + MaxOpenConns int `koanf:"max_open_conns"` + MaxIdleConns int `koanf:"max_idle_conns"` + ConnMaxLifetime time.Duration `koanf:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `koanf:"conn_max_idle_time"` +} + +type RedisConfig struct { + URL string `koanf:"url"` + PoolSize int `koanf:"pool_size"` + MinIdleConns int `koanf:"min_idle_conns"` +} + +type JWTConfig struct { + PrivateKeyPath string `koanf:"private_key_path"` + PublicKeyPath string `koanf:"public_key_path"` + AccessTokenExpire time.Duration `koanf:"access_token_expire"` + RefreshTokenExpire time.Duration `koanf:"refresh_token_expire"` + Issuer string `koanf:"issuer"` + Audience string `koanf:"audience"` +} + +type RateLimitConfig struct { + Requests int `koanf:"requests"` + Window time.Duration `koanf:"window"` + Burst int `koanf:"burst"` +} + +type CORSConfig struct { + AllowedOrigins []string `koanf:"allowed_origins"` + AllowedMethods []string `koanf:"allowed_methods"` + AllowedHeaders []string `koanf:"allowed_headers"` + AllowCredentials bool `koanf:"allow_credentials"` + MaxAge int `koanf:"max_age"` +} + +type LogConfig struct { + Level string `koanf:"level"` + Format string `koanf:"format"` +} + +type OtelConfig struct { + Endpoint string `koanf:"endpoint"` + ServiceName string `koanf:"service_name"` + Enabled bool `koanf:"enabled"` + Insecure bool `koanf:"insecure"` + SampleRate float64 `koanf:"sample_rate"` +} + +var ( + cfg *Config + once sync.Once +) + +func Load(configPath string) (*Config, error) { + var loadErr error + + once.Do(func() { + k := koanf.New(".") + + if err := loadDefaults(k); err != nil { + loadErr = fmt.Errorf("load defaults: %w", err) + return + } + + if configPath != "" { + if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + loadErr = fmt.Errorf("load config file: %w", err) + return + } + } + + if err := k.Load(env.Provider("", ".", envKeyReplacer), nil); err != nil { + loadErr = fmt.Errorf("load env vars: %w", err) + return + } + + cfg = &Config{} + if err := k.Unmarshal("", cfg); err != nil { + loadErr = fmt.Errorf("unmarshal config: %w", err) + return + } + + if err := validate(cfg); err != nil { + loadErr = fmt.Errorf("validate config: %w", err) + return + } + }) + + if loadErr != nil { + return nil, loadErr + } + + return cfg, nil +} + +func Get() *Config { + if cfg == nil { + panic("config not loaded: call Load() first") + } + return cfg +} + +func loadDefaults(k *koanf.Koanf) error { + defaults := map[string]any{ + "app.name": "Go Backend", + "app.version": "1.0.0", + "app.environment": "development", + + "server.host": "0.0.0.0", + "server.port": 8080, + "server.read_timeout": "30s", + "server.write_timeout": "30s", + "server.idle_timeout": "120s", + "server.shutdown_timeout": "15s", + + "database.max_open_conns": 25, + "database.max_idle_conns": 5, + "database.conn_max_lifetime": "1h", + "database.conn_max_idle_time": "30m", + + "redis.pool_size": 10, + "redis.min_idle_conns": 5, + + "jwt.access_token_expire": "15m", + "jwt.refresh_token_expire": "168h", + "jwt.issuer": "go-backend", + "jwt.audience": "go-backend-api", + "jwt.private_key_path": "keys/private.pem", + "jwt.public_key_path": "keys/public.pem", + + "rate_limit.requests": 100, + "rate_limit.window": "1m", + "rate_limit.burst": 20, + + "cors.allowed_origins": []string{"http://localhost:3000"}, + "cors.allowed_methods": []string{ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + }, + "cors.allowed_headers": []string{ + "Accept", + "Authorization", + "Content-Type", + "X-Request-ID", + }, + "cors.allow_credentials": true, + "cors.max_age": 300, + + "log.level": "info", + "log.format": "json", + + "otel.enabled": false, + "otel.insecure": true, + "otel.sample_rate": 0.1, + "otel.service_name": "go-backend", + } + + for key, value := range defaults { + if err := k.Set(key, value); err != nil { + return fmt.Errorf("set default %s: %w", key, err) + } + } + + return nil +} + +var envKeyMap = map[string]string{ + "DATABASE_URL": "database.url", + "REDIS_URL": "redis.url", + "ENVIRONMENT": "app.environment", + "HOST": "server.host", + "PORT": "server.port", + "LOG_LEVEL": "log.level", + "LOG_FORMAT": "log.format", + "JWT_PRIVATE_KEY_PATH": "jwt.private_key_path", + "JWT_PUBLIC_KEY_PATH": "jwt.public_key_path", + "JWT_ACCESS_TOKEN_EXPIRE": "jwt.access_token_expire", + "JWT_REFRESH_TOKEN_EXPIRE": "jwt.refresh_token_expire", + "JWT_ISSUER": "jwt.issuer", + "JWT_AUDIENCE": "jwt.audience", + "RATE_LIMIT_REQUESTS": "rate_limit.requests", + "RATE_LIMIT_WINDOW": "rate_limit.window", + "RATE_LIMIT_BURST": "rate_limit.burst", + "OTEL_ENDPOINT": "otel.endpoint", + "OTEL_EXPORTER_OTLP_ENDPOINT": "otel.endpoint", + "OTEL_SERVICE_NAME": "otel.service_name", + "OTEL_ENABLED": "otel.enabled", + "OTEL_INSECURE": "otel.insecure", + "OTEL_SAMPLE_RATE": "otel.sample_rate", +} + +func envKeyReplacer(s string) string { + if mapped, ok := envKeyMap[s]; ok { + return mapped + } + return "" +} + +func validate(c *Config) error { + if c.Database.URL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + + if c.Redis.URL == "" { + return fmt.Errorf("REDIS_URL is required") + } + + if c.JWT.PrivateKeyPath == "" { + return fmt.Errorf("JWT_PRIVATE_KEY_PATH is required") + } + + if c.JWT.PublicKeyPath == "" { + return fmt.Errorf("JWT_PUBLIC_KEY_PATH is required") + } + + if c.CORS.AllowCredentials { + for _, origin := range c.CORS.AllowedOrigins { + if origin == "*" { + return fmt.Errorf( + "CORS wildcard '*' cannot be used with AllowCredentials", + ) + } + } + } + + if c.App.Environment == "production" { + if c.Otel.Enabled && c.Otel.Insecure { + return fmt.Errorf("OTEL_INSECURE must be false in production") + } + } + + if c.Server.ReadTimeout <= 0 { + return fmt.Errorf("server.read_timeout must be positive") + } + + if c.Server.WriteTimeout <= 0 { + return fmt.Errorf("server.write_timeout must be positive") + } + + return nil +} + +func (c *Config) IsProduction() bool { + return c.App.Environment == "production" +} + +func (c *Config) IsDevelopment() bool { + return c.App.Environment == "development" +} + +func (s *ServerConfig) Address() string { + return fmt.Sprintf("%s:%d", s.Host, s.Port) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go new file mode 100644 index 00000000..766af7f8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go @@ -0,0 +1,145 @@ +// AngelaMos | 2026 +// database.go + +package core + +import ( + "context" + "database/sql" + "fmt" + "math/rand/v2" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +type Database struct { + DB *sqlx.DB +} + +func NewDatabase( + ctx context.Context, + cfg config.DatabaseConfig, +) (*Database, error) { + db, err := sqlx.ConnectContext(ctx, "pgx", cfg.URL) + if err != nil { + return nil, fmt.Errorf("connect to database: %w", err) + } + + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(jitteredDuration(cfg.ConnMaxLifetime)) + db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := db.PingContext(pingCtx); err != nil { + _ = db.Close() //nolint:errcheck // cleanup on connection failure + return nil, fmt.Errorf("ping database: %w", err) + } + + return &Database{DB: db}, nil +} + +func (d *Database) Close() error { + if d.DB != nil { + return d.DB.Close() + } + return nil +} + +func (d *Database) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := d.DB.PingContext(pingCtx); err != nil { + return fmt.Errorf("database ping failed: %w", err) + } + + return nil +} + +func (d *Database) Stats() sql.DBStats { + return d.DB.Stats() +} + +type DBTX interface { + sqlx.ExtContext + sqlx.ExecerContext + GetContext(ctx context.Context, dest any, query string, args ...any) error + SelectContext( + ctx context.Context, + dest any, + query string, + args ...any, + ) error +} + +func InTx(ctx context.Context, db *sqlx.DB, fn func(tx *sqlx.Tx) error) error { + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func InTxWithOptions( + ctx context.Context, + db *sqlx.DB, + opts *sql.TxOptions, + fn func(tx *sqlx.Tx) error, +) error { + tx, err := db.BeginTxx(ctx, opts) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + + defer func() { + if p := recover(); p != nil { + _ = tx.Rollback() //nolint:errcheck // best-effort rollback on panic + panic(p) + } + }() + + if err := fn(tx); err != nil { + if rbErr := tx.Rollback(); rbErr != nil { + return fmt.Errorf("rollback failed: %w (original: %w)", rbErr, err) + } + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} + +func jitteredDuration(base time.Duration) time.Duration { + //nolint:gosec // G404: non-security-sensitive jitter for connection pool + jitter := time.Duration(rand.Int64N(int64(base / 7))) + return base + jitter +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go new file mode 100644 index 00000000..b478a6ee --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/errors.go @@ -0,0 +1,169 @@ +// AngelaMos | 2026 +// errors.go + +package core + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + ErrNotFound = errors.New("resource not found") + ErrDuplicateKey = errors.New("duplicate key violation") + ErrForeignKey = errors.New("foreign key violation") + ErrInvalidInput = errors.New("invalid input") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrInternal = errors.New("internal server error") + ErrConflict = errors.New("resource conflict") + ErrRateLimited = errors.New("rate limit exceeded") + ErrTokenExpired = errors.New("token expired") + ErrTokenInvalid = errors.New("token invalid") + ErrTokenRevoked = errors.New("token revoked") +) + +type AppError struct { + Err error `json:"-"` + Message string `json:"message"` + StatusCode int `json:"-"` + Code string `json:"code"` +} + +func (e *AppError) Error() string { + if e.Message != "" { + return e.Message + } + if e.Err != nil { + return e.Err.Error() + } + return "unknown error" +} + +func (e *AppError) Unwrap() error { + return e.Err +} + +func NewAppError( + err error, + message string, + statusCode int, + code string, +) *AppError { + return &AppError{ + Err: err, + Message: message, + StatusCode: statusCode, + Code: code, + } +} + +func NotFoundError(resource string) *AppError { + return &AppError{ + Err: ErrNotFound, + Message: fmt.Sprintf("%s not found", resource), + StatusCode: http.StatusNotFound, + Code: "NOT_FOUND", + } +} + +func DuplicateError(field string) *AppError { + return &AppError{ + Err: ErrDuplicateKey, + Message: fmt.Sprintf("%s already exists", field), + StatusCode: http.StatusConflict, + Code: "DUPLICATE", + } +} + +func ValidationError(message string) *AppError { + return &AppError{ + Err: ErrInvalidInput, + Message: message, + StatusCode: http.StatusBadRequest, + Code: "VALIDATION_ERROR", + } +} + +func UnauthorizedError(message string) *AppError { + if message == "" { + message = "authentication required" + } + return &AppError{ + Err: ErrUnauthorized, + Message: message, + StatusCode: http.StatusUnauthorized, + Code: "UNAUTHORIZED", + } +} + +func ForbiddenError(message string) *AppError { + if message == "" { + message = "access denied" + } + return &AppError{ + Err: ErrForbidden, + Message: message, + StatusCode: http.StatusForbidden, + Code: "FORBIDDEN", + } +} + +func InternalError(err error) *AppError { + return &AppError{ + Err: err, + Message: "internal server error", + StatusCode: http.StatusInternalServerError, + Code: "INTERNAL_ERROR", + } +} + +func RateLimitError() *AppError { + return &AppError{ + Err: ErrRateLimited, + Message: "too many requests", + StatusCode: http.StatusTooManyRequests, + Code: "RATE_LIMITED", + } +} + +func TokenExpiredError() *AppError { + return &AppError{ + Err: ErrTokenExpired, + Message: "token has expired", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_EXPIRED", + } +} + +func TokenInvalidError() *AppError { + return &AppError{ + Err: ErrTokenInvalid, + Message: "invalid token", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_INVALID", + } +} + +func TokenRevokedError() *AppError { + return &AppError{ + Err: ErrTokenRevoked, + Message: "token has been revoked", + StatusCode: http.StatusUnauthorized, + Code: "TOKEN_REVOKED", + } +} + +func IsAppError(err error) bool { + var appErr *AppError + return errors.As(err, &appErr) +} + +func GetAppError(err error) *AppError { + var appErr *AppError + if errors.As(err, &appErr) { + return appErr + } + return InternalError(err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go new file mode 100644 index 00000000..822b6cc9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go @@ -0,0 +1,63 @@ +// AngelaMos | 2026 +// redis.go + +package core + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +type Redis struct { + Client *redis.Client +} + +func NewRedis(ctx context.Context, cfg config.RedisConfig) (*Redis, error) { + opts, err := redis.ParseURL(cfg.URL) + if err != nil { + return nil, fmt.Errorf("parse redis url: %w", err) + } + + opts.PoolSize = cfg.PoolSize + opts.MinIdleConns = cfg.MinIdleConns + opts.PoolTimeout = 30 * time.Second + opts.ConnMaxIdleTime = 5 * time.Minute + + client := redis.NewClient(opts) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := client.Ping(pingCtx).Err(); err != nil { + return nil, fmt.Errorf("ping redis: %w", err) + } + + return &Redis{Client: client}, nil +} + +func (r *Redis) Close() error { + if r.Client != nil { + return r.Client.Close() + } + return nil +} + +func (r *Redis) Ping(ctx context.Context) error { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := r.Client.Ping(pingCtx).Err(); err != nil { + return fmt.Errorf("redis ping failed: %w", err) + } + + return nil +} + +func (r *Redis) PoolStats() *redis.PoolStats { + return r.Client.PoolStats() +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go new file mode 100644 index 00000000..e37ac737 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/response.go @@ -0,0 +1,120 @@ +// AngelaMos | 2026 +// response.go + +package core + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +type Response struct { + Success bool `json:"success"` + Data any `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type Meta struct { + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Total int `json:"total,omitempty"` + TotalPages int `json:"total_pages,omitempty"` +} + +func JSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: status >= 200 && status < 300, + Data: data, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONWithMeta(w http.ResponseWriter, status int, data any, meta *Meta) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + response := Response{ + Success: true, + Data: data, + Meta: meta, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode response", "error", err) + } +} + +func JSONError(w http.ResponseWriter, err error) { + appErr := GetAppError(err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(appErr.StatusCode) + + response := Response{ + Success: false, + Error: &Error{ + Code: appErr.Code, + Message: appErr.Message, + }, + } + + if encErr := json.NewEncoder(w).Encode(response); encErr != nil { + slog.Error("failed to encode error response", "error", encErr) + } +} + +func Created(w http.ResponseWriter, data any) { + JSON(w, http.StatusCreated, data) +} + +func OK(w http.ResponseWriter, data any) { + JSON(w, http.StatusOK, data) +} + +func NoContent(w http.ResponseWriter) { + w.WriteHeader(http.StatusNoContent) +} + +func BadRequest(w http.ResponseWriter, message string) { + JSONError(w, ValidationError(message)) +} + +func NotFound(w http.ResponseWriter, resource string) { + JSONError(w, NotFoundError(resource)) +} + +func Unauthorized(w http.ResponseWriter, message string) { + JSONError(w, UnauthorizedError(message)) +} + +func Forbidden(w http.ResponseWriter, message string) { + JSONError(w, ForbiddenError(message)) +} + +func InternalServerError(w http.ResponseWriter, err error) { + slog.Error("internal server error", "error", err) + JSONError(w, InternalError(err)) +} + +func Paginated(w http.ResponseWriter, data any, page, pageSize, total int) { + totalPages := (total + pageSize - 1) / pageSize + JSONWithMeta(w, http.StatusOK, data, &Meta{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: totalPages, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go new file mode 100644 index 00000000..05065e1a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/security.go @@ -0,0 +1,218 @@ +// AngelaMos | 2026 +// security.go + +package core + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonTime = 1 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 + saltLength = 16 +) + +func HashPassword(password string) (string, error) { + salt := make([]byte, saltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + hash := argon2.IDKey( + []byte(password), + salt, + argonTime, + argonMemory, + argonThreads, + argonKeyLen, + ) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + encoded := fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argonMemory, + argonTime, + argonThreads, + b64Salt, + b64Hash, + ) + + return encoded, nil +} + +func VerifyPassword(password, encodedHash string) (bool, error) { + params, salt, hash, err := decodeHash(encodedHash) + if err != nil { + return false, err + } + + otherHash := argon2.IDKey( + []byte(password), + salt, + params.time, + params.memory, + params.threads, + params.keyLen, + ) + + if subtle.ConstantTimeCompare(hash, otherHash) == 1 { + return true, nil + } + + return false, nil +} + +func VerifyPasswordWithRehash( + password, encodedHash string, +) (bool, string, error) { + valid, err := VerifyPassword(password, encodedHash) + if err != nil { + return false, "", err + } + + if !valid { + return false, "", nil + } + + if needsRehash(encodedHash) { + newHash, hashErr := HashPassword(password) + if hashErr != nil { + //nolint:nilerr // password verified successfully; rehash failure is non-critical + return true, "", nil + } + return true, newHash, nil + } + + return true, "", nil +} + +var dummyHash string + +func init() { + hash, err := HashPassword("dummy_password_for_timing_attack_prevention") + if err != nil { + panic(fmt.Sprintf("security: failed to generate dummy hash: %v", err)) + } + dummyHash = hash +} + +func VerifyPasswordTimingSafe( + password string, + encodedHash *string, +) (bool, string, error) { + hashToVerify := dummyHash + if encodedHash != nil && *encodedHash != "" { + hashToVerify = *encodedHash + } + + valid, newHash, err := VerifyPasswordWithRehash(password, hashToVerify) + + if encodedHash == nil || *encodedHash == "" { + return false, "", nil + } + + return valid, newHash, err +} + +type argonParams struct { + memory uint32 + time uint32 + threads uint8 + keyLen uint32 +} + +func decodeHash(encodedHash string) (*argonParams, []byte, []byte, error) { + parts := strings.Split(encodedHash, "$") + if len(parts) != 6 { + return nil, nil, nil, fmt.Errorf("invalid hash format") + } + + if parts[1] != "argon2id" { + return nil, nil, nil, fmt.Errorf("unsupported algorithm: %s", parts[1]) + } + + var version int + _, err := fmt.Sscanf(parts[2], "v=%d", &version) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid version: %w", err) + } + + if version != argon2.Version { + return nil, nil, nil, fmt.Errorf("incompatible version: %d", version) + } + + params := &argonParams{} + _, err = fmt.Sscanf( + parts[3], + "m=%d,t=%d,p=%d", + ¶ms.memory, + ¶ms.time, + ¶ms.threads, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid params: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode salt: %w", err) + } + + hash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode hash: %w", err) + } + + //nolint:gosec // G115: hash length is always small (32 bytes for Argon2id) + params.keyLen = uint32(len(hash)) + + return params, salt, hash, nil +} + +func needsRehash(encodedHash string) bool { + params, _, _, err := decodeHash(encodedHash) + if err != nil { + return true + } + + return params.memory != argonMemory || + params.time != argonTime || + params.threads != argonThreads || + params.keyLen != argonKeyLen +} + +func GenerateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func GenerateRefreshToken() (string, error) { + return GenerateSecureToken(32) +} + +func HashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func CompareTokenHash(token, hash string) bool { + tokenHash := HashToken(token) + return subtle.ConstantTimeCompare([]byte(tokenHash), []byte(hash)) == 1 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go new file mode 100644 index 00000000..950bba50 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go @@ -0,0 +1,142 @@ +// AngelaMos | 2026 +// telemetry.go + +package core + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "go.opentelemetry.io/otel/trace" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +type Telemetry struct { + TracerProvider *sdktrace.TracerProvider + Tracer trace.Tracer +} + +func NewTelemetry( + ctx context.Context, + otelCfg config.OtelConfig, + appCfg config.AppConfig, +) (*Telemetry, error) { + if !otelCfg.Enabled || otelCfg.Endpoint == "" { + noopProvider := sdktrace.NewTracerProvider() + return &Telemetry{ + TracerProvider: noopProvider, + Tracer: noopProvider.Tracer(otelCfg.ServiceName), + }, nil + } + + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(otelCfg.Endpoint), + otlptracegrpc.WithTimeout(5 * time.Second), + } + + if otelCfg.Insecure { + opts = append( + opts, + otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()), + ) + } else { + opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, ""))) + } + + exporter, err := otlptracegrpc.New(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create otlp exporter: %w", err) + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(otelCfg.ServiceName), + semconv.ServiceVersion(appCfg.Version), + attribute.String("environment", appCfg.Environment), + ), + resource.WithHost(), + resource.WithProcess(), + ) + if err != nil { + return nil, fmt.Errorf("create resource: %w", err) + } + + sampleRate := otelCfg.SampleRate + if sampleRate <= 0 || sampleRate > 1 { + sampleRate = 0.1 + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter, + sdktrace.WithBatchTimeout(5*time.Second), + sdktrace.WithMaxExportBatchSize(512), + ), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.ParentBased( + sdktrace.TraceIDRatioBased(sampleRate), + )), + ) + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return &Telemetry{ + TracerProvider: tp, + Tracer: tp.Tracer(otelCfg.ServiceName), + }, nil +} + +func (t *Telemetry) Shutdown(ctx context.Context) error { + if t.TracerProvider == nil { + return nil + } + + shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := t.TracerProvider.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown tracer provider: %w", err) + } + + return nil +} + +func SpanFromContext(ctx context.Context) trace.Span { + return trace.SpanFromContext(ctx) +} + +func TraceIDFromContext(ctx context.Context) string { + span := trace.SpanFromContext(ctx) + if span.SpanContext().IsValid() { + return span.SpanContext().TraceID().String() + } + return "" +} + +func AddSpanEvent( + ctx context.Context, + name string, + attrs ...attribute.KeyValue, +) { + span := trace.SpanFromContext(ctx) + span.AddEvent(name, trace.WithAttributes(attrs...)) +} + +func SetSpanError(ctx context.Context, err error) { + span := trace.SpanFromContext(ctx) + span.RecordError(err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go new file mode 100644 index 00000000..f77cbd96 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/validation.go @@ -0,0 +1,42 @@ +// AngelaMos | 2026 +// validation.go + +package core + +import ( + "errors" + "strings" + + "github.com/go-playground/validator/v10" +) + +func FormatValidationError(err error) string { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + messages := make([]string, 0, len(ve)) + for _, fe := range ve { + messages = append(messages, FormatFieldError(fe)) + } + return strings.Join(messages, "; ") + } + return "validation failed" +} + +func FormatFieldError(fe validator.FieldError) string { + field := strings.ToLower(fe.Field()) + + switch fe.Tag() { + case "required": + return field + " is required" + case "email": + return field + " must be a valid email" + case "min": + return field + " must be at least " + fe.Param() + " characters" + case "max": + return field + " must be at most " + fe.Param() + " characters" + case "oneof": + return field + " must be one of: " + fe.Param() + default: + return field + " is invalid" + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go new file mode 100644 index 00000000..e7003c6a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/health/handler.go @@ -0,0 +1,195 @@ +// AngelaMos | 2026 +// handler.go + +package health + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" +) + +type Checker interface { + Ping(ctx context.Context) error +} + +type Handler struct { + db Checker + redis Checker + ready atomic.Bool + shutdown atomic.Bool +} + +func NewHandler(db, redis Checker) *Handler { + h := &Handler{ + db: db, + redis: redis, + } + h.ready.Store(true) + return h +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/healthz", h.Liveness) + r.Get("/livez", h.Liveness) + r.Get("/readyz", h.Readiness) +} + +func (h *Handler) Liveness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + h.writeStatus(w, http.StatusOK, StatusResponse{ + Status: "ok", + }) +} + +func (h *Handler) Readiness(w http.ResponseWriter, r *http.Request) { + if h.shutdown.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "shutting_down", + }) + return + } + + if !h.ready.Load() { + h.writeStatus(w, http.StatusServiceUnavailable, StatusResponse{ + Status: "not_ready", + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + checks := h.runHealthChecks(ctx) + + allHealthy := true + for _, check := range checks { + if !check.Healthy { + allHealthy = false + break + } + } + + status := "ok" + statusCode := http.StatusOK + if !allHealthy { + status = "degraded" + statusCode = http.StatusServiceUnavailable + } + + h.writeStatus(w, statusCode, ReadinessResponse{ + Status: status, + Checks: checks, + }) +} + +func (h *Handler) runHealthChecks(ctx context.Context) []HealthCheck { + var wg sync.WaitGroup + checks := make([]HealthCheck, 2) + + wg.Add(2) + + go func() { + defer wg.Done() + checks[0] = h.checkDatabase(ctx) + }() + + go func() { + defer wg.Done() + checks[1] = h.checkRedis(ctx) + }() + + wg.Wait() + return checks +} + +func (h *Handler) checkDatabase(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "database", + Healthy: true, + } + + if h.db == nil { + check.Healthy = false + check.Message = "database checker not configured" + return check + } + + start := time.Now() + err := h.db.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) checkRedis(ctx context.Context) HealthCheck { + check := HealthCheck{ + Name: "redis", + Healthy: true, + } + + if h.redis == nil { + check.Healthy = false + check.Message = "redis checker not configured" + return check + } + + start := time.Now() + err := h.redis.Ping(ctx) + check.Latency = time.Since(start).String() + + if err != nil { + check.Healthy = false + check.Message = "ping failed" + } + + return check +} + +func (h *Handler) SetReady(ready bool) { + h.ready.Store(ready) +} + +func (h *Handler) SetShutdown(shutdown bool) { + h.shutdown.Store(shutdown) +} + +func (h *Handler) writeStatus(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.WriteHeader(status) + //nolint:errcheck // best-effort response + _ = json.NewEncoder(w).Encode(data) +} + +type StatusResponse struct { + Status string `json:"status"` +} + +type ReadinessResponse struct { + Status string `json:"status"` + Checks []HealthCheck `json:"checks"` +} + +type HealthCheck struct { + Name string `json:"name"` + Healthy bool `json:"healthy"` + Latency string `json:"latency,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go new file mode 100644 index 00000000..0f9fb5f2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go @@ -0,0 +1,189 @@ +// AngelaMos | 2026 +// auth.go + +package middleware + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +const ( + UserIDKey contextKey = "user_id" + UserRoleKey contextKey = "user_role" + UserTierKey contextKey = "user_tier" + ClaimsKey contextKey = "jwt_claims" +) + +type TokenVerifier interface { + VerifyAccessToken( + ctx context.Context, + token string, + ) (*AccessTokenClaims, error) +} + +type AccessTokenClaims struct { + UserID string + Role string + Tier string + TokenVersion int +} + +func Authenticator(verifier TokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := ExtractToken(r) + + if token == "" { + core.JSONError( + w, + core.UnauthorizedError("missing authorization token"), + ) + return + } + + claims, err := verifier.VerifyAccessToken(r.Context(), token) + if err != nil { + handleAuthError(w, err) + return + } + + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, UserTierKey, claims.Tier) + ctx = context.WithValue(ctx, ClaimsKey, claims) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func OptionalAuth(verifier TokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := ExtractToken(r) + + if token != "" { + claims, err := verifier.VerifyAccessToken(r.Context(), token) + if err == nil { + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, UserTierKey, claims.Tier) + ctx = context.WithValue(ctx, ClaimsKey, claims) + r = r.WithContext(ctx) + } + } + + next.ServeHTTP(w, r) + }) + } +} + +func RequireRole(roles ...string) func(http.Handler) http.Handler { + roleSet := make(map[string]struct{}, len(roles)) + for _, role := range roles { + roleSet[role] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userRole := GetUserRole(r.Context()) + + if userRole == "" { + core.JSONError( + w, + core.UnauthorizedError("authentication required"), + ) + return + } + + if _, ok := roleSet[userRole]; !ok { + core.JSONError( + w, + core.ForbiddenError("insufficient permissions"), + ) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func RequireAdmin(next http.Handler) http.Handler { + return RequireRole("admin")(next) +} + +func ExtractToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "" + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "" + } + + return strings.TrimSpace(parts[1]) +} + +func handleAuthError(w http.ResponseWriter, err error) { + if core.IsAppError(err) { + core.JSONError(w, err) + return + } + + switch { + case errors.Is(err, core.ErrTokenExpired): + core.JSONError(w, core.TokenExpiredError()) + case errors.Is(err, core.ErrTokenRevoked): + core.JSONError(w, core.TokenRevokedError()) + case errors.Is(err, core.ErrTokenInvalid): + core.JSONError(w, core.TokenInvalidError()) + default: + core.JSONError(w, core.TokenInvalidError()) + } +} + +func GetUserID(ctx context.Context) string { + if id, ok := ctx.Value(UserIDKey).(string); ok { + return id + } + return "" +} + +func GetUserRole(ctx context.Context) string { + if role, ok := ctx.Value(UserRoleKey).(string); ok { + return role + } + return "" +} + +func GetUserTier(ctx context.Context) string { + if tier, ok := ctx.Value(UserTierKey).(string); ok { + return tier + } + return "" +} + +func GetClaims(ctx context.Context) *AccessTokenClaims { + if claims, ok := ctx.Value(ClaimsKey).(*AccessTokenClaims); ok { + return claims + } + return nil +} + +func IsAuthenticated(ctx context.Context) bool { + return GetUserID(ctx) != "" +} + +func IsAdmin(ctx context.Context) bool { + return GetUserRole(ctx) == "admin" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go new file mode 100644 index 00000000..332d48f3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go @@ -0,0 +1,103 @@ +// AngelaMos | 2026 +// headers.go + +package middleware + +import ( + "net/http" + "strconv" + "strings" + + "github.com/carterperez-dev/templates/go-backend/internal/config" +) + +func SecurityHeaders(isProduction bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("X-XSS-Protection", "1; mode=block") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()", + ) + + if isProduction { + h.Set( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload", + ) + } + + h.Set("Content-Security-Policy", buildCSP(isProduction)) + + next.ServeHTTP(w, r) + }) + } +} + +func buildCSP(isProduction bool) string { + directives := []string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + } + + if !isProduction { + directives[1] = "script-src 'self' 'unsafe-inline' 'unsafe-eval'" + } + + return strings.Join(directives, "; ") +} + +func CORS(cfg config.CORSConfig) func(http.Handler) http.Handler { + allowedOrigins := make(map[string]struct{}, len(cfg.AllowedOrigins)) + for _, origin := range cfg.AllowedOrigins { + allowedOrigins[origin] = struct{}{} + } + + methodsStr := strings.Join(cfg.AllowedMethods, ", ") + headersStr := strings.Join(cfg.AllowedHeaders, ", ") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin != "" { + if _, ok := allowedOrigins[origin]; ok { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + + if cfg.AllowCredentials { + w.Header(). + Set("Access-Control-Allow-Credentials", "true") + } + } + } + + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", methodsStr) + w.Header().Set("Access-Control-Allow-Headers", headersStr) + + if cfg.MaxAge > 0 { + w.Header(). + Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge)) + } + + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go new file mode 100644 index 00000000..602ed4c7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/logging.go @@ -0,0 +1,99 @@ +// AngelaMos | 2026 +// logging.go + +package middleware + +import ( + "context" + "log/slog" + "net/http" + "time" + + "go.opentelemetry.io/otel/trace" +) + +type loggerKey struct{} + +func Logger(baseLogger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + requestID := GetRequestID(r.Context()) + + reqLogger := baseLogger.With( + slog.String("request_id", requestID), + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + ) + + if span := trace.SpanFromContext(r.Context()); span.SpanContext(). + IsValid() { + reqLogger = reqLogger.With( + slog.String( + "trace_id", + span.SpanContext().TraceID().String(), + ), + slog.String( + "span_id", + span.SpanContext().SpanID().String(), + ), + ) + } + + ctx := context.WithValue(r.Context(), loggerKey{}, reqLogger) + + ww := &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } + + next.ServeHTTP(ww, r.WithContext(ctx)) + + latency := time.Since(start) + + logLevel := slog.LevelInfo + if ww.status >= 500 { + logLevel = slog.LevelError + } else if ww.status >= 400 { + logLevel = slog.LevelWarn + } + + reqLogger.Log(r.Context(), logLevel, "request completed", + slog.Int("status", ww.status), + slog.Int("bytes", ww.bytes), + slog.Duration("latency", latency), + slog.String("user_agent", r.UserAgent()), + ) + }) + } +} + +func GetLogger(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +type responseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + n, err := rw.ResponseWriter.Write(b) + rw.bytes += n + return n, err +} + +func (rw *responseWriter) Unwrap() http.ResponseWriter { + return rw.ResponseWriter +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go new file mode 100644 index 00000000..c85519a1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go @@ -0,0 +1,375 @@ +// AngelaMos | 2026 +// ratelimit.go + +package middleware + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + redis_rate "github.com/go-redis/redis_rate/v10" + "github.com/redis/go-redis/v9" + "golang.org/x/time/rate" +) + +type RateLimitConfig struct { + Limit redis_rate.Limit + KeyFunc func(*http.Request) string + FailOpen bool + BypassFunc func(*http.Request) bool + OnLimited func(http.ResponseWriter, *http.Request, *redis_rate.Result) +} + +type RateLimiter struct { + limiter *redis_rate.Limiter + fallback *localLimiter + config RateLimitConfig +} + +func NewRateLimiter(rdb *redis.Client, cfg RateLimitConfig) *RateLimiter { + if cfg.KeyFunc == nil { + cfg.KeyFunc = KeyByIP + } + + return &RateLimiter{ + limiter: redis_rate.NewLimiter(rdb), + fallback: newLocalLimiter(), + config: cfg, + } +} + +func (rl *RateLimiter) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if rl.config.BypassFunc != nil && rl.config.BypassFunc(r) { + next.ServeHTTP(w, r) + return + } + + key := rl.config.KeyFunc(r) + res, err := rl.allow(r.Context(), key) + if err != nil { + if rl.config.FailOpen { + slog.Warn("rate limiter error, failing open", + "error", err, + "key", key, + ) + next.ServeHTTP(w, r) + return + } + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + return + } + + setRateLimitHeaders(w, res, rl.config.Limit) + + if res.Allowed == 0 { + if rl.config.OnLimited != nil { + rl.config.OnLimited(w, r, res) + return + } + writeRateLimitExceeded(w, res) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) allow( + ctx context.Context, + key string, +) (*redis_rate.Result, error) { + res, err := rl.limiter.Allow(ctx, key, rl.config.Limit) + if err != nil { + return rl.fallback.allow(key, rl.config.Limit) + } + return res, nil +} + +func KeyByIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + ip := strings.TrimSpace(ips[len(ips)-1]) + return "ratelimit:ip:" + ip + } + + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return "ratelimit:ip:" + xri + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + + return "ratelimit:ip:" + ip +} + +func KeyByUser(r *http.Request) string { + if userID := GetUserID(r.Context()); userID != "" { + return "ratelimit:user:" + userID + } + return KeyByIP(r) +} + +func KeyByUserAndEndpoint(r *http.Request) string { + userKey := KeyByUser(r) + endpoint := normalizeEndpoint(r.URL.Path) + return fmt.Sprintf("%s:endpoint:%s", userKey, endpoint) +} + +func normalizeEndpoint(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + normalized := make([]string, 0, len(parts)) + + for _, part := range parts { + if isUUID(part) || isNumeric(part) { + normalized = append(normalized, "{id}") + } else { + normalized = append(normalized, part) + } + } + + return "/" + strings.Join(normalized, "/") +} + +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + return s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-' +} + +func isNumeric(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return len(s) > 0 +} + +func setRateLimitHeaders( + w http.ResponseWriter, + res *redis_rate.Result, + limit redis_rate.Limit, +) { + h := w.Header() + + h.Set("X-RateLimit-Limit", strconv.Itoa(limit.Rate)) + h.Set("X-RateLimit-Remaining", strconv.Itoa(res.Remaining)) + h.Set("X-RateLimit-Reset", strconv.FormatInt( + time.Now().Add(res.ResetAfter).Unix(), 10)) + + windowSecs := int(limit.Period.Seconds()) + h.Set("RateLimit-Policy", fmt.Sprintf(`%d;w=%d`, limit.Rate, windowSecs)) + h.Set( + "RateLimit", + fmt.Sprintf(`%d;t=%d`, res.Remaining, int(res.ResetAfter.Seconds())), + ) +} + +func writeRateLimitExceeded(w http.ResponseWriter, res *redis_rate.Result) { + retryAfter := int(res.RetryAfter.Seconds()) + if retryAfter < 1 { + retryAfter = 1 + } + + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + + response := map[string]any{ + "success": false, + "error": map[string]any{ + "code": "RATE_LIMITED", + "message": fmt.Sprintf( + "Rate limit exceeded. Retry after %d seconds.", + retryAfter, + ), + }, + } + + //nolint:errcheck // best-effort response write + _ = json.NewEncoder(w).Encode(response) +} + +type limiterEntry struct { + limiter *rate.Limiter + lastAccess int64 +} + +type localLimiter struct { + limiters sync.Map +} + +const ( + cleanupInterval = 5 * time.Minute + entryTTL = 10 * time.Minute +) + +func newLocalLimiter() *localLimiter { + l := &localLimiter{} + go l.cleanup() + return l +} + +func (l *localLimiter) cleanup() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for range ticker.C { + cutoff := time.Now().Add(-entryTTL).Unix() + l.limiters.Range(func(key, value any) bool { + entry, ok := value.(*limiterEntry) + if ok && entry.lastAccess < cutoff { + l.limiters.Delete(key) + } + return true + }) + } +} + +func (l *localLimiter) allow( + key string, + limit redis_rate.Limit, +) (*redis_rate.Result, error) { + ratePerSec := float64(limit.Rate) / limit.Period.Seconds() + now := time.Now().Unix() + + entryI, loaded := l.limiters.Load(key) + if !loaded { + newEntry := &limiterEntry{ + limiter: rate.NewLimiter( + rate.Limit(ratePerSec), + limit.Burst, + ), + lastAccess: now, + } + entryI, _ = l.limiters.LoadOrStore(key, newEntry) + } + + entry, ok := entryI.(*limiterEntry) + if !ok { + return nil, fmt.Errorf("invalid limiter entry type") + } + entry.lastAccess = now + + allowed := entry.limiter.Allow() + + remaining := int(entry.limiter.Tokens()) + if remaining < 0 { + remaining = 0 + } + + var retryAfter time.Duration + if !allowed { + retryAfter = time.Duration(float64(time.Second) / ratePerSec) + } else { + retryAfter = -1 + } + + allowedInt := 0 + if allowed { + allowedInt = 1 + } + + return &redis_rate.Result{ + Limit: limit, + Allowed: allowedInt, + Remaining: remaining, + RetryAfter: retryAfter, + ResetAfter: time.Duration(float64(time.Second) / ratePerSec), + }, nil +} + +type TierConfig struct { + RequestsPerMinute int + BurstSize int +} + +var DefaultTiers = map[string]TierConfig{ + "free": {RequestsPerMinute: 60, BurstSize: 10}, + "pro": {RequestsPerMinute: 600, BurstSize: 100}, + "enterprise": {RequestsPerMinute: 6000, BurstSize: 1000}, +} + +func TieredRateLimiter( + rdb *redis.Client, + tiers map[string]TierConfig, +) func(http.Handler) http.Handler { + limiter := redis_rate.NewLimiter(rdb) + fallback := newLocalLimiter() + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := GetUserID(r.Context()) + tier := GetUserTier(r.Context()) + + if tier == "" { + tier = "free" + } + + config, ok := tiers[tier] + if !ok { + config = tiers["free"] + } + + limit := redis_rate.Limit{ + Rate: config.RequestsPerMinute, + Burst: config.BurstSize, + Period: time.Minute, + } + + key := fmt.Sprintf("ratelimit:user:%s", userID) + + res, err := limiter.Allow(r.Context(), key, limit) + if err != nil { + //nolint:errcheck // fallback never fails + res, _ = fallback.allow(key, limit) + } + + w.Header().Set("X-RateLimit-Tier", tier) + setRateLimitHeaders(w, res, limit) + + if res.Allowed == 0 { + writeRateLimitExceeded(w, res) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func PerMinute(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Minute, + } +} + +func PerSecond(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Second, + } +} + +func PerHour(rate, burst int) redis_rate.Limit { + return redis_rate.Limit{ + Rate: rate, + Burst: burst, + Period: time.Hour, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go new file mode 100644 index 00000000..77d98165 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/request_id.go @@ -0,0 +1,40 @@ +// AngelaMos | 2026 +// request_id.go + +package middleware + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +type contextKey string + +const RequestIDKey contextKey = "request_id" + +const RequestIDHeader = "X-Request-ID" + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := r.Header.Get(RequestIDHeader) + + if requestID == "" { + requestID = uuid.New().String() + } + + ctx := context.WithValue(r.Context(), RequestIDKey, requestID) + + w.Header().Set(RequestIDHeader, requestID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetRequestID(ctx context.Context) string { + if id, ok := ctx.Value(RequestIDKey).(string); ok { + return id + } + return "" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go new file mode 100644 index 00000000..58d5cbac --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go @@ -0,0 +1,108 @@ +// AngelaMos | 2026 +// server.go + +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + + "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/templates/go-backend/internal/health" +) + +type Server struct { + httpServer *http.Server + router *chi.Mux + config config.ServerConfig + healthHandler *health.Handler + logger *slog.Logger +} + +type Config struct { + ServerConfig config.ServerConfig + HealthHandler *health.Handler + Logger *slog.Logger +} + +func New(cfg Config) *Server { + router := chi.NewRouter() + + router.Use(chimw.CleanPath) + router.Use(chimw.StripSlashes) + + return &Server{ + httpServer: &http.Server{ + Addr: cfg.ServerConfig.Address(), + Handler: router, + ReadTimeout: cfg.ServerConfig.ReadTimeout, + WriteTimeout: cfg.ServerConfig.WriteTimeout, + IdleTimeout: cfg.ServerConfig.IdleTimeout, + }, + router: router, + config: cfg.ServerConfig, + healthHandler: cfg.HealthHandler, + logger: cfg.Logger, + } +} + +func (s *Server) Router() *chi.Mux { + return s.router +} + +func (s *Server) Start() error { + s.logger.Info("starting HTTP server", + "addr", s.config.Address(), + "read_timeout", s.config.ReadTimeout, + "write_timeout", s.config.WriteTimeout, + "idle_timeout", s.config.IdleTimeout, + ) + + if err := s.httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server error: %w", err) + } + + return nil +} + +func (s *Server) Shutdown(ctx context.Context, drainDelay time.Duration) error { + s.logger.Info("initiating graceful shutdown") + + s.logger.Info("marking server as not ready") + if s.healthHandler != nil { + s.healthHandler.SetReady(false) + s.healthHandler.SetShutdown(true) + } + + s.logger.Info("waiting for load balancer to drain", + "delay", drainDelay, + ) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(drainDelay): + } + + s.logger.Info("stopping HTTP server") + shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout) + defer cancel() + + if err := s.httpServer.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("http server shutdown: %w", err) + } + + s.logger.Info("HTTP server stopped gracefully") + return nil +} + +func (s *Server) Address() string { + return s.httpServer.Addr +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go new file mode 100644 index 00000000..ba2b0686 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go @@ -0,0 +1,84 @@ +// AngelaMos | 2026 +// dto.go + +package user + +import ( + "time" +) + +type CreateUserRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` +} + +type UpdateUserRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` +} + +type UpdateUserRoleRequest struct { + Role string `json:"role" validate:"required,oneof=user admin"` +} + +type UpdateUserTierRequest struct { + Tier string `json:"tier" validate:"required,oneof=free pro enterprise"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Tier string `json:"tier"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserListResponse struct { + Users []UserResponse `json:"users"` +} + +type ListUsersParams struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Search string `json:"search"` + Role string `json:"role"` + Tier string `json:"tier"` +} + +func (p *ListUsersParams) Normalize() { + if p.Page < 1 { + p.Page = 1 + } + if p.PageSize < 1 { + p.PageSize = 20 + } + if p.PageSize > 100 { + p.PageSize = 100 + } +} + +func (p *ListUsersParams) Offset() int { + return (p.Page - 1) * p.PageSize +} + +func ToUserResponse(u *User) UserResponse { + return UserResponse{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + Role: u.Role, + Tier: u.Tier, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +func ToUserResponseList(users []User) []UserResponse { + responses := make([]UserResponse, 0, len(users)) + for _, u := range users { + responses = append(responses, ToUserResponse(&u)) + } + return responses +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go new file mode 100644 index 00000000..f830b054 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/entity.go @@ -0,0 +1,40 @@ +// AngelaMos | 2026 +// entity.go + +package user + +import ( + "time" +) + +type User struct { + ID string `db:"id"` + Email string `db:"email"` + PasswordHash string `db:"password_hash"` + Name string `db:"name"` + Role string `db:"role"` + Tier string `db:"tier"` + TokenVersion int `db:"token_version"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt *time.Time `db:"deleted_at"` +} + +func (u *User) IsDeleted() bool { + return u.DeletedAt != nil +} + +func (u *User) IsAdmin() bool { + return u.Role == RoleAdmin +} + +const ( + RoleUser = "user" + RoleAdmin = "admin" +) + +const ( + TierFree = "free" + TierPro = "pro" + TierEnterprise = "enterprise" +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go new file mode 100644 index 00000000..8a21889b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go @@ -0,0 +1,288 @@ +// AngelaMos | 2026 +// handler.go + +package user + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/templates/go-backend/internal/middleware" +) + +type Handler struct { + service *Service + validator *validator.Validate +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/users", func(r chi.Router) { + r.Use(authenticator) + + r.Get("/me", h.GetMe) + r.Put("/me", h.UpdateMe) + r.Delete("/me", h.DeleteMe) + }) +} + +func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + user, err := h.service.GetMe(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateMe(r.Context(), userID, req) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +func (h *Handler) DeleteMe(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + if err := h.service.DeleteMe(r.Context(), userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +// RegisterAdminRoutes registers admin-only user management endpoints. +func (h *Handler) RegisterAdminRoutes( + r chi.Router, + authenticator, adminOnly func(http.Handler) http.Handler, +) { + r.Route("/admin/users", func(r chi.Router) { + r.Use(authenticator) + r.Use(adminOnly) + + r.Get("/", h.ListUsers) + r.Get("/{userID}", h.GetUser) + r.Put("/{userID}", h.UpdateUser) + r.Put("/{userID}/role", h.UpdateUserRole) + r.Put("/{userID}/tier", h.UpdateUserTier) + r.Delete("/{userID}", h.DeleteUser) + }) +} + +// ListUsers returns a paginated list of users with optional filtering. +func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { + params := ListUsersParams{ + Page: parseIntQuery(r, "page", 1), + PageSize: parseIntQuery(r, "page_size", 20), + Search: r.URL.Query().Get("search"), + Role: r.URL.Query().Get("role"), + Tier: r.URL.Query().Get("tier"), + } + + users, total, err := h.service.ListUsers(r.Context(), params) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.Paginated( + w, + ToUserResponseList(users), + params.Page, + params.PageSize, + total, + ) +} + +// GetUser returns a specific user by ID (admin only). +func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + user, err := h.service.GetUser(r.Context(), userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// UpdateUser updates a specific user's profile (admin only). +func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + var req UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateUser(r.Context(), userID, req) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// UpdateUserRole changes a user's role (admin only). +func (h *Handler) UpdateUserRole(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + var req UpdateUserRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateUserRole(r.Context(), userID, req.Role) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// UpdateUserTier changes a user's subscription tier (admin only). +func (h *Handler) UpdateUserTier(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + + var req UpdateUserTierRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateUserTier(r.Context(), userID, req.Tier) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + +// DeleteUser soft deletes a user account (admin only). +func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { + requesterID := middleware.GetUserID(r.Context()) + targetID := chi.URLParam(r, "userID") + + if err := h.service.CanDeleteUser(r.Context(), requesterID, targetID); err != nil { + if errors.Is(err, core.ErrForbidden) { + core.Forbidden(w, "insufficient permissions") + return + } + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + if err := h.service.DeleteUser(r.Context(), targetID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func parseIntQuery(r *http.Request, key string, defaultVal int) int { + val := r.URL.Query().Get(key) + if val == "" { + return defaultVal + } + + parsed, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + + return parsed +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go new file mode 100644 index 00000000..a255b08e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go @@ -0,0 +1,289 @@ +// AngelaMos | 2026 +// repository.go + +package user + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type Repository interface { + Create(ctx context.Context, user *User) error + GetByID(ctx context.Context, id string) (*User, error) + GetByEmail(ctx context.Context, email string) (*User, error) + Update(ctx context.Context, user *User) error + UpdatePassword(ctx context.Context, id, passwordHash string) error + IncrementTokenVersion(ctx context.Context, id string) error + SoftDelete(ctx context.Context, id string) error + List(ctx context.Context, params ListUsersParams) ([]User, int, error) + ExistsByEmail(ctx context.Context, email string) (bool, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) Create(ctx context.Context, user *User) error { + query := ` + INSERT INTO users (id, email, password_hash, name, role, tier) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at, token_version` + + err := r.db.GetContext(ctx, user, query, + user.ID, + user.Email, + user.PasswordHash, + user.Name, + user.Role, + user.Tier, + ) + if err != nil { + if isDuplicateKeyError(err) { + return fmt.Errorf("create user: %w", core.ErrDuplicateKey) + } + return fmt.Errorf("create user: %w", err) + } + + return nil +} + +func (r *repository) GetByID(ctx context.Context, id string) (*User, error) { + query := ` + SELECT id, email, password_hash, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE id = $1 AND deleted_at IS NULL` + + var user User + err := r.db.GetContext(ctx, &user, query, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get user: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + return &user, nil +} + +func (r *repository) GetByEmail( + ctx context.Context, + email string, +) (*User, error) { + query := ` + SELECT id, email, password_hash, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE email = $1 AND deleted_at IS NULL` + + var user User + err := r.db.GetContext(ctx, &user, query, email) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get user by email: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get user by email: %w", err) + } + + return &user, nil +} + +func (r *repository) Update(ctx context.Context, user *User) error { + query := ` + UPDATE users + SET name = $2, role = $3, tier = $4, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + RETURNING updated_at` + + err := r.db.GetContext(ctx, &user.UpdatedAt, query, + user.ID, + user.Name, + user.Role, + user.Tier, + ) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("update user: %w", core.ErrNotFound) + } + if err != nil { + return fmt.Errorf("update user: %w", err) + } + + return nil +} + +func (r *repository) UpdatePassword( + ctx context.Context, + id, passwordHash string, +) error { + query := ` + UPDATE users + SET password_hash = $2, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id, passwordHash) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update password: %w", err) + } + + if rows == 0 { + return fmt.Errorf("update password: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) IncrementTokenVersion( + ctx context.Context, + id string, +) error { + query := ` + UPDATE users + SET token_version = token_version + 1, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("increment token version: %w", err) + } + + if rows == 0 { + return fmt.Errorf("increment token version: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) SoftDelete(ctx context.Context, id string) error { + query := ` + UPDATE users + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + + if rows == 0 { + return fmt.Errorf("delete user: %w", core.ErrNotFound) + } + + return nil +} + +func (r *repository) List( + ctx context.Context, + params ListUsersParams, +) ([]User, int, error) { + params.Normalize() + + var conditions []string + var args []any + argIdx := 1 + + conditions = append(conditions, "deleted_at IS NULL") + + if params.Search != "" { + conditions = append(conditions, fmt.Sprintf( + "(email ILIKE $%d OR name ILIKE $%d)", argIdx, argIdx)) + args = append(args, "%"+escapeLike(params.Search)+"%") + argIdx++ + } + + if params.Role != "" { + conditions = append(conditions, fmt.Sprintf("role = $%d", argIdx)) + args = append(args, params.Role) + argIdx++ + } + + if params.Tier != "" { + conditions = append(conditions, fmt.Sprintf("tier = $%d", argIdx)) + args = append(args, params.Tier) + argIdx++ + } + + whereClause := strings.Join(conditions, " AND ") + + countQuery := fmt.Sprintf( + "SELECT COUNT(*) FROM users WHERE %s", + whereClause, + ) + var total int + if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { + return nil, 0, fmt.Errorf("count users: %w", err) + } + + query := fmt.Sprintf(` + SELECT id, email, name, role, tier, token_version, + created_at, updated_at, deleted_at + FROM users + WHERE %s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d`, + whereClause, argIdx, argIdx+1) + + args = append(args, params.PageSize, params.Offset()) + + var users []User + if err := r.db.SelectContext(ctx, &users, query, args...); err != nil { + return nil, 0, fmt.Errorf("list users: %w", err) + } + + return users, total, nil +} + +func (r *repository) ExistsByEmail( + ctx context.Context, + email string, +) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1 AND deleted_at IS NULL)` + + var exists bool + if err := r.db.GetContext(ctx, &exists, query, email); err != nil { + return false, fmt.Errorf("check email exists: %w", err) + } + + return exists, nil +} + +func isDuplicateKeyError(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} + +func escapeLike(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "%", "\\%") + s = strings.ReplaceAll(s, "_", "\\_") + return s +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go new file mode 100644 index 00000000..699d24d5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go @@ -0,0 +1,256 @@ +// AngelaMos | 2026 +// service.go + +package user + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/carterperez-dev/templates/go-backend/internal/auth" + "github.com/carterperez-dev/templates/go-backend/internal/core" +) + +type Service struct { + repo Repository +} + +func NewService(repo Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetByID( + ctx context.Context, + id string, +) (*auth.UserInfo, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) GetByEmail( + ctx context.Context, + email string, +) (*auth.UserInfo, error) { + user, err := s.repo.GetByEmail(ctx, strings.ToLower(email)) + if err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) Create( + ctx context.Context, + email, passwordHash, name string, +) (*auth.UserInfo, error) { + user := &User{ + ID: uuid.New().String(), + Email: strings.ToLower(email), + PasswordHash: passwordHash, + Name: name, + Role: RoleUser, + Tier: TierFree, + } + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return toUserInfo(user), nil +} + +func (s *Service) IncrementTokenVersion( + ctx context.Context, + userID string, +) error { + return s.repo.IncrementTokenVersion(ctx, userID) +} + +func (s *Service) UpdatePassword( + ctx context.Context, + userID, passwordHash string, +) error { + return s.repo.UpdatePassword(ctx, userID, passwordHash) +} + +func (s *Service) GetUser(ctx context.Context, id string) (*User, error) { + return s.repo.GetByID(ctx, id) +} + +func (s *Service) UpdateUser( + ctx context.Context, + id string, + req UpdateUserRequest, +) (*User, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if req.Name != nil { + user.Name = *req.Name + } + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) UpdateUserRole( + ctx context.Context, + id, role string, +) (*User, error) { + if role != RoleUser && role != RoleAdmin { + return nil, fmt.Errorf( + "update role: invalid role %q: %w", + role, + core.ErrInvalidInput, + ) + } + + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + user.Role = role + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) UpdateUserTier( + ctx context.Context, + id, tier string, +) (*User, error) { + if tier != TierFree && tier != TierPro && tier != TierEnterprise { + return nil, fmt.Errorf( + "update tier: invalid tier %q: %w", + tier, + core.ErrInvalidInput, + ) + } + + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + user.Tier = tier + + if err := s.repo.Update(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) DeleteUser(ctx context.Context, id string) error { + return s.repo.SoftDelete(ctx, id) +} + +func (s *Service) ListUsers( + ctx context.Context, + params ListUsersParams, +) ([]User, int, error) { + return s.repo.List(ctx, params) +} + +func (s *Service) GetMe(ctx context.Context, userID string) (*User, error) { + if userID == "" { + return nil, fmt.Errorf("get me: %w", core.ErrUnauthorized) + } + + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) UpdateMe( + ctx context.Context, + userID string, + req UpdateUserRequest, +) (*User, error) { + if userID == "" { + return nil, fmt.Errorf("update me: %w", core.ErrUnauthorized) + } + + return s.UpdateUser(ctx, userID, req) +} + +func (s *Service) DeleteMe(ctx context.Context, userID string) error { + if userID == "" { + return fmt.Errorf("delete me: %w", core.ErrUnauthorized) + } + + return s.repo.SoftDelete(ctx, userID) +} + +func (s *Service) EmailExists( + ctx context.Context, + email string, +) (bool, error) { + exists, err := s.repo.ExistsByEmail(ctx, email) + if err != nil { + return false, err + } + return exists, nil +} + +func (s *Service) CanDeleteUser( + ctx context.Context, + requesterID, targetID string, +) error { + if requesterID == targetID { + return nil + } + + requester, err := s.repo.GetByID(ctx, requesterID) + if err != nil { + return err + } + + if !requester.IsAdmin() { + return fmt.Errorf("delete user: %w", core.ErrForbidden) + } + + target, err := s.repo.GetByID(ctx, targetID) + if err != nil { + return err + } + + if target.IsAdmin() { + return fmt.Errorf("cannot delete admin users: %w", core.ErrForbidden) + } + + return nil +} + +func toUserInfo(u *User) *auth.UserInfo { + return &auth.UserInfo{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + PasswordHash: u.PasswordHash, + Role: u.Role, + Tier: u.Tier, + TokenVersion: u.TokenVersion, + } +} + +var _ auth.UserProvider = (*Service)(nil) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker new file mode 100644 index 00000000..557fd500 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/go.docker @@ -0,0 +1,19 @@ +# ©AngelaMos | 2026 +# go.docker + +FROM golang:1.25-bookworm + +WORKDIR /app + +RUN go install github.com/air-verse/air@latest \ + && go install github.com/pressly/goose/v3/cmd/goose@latest + +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +ENV GO_ENV=development \ + PATH=/go/bin:$PATH + +EXPOSE 8080 + +CMD ["air", "-c", ".air.toml"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker new file mode 100644 index 00000000..e3400dcb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker @@ -0,0 +1,14 @@ +# ©AngelaMos | 2026 +# vite.docker + +FROM node:24-alpine + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +ENV NODE_ENV=development + +EXPOSE 5173 + +CMD ["pnpm", "dev", "--host", "0.0.0.0"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker new file mode 100644 index 00000000..d903317f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/go.docker @@ -0,0 +1,26 @@ +# ©AngelaMos | 2026 +# go.docker + +FROM golang:1.25-bookworm AS builder + +WORKDIR /build + +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +COPY backend/ . + +RUN CGO_ENABLED=0 GOOS=linux \ + go build -trimpath -ldflags="-s -w" -o /app ./cmd/api + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /app /app +COPY --from=builder /build/migrations /migrations +COPY --from=builder /build/config.yaml /config.yaml + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/app", "-config", "/config.yaml"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker new file mode 100644 index 00000000..9d5bf2b5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/prod/vite.docker @@ -0,0 +1,26 @@ +# ©AngelaMos | 2026 +# vite.docker + +FROM node:24-alpine AS build + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +COPY frontend/package.json frontend/pnpm-lock.yaml* ./ + +RUN pnpm install --frozen-lockfile || pnpm install + +COPY frontend/ . + +RUN pnpm build + +FROM nginx:1.27-alpine + +COPY conf/nginx/nginx.prod.conf /etc/nginx/nginx.conf +COPY conf/nginx/prod.nginx /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore new file mode 100644 index 00000000..a0256ec7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +build +dist +.git +.gitignore +*.md +.env* +.vscode +.idea +*.log +npm-debug.log* +pnpm-debug.log* +.DS_Store +coverage +.nyc_output diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore new file mode 100644 index 00000000..61cb0c2e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.vite + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore new file mode 100644 index 00000000..37da03e1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/.stylelintignore @@ -0,0 +1,22 @@ +# ©AngelaMos | 2025 +# .stylelintignore + +# Dependencies +node_modules/ + +# Production builds +dist/ +build/ +out/ + +# JS/TS files +**/*.js +**/*.jsx +**/*.ts +**/*.tsx + +# Generated files +*.min.css + +# Error system styles - ignore from linting +src/core/app/_toastStyles.scss diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json new file mode 100644 index 00000000..7f29029a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 82, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5", + "arrowParentheses": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 25 } + }, + "noForEach": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "noUndeclaredVariables": "error" + }, + "style": { + "useImportType": "error", + "useConst": "error", + "useTemplate": "error", + "useSelfClosingElements": "error", + "useFragmentSyntax": "error", + "noNonNullAssertion": "error", + "useConsistentArrayType": { + "level": "error", + "options": { "syntax": "shorthand" } + }, + "useNamingConvention": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noConsole": "warn", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noDoubleEquals": "error", + "noRedeclare": "error", + "noVar": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error" + }, + "a11y": { + "useAltText": "error", + "useAnchorContent": "error", + "useKeyWithClickEvents": "error", + "noStaticElementInteractions": "error", + "useButtonType": "error", + "useValidAnchor": "error" + } + } + }, + "overrides": [ + { + "includes": ["src/main.tsx"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html new file mode 100644 index 00000000..ce1f11bb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + Monitor The Situation Dashboard + + + + +
+ + + diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json new file mode 100644 index 00000000..fc6e78c7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "monitor-the-situation-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc -b", + "lint:scss": "stylelint '**/*.scss'", + "lint:scss:fix": "stylelint '**/*.scss' --fix" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.12", + "axios": "^1.13.0", + "react": "^19.2.1", + "react-dom": "^19.2.0", + "react-error-boundary": "^6.0.0", + "react-icon": "^1.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.1.1", + "sonner": "^2.0.7", + "zod": "^4.1.13", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tanstack/react-query-devtools": "^5.91.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "sass": "^1.95.0", + "stylelint": "^16.26.1", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^16.0.0", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.2.5", + "vite-tsconfig-paths": "^5.1.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..d04a7163 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml @@ -0,0 +1,2603 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.2.5 + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@19.2.1) + axios: + specifier: ^1.13.0 + version: 1.13.2 + react: + specifier: ^19.2.1 + version: 19.2.1 + react-dom: + specifier: ^19.2.0 + version: 19.2.1(react@19.2.1) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.0(react@19.2.1) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@19.2.1) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.1) + react-router-dom: + specifier: ^7.1.1 + version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + zod: + specifier: ^4.1.13 + version: 4.1.13 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.1) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.8 + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + '@types/node': + specifier: ^24.10.2 + version: 24.10.2 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + sass: + specifier: ^1.95.0 + version: 1.95.0 + stylelint: + specifier: ^16.26.1 + version: 16.26.1(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^16.0.0 + version: 16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.2.5 + version: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + vite-tsconfig-paths: + specifier: ^5.1.0 + version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.8': + resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.8': + resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.8': + resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.8': + resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.8': + resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.8': + resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.8': + resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.8': + resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.8': + resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.6': + resolution: {integrity: sha512-7e8SScMocHxcAb8YhtkbMhGG+EKLRIficb1F5sjvhSYsWTZGxvg4KIDp8kgxnV2PUJ3ddPe6J9QESjKvBWRDkg==} + + '@cacheable/utils@2.3.2': + resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': + resolution: {integrity: sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.0': + resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.5.4 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.1.0': + resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.97.0': + resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.97.0': + resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.50': + resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@tanstack/query-core@5.90.12': + resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + + '@tanstack/query-devtools@5.91.1': + resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + + '@tanstack/react-query-devtools@5.91.1': + resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.12': + resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/node@24.10.2': + resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.9.5: + resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.0: + resolution: {integrity: sha512-HHiAvOBmlcR2f3SQ7kdlYD8+AUJG+wlFZ/Ze8tl1Vzvz0MdOh8IYA/EFU4ve8t1/sZ0j4MGi7ST5MoTwHessQA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.1: + resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.19: + resolution: {integrity: sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.3.0: + resolution: {integrity: sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.14.0: + resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.5.5: + resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdn-data@2.25.0: + resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qified@0.5.3: + resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + peerDependencies: + react: ^19.2.1 + + react-error-boundary@6.0.0: + resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + peerDependencies: + react: '>=16.13.1' + + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.10.1: + resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.10.1: + resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-vite@7.2.5: + resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.50: + resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.95.0: + resolution: {integrity: sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@16.0.2: + resolution: {integrity: sha512-aUTHhPPWCvFyWaxtckJlCPaXTDFsp4pKO8evXNCsW9OwsaUWyMd6jvcUhSmfGWPrTddvzNqK4rS/UuSLcbVGdQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.24.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@17.0.0: + resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-config-standard-scss@16.0.0: + resolution: {integrity: sha512-/FHECLUu+med/e6OaPFpprG86ShC4SYT7Tzb2PTVdDjJsehhFBOioSlWqYFqJxmGPIwO3AMBxNo+kY3dxrbczA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.23.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@39.0.1: + resolution: {integrity: sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-scss@6.13.0: + resolution: {integrity: sha512-kZPwFUJkfup2gP1enlrS2h9U5+T5wFoqzJ1n/56AlpwSj28kmFe7ww/QFydvPsg5gLjWchAwWWBLtterynZrOw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.3.8': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.8 + '@biomejs/cli-darwin-x64': 2.3.8 + '@biomejs/cli-linux-arm64': 2.3.8 + '@biomejs/cli-linux-arm64-musl': 2.3.8 + '@biomejs/cli-linux-x64': 2.3.8 + '@biomejs/cli-linux-x64-musl': 2.3.8 + '@biomejs/cli-win32-arm64': 2.3.8 + '@biomejs/cli-win32-x64': 2.3.8 + + '@biomejs/cli-darwin-arm64@2.3.8': + optional: true + + '@biomejs/cli-darwin-x64@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-arm64@2.3.8': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.8': + optional: true + + '@biomejs/cli-linux-x64@2.3.8': + optional: true + + '@biomejs/cli-win32-arm64@2.3.8': + optional: true + + '@biomejs/cli-win32-x64@2.3.8': + optional: true + + '@cacheable/memory@2.0.6': + dependencies: + '@cacheable/utils': 2.3.2 + '@keyv/bigmap': 1.3.0(keyv@5.5.5) + hookified: 1.14.0 + keyv: 5.5.5 + + '@cacheable/utils@2.3.2': + dependencies: + hashery: 1.3.0 + keyv: 5.5.5 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.20': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.0(keyv@5.5.5)': + dependencies: + hashery: 1.3.0 + hookified: 1.14.0 + keyv: 5.5.5 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.1.0': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@oxc-project/runtime@0.97.0': {} + + '@oxc-project/types@0.97.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + dependencies: + '@napi-rs/wasm-runtime': 1.1.0 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.50': {} + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@tanstack/query-core@5.90.12': {} + + '@tanstack/query-devtools@5.91.1': {} + + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)': + dependencies: + '@tanstack/query-devtools': 5.91.1 + '@tanstack/react-query': 5.90.12(react@19.2.1) + react: 19.2.1 + + '@tanstack/react-query@5.90.12(react@19.2.1)': + dependencies: + '@tanstack/query-core': 5.90.12 + react: 19.2.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/node@24.10.2': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.9.5: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.5 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + + cacheable@2.3.0: + dependencies: + '@cacheable/memory': 2.0.6 + '@cacheable/utils': 2.3.2 + hookified: 1.14.0 + keyv: 5.5.5 + qified: 0.5.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001759: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js@1.2.7: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.2.3: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@11.1.1: + dependencies: + flat-cache: 6.1.19 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.19: + dependencies: + cacheable: 2.3.0 + flatted: 3.3.3 + hookified: 1.14.0 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.3.0: + dependencies: + hookified: 1.14.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.14.0: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.5.5: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + mdn-data@2.25.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + qified@0.5.3: + dependencies: + hookified: 1.14.0 + + queue-microtask@1.2.3: {} + + react-dom@19.2.1(react@19.2.1): + dependencies: + react: 19.2.1 + scheduler: 0.27.0 + + react-error-boundary@6.0.0(react@19.2.1): + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.1 + + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.1): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.1 + + react-icons@5.5.0(react@19.2.1): + dependencies: + react: 19.2.1 + + react-refresh@0.18.0: {} + + react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + + react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + cookie: 1.1.1 + react: 19.2.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.1(react@19.2.1) + + react@19.2.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0): + dependencies: + '@oxc-project/runtime': 0.97.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.50 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.2 + fsevents: 2.3.3 + sass: 1.95.0 + + rolldown@1.0.0-beta.50: + dependencies: + '@oxc-project/types': 0.97.0 + '@rolldown/pluginutils': 1.0.0-beta.50 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-x64': 1.0.0-beta.50 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.50 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.95.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylelint-config-prettier-scss@1.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-recommended-scss@16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.6) + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-scss: 6.13.0(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-recommended@17.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-standard-scss@16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended-scss: 16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard: 39.0.1(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-standard@39.0.1(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + + stylelint-scss@6.13.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.25.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.9.3) + + stylelint@16.26.1(typescript@5.9.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.0.20 + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-tsconfig-paths@5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + transitivePeerDependencies: + - supports-color + - typescript + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + yallist@3.1.1: {} + + zod@4.1.13: {} + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.1): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.1 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..01d0a08ef524c65021a71f0c177b9d1317e7f986 GIT binary patch literal 25807 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%0Aq;P^m<|We$(VC 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 gF)%Qokv0KRi0n{r~^~ literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ec126db919dc679289e9ef69e842e82d8c5bbf5d GIT binary patch literal 22963 zcmXt9Q*Rqd)s z1vzm9I2^byU%ntnN{A?dUz`7Xz(9eYd8eS5zI;LcA}J!M;_>w&2ew;v0dv&$D`0PPa3J(vjqJ z2GNBU3lM7d~zX}rLC2w8RfQAHVxCaQyyqoOj+d z!#DAjcMPPG9xeARhQx9NGD zQ`-gpN32FeFtnPDB5zNZ(o`8OTXlUOXKWt3p_rW?k$Ho-f&01npC51bOEuc+=+tV- zlnE?e4;H+@4d+^1R;%Qzj$?wQaz$a&@#ML5M&so8cn1$s0;RV_WqDWS_Ap$ITW1YY zhmD^sA)MCBG#P9v&PS7y?#Q3d`$(==8>_URXH|8j+I&6V*4yj>jiK-?mkoNIj3aYE zX^b-ULlHkHF(Lr!!w2Y1w^pk_2$I!mQ9|DzhnPgNA+BgR=<~LN*o;qVzEx2-*%&2u|zt} zZ8)mO8))_Va4IzU-%{xW@jL7U@V%dqKAtX!r%*Mf9{h+Fs3o&# zzfVO{t1=&ur7HEIs`}aKZ_HZr=A@e0_A$+UeKLr|uX4LL{J3Ua2OE3NA*M>)Qdn5% zVwytbb?f-jP^Y`aDwpUWd;5}=WxLcEk%)alBiY8t#?r(>2^QT#SJxrVqj&PG+Z76h ztFT(C%w+KE-txJZ1s8`*p5JRlRP^859;e*TVN89C{vb%YU*Qp7cb#c#Kkw`MC_euB z)s>(7p8_jvRvYb}hh_@m1YYIuX4m=lSc%8qmuBYwoeaN<;yx68%txdHSzb3KUyt+r zm2?RU!CjR$^sNrO-JPeUSxPBm65rBq-(T)^zxnJ0LN~jd&Z8}PIMZmi%6x*Z{T~ + + + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..1bc139b2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './useAdmin' +export * from './useAuth' +export * from './useUsers' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts new file mode 100644 index 00000000..b339b0de --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAdmin.ts @@ -0,0 +1,219 @@ +// =================== +// © AngelaMos | 2025 +// useAdmin.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + type AdminUserCreateRequest, + type AdminUserUpdateRequest, + isValidUserListResponse, + isValidUserResponse, + USER_ERROR_MESSAGES, + USER_SUCCESS_MESSAGES, + type UserListResponse, + type UserResponse, + UserResponseError, +} from '@/api/types' +import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const adminQueries = { + all: () => QUERY_KEYS.ADMIN.ALL, + users: { + all: () => QUERY_KEYS.ADMIN.USERS.ALL(), + list: (page: number, size: number) => QUERY_KEYS.ADMIN.USERS.LIST(page, size), + byId: (id: string) => QUERY_KEYS.ADMIN.USERS.BY_ID(id), + }, +} as const + +interface UseAdminUsersParams { + page?: number + size?: number +} + +const fetchAdminUsers = async ( + page: number, + size: number +): Promise => { + const response = await apiClient.get(API_ENDPOINTS.ADMIN.USERS.LIST, { + params: { page, size }, + }) + const data: unknown = response.data + + if (!isValidUserListResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_LIST_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.LIST + ) + } + + return data +} + +export const useAdminUsers = ( + params: UseAdminUsersParams = {} +): UseQueryResult => { + const page = params.page ?? PAGINATION.DEFAULT_PAGE + const size = params.size ?? PAGINATION.DEFAULT_SIZE + + return useQuery({ + queryKey: adminQueries.users.list(page, size), + queryFn: () => fetchAdminUsers(page, size), + ...QUERY_STRATEGIES.standard, + }) +} + +const fetchAdminUserById = async (id: string): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.ADMIN.USERS.BY_ID(id) + ) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.BY_ID(id) + ) + } + + return data +} + +export const useAdminUser = (id: string): UseQueryResult => { + return useQuery({ + queryKey: adminQueries.users.byId(id), + queryFn: () => fetchAdminUserById(id), + enabled: id.length > 0, + ...QUERY_STRATEGIES.standard, + }) +} + +const performAdminCreateUser = async ( + data: AdminUserCreateRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.ADMIN.USERS.CREATE, + data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.CREATE + ) + } + + return responseData +} + +export const useAdminCreateUser = (): UseMutationResult< + UserResponse, + Error, + AdminUserCreateRequest +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminCreateUser, + onSuccess: async (): Promise => { + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + + toast.success(USER_SUCCESS_MESSAGES.CREATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_CREATE + toast.error(message) + }, + }) +} + +interface AdminUpdateUserParams { + id: string + data: AdminUserUpdateRequest +} + +const performAdminUpdateUser = async ( + params: AdminUpdateUserParams +): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id), + params.data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id) + ) + } + + return responseData +} + +export const useAdminUpdateUser = (): UseMutationResult< + UserResponse, + Error, + AdminUpdateUserParams +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminUpdateUser, + onSuccess: async ( + data: UserResponse, + variables: AdminUpdateUserParams + ): Promise => { + queryClient.setQueryData(adminQueries.users.byId(variables.id), data) + + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + + toast.success(USER_SUCCESS_MESSAGES.UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_UPDATE + toast.error(message) + }, + }) +} + +const performAdminDeleteUser = async (id: string): Promise => { + await apiClient.delete(API_ENDPOINTS.ADMIN.USERS.DELETE(id)) +} + +export const useAdminDeleteUser = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminDeleteUser, + onSuccess: async (_, id: string): Promise => { + queryClient.removeQueries({ queryKey: adminQueries.users.byId(id) }) + + await queryClient.invalidateQueries({ queryKey: adminQueries.users.all() }) + + toast.success(USER_SUCCESS_MESSAGES.DELETED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_DELETE + toast.error(message) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts new file mode 100644 index 00000000..a0aea655 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts @@ -0,0 +1,243 @@ +// =================== +// © AngelaMos | 2025 +// useAuth.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + AUTH_ERROR_MESSAGES, + AUTH_SUCCESS_MESSAGES, + AuthResponseError, + isValidLogoutAllResponse, + isValidTokenResponse, + isValidTokenWithUserResponse, + isValidUserResponse, + type LoginRequest, + type LogoutAllResponse, + type PasswordChangeRequest, + type TokenWithUserResponse, + type UserResponse, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS, ROUTES } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' +import { useAuthStore } from '@/core/lib' + +export const authQueries = { + all: () => QUERY_KEYS.AUTH.ALL, + me: () => QUERY_KEYS.AUTH.ME(), +} as const + +const fetchCurrentUser = async (): Promise => { + const response = await apiClient.get(API_ENDPOINTS.AUTH.ME) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.AUTH.ME + ) + } + + return data +} + +export const useCurrentUser = (): UseQueryResult => { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + + return useQuery({ + queryKey: authQueries.me(), + queryFn: fetchCurrentUser, + enabled: isAuthenticated, + ...QUERY_STRATEGIES.auth, + }) +} + +const performLogin = async ( + credentials: LoginRequest +): Promise => { + const formData = new URLSearchParams() + formData.append('username', credentials.username) + formData.append('password', credentials.password) + + const response = await apiClient.post( + API_ENDPOINTS.AUTH.LOGIN, + formData, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ) + + const data: unknown = response.data + + if (!isValidTokenWithUserResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_LOGIN_RESPONSE, + API_ENDPOINTS.AUTH.LOGIN + ) + } + + return data +} + +export const useLogin = (): UseMutationResult< + TokenWithUserResponse, + Error, + LoginRequest +> => { + const queryClient = useQueryClient() + const login = useAuthStore((s) => s.login) + + return useMutation({ + mutationFn: performLogin, + onSuccess: (data: TokenWithUserResponse): void => { + login(data.user, data.access_token) + + queryClient.setQueryData(authQueries.me(), data.user) + + const welcomeMessage = AUTH_SUCCESS_MESSAGES.WELCOME_BACK( + data.user.full_name + ) + toast.success(welcomeMessage) + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError ? error.message : 'Login failed' + toast.error(message) + }, + }) +} + +const performLogout = async (): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT) +} + +export const useLogout = (): UseMutationResult => { + const queryClient = useQueryClient() + const logout = useAuthStore((s) => s.logout) + + return useMutation({ + mutationFn: performLogout, + onSuccess: (): void => { + logout() + + queryClient.removeQueries({ queryKey: authQueries.all() }) + + toast.success(AUTH_SUCCESS_MESSAGES.LOGOUT_SUCCESS) + + window.location.href = ROUTES.LOGIN + }, + onError: (): void => { + logout() + queryClient.removeQueries({ queryKey: authQueries.all() }) + window.location.href = ROUTES.LOGIN + }, + }) +} + +const performLogoutAll = async (): Promise => { + const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT_ALL) + const data: unknown = response.data + + if (!isValidLogoutAllResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_LOGOUT_RESPONSE, + API_ENDPOINTS.AUTH.LOGOUT_ALL + ) + } + + return data +} + +export const useLogoutAll = (): UseMutationResult< + LogoutAllResponse, + Error, + void +> => { + const queryClient = useQueryClient() + const logout = useAuthStore((s) => s.logout) + + return useMutation({ + mutationFn: performLogoutAll, + onSuccess: (data: LogoutAllResponse): void => { + logout() + + queryClient.removeQueries({ queryKey: authQueries.all() }) + + toast.success(`Logged out from ${data.revoked_sessions} session(s)`) + + window.location.href = ROUTES.LOGIN + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError + ? error.message + : 'Failed to logout all sessions' + toast.error(message) + }, + }) +} + +const performPasswordChange = async ( + data: PasswordChangeRequest +): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.CHANGE_PASSWORD, data) +} + +export const useChangePassword = (): UseMutationResult< + void, + Error, + PasswordChangeRequest +> => { + return useMutation({ + mutationFn: performPasswordChange, + onSuccess: (): void => { + toast.success(AUTH_SUCCESS_MESSAGES.PASSWORD_CHANGED) + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError + ? error.message + : 'Failed to change password' + toast.error(message) + }, + }) +} + +export const useRefreshAuth = (): (() => Promise) => { + const queryClient = useQueryClient() + const { setAccessToken, login, logout } = useAuthStore() + + return async (): Promise => { + try { + const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH) + const data: unknown = response.data + + if (!isValidTokenResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_TOKEN_RESPONSE, + API_ENDPOINTS.AUTH.REFRESH + ) + } + + setAccessToken(data.access_token) + + const userResponse = await apiClient.get(API_ENDPOINTS.AUTH.ME) + const userData: unknown = userResponse.data + + if (isValidUserResponse(userData)) { + login(userData, data.access_token) + queryClient.setQueryData(authQueries.me(), userData) + } + } catch { + logout() + queryClient.removeQueries({ queryKey: authQueries.all() }) + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts new file mode 100644 index 00000000..ba3a2264 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts @@ -0,0 +1,138 @@ +// =================== +// © AngelaMos | 2025 +// useUsers.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + isValidUserResponse, + USER_ERROR_MESSAGES, + USER_SUCCESS_MESSAGES, + type UserCreateRequest, + type UserResponse, + UserResponseError, + type UserUpdateRequest, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' +import { useAuthStore } from '@/core/lib' +import { authQueries } from './useAuth' + +export const userQueries = { + all: () => QUERY_KEYS.USERS.ALL, + byId: (id: string) => QUERY_KEYS.USERS.BY_ID(id), + me: () => QUERY_KEYS.USERS.ME(), +} as const + +const fetchUserById = async (id: string): Promise => { + const response = await apiClient.get(API_ENDPOINTS.USERS.BY_ID(id)) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.BY_ID(id) + ) + } + + return data +} + +export const useUser = (id: string): UseQueryResult => { + return useQuery({ + queryKey: userQueries.byId(id), + queryFn: () => fetchUserById(id), + enabled: id.length > 0, + ...QUERY_STRATEGIES.standard, + }) +} + +const performRegister = async ( + data: UserCreateRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.USERS.REGISTER, + data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.REGISTER + ) + } + + return responseData +} + +export const useRegister = (): UseMutationResult< + UserResponse, + Error, + UserCreateRequest +> => { + return useMutation({ + mutationFn: performRegister, + onSuccess: (): void => { + toast.success(USER_SUCCESS_MESSAGES.REGISTERED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_CREATE + toast.error(message) + }, + }) +} + +const performUpdateProfile = async ( + data: UserUpdateRequest +): Promise => { + const response = await apiClient.patch(API_ENDPOINTS.USERS.ME, data) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.ME + ) + } + + return responseData +} + +export const useUpdateProfile = (): UseMutationResult< + UserResponse, + Error, + UserUpdateRequest +> => { + const queryClient = useQueryClient() + const updateUser = useAuthStore((s) => s.updateUser) + + return useMutation({ + mutationFn: performUpdateProfile, + onSuccess: (data: UserResponse): void => { + updateUser(data) + + queryClient.setQueryData(authQueries.me(), data) + queryClient.setQueryData(userQueries.me(), data) + + toast.success(USER_SUCCESS_MESSAGES.PROFILE_UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_UPDATE + toast.error(message) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts new file mode 100644 index 00000000..0840a7c1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './hooks' +export * from './types' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts new file mode 100644 index 00000000..bf1ff764 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts @@ -0,0 +1,140 @@ +// =================== +// © AngelaMos | 2025 +// auth.types.ts +// =================== + +import { z } from 'zod' +import { PASSWORD_CONSTRAINTS } from '@/config' + +export const UserRole = { + UNKNOWN: 'unknown', + USER: 'user', + ADMIN: 'admin', +} as const + +export type UserRole = (typeof UserRole)[keyof typeof UserRole] + +export const userResponseSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + email: z.string().email(), + full_name: z.string().nullable(), + is_active: z.boolean(), + is_verified: z.boolean(), + role: z.nativeEnum(UserRole), +}) + +export const tokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string(), +}) + +export const tokenWithUserResponseSchema = tokenResponseSchema.extend({ + user: userResponseSchema, +}) + +export const loginRequestSchema = z.object({ + username: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), +}) + +export const registerRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + full_name: z.string().max(255).optional(), +}) + +export const passwordChangeRequestSchema = z.object({ + current_password: z.string(), + new_password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), +}) + +export const logoutAllResponseSchema = z.object({ + revoked_sessions: z.number(), +}) + +export type UserResponse = z.infer +export type TokenResponse = z.infer +export type TokenWithUserResponse = z.infer +export type LoginRequest = z.infer +export type RegisterRequest = z.infer +export type PasswordChangeRequest = z.infer +export type LogoutAllResponse = z.infer + +export const isValidUserResponse = (data: unknown): data is UserResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = userResponseSchema.safeParse(data) + return result.success +} + +export const isValidTokenResponse = (data: unknown): data is TokenResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = tokenResponseSchema.safeParse(data) + return result.success +} + +export const isValidTokenWithUserResponse = ( + data: unknown +): data is TokenWithUserResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = tokenWithUserResponseSchema.safeParse(data) + return result.success +} + +export const isValidLogoutAllResponse = ( + data: unknown +): data is LogoutAllResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = logoutAllResponseSchema.safeParse(data) + return result.success +} + +export class AuthResponseError extends Error { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { + super(message) + this.name = 'AuthResponseError' + this.endpoint = endpoint + Object.setPrototypeOf(this, AuthResponseError.prototype) + } +} + +export const AUTH_ERROR_MESSAGES = { + INVALID_USER_RESPONSE: 'Invalid user data from server', + INVALID_LOGIN_RESPONSE: 'Invalid login response from server', + INVALID_TOKEN_RESPONSE: 'Invalid token response from server', + INVALID_LOGOUT_RESPONSE: 'Invalid logout response from server', + NO_REFRESH_TOKEN: 'No refresh token available', + SESSION_EXPIRED: 'Session expired', +} as const + +export const AUTH_SUCCESS_MESSAGES = { + WELCOME_BACK: (name: string | null) => + `Welcome back${name !== null ? `, ${name}` : ''}!`, + LOGOUT_SUCCESS: 'Logged out successfully', + PASSWORD_CHANGED: 'Password changed successfully', + REGISTERED: 'Account created successfully!', +} as const + +export type AuthErrorMessage = + (typeof AUTH_ERROR_MESSAGES)[keyof typeof AUTH_ERROR_MESSAGES] +export type AuthSuccessMessage = typeof AUTH_SUCCESS_MESSAGES diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts new file mode 100644 index 00000000..7f26f3c6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './auth.types' +export * from './user.types' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts new file mode 100644 index 00000000..2fd36234 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts @@ -0,0 +1,127 @@ +// =================== +// © AngelaMos | 2025 +// user.types.ts +// =================== + +import { z } from 'zod' +import { PASSWORD_CONSTRAINTS } from '@/config' +import { UserRole, userResponseSchema } from './auth.types' + +export { type UserResponse, UserRole, userResponseSchema } from './auth.types' + +export const userListResponseSchema = z.object({ + items: z.array(userResponseSchema), + total: z.number(), + page: z.number(), + size: z.number(), +}) + +export const userCreateRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + full_name: z.string().max(255).nullable().optional(), +}) + +export const userUpdateRequestSchema = z.object({ + full_name: z.string().max(255).nullable().optional(), +}) + +export const adminUserCreateRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + full_name: z.string().max(255).nullable().optional(), + role: z.nativeEnum(UserRole).optional(), + is_active: z.boolean().optional(), + is_verified: z.boolean().optional(), +}) + +export const adminUserUpdateRequestSchema = z.object({ + email: z.string().email().optional(), + full_name: z.string().max(255).nullable().optional(), + role: z.nativeEnum(UserRole).optional(), + is_active: z.boolean().optional(), + is_verified: z.boolean().optional(), +}) + +export const paginationParamsSchema = z.object({ + page: z.number().min(1), + size: z.number().min(1).max(100), +}) + +export type UserListResponse = z.infer +export type UserCreateRequest = z.infer +export type UserUpdateRequest = z.infer +export type AdminUserCreateRequest = z.infer +export type AdminUserUpdateRequest = z.infer +export type PaginationParams = z.infer + +export const isValidUserListResponse = ( + data: unknown +): data is UserListResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = userListResponseSchema.safeParse(data) + return result.success +} + +export const isValidUserCreateRequest = ( + data: unknown +): data is UserCreateRequest => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = userCreateRequestSchema.safeParse(data) + return result.success +} + +export const isValidAdminUserCreateRequest = ( + data: unknown +): data is AdminUserCreateRequest => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = adminUserCreateRequestSchema.safeParse(data) + return result.success +} + +export class UserResponseError extends Error { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { + super(message) + this.name = 'UserResponseError' + this.endpoint = endpoint + Object.setPrototypeOf(this, UserResponseError.prototype) + } +} + +export const USER_ERROR_MESSAGES = { + INVALID_USER_RESPONSE: 'Invalid user data from server', + INVALID_USER_LIST_RESPONSE: 'Invalid user list from server', + USER_NOT_FOUND: 'User not found', + EMAIL_ALREADY_EXISTS: 'Email already exists', + FAILED_TO_CREATE: 'Failed to create user', + FAILED_TO_UPDATE: 'Failed to update user', + FAILED_TO_DELETE: 'Failed to delete user', +} as const + +export const USER_SUCCESS_MESSAGES = { + CREATED: 'User created successfully', + UPDATED: 'User updated successfully', + DELETED: 'User deleted successfully', + PROFILE_UPDATED: 'Profile updated successfully', + REGISTERED: + 'Registration successful! Please check your email to verify your account.', +} as const + +export type UserErrorMessage = + (typeof USER_ERROR_MESSAGES)[keyof typeof USER_ERROR_MESSAGES] +export type UserSuccessMessage = + (typeof USER_SUCCESS_MESSAGES)[keyof typeof USER_SUCCESS_MESSAGES] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx new file mode 100644 index 00000000..6b0a7329 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx @@ -0,0 +1,4 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts new file mode 100644 index 00000000..8c0d2244 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts @@ -0,0 +1,116 @@ +// =================== +// © AngelaMos | 2026 +// config.ts +// =================== +const API_VERSION = 'v1' + +export const API_ENDPOINTS = { + AUTH: { + LOGIN: `/${API_VERSION}/auth/login`, + REFRESH: `/${API_VERSION}/auth/refresh`, + LOGOUT: `/${API_VERSION}/auth/logout`, + LOGOUT_ALL: `/${API_VERSION}/auth/logout-all`, + ME: `/${API_VERSION}/auth/me`, + CHANGE_PASSWORD: `/${API_VERSION}/auth/change-password`, + }, + USERS: { + BASE: `/${API_VERSION}/users`, + BY_ID: (id: string) => `/${API_VERSION}/users/${id}`, + ME: `/${API_VERSION}/users/me`, + REGISTER: `/${API_VERSION}/users`, + }, + ADMIN: { + USERS: { + LIST: `/${API_VERSION}/admin/users`, + CREATE: `/${API_VERSION}/admin/users`, + BY_ID: (id: string) => `/${API_VERSION}/admin/users/${id}`, + UPDATE: (id: string) => `/${API_VERSION}/admin/users/${id}`, + DELETE: (id: string) => `/${API_VERSION}/admin/users/${id}`, + }, + }, +} as const + +export const QUERY_KEYS = { + AUTH: { + ALL: ['auth'] as const, + ME: () => [...QUERY_KEYS.AUTH.ALL, 'me'] as const, + }, + USERS: { + ALL: ['users'] as const, + BY_ID: (id: string) => [...QUERY_KEYS.USERS.ALL, 'detail', id] as const, + ME: () => [...QUERY_KEYS.USERS.ALL, 'me'] as const, + }, + ADMIN: { + ALL: ['admin'] as const, + USERS: { + ALL: () => [...QUERY_KEYS.ADMIN.ALL, 'users'] as const, + LIST: (page: number, size: number) => + [...QUERY_KEYS.ADMIN.USERS.ALL(), 'list', { page, size }] as const, + BY_ID: (id: string) => + [...QUERY_KEYS.ADMIN.USERS.ALL(), 'detail', id] as const, + }, + }, +} as const + +export const ROUTES = { + HOME: '/', + LOGIN: '/login', + REGISTER: '/register', + DASHBOARD: '/dashboard', + SETTINGS: '/settings', + UNAUTHORIZED: '/unauthorized', + ADMIN: { + DASHBOARD: '/admin', + USERS: '/admin/users', + USER_DETAIL: (id: string) => `/admin/users/${id}`, + }, +} as const + +export const STORAGE_KEYS = { + AUTH: 'auth-storage', + UI: 'ui-storage', +} as const + +export const QUERY_CONFIG = { + STALE_TIME: { + USER: 1000 * 60 * 5, + STATIC: Infinity, + FREQUENT: 1000 * 30, + }, + GC_TIME: { + DEFAULT: 1000 * 60 * 30, + LONG: 1000 * 60 * 60, + }, + RETRY: { + DEFAULT: 3, + NONE: 0, + }, +} as const + +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER: 500, +} as const + +export const PASSWORD_CONSTRAINTS = { + MIN_LENGTH: 8, + MAX_LENGTH: 128, +} as const + +export const PAGINATION = { + DEFAULT_PAGE: 1, + DEFAULT_SIZE: 20, + MAX_SIZE: 100, +} as const + +export type ApiEndpoint = typeof API_ENDPOINTS +export type QueryKey = typeof QUERY_KEYS +export type Route = typeof ROUTES diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts new file mode 100644 index 00000000..8347fa00 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts @@ -0,0 +1,160 @@ +// =================== +// © AngelaMos | 2025 +// api.config.ts +// =================== + +import axios, { + type AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios' +import { API_ENDPOINTS, HTTP_STATUS } from '@/config' +import { useAuthStore } from '@/core/lib' +import { ApiError, ApiErrorCode, transformAxiosError } from './errors' + +interface RequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean +} + +interface RefreshSubscriber { + resolve: (token: string) => void + reject: (error: Error) => void +} + +const getBaseURL = (): string => { + return import.meta.env.VITE_API_URL ?? '/api' +} + +export const apiClient: AxiosInstance = axios.create({ + baseURL: getBaseURL(), + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, +}) + +let isRefreshing = false +let refreshSubscribers: RefreshSubscriber[] = [] + +const processRefreshQueue = (error: Error | null, token: string | null): void => { + refreshSubscribers.forEach((subscriber) => { + if (error !== null) { + subscriber.reject(error) + } else if (token !== null) { + subscriber.resolve(token) + } + }) + refreshSubscribers = [] +} + +const addRefreshSubscriber = ( + resolve: (token: string) => void, + reject: (error: Error) => void +): void => { + refreshSubscribers.push({ resolve, reject }) +} + +const handleTokenRefresh = async (): Promise => { + const response = await apiClient.post<{ access_token: string }>( + API_ENDPOINTS.AUTH.REFRESH + ) + + if ( + response.data === null || + response.data === undefined || + typeof response.data !== 'object' + ) { + throw new ApiError( + 'Invalid refresh response', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + } + + const accessToken = response.data.access_token + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new ApiError( + 'Invalid access token', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + } + + return accessToken +} + +const handleAuthFailure = (): void => { + useAuthStore.getState().logout() + window.location.href = '/login' +} + +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + const token = useAuthStore.getState().accessToken + if (token !== null && token.length > 0) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error: unknown): Promise => { + return Promise.reject(error) + } +) + +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError): Promise => { + const originalRequest = error.config as RequestConfig | undefined + + if (originalRequest === undefined) { + return Promise.reject(transformAxiosError(error)) + } + + const isUnauthorized = error.response?.status === HTTP_STATUS.UNAUTHORIZED + const isNotRetried = originalRequest._retry !== true + const isNotRefreshEndpoint = + originalRequest.url?.includes(API_ENDPOINTS.AUTH.REFRESH) !== true + + if (isUnauthorized && isNotRetried && isNotRefreshEndpoint) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + addRefreshSubscriber( + (newToken: string): void => { + originalRequest.headers.Authorization = `Bearer ${newToken}` + resolve(apiClient(originalRequest)) + }, + (refreshError: Error): void => { + reject(refreshError) + } + ) + }) + } + + originalRequest._retry = true + isRefreshing = true + + try { + const newToken = await handleTokenRefresh() + useAuthStore.getState().setAccessToken(newToken) + processRefreshQueue(null, newToken) + originalRequest.headers.Authorization = `Bearer ${newToken}` + return await apiClient(originalRequest) + } catch (refreshError: unknown) { + const apiError = + refreshError instanceof ApiError + ? refreshError + : new ApiError( + 'Session expired', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + processRefreshQueue(apiError, null) + handleAuthFailure() + return Promise.reject(apiError) + } finally { + isRefreshing = false + } + } + + return Promise.reject(transformAxiosError(error)) + } +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts new file mode 100644 index 00000000..fde5ec65 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts @@ -0,0 +1,114 @@ +/** + * ©AngelaMos | 2025 + * errors.ts + */ + +import type { AxiosError } from 'axios' + +export const ApiErrorCode = { + NETWORK_ERROR: 'NETWORK_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR', + AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + SERVER_ERROR: 'SERVER_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const + +export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode] + +export class ApiError extends Error { + readonly code: ApiErrorCode + readonly statusCode: number + readonly details?: Record + + constructor( + message: string, + code: ApiErrorCode, + statusCode: number, + details?: Record + ) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + this.details = details + } + + getUserMessage(): string { + const messages: Record = { + [ApiErrorCode.NETWORK_ERROR]: + 'Unable to connect. Please check your internet connection.', + [ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.', + [ApiErrorCode.AUTHENTICATION_ERROR]: + 'Your session has expired. Please log in again.', + [ApiErrorCode.AUTHORIZATION_ERROR]: + 'You do not have permission to perform this action.', + [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.', + [ApiErrorCode.CONFLICT]: + 'This operation conflicts with an existing resource.', + [ApiErrorCode.RATE_LIMITED]: + 'Too many requests. Please wait a moment and try again.', + [ApiErrorCode.SERVER_ERROR]: + 'Something went wrong on our end. Please try again later.', + [ApiErrorCode.UNKNOWN_ERROR]: + 'An unexpected error occurred. Please try again.', + } + return messages[this.code] + } +} + +interface ApiErrorResponse { + detail?: string | { msg: string; type: string }[] + message?: string +} + +export function transformAxiosError(error: AxiosError): ApiError { + if (!error.response) { + return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) + } + + const { status } = error.response + const data = error.response.data as ApiErrorResponse | undefined + let message = 'An error occurred' + let details: Record | undefined + + if (data?.detail) { + if (typeof data.detail === 'string') { + message = data.detail + } else if (Array.isArray(data.detail)) { + details = { validation: [] } + data.detail.forEach((err) => { + details?.validation.push(err.msg) + }) + message = 'Validation error' + } + } else if (data?.message) { + message = data.message + } + + const codeMap: Record = { + 400: ApiErrorCode.VALIDATION_ERROR, + 401: ApiErrorCode.AUTHENTICATION_ERROR, + 403: ApiErrorCode.AUTHORIZATION_ERROR, + 404: ApiErrorCode.NOT_FOUND, + 409: ApiErrorCode.CONFLICT, + 429: ApiErrorCode.RATE_LIMITED, + 500: ApiErrorCode.SERVER_ERROR, + 502: ApiErrorCode.SERVER_ERROR, + 503: ApiErrorCode.SERVER_ERROR, + 504: ApiErrorCode.SERVER_ERROR, + } + + const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR + + return new ApiError(message, code, status, details) +} + +declare module '@tanstack/react-query' { + interface Register { + defaultError: ApiError + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts new file mode 100644 index 00000000..1818cd7e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './api.config' +export * from './errors' +export * from './query.config' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts new file mode 100644 index 00000000..8244b22d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/query.config.ts @@ -0,0 +1,105 @@ +// =================== +// © AngelaMos | 2025 +// query.config.ts +// =================== + +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { QUERY_CONFIG } from '@/config' +import { ApiError, ApiErrorCode } from './errors' + +const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [ + ApiErrorCode.AUTHENTICATION_ERROR, + ApiErrorCode.AUTHORIZATION_ERROR, + ApiErrorCode.NOT_FOUND, + ApiErrorCode.VALIDATION_ERROR, +] as const + +const shouldRetryQuery = (failureCount: number, error: Error): boolean => { + if (error instanceof ApiError) { + if (NO_RETRY_ERROR_CODES.includes(error.code)) { + return false + } + } + return failureCount < QUERY_CONFIG.RETRY.DEFAULT +} + +const calculateRetryDelay = (attemptIndex: number): number => { + const baseDelay = 1000 + const maxDelay = 30000 + return Math.min(baseDelay * 2 ** attemptIndex, maxDelay) +} + +const handleQueryCacheError = ( + error: Error, + query: { state: { data: unknown } } +): void => { + if (query.state.data !== undefined) { + const message = + error instanceof ApiError + ? error.getUserMessage() + : 'Background update failed' + toast.error(message) + } +} + +const handleMutationCacheError = ( + error: Error, + _variables: unknown, + _context: unknown, + mutation: { options: { onError?: unknown } } +): void => { + if (mutation.options.onError === undefined) { + const message = + error instanceof ApiError ? error.getUserMessage() : 'Operation failed' + toast.error(message) + } +} + +export const QUERY_STRATEGIES = { + standard: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + }, + frequent: { + staleTime: QUERY_CONFIG.STALE_TIME.FREQUENT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + refetchInterval: QUERY_CONFIG.STALE_TIME.FREQUENT, + }, + static: { + staleTime: QUERY_CONFIG.STALE_TIME.STATIC, + gcTime: QUERY_CONFIG.GC_TIME.LONG, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + auth: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: QUERY_CONFIG.RETRY.NONE, + }, +} as const + +export type QueryStrategy = keyof typeof QUERY_STRATEGIES + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: shouldRetryQuery, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }, + mutations: { + retry: QUERY_CONFIG.RETRY.NONE, + }, + }, + queryCache: new QueryCache({ + onError: handleQueryCacheError, + }), + mutationCache: new MutationCache({ + onError: handleMutationCacheError, + }), +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx new file mode 100644 index 00000000..486eec78 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx @@ -0,0 +1,47 @@ +// =================== +// © AngelaMos | 2025 +// protected-route.tsx +// =================== + +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import type { UserRole } from '@/api/types' +import { ROUTES } from '@/config' +import { useAuthStore } from '@/core/lib' + +interface ProtectedRouteProps { + allowedRoles?: UserRole[] + redirectTo?: string +} + +export function ProtectedRoute({ + allowedRoles, + redirectTo = ROUTES.LOGIN, +}: ProtectedRouteProps): React.ReactElement { + const location = useLocation() + const { isAuthenticated, isLoading, user } = useAuthStore() + + if (isLoading) { + return
Loading...
+ } + + if (!isAuthenticated) { + return ( + + ) + } + + if ( + allowedRoles !== undefined && + allowedRoles.length > 0 && + user !== null && + !allowedRoles.includes(user.role) + ) { + return + } + + return +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx new file mode 100644 index 00000000..cb2473c9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx @@ -0,0 +1,67 @@ +// =================== +// © AngelaMos | 2025 +// routers.tsx +// =================== + +import { createBrowserRouter, type RouteObject } from 'react-router-dom' +import { UserRole } from '@/api/types' +import { ROUTES } from '@/config' +import { ProtectedRoute } from './protected-route' +import { Shell } from './shell' + +const routes: RouteObject[] = [ + { + path: ROUTES.HOME, + lazy: () => import('@/pages/landing'), + }, + { + path: ROUTES.LOGIN, + lazy: () => import('@/pages/login'), + }, + { + path: ROUTES.REGISTER, + lazy: () => import('@/pages/register'), + }, + { + element: , + children: [ + { + element: , + children: [ + { + path: ROUTES.DASHBOARD, + lazy: () => import('@/pages/dashboard'), + }, + { + path: ROUTES.SETTINGS, + lazy: () => import('@/pages/settings'), + }, + ], + }, + ], + }, + { + element: , + children: [ + { + element: , + children: [ + { + path: ROUTES.ADMIN.USERS, + lazy: () => import('@/pages/admin'), + }, + ], + }, + ], + }, + { + path: ROUTES.UNAUTHORIZED, + lazy: () => import('@/pages/landing'), + }, + { + path: '*', + lazy: () => import('@/pages/landing'), + }, +] + +export const router = createBrowserRouter(routes) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss new file mode 100644 index 00000000..b57d7a96 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss @@ -0,0 +1,331 @@ +// =================== +// © AngelaMos | 2025 +// shell.module.scss +// =================== + +@use '@/styles' as *; + +$sidebar-width: 240px; +$sidebar-collapsed-width: 64px; +$header-height: 56px; + +.shell { + display: flex; + min-height: 100vh; + min-height: 100dvh; +} + +.sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: $sidebar-width; + background: $bg-surface-100; + border-right: 1px solid $border-default; + display: flex; + flex-direction: column; + z-index: $z-fixed; + @include transition-fast; + + &.collapsed { + width: $sidebar-collapsed-width; + } + + @include breakpoint-down('sm') { + transform: translateX(-100%); + + &.open { + transform: translateX(0); + } + + &.collapsed { + width: $sidebar-width; + } + } +} + +.sidebarHeader { + height: $header-height; + padding: 0 $space-3; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid $border-default; + + .sidebar.collapsed & { + justify-content: center; + padding: 0; + } +} + +.logo { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-default; + @include transition-fast; + + .sidebar.collapsed & { + display: none; + } +} + +.nav { + flex: 1; + padding: $space-3; + display: flex; + flex-direction: column; + gap: $space-1; +} + +.navItem { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-2 $space-3; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-light; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.active { + background: $bg-selection; + color: $text-default; + } + + .sidebar.collapsed & { + justify-content: center; + } +} + +.navIcon { + width: 17px; + height: 17px; + flex-shrink: 0; +} + +.navLabel { + @include transition-fast; + + .sidebar.collapsed & { + display: none; + } +} + +.adminItem { + margin-top: auto; + border-top: 1px solid $border-default; + padding-top: $space-3; +} + +.collapseBtn { + width: 45px; + height: 45px; + border-radius: $radius-md; + color: $text-light; + @include flex-center; + @include transition-fast; + + svg { + width: 23.5px; + height: 23.5px; + } + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + @include breakpoint-down('sm') { + display: none; + } +} + +.sidebarFooter { + padding: $space-3; + border-top: 1px solid $border-default; +} + +.logoutBtn { + width: 100%; + display: flex; + align-items: center; + gap: $space-3; + padding: $space-3; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + } + + .sidebar.collapsed & { + justify-content: center; + + .logoutText { + display: none; + } + } +} + +.logoutIcon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.logoutText { + font-weight: $font-weight-medium; + @include transition-fast; +} + +.overlay { + position: fixed; + inset: 0; + background: rgb(0, 0, 0, 50%); + z-index: calc($z-fixed - 1); + display: none; + border: none; + padding: 0; + cursor: pointer; + + @include breakpoint-down('sm') { + display: block; + } +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + margin-left: $sidebar-width; + min-width: 0; + @include transition-fast; + + &.collapsed { + margin-left: $sidebar-collapsed-width; + } + + @include breakpoint-down('sm') { + margin-left: 0; + + &.collapsed { + margin-left: 0; + } + } +} + +.header { + position: sticky; + top: 0; + height: $header-height; + background: $bg-surface-100; + border-bottom: 1px solid $border-default; + z-index: $z-sticky; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 $space-4; +} + +.headerLeft { + display: flex; + align-items: center; + gap: $space-3; +} + +.menuBtn { + display: none; + width: 36px; + height: 36px; + border-radius: $radius-md; + color: $text-light; + align-items: center; + justify-content: center; + @include transition-fast; + + svg { + width: 20px; + height: 20px; + } + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + @media (width <= 479px) { + display: flex; + } +} + +.pageTitle { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + margin-left: 7px; +} + +.headerRight { + display: flex; + align-items: center; + gap: $space-3; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: $radius-full; + background: $bg-surface-300; + color: $text-light; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + @include flex-center; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(1.2); + } +} + +.content { + flex: 1; + overflow-y: auto; +} + +.loading { + @include flex-center; + height: 100%; + color: $text-muted; +} + +.error { + @include flex-column-center; + height: 100%; + gap: $space-4; + padding: $space-6; + color: $error-default; + + h2 { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + } + + pre { + font-family: $font-mono; + font-size: $font-size-sm; + padding: $space-4; + background: $bg-surface-200; + border-radius: $radius-lg; + overflow-x: auto; + max-width: 100%; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx new file mode 100644 index 00000000..65fe7f37 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx @@ -0,0 +1,160 @@ +/** + * ©AngelaMos | 2025 + * shell.tsx + */ + +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { GiCardAceClubs, GiCardJoker, GiExitDoor } from 'react-icons/gi' +import { LuChevronLeft, LuChevronRight, LuMenu, LuShield } from 'react-icons/lu' +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' +import { useLogout } from '@/api/hooks' +import { ROUTES } from '@/config' +import { useIsAdmin, useUIStore, useUser } from '@/core/lib' +import styles from './shell.module.scss' + +const NAV_ITEMS = [ + { path: ROUTES.DASHBOARD, label: 'Dashboard', icon: GiCardJoker }, + { path: ROUTES.SETTINGS, label: 'Settings', icon: GiCardAceClubs }, +] + +const ADMIN_NAV_ITEM = { + path: ROUTES.ADMIN.USERS, + label: 'Admin', + icon: LuShield, +} + +function ShellErrorFallback({ error }: { error: Error }): React.ReactElement { + return ( +
+

Something went wrong

+
{error.message}
+
+ ) +} + +function ShellLoading(): React.ReactElement { + return
Loading...
+} + +function getPageTitle(pathname: string, isAdmin: boolean): string { + if (isAdmin && pathname === ADMIN_NAV_ITEM.path) { + return ADMIN_NAV_ITEM.label + } + const item = NAV_ITEMS.find((i) => i.path === pathname) + return item?.label ?? 'Dashboard' +} + +export function Shell(): React.ReactElement { + const location = useLocation() + const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } = + useUIStore() + const { mutate: logout } = useLogout() + const isAdmin = useIsAdmin() + const user = useUser() + + const pageTitle = getPageTitle(location.pathname, isAdmin) + const avatarLetter = + user?.full_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U' + + return ( +
+ + + {sidebarOpen && ( + +

{pageTitle}

+
+ +
+ + {avatarLetter} + +
+ + +
+ + }> + + + +
+ + + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss new file mode 100644 index 00000000..d50ab7fb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss @@ -0,0 +1,67 @@ +// =================== +// © AngelaMos | 2025 +// toast.module.scss +// =================== + +@use '@/styles' as *; + +:global { + [data-sonner-toaster] { + --normal-bg: #{$bg-surface-100}; + --normal-border: #{$border-default}; + --normal-text: #{$text-default}; + + --success-bg: #{$bg-surface-100}; + --success-border: #{$border-default}; + --success-text: #{$text-default}; + + --error-bg: #{$bg-surface-100}; + --error-border: #{$error-default}; + --error-text: #{$text-default}; + + --warning-bg: #{$bg-surface-100}; + --warning-border: #{$border-default}; + --warning-text: #{$text-default}; + + --info-bg: #{$bg-surface-100}; + --info-border: #{$border-default}; + --info-text: #{$text-default}; + + font-family: $font-sans; + } + + [data-sonner-toast] { + border-radius: $radius-md; + padding: $space-3 $space-4; + font-size: $font-size-sm; + border: 1px solid $border-default; + background: $bg-surface-100; + color: $text-default; + + [data-title] { + font-weight: $font-weight-medium; + } + + [data-description] { + color: $text-light; + font-size: $font-size-xs; + } + + [data-close-button] { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: $text-muted; + @include transition-fast; + + @include hover { + color: $text-default; + } + } + } + + [data-sonner-toast][data-type='error'] { + border-color: $error-default; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts new file mode 100644 index 00000000..22c422c5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.form.store.ts @@ -0,0 +1,43 @@ +/** + * ©AngelaMos | 2025 + * auth.form.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +interface AuthFormState { + loginEmail: string + registerEmail: string + setLoginEmail: (email: string) => void + setRegisterEmail: (email: string) => void + clearLoginForm: () => void + clearRegisterForm: () => void +} + +export const useAuthFormStore = create()( + devtools( + persist( + (set) => ({ + loginEmail: '', + registerEmail: '', + + setLoginEmail: (email) => + set({ loginEmail: email }, false, 'authForm/setLoginEmail'), + + setRegisterEmail: (email) => + set({ registerEmail: email }, false, 'authForm/setRegisterEmail'), + + clearLoginForm: () => + set({ loginEmail: '' }, false, 'authForm/clearLoginForm'), + + clearRegisterForm: () => + set({ registerEmail: '' }, false, 'authForm/clearRegisterForm'), + }), + { + name: 'auth-form-storage', + } + ), + { name: 'AuthFormStore' } + ) +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts new file mode 100644 index 00000000..a02cd0c9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/auth.store.ts @@ -0,0 +1,105 @@ +// =================== +// © AngelaMos | 2025 +// auth.store.ts +// =================== + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { type UserResponse, UserRole } from '@/api/types' +import { STORAGE_KEYS } from '@/config' + +interface AuthState { + user: UserResponse | null + accessToken: string | null + isAuthenticated: boolean + isLoading: boolean +} + +interface AuthActions { + login: (user: UserResponse, accessToken: string) => void + logout: () => void + setLoading: (loading: boolean) => void + setAccessToken: (token: string | null) => void + updateUser: (updates: Partial) => void +} + +type AuthStore = AuthState & AuthActions + +export const useAuthStore = create()( + devtools( + persist( + (set) => ({ + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + + login: (user, accessToken) => + set( + { + user, + accessToken, + isAuthenticated: true, + isLoading: false, + }, + false, + 'auth/login' + ), + + logout: () => + set( + { + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + }, + false, + 'auth/logout' + ), + + setLoading: (loading) => + set({ isLoading: loading }, false, 'auth/setLoading'), + + setAccessToken: (token) => + set({ accessToken: token }, false, 'auth/setAccessToken'), + + updateUser: (updates) => + set( + (state) => ({ + user: state.user !== null ? { ...state.user, ...updates } : null, + }), + false, + 'auth/updateUser' + ), + }), + { + name: STORAGE_KEYS.AUTH, + partialize: (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + }), + } + ), + { name: 'AuthStore' } + ) +) + +export const useUser = (): UserResponse | null => useAuthStore((s) => s.user) +export const useIsAuthenticated = (): boolean => + useAuthStore((s) => s.isAuthenticated) +export const useIsAuthLoading = (): boolean => useAuthStore((s) => s.isLoading) +export const useAccessToken = (): string | null => + useAuthStore((s) => s.accessToken) + +export const useHasRole = (role: UserRole): boolean => { + const user = useAuthStore((s) => s.user) + return user !== null && user.role === role +} + +export const useIsAdmin = (): boolean => { + const user = useAuthStore((s) => s.user) + return user !== null && user.role === UserRole.ADMIN +} + +export { UserRole } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts new file mode 100644 index 00000000..191757f4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './auth.form.store' +export * from './auth.store' +export * from './shell.ui.store' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts new file mode 100644 index 00000000..d601a53b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts @@ -0,0 +1,63 @@ +/** + * ©AngelaMos | 2025 + * ui.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +type Theme = 'light' | 'dark' | 'system' + +interface UIState { + theme: Theme + sidebarOpen: boolean + sidebarCollapsed: boolean + setTheme: (theme: Theme) => void + toggleSidebar: () => void + setSidebarOpen: (open: boolean) => void + toggleSidebarCollapsed: () => void +} + +export const useUIStore = create()( + devtools( + persist( + (set) => ({ + theme: 'dark', + sidebarOpen: false, + sidebarCollapsed: false, + + setTheme: (theme) => set({ theme }, false, 'ui/setTheme'), + + toggleSidebar: () => + set( + (state) => ({ sidebarOpen: !state.sidebarOpen }), + false, + 'ui/toggleSidebar' + ), + + setSidebarOpen: (open) => + set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'), + + toggleSidebarCollapsed: () => + set( + (state) => ({ sidebarCollapsed: !state.sidebarCollapsed }), + false, + 'ui/toggleSidebarCollapsed' + ), + }), + { + name: 'ui-storage', + partialize: (state) => ({ + theme: state.theme, + sidebarCollapsed: state.sidebarCollapsed, + }), + } + ), + { name: 'UIStore' } + ) +) + +export const useTheme = (): Theme => useUIStore((s) => s.theme) +export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen) +export const useSidebarCollapsed = (): boolean => + useUIStore((s) => s.sidebarCollapsed) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx new file mode 100644 index 00000000..1b1f2c8a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx @@ -0,0 +1,15 @@ +// =========================== +// ©AngelaMos | 2025 +// main.tsx +// =========================== + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.scss' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss new file mode 100644 index 00000000..ef515000 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/admin.module.scss @@ -0,0 +1,468 @@ +// =================== +// © AngelaMos | 2025 +// admin.module.scss +// =================== + +@use '@/styles' as *; + +.page { + padding: $space-6; + min-height: calc(100vh - 56px); + background-color: $bg-default; +} + +.header { + @include flex-between; + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.createBtn { + display: flex; + align-items: center; + gap: $space-2; + padding: $space-2 $space-4; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + svg { + width: 16px; + height: 16px; + } +} + +.table { + width: 100%; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + overflow: hidden; +} + +.tableHeader { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + background: $bg-surface-200; + border-bottom: 1px solid $border-default; + + @include breakpoint-down('md') { + display: none; + } +} + +.tableHeaderCell { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.tableBody { + @include flex-column; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + border-bottom: 1px solid $border-default; + @include transition-fast; + + &:last-child { + border-bottom: none; + } + + @include hover { + background: $bg-surface-75; + } + + @include breakpoint-down('md') { + grid-template-columns: 1fr; + gap: $space-2; + } +} + +.tableCell { + display: flex; + align-items: center; + font-size: $font-size-sm; + color: $text-default; + min-width: 0; + + @include breakpoint-down('md') { + &::before { + content: attr(data-label); + font-size: $font-size-xs; + color: $text-lighter; + margin-right: $space-2; + min-width: 80px; + } + } +} + +.email { + @include truncate; +} + +.badge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &.admin { + background: $bg-selection; + color: $text-default; + } + + &.user { + background: $bg-surface-200; + color: $text-light; + } + + &.active { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &.inactive { + background: hsl(0 72% 51% / 20%); + color: $error-light; + } +} + +.actions { + display: flex; + gap: $space-2; + justify-content: flex-end; + + @include breakpoint-down('md') { + justify-content: flex-start; + } +} + +.actionBtn { + width: 32px; + height: 32px; + @include flex-center; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.delete { + @include hover { + border-color: $error-default; + color: $error-default; + } + } + + svg { + width: 16px; + height: 16px; + } +} + +.pagination { + @include flex-between; + padding: $space-4; + border-top: 1px solid $border-default; +} + +.paginationInfo { + font-size: $font-size-sm; + color: $text-lighter; +} + +.paginationBtns { + display: flex; + gap: $space-2; +} + +.paginationBtn { + padding: $space-2 $space-3; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.empty { + @include flex-column-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; +} + +.modal { + position: fixed; + inset: 0; + z-index: $z-modal; + @include flex-center; +} + +.modalOverlay { + @include absolute-fill; + background: rgb(0, 0, 0, 70%); +} + +.modalContent { + position: relative; + width: 100%; + max-width: 400px; + margin: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-6; +} + +.modalHeader { + @include flex-between; + margin-bottom: $space-5; +} + +.modalTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.modalClose { + width: 32px; + height: 32px; + @include flex-center; + border: none; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + svg { + width: 20px; + height: 20px; + } +} + +.form { + @include flex-column; + gap: $space-4; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; +} + +.input { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } +} + +.select { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.checkbox { + display: flex; + align-items: center; + gap: $space-2; + cursor: pointer; + + input { + width: 18px; + height: 18px; + accent-color: $white; + } + + span { + font-size: $font-size-sm; + color: $text-light; + } +} + +.formActions { + display: flex; + gap: $space-3; + margin-top: $space-2; +} + +.submitBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelBtn { + flex: 1; + height: 44px; + @include flex-center; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } +} + +.deleteConfirm { + @include flex-column; + gap: $space-4; +} + +.deleteText { + font-size: $font-size-sm; + color: $text-light; + line-height: $line-height-relaxed; +} + +.deleteEmail { + font-weight: $font-weight-medium; + color: $text-default; +} + +.deleteBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $error-default; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx new file mode 100644 index 00000000..53fa22a1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx @@ -0,0 +1,415 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuPencil, LuPlus, LuTrash2, LuX } from 'react-icons/lu' +import { + useAdminCreateUser, + useAdminDeleteUser, + useAdminUpdateUser, + useAdminUsers, +} from '@/api/hooks' +import type { UserResponse } from '@/api/types' +import { UserRole } from '@/api/types' +import { PAGINATION } from '@/config' +import styles from './admin.module.scss' + +type ModalState = + | { type: 'closed' } + | { type: 'create' } + | { type: 'edit'; user: UserResponse } + | { type: 'delete'; user: UserResponse } + +export function Component(): React.ReactElement { + const [page, setPage] = useState(PAGINATION.DEFAULT_PAGE) + const [modal, setModal] = useState({ type: 'closed' }) + + const { data, isLoading } = useAdminUsers({ + page, + size: PAGINATION.DEFAULT_SIZE, + }) + const createUser = useAdminCreateUser() + const updateUser = useAdminUpdateUser() + const deleteUser = useAdminDeleteUser() + + const handleCreate = (formData: FormData): void => { + const email = formData.get('email') as string + const password = formData.get('password') as string + const fullName = (formData.get('fullName') as string) || undefined + const role = formData.get('role') as UserRole + const isActive = formData.get('isActive') === 'on' + + createUser.mutate( + { email, password, full_name: fullName, role, is_active: isActive }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleUpdate = (userId: string, formData: FormData): void => { + const email = formData.get('email') as string + const fullName = (formData.get('fullName') as string) || undefined + const role = formData.get('role') as UserRole + const isActive = formData.get('isActive') === 'on' + + updateUser.mutate( + { + id: userId, + data: { email, full_name: fullName, role, is_active: isActive }, + }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleDelete = (userId: string): void => { + deleteUser.mutate(userId, { onSuccess: () => setModal({ type: 'closed' }) }) + } + + const totalPages = data ? Math.ceil(data.total / PAGINATION.DEFAULT_SIZE) : 0 + + return ( +
+
+

Users

+ +
+ +
+
+
Email
+
Name
+
Role
+
Status
+
Actions
+
+ +
+ {isLoading &&
Loading...
} + + {!isLoading && data?.items.length === 0 && ( +
No users found
+ )} + + {data?.items.map((user) => ( +
+
+ {user.email} +
+
+ {user.full_name ?? '—'} +
+
+ + {user.role} + +
+
+ + {user.is_active ? 'Active' : 'Inactive'} + +
+
+ + +
+
+ ))} +
+ + {data && data.total > PAGINATION.DEFAULT_SIZE && ( +
+ + Page {page} of {totalPages} ({data.total} users) + +
+ + +
+
+ )} +
+ + {modal.type === 'create' && ( +
+ +
+
{ + e.preventDefault() + handleCreate(new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ + )} + + {modal.type === 'edit' && ( +
+ +
+
{ + e.preventDefault() + handleUpdate(modal.user.id, new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + )} + + {modal.type === 'delete' && ( +
+ +
+
+

+ Are you sure you want to delete{' '} + {modal.user.email}? + This action cannot be undone. +

+
+ + +
+
+ + + )} + + ) +} + +Component.displayName = 'AdminUsers' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss new file mode 100644 index 00000000..ecac295d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss @@ -0,0 +1,152 @@ +// =================== +// © AngelaMos | 2025 +// dashboard.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: calc(100vh - 56px); + padding: $space-6; + background-color: $bg-default; +} + +.container { + max-width: 800px; + margin: 0 auto; +} + +.header { + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-lighter; +} + +.userCard { + display: flex; + align-items: center; + gap: $space-4; + padding: $space-5; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + margin-bottom: $space-8; +} + +.avatar { + width: 56px; + height: 56px; + border-radius: $radius-full; + background: $bg-surface-300; + color: $text-default; + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + @include flex-center; + flex-shrink: 0; +} + +.userInfo { + @include flex-column; + gap: $space-1; + min-width: 0; +} + +.userName { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + @include truncate; +} + +.userEmail { + font-size: $font-size-sm; + color: $text-light; + @include truncate; +} + +.userRole { + display: inline-flex; + align-self: flex-start; + padding: $space-0-5 $space-2; + background: $bg-surface-200; + border-radius: $radius-full; + font-size: $font-size-xs; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.section { + margin-bottom: $space-8; +} + +.sectionTitle { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + text-transform: uppercase; + letter-spacing: $tracking-wide; + margin-bottom: $space-4; +} + +.grid { + display: grid; + gap: $space-4; + + @include breakpoint-up('md') { + grid-template-columns: repeat(3, 1fr); + } +} + +.card { + padding: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; +} + +.hookName { + display: inline-block; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + background: $bg-surface-200; + padding: $space-1 $space-2; + border-radius: $radius-sm; + margin-bottom: $space-2; +} + +.description { + font-size: $font-size-sm; + color: $text-light; + margin-bottom: $space-2; + line-height: $line-height-relaxed; +} + +.file { + font-size: $font-size-xs; + color: $text-muted; + font-family: $font-mono; +} + +.list { + @include flex-column; + gap: $space-2; + padding-left: $space-5; + list-style: disc; + + li { + font-size: $font-size-sm; + color: $text-light; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx new file mode 100644 index 00000000..5d3f97ec --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx @@ -0,0 +1,90 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useUser } from '@/core/lib' +import styles from './dashboard.module.scss' + +const AVAILABLE_STORES = [ + { + name: 'useUser()', + file: 'core/lib/auth.store.ts', + description: 'Get current authenticated user', + }, + { + name: 'useIsAuthenticated()', + file: 'core/lib/auth.store.ts', + description: 'Check if user is logged in', + }, + { + name: 'useIsAdmin()', + file: 'core/lib/auth.store.ts', + description: 'Check if user has admin role', + }, +] + +const SUGGESTED_FEATURES = [ + 'User stats and metrics', + 'Recent activity feed', + 'Quick actions', + 'Charts and analytics', + 'Notifications overview', + 'Task/project summary', +] + +export function Component(): React.ReactElement { + const user = useUser() + + return ( +
+
+
+

+ Welcome{user?.full_name ? `, ${user.full_name}` : ''} +

+

+ Template page — build your dashboard here +

+
+ +
+
+ {user?.full_name?.[0]?.toUpperCase() ?? + user?.email?.[0]?.toUpperCase() ?? + 'U'} +
+
+ {user?.full_name ?? 'User'} + {user?.email} + {user?.role} +
+
+ +
+

Available Stores

+
+ {AVAILABLE_STORES.map((store) => ( +
+ {store.name} +

{store.description}

+ {store.file} +
+ ))} +
+
+ +
+

Suggested Features

+
    + {SUGGESTED_FEATURES.map((feature) => ( +
  • {feature}
  • + ))} +
+
+
+
+ ) +} + +Component.displayName = 'Dashboard' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx new file mode 100644 index 00000000..48729f0b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx @@ -0,0 +1,97 @@ +/** + * ©AngelaMos | 2026 + * index.tsx + */ + +import { FiGithub } from 'react-icons/fi' +import { Link } from 'react-router-dom' +import { ROUTES } from '@/config' +import styles from './landing.module.scss' + +export function Component(): React.ReactElement { + return ( +
+
+

Full Stack Template

+

by Carter Perez

+
+ + +
+ +
+

+ Boilerplate for medium-large scale applications. Built with modern + patterns, strict typing, and security best practices. +

+ +
+
+

Frontend

+
    +
  • React 19 + TypeScript with strict mode
  • +
  • TanStack Query for server state caching
  • +
  • Zustand stores with persistence
  • +
  • Axios interceptors with auto token refresh
  • +
  • Zod runtime validation on API responses
  • +
  • SCSS modules with design tokens
  • +
+
+ +
+

Backend

+
    +
  • DDD + DI Architecture
  • +
  • FastAPI with async/await throughout
  • +
  • SQLAlchemy 2.0+ async with connection pooling
  • +
  • JWT auth with token rotation and replay detection
  • +
  • Argon2id hashing with timing safe verification
  • +
  • Pydantic v2 strict validation
  • +
+
+ +
+

Infrastructure

+
    +
  • Docker Compose with dev/prod configs
  • +
  • Nginx reverse proxy with rate limiting
  • +
  • PostgreSQL 18 + Redis 7
  • +
  • Health checks and graceful shutdown
  • +
+
+ +
+

DevOps

+
    +
  • GitHub Actions CI (Ruff, Pylint, Mypy, Biome)
  • +
  • Strict type checking on both ends
  • +
  • Alembic async migrations
  • +
+
+
+ +
+ + Try Auth Flow + + + API Docs + +
+
+
+ ) +} + +Component.displayName = 'Landing' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss new file mode 100644 index 00000000..3013e87a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss @@ -0,0 +1,161 @@ +// =================== +// © AngelaMos | 2025 +// landing.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: 100vh; + min-height: 100dvh; + @include flex-column-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-landing 1px, + transparent 1px + ); + background-size: 20px 20px; + padding: $space-8; +} + +.header { + text-align: center; + margin-bottom: $space-5; +} + +.title { + font-size: $font-size-4xl; + font-weight: $font-weight-semibold; + color: $text-default; + letter-spacing: $tracking-tight; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-lg; + color: $text-light; + margin-bottom: $space-3; +} + +.github { + display: inline-flex; + color: $text-muted; + font-size: $font-size-2xl; + @include transition-fast; + + @include hover { + &:hover { + color: $text-default; + } + } +} + +.content { + max-width: 800px; + text-align: center; +} + +.description { + font-size: $font-size-base; + color: $text-light; + line-height: $line-height-relaxed; + margin-bottom: $space-8; +} + +.sections { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $space-6; + margin-bottom: $space-10; + text-align: left; + + @include breakpoint-down(md) { + grid-template-columns: 1fr; + } +} + +.section { + padding: $space-5; + background-color: $bg-surface-75; + border: 1px solid $border-muted; + border-radius: $radius-lg; +} + +.sectionTitle { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-3; + letter-spacing: $tracking-wide; + text-transform: uppercase; +} + +.features { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: $space-2; + + li { + font-size: $font-size-sm; + color: $text-muted; + + &::before { + content: '→'; + margin-right: $space-2; + color: $text-lighter; + } + } +} + +.actions { + display: flex; + gap: $space-3; + justify-content: center; + flex-wrap: wrap; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $space-3 $space-5; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $bg-default; + background-color: $text-default; + border-radius: $radius-md; + text-decoration: none; + @include transition-fast; + + @include hover { + &:hover { + filter: brightness(0.9); + } + } +} + +.buttonOutline { + display: inline-flex; + align-items: center; + justify-content: center; + padding: $space-3 $space-5; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + text-decoration: none; + @include transition-fast; + + @include hover { + &:hover { + border-color: $border-strong; + background-color: $bg-surface-75; + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx new file mode 100644 index 00000000..13caa91c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/index.tsx @@ -0,0 +1,114 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuEye, LuEyeOff } from 'react-icons/lu' +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { useLogin } from '@/api/hooks' +import { loginRequestSchema } from '@/api/types' +import { ROUTES } from '@/config' +import { useAuthFormStore } from '@/core/lib' +import styles from './login.module.scss' + +export function Component(): React.ReactElement { + const navigate = useNavigate() + const login = useLogin() + + const { loginEmail, setLoginEmail, clearLoginForm } = useAuthFormStore() + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault() + + const result = loginRequestSchema.safeParse({ + username: loginEmail, + password, + }) + + if (!result.success) { + const firstError = result.error.issues[0] + toast.error(firstError.message) + return + } + + login.mutate(result.data, { + onSuccess: () => { + clearLoginForm() + navigate(ROUTES.DASHBOARD) + }, + }) + } + + return ( +
+
+
+

Login

+

Welcome back

+
+ +
+
+ + setLoginEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="current-password" + /> + +
+
+ + +
+ +

+ Don't have an account?{' '} + + Sign up + +

+
+
+ ) +} + +Component.displayName = 'Login' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss new file mode 100644 index 00000000..6d01ed24 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss @@ -0,0 +1,171 @@ +// =================== +// © AngelaMos | 2025 +// login.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: 100vh; + min-height: 100dvh; + @include flex-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-page 1px, + transparent 1px + ); + background-size: 22px 22px; + padding: $space-4; +} + +.card { + width: 100%; + max-width: 400px; + background-color: $black; + background-image: radial-gradient( + circle, + $bg-card 1px, + transparent 1px + ); + background-size: 20px 20px; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-8; +} + +.header { + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $white; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-light; +} + +.form { + @include flex-column; + gap: $space-5; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; +} + +.input { + width: 100%; + height: 48px; + padding: 0 $space-4; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-base; + color: $white; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } + + &[aria-invalid='true'] { + border-color: $error-default; + } +} + +.inputWrapper { + position: relative; + width: 100%; +} + +.eyeButton { + position: absolute; + right: $space-3; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: $text-muted; + cursor: pointer; + padding: $space-1; + @include transition-fast; + + &:hover { + color: $white; + } + + svg { + width: 20px; + height: 20px; + } +} + +.error { + font-size: $font-size-xs; + color: $error-default; +} + +.submit { + width: 100%; + height: 48px; + margin-top: $space-2; + display: flex; + align-items: center; + justify-content: center; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.footer { + margin-top: $space-6; + text-align: center; + font-size: $font-size-sm; + color: $text-light; +} + +.link { + color: $text-default; + text-decoration: underline; + text-underline-offset: 4px; + @include transition-fast; + + @include hover { + color: $text-light; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx new file mode 100644 index 00000000..f0f003f7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx @@ -0,0 +1,164 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuEye, LuEyeOff } from 'react-icons/lu' +import { Link, useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { z } from 'zod' +import { useRegister } from '@/api/hooks' +import { userCreateRequestSchema } from '@/api/types' +import { PASSWORD_CONSTRAINTS, ROUTES } from '@/config' +import { useAuthFormStore } from '@/core/lib' +import styles from './register.module.scss' + +const registerFormSchema = userCreateRequestSchema + .extend({ + confirmPassword: z + .string() + .min( + PASSWORD_CONSTRAINTS.MIN_LENGTH, + `Password must be at least ${PASSWORD_CONSTRAINTS.MIN_LENGTH} characters` + ) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }) + +export function Component(): React.ReactElement { + const navigate = useNavigate() + const register = useRegister() + + const { registerEmail, setRegisterEmail, clearRegisterForm } = + useAuthFormStore() + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault() + + const result = registerFormSchema.safeParse({ + email: registerEmail, + password, + confirmPassword, + }) + + if (!result.success) { + const firstError = result.error.issues[0] + toast.error(firstError.message) + return + } + + register.mutate( + { email: result.data.email, password: result.data.password }, + { + onSuccess: () => { + clearRegisterForm() + toast.success('Account created successfully') + navigate(ROUTES.LOGIN) + }, + } + ) + } + + return ( +
+
+
+

Sign up

+

Create a new account

+
+ +
+
+ + setRegisterEmail(e.target.value)} + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> + +
+
+ + +
+ +

+ Already have an account?{' '} + + Login + +

+
+
+ ) +} + +Component.displayName = 'Register' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss new file mode 100644 index 00000000..55ad5717 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/register.module.scss @@ -0,0 +1,171 @@ +// =================== +// © AngelaMos | 2025 +// register.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: 100vh; + min-height: 100dvh; + @include flex-center; + background-color: $bg-default; + background-image: radial-gradient( + circle, + $bg-page 1px, + transparent 1px + ); + background-size: 22px 22px; + padding: $space-4; +} + +.card { + width: 100%; + max-width: 400px; + background-color: $black; + background-image: radial-gradient( + circle, + $bg-card 1px, + transparent 1px + ); + background-size: 20px 20px; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-8; +} + +.header { + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $white; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-light; +} + +.form { + @include flex-column; + gap: $space-5; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; +} + +.input { + width: 100%; + height: 48px; + padding: 0 $space-4; + background-color: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-base; + color: $white; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } + + &[aria-invalid='true'] { + border-color: $error-default; + } +} + +.inputWrapper { + position: relative; + width: 100%; +} + +.eyeButton { + position: absolute; + right: $space-3; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: $text-muted; + cursor: pointer; + padding: $space-1; + @include transition-fast; + + &:hover { + color: $white; + } + + svg { + width: 20px; + height: 20px; + } +} + +.error { + font-size: $font-size-xs; + color: $error-default; +} + +.submit { + width: 100%; + height: 48px; + margin-top: $space-2; + display: flex; + align-items: center; + justify-content: center; + background-color: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.footer { + margin-top: $space-6; + text-align: center; + font-size: $font-size-sm; + color: $text-light; +} + +.link { + color: $text-default; + text-decoration: underline; + text-underline-offset: 4px; + @include transition-fast; + + @include hover { + color: $text-light; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx new file mode 100644 index 00000000..8a22eb65 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/index.tsx @@ -0,0 +1,95 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import styles from './settings.module.scss' + +const AVAILABLE_HOOKS = [ + { + name: 'useUpdateProfile()', + file: 'api/hooks/useUsers.ts', + description: 'Update user profile (full_name)', + endpoint: 'PATCH /api/v1/users/me', + }, + { + name: 'useChangePassword()', + file: 'api/hooks/useAuth.ts', + description: 'Change password (current + new)', + endpoint: 'POST /api/v1/auth/change-password', + }, +] + +const AVAILABLE_STORES = [ + { + name: 'useAuthStore()', + file: 'core/lib/auth.store.ts', + description: 'Access user state, logout, updateUser', + }, + { + name: 'useUser()', + file: 'core/lib/auth.store.ts', + description: 'Get current user from store', + }, +] + +export function Component(): React.ReactElement { + return ( +
+
+
+

Settings

+

+ Template page — available hooks and stores for building your settings + UI +

+
+ +
+

Available Hooks

+
+ {AVAILABLE_HOOKS.map((hook) => ( +
+ {hook.name} +

{hook.description}

+
+ {hook.file} + {hook.endpoint} +
+
+ ))} +
+
+ +
+

Available Stores

+
+ {AVAILABLE_STORES.map((store) => ( +
+ {store.name} +

{store.description}

+
+ {store.file} +
+
+ ))} +
+
+ +
+

Suggested Features

+
    +
  • Profile form (full name, avatar)
  • +
  • Change password form
  • +
  • Email preferences
  • +
  • Theme toggle (dark/light)
  • +
  • Notification settings
  • +
  • Delete account
  • +
+
+
+
+ ) +} + +Component.displayName = 'Settings' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss new file mode 100644 index 00000000..16d85855 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/settings/settings.module.scss @@ -0,0 +1,109 @@ +// =================== +// © AngelaMos | 2025 +// settings.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: calc(100vh - 56px); + padding: $space-6; + background-color: $bg-default; +} + +.container { + max-width: 800px; + margin: 0 auto; +} + +.header { + margin-bottom: $space-8; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-lighter; +} + +.section { + margin-bottom: $space-8; +} + +.sectionTitle { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + text-transform: uppercase; + letter-spacing: $tracking-wide; + margin-bottom: $space-4; +} + +.grid { + display: grid; + gap: $space-4; + + @include breakpoint-up('md') { + grid-template-columns: repeat(2, 1fr); + } +} + +.card { + padding: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; +} + +.hookName { + display: inline-block; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + background: $bg-surface-200; + padding: $space-1 $space-2; + border-radius: $radius-sm; + margin-bottom: $space-2; +} + +.description { + font-size: $font-size-sm; + color: $text-light; + margin-bottom: $space-3; + line-height: $line-height-relaxed; +} + +.meta { + @include flex-column; + gap: $space-1; +} + +.file { + font-size: $font-size-xs; + color: $text-lighter; + font-family: $font-mono; +} + +.endpoint { + font-size: $font-size-xs; + color: $text-muted; + font-family: $font-mono; +} + +.list { + @include flex-column; + gap: $space-2; + padding-left: $space-5; + list-style: disc; + + li { + font-size: $font-size-sm; + color: $text-light; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss new file mode 100644 index 00000000..4ac95fe0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles.scss @@ -0,0 +1,29 @@ +// =================== +// © AngelaMos | 2025 +// styles.scss +// =================== + +@forward 'styles/tokens'; +@forward 'styles/fonts'; +@forward 'styles/mixins'; + +@use 'styles/reset'; +@use 'styles/tokens' as *; +@use 'styles/fonts' as *; + +#root { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + background: $bg-default; +} + +.app { + flex: 1; + display: flex; + flex-direction: column; + background: $bg-default; + color: $text-default; + font-family: $font-sans; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss new file mode 100644 index 00000000..4f517884 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_fonts.scss @@ -0,0 +1,12 @@ +// =================== +// © AngelaMos | 2025 +// _fonts.scss +// =================== + +@use 'tokens' as *; + +$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, + 'Helvetica Neue', Arial, sans-serif; + +$font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss new file mode 100644 index 00000000..ac5a8266 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_index.scss @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// _index.scss +// =================== + +@forward 'tokens'; +@forward 'fonts'; +@forward 'mixins'; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss new file mode 100644 index 00000000..6b5b1ba9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_mixins.scss @@ -0,0 +1,120 @@ +// =================== +// © AngelaMos | 2025 +// _mixins.scss +// =================== + +@use 'sass:map'; +@use 'sass:list'; +@use 'tokens' as *; + +$breakpoints: ( + 'xs': $breakpoint-xs, + 'sm': $breakpoint-sm, + 'md': $breakpoint-md, + 'lg': $breakpoint-lg, + 'xl': $breakpoint-xl, + '2xl': $breakpoint-2xl, +); + +@mixin breakpoint-up($size) { + @media (min-width: map.get($breakpoints, $size)) { + @content; + } +} + +@mixin breakpoint-down($size) { + @media (width < map.get($breakpoints, $size)) { + @content; + } +} + +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +@mixin flex-column-center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +@mixin sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +@mixin truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@mixin line-clamp($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: #{$lines}; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@mixin transition-fast { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-fast; + transition-timing-function: $ease-out; +} + +@mixin transition-normal { + transition-property: background-color, border-color, color, opacity; + transition-duration: $duration-normal; + transition-timing-function: $ease-out; +} + +@mixin absolute-fill { + position: absolute; + inset: 0; +} + +@mixin absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +@mixin hover { + @media (hover: hover) and (pointer: fine) { + &:hover { + @content; + } + } +} + +@mixin hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss new file mode 100644 index 00000000..b6af56b5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_reset.scss @@ -0,0 +1,198 @@ +// =================== +// © AngelaMos | 2025 +// _reset.scss +// =================== + +@use 'tokens' as *; +@use 'fonts' as *; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html { + font-size: 16px; + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; + overflow-x: hidden; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + scroll-behavior: smooth; + } +} + +body { + min-height: 100vh; + min-height: 100dvh; + line-height: $line-height-normal; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + background-color: $bg-default; + color: $text-default; + font-family: $font-sans; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: $line-height-tight; + text-wrap: balance; + overflow-wrap: break-word; + font-weight: $font-weight-semibold; +} + +p { + text-wrap: pretty; + overflow-wrap: break-word; +} + +ul, +ol { + list-style: none; +} + +a { + color: inherit; + text-decoration: none; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; + height: auto; +} + +input, +button, +textarea, +select { + font: inherit; + color: inherit; +} + +input[type='text'], +input[type='email'], +input[type='password'], +input[type='search'], +input[type='number'], +input[type='tel'], +input[type='url'], +textarea, +select { + font-size: $font-size-sm; + appearance: none; +} + +button { + background: none; + border: none; + cursor: pointer; + text-align: inherit; + font-family: inherit; +} + +fieldset { + border: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +textarea:not([rows]) { + min-height: 10em; +} + +:target { + scroll-margin-block: 5ex; +} + +:focus-visible { + outline: 2px solid $border-strong; + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +[hidden] { + display: none !important; +} + +[disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +dialog { + padding: 0; + border: none; + background: transparent; +} + +summary { + cursor: pointer; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@supports (padding: max(0px)) { + body { + padding-left: max(0px, env(safe-area-inset-left)); + padding-right: max(0px, env(safe-area-inset-right)); + } +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: $border-default; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: $border-strong; +} + +::selection { + background-color: $bg-selection; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss new file mode 100644 index 00000000..be1f6dd6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss @@ -0,0 +1,168 @@ +// =================== +// © AngelaMos | 2025 +// _tokens.scss +// =================== + +// ============================================================================ +// SPACING (8px base system) +// ============================================================================ +$space-0: 0; +$space-px: 1px; +$space-0-5: 0.125rem; +$space-1: 0.25rem; +$space-1-5: 0.375rem; +$space-2: 0.5rem; +$space-2-5: 0.625rem; +$space-3: 0.75rem; +$space-3-5: 0.875rem; +$space-4: 1rem; +$space-5: 1.25rem; +$space-6: 1.5rem; +$space-7: 1.75rem; +$space-8: 2rem; +$space-9: 2.25rem; +$space-10: 2.5rem; +$space-11: 2.75rem; +$space-12: 3rem; +$space-14: 3.5rem; +$space-16: 4rem; +$space-20: 5rem; +$space-24: 6rem; +$space-28: 7rem; +$space-32: 8rem; + +// ============================================================================ +// TYPOGRAPHY SCALE +// ============================================================================ +$font-size-3xs: 0.625rem; +$font-size-2xs: 0.6875rem; +$font-size-xs: 0.75rem; +$font-size-sm: 0.875rem; +$font-size-base: 1rem; +$font-size-lg: 1.125rem; +$font-size-xl: 1.25rem; +$font-size-2xl: 1.5rem; +$font-size-3xl: 1.875rem; +$font-size-4xl: 2.25rem; +$font-size-5xl: 3rem; + +// ============================================================================ +// FONT WEIGHTS +// ============================================================================ +$font-weight-regular: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; + +// ============================================================================ +// LINE HEIGHTS +// ============================================================================ +$line-height-none: 1; +$line-height-tight: 1.2; +$line-height-snug: 1.375; +$line-height-normal: 1.5; +$line-height-relaxed: 1.625; + +// ============================================================================ +// LETTER SPACING +// ============================================================================ +$tracking-tighter: -0.05em; +$tracking-tight: -0.025em; +$tracking-normal: 0; +$tracking-wide: 0.025em; +$tracking-wider: 0.05em; + +// ============================================================================ +// COLORS +// ============================================================================ +$white: hsl(0, 0%, 100%); +$black: hsl(0, 0%, 0%); + +// Auth +$bg-page: hsl(0, 0%, 10.5%); +$bg-card: hsl(0, 0%, 6.2%); + +// Home/landing +$bg-landing: hsl(0, 0%, 10.8%); + +$bg-default: hsl(0, 0%, 7.1%); +$bg-alternative: hsl(0, 0%, 5.9%); +$bg-surface-75: hsl(0, 0%, 9%); +$bg-surface-100: hsl(0, 0%, 12.2%); +$bg-surface-200: hsl(0, 0%, 14.1%); +$bg-surface-300: hsl(0, 0%, 16.1%); +$bg-control: hsl(0, 0%, 10%); +$bg-selection: hsl(0, 0%, 19.2%); +$bg-overlay: hsl(0, 0%, 14.1%); +$bg-overlay-hover: hsl(0, 0%, 18%); + +$border-muted: hsl(0, 0%, 11.1%); +$border-default: hsl(0, 0%, 18%); +$border-strong: hsl(0, 0%, 22.4%); +$border-stronger: hsl(0, 0%, 27.1%); +$border-control: hsl(0, 0%, 22.4%); + +$text-default: hsl(0, 0%, 98%); +$text-light: hsl(0, 0%, 70.6%); +$text-lighter: hsl(0, 0%, 53.7%); +$text-muted: hsl(0, 0%, 30.2%); + +$error-default: hsl(0, 72%, 51%); +$error-light: hsl(0, 72%, 65%); + +// ============================================================================ +// BORDER RADIUS +// ============================================================================ +$radius-none: 0; +$radius-xs: 2px; +$radius-sm: 4px; +$radius-md: 6px; +$radius-lg: 8px; +$radius-xl: 12px; +$radius-full: 9999px; + +// ============================================================================ +// Z-INDEX SCALE +// ============================================================================ +$z-hide: -1; +$z-base: 0; +$z-dropdown: 100; +$z-sticky: 200; +$z-fixed: 300; +$z-overlay: 400; +$z-modal: 500; +$z-popover: 600; +$z-tooltip: 700; +$z-toast: 800; +$z-max: 9999; + +// ============================================================================ +// TRANSITIONS +// ============================================================================ +$duration-instant: 0ms; +$duration-fast: 100ms; +$duration-normal: 150ms; +$duration-slow: 200ms; + +$ease-out: cubic-bezier(0, 0, 0.2, 1); +$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + +// ============================================================================ +// BREAKPOINTS +// ============================================================================ +$breakpoint-xs: 360px; +$breakpoint-sm: 480px; +$breakpoint-md: 768px; +$breakpoint-lg: 1024px; +$breakpoint-xl: 1280px; +$breakpoint-2xl: 1536px; + +// ============================================================================ +// CONTAINER WIDTHS +// ============================================================================ +$container-xs: 20rem; +$container-sm: 24rem; +$container-md: 28rem; +$container-lg: 32rem; +$container-xl: 36rem; +$container-2xl: 42rem; +$container-full: 100%; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js new file mode 100644 index 00000000..56ebd7bc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/stylelint.config.js @@ -0,0 +1,107 @@ +// ©AngelaMos | 2025 +// stylelint.config.js + +/** @type {import('stylelint').Config} */ +export default { + extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'], + rules: { + 'block-no-empty': true, + 'declaration-no-important': true, + 'color-no-invalid-hex': true, + 'property-no-unknown': true, + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global'], + }, + ], + + 'selector-class-pattern': [ + '^[a-z]([a-z0-9-]+)?(__[a-z0-9]([a-z0-9-]+)?)?(--[a-z0-9]([a-z0-9-]+)?)?$|^[a-z][a-zA-Z0-9]*$', + { + message: + 'Selector should be in BEM format (e.g., .block__element--modifier) or CSS Modules camelCase (e.g., .testButton)', + }, + ], + + 'value-keyword-case': [ + 'lower', + { + camelCaseSvgKeywords: true, + ignoreKeywords: [ + 'BlinkMacSystemFont', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + 'Roboto', + 'Arial', + 'Helvetica', + 'Times', + 'Georgia', + 'Verdana', + 'Tahoma', + 'Trebuchet', + 'Impact', + 'Comic', + ], + }, + ], + + 'property-no-vendor-prefix': [ + true, + { + ignoreProperties: ['text-size-adjust', 'appearance', 'backdrop-filter'], + }, + ], + 'value-no-vendor-prefix': true, + 'selector-no-vendor-prefix': true, + + 'property-no-deprecated': [ + true, + { + ignoreProperties: ['clip'], + }, + ], + + 'container-name-pattern': null, + 'layer-name-pattern': null, + + 'scss/at-rule-no-unknown': true, + 'scss/declaration-nested-properties-no-divided-groups': true, + 'scss/dollar-variable-no-missing-interpolation': true, + 'scss/dollar-variable-empty-line-before': null, + + 'declaration-empty-line-before': null, + 'custom-property-empty-line-before': null, + + 'no-descending-specificity': null, + + 'media-feature-name-no-unknown': [ + true, + { + ignoreMediaFeatureNames: ['map'], + }, + ], + + 'color-function-notation': null, + 'hue-degree-notation': null, + }, + ignoreFiles: [ + 'node_modules/**', + 'dist/**', + 'build/**', + '**/*.js', + '**/*.ts', + '**/*.tsx', + ], + overrides: [ + { + files: ['**/styles/_reset.scss', '**/styles/_fonts.scss'], + rules: { + 'declaration-no-important': null, + 'scss/comment-no-empty': null, + }, + }, + ], +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json new file mode 100644 index 00000000..afe17f60 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json new file mode 100644 index 00000000..a96b3e59 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts new file mode 100644 index 00000000..a8d5d478 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/vite.config.ts @@ -0,0 +1,71 @@ +/** + * ©AngelaMos | 2025 + * vite.config.ts + */ + +import path from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, path.resolve(__dirname, '..'), '') + const isDev = mode === 'development' + + return { + plugins: [react(), tsconfigPaths()], + + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + + css: { + preprocessorOptions: { + scss: {}, + }, + }, + + server: { + port: 5173, + host: '0.0.0.0', + proxy: { + '/api': { + target: env.VITE_API_TARGET || 'http://localhost:8000', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ''), + }, + }, + }, + + build: { + target: 'esnext', + cssTarget: 'chrome100', + sourcemap: isDev ? true : 'hidden', + minify: 'oxc', + rollupOptions: { + output: { + manualChunks(id: string): string | undefined { + if (id.includes('node_modules')) { + if (id.includes('react-dom') || id.includes('react-router')) { + return 'vendor-react' + } + if (id.includes('@tanstack/react-query')) { + return 'vendor-query' + } + if (id.includes('zustand')) { + return 'vendor-state' + } + } + return undefined + }, + }, + }, + }, + + preview: { + port: 4173, + }, + } +}) From a03c060b9f53b7e7f2c9b8993c24599685bdce9e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 06:16:12 -0400 Subject: [PATCH 002/109] feat(monitor/nginx): base + dev + prod configs with WS upgrade routing --- .../conf/nginx/dev.nginx | 66 ++++++++++++++ .../conf/nginx/nginx.conf | 72 ++++++++++++++++ .../conf/nginx/nginx.prod.conf | 64 ++++++++++++++ .../conf/nginx/prod.nginx | 85 +++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.conf create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.prod.conf create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx new file mode 100644 index 00000000..931b599f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx @@ -0,0 +1,66 @@ +# ©AngelaMos | 2026 +# dev.nginx + +server { + listen 80; + listen [::]:80; + server_name _; + + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + + location /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + location /api/v1/ws { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + chunked_transfer_encoding off; + } + + location /api/ { + limit_req zone=api_limit burst=50 nodelay; + limit_conn conn_limit 20; + + proxy_pass http://backend/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_request_buffering off; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location / { + proxy_pass http://frontend_dev; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_read_timeout 60s; + proxy_buffering off; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.conf b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.conf new file mode 100644 index 00000000..d3250f70 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.conf @@ -0,0 +1,72 @@ +# ©AngelaMos | 2026 +# nginx.conf + +worker_processes auto; +worker_rlimit_nofile 4096; + +events { + worker_connections 2048; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + log_format main_timed '$remote_addr - $remote_user [$time_iso8601] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main_timed; + error_log /var/log/nginx/error.log info; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + client_max_body_size 2m; + client_body_buffer_size 16k; + client_header_buffer_size 4k; + large_client_header_buffers 4 16k; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + application/javascript + application/json + application/manifest+json + application/xml + font/woff2 + text/css + text/plain + text/xml; + + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + + upstream backend { + server backend:8080; + keepalive 32; + } + + upstream frontend_dev { + server frontend:5173; + keepalive 16; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.prod.conf b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.prod.conf new file mode 100644 index 00000000..cee366f2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/nginx.prod.conf @@ -0,0 +1,64 @@ +# ©AngelaMos | 2026 +# nginx.prod.conf + +worker_processes auto; +worker_rlimit_nofile 8192; + +events { + worker_connections 4096; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + log_format main_timed '$remote_addr - $remote_user [$time_iso8601] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main_timed buffer=32k flush=5s; + error_log /var/log/nginx/error.log warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + client_max_body_size 2m; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + application/javascript + application/json + application/manifest+json + application/xml + font/woff2 + text/css + text/plain + text/xml; + + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + + upstream backend { + server backend:8080; + keepalive 64; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx new file mode 100644 index 00000000..8a80556f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx @@ -0,0 +1,85 @@ +# ©AngelaMos | 2026 +# prod.nginx + +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + location /healthz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + location /api/v1/ws { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + chunked_transfer_encoding off; + } + + location /api/ { + limit_req zone=api_limit burst=20 nodelay; + limit_conn conn_limit 50; + + proxy_pass http://backend/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering on; + proxy_buffers 8 32k; + proxy_buffer_size 4k; + + proxy_connect_timeout 60s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif|woff|woff2|ttf|eot|otf)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + location / { + add_header Cache-Control "no-cache, must-revalidate"; + try_files $uri $uri/ /index.html; + } + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} From e46728bd80154b797f8141a8f5500d7b5e99a2c3 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 06:17:28 -0400 Subject: [PATCH 003/109] feat(monitor/infra): three compose files, Makefile, .env.example --- .../.env.example | 28 ++++ .../monitor-the-situation-dashboard/Makefile | 107 ++++++++++++++ .../cloudflared.compose.yml | 22 +++ .../compose.yml | 130 ++++++++++++++++++ .../dev.compose.yml | 126 +++++++++++++++++ 5 files changed, 413 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/.env.example create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/Makefile create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/cloudflared.compose.yml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example new file mode 100644 index 00000000..39b2128b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example @@ -0,0 +1,28 @@ +# ©AngelaMos | 2026 +# .env.example + +APP_NAME=monitor +PUBLIC_URL=http://localhost:8430 + +NGINX_HOST_PORT=8430 +BACKEND_HOST_PORT=5430 +FRONTEND_HOST_PORT=3430 +POSTGRES_HOST_PORT=5431 +REDIS_HOST_PORT=6430 + +POSTGRES_USER=monitor +POSTGRES_PASSWORD=changeme +POSTGRES_DB=monitor + +REDIS_PASSWORD= + +JWT_SECRET= +NOTIFICATION_ENCRYPTION_KEY= + +NVD_API_KEY= +CF_RADAR_TOKEN= +GREYNOISE_API_KEY= + +CLOUDFLARE_TUNNEL_TOKEN= + +VITE_API_URL=/api diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/Makefile b/PROJECTS/advanced/monitor-the-situation-dashboard/Makefile new file mode 100644 index 00000000..d929628f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/Makefile @@ -0,0 +1,107 @@ +# ©AngelaMos | 2026 +# Makefile + +.DEFAULT_GOAL := help +SHELL := /bin/bash + +COMPOSE_PROD := docker compose -f compose.yml +COMPOSE_DEV := docker compose -f dev.compose.yml +COMPOSE_TUN := docker compose -f compose.yml -f cloudflared.compose.yml + +.PHONY: help up up-dev up-tunnel down down-dev logs logs-dev ps ps-dev migrate migrate-dev psql redis-cli backend-shell frontend-shell tidy fmt test build-prod stop clean + +help: + @echo 'Monitor the Situation — Make targets:' + @echo '' + @echo ' Production:' + @echo ' up start production stack (no tunnel)' + @echo ' up-tunnel start production stack + cloudflared' + @echo ' down stop production stack' + @echo ' logs tail production logs' + @echo ' ps status of production services' + @echo '' + @echo ' Development:' + @echo ' up-dev start dev stack (vite + air)' + @echo ' down-dev stop dev stack' + @echo ' logs-dev tail dev logs' + @echo ' ps-dev status of dev services' + @echo '' + @echo ' Database:' + @echo ' migrate run goose migrations against prod db' + @echo ' migrate-dev run goose migrations against dev db' + @echo ' psql psql shell into dev db' + @echo ' redis-cli redis-cli shell into dev redis' + @echo '' + @echo ' Backend:' + @echo ' backend-shell shell into the dev backend container' + @echo ' test run all backend tests with -race' + @echo ' fmt format Go code' + @echo ' tidy go mod tidy' + @echo '' + @echo ' Frontend:' + @echo ' frontend-shell shell into the dev frontend container' + @echo '' + +up: + $(COMPOSE_PROD) up -d --build + +up-tunnel: + $(COMPOSE_TUN) up -d --build + +up-dev: + $(COMPOSE_DEV) up -d --build + +down: + $(COMPOSE_PROD) down + +down-dev: + $(COMPOSE_DEV) down + +logs: + $(COMPOSE_PROD) logs -f --tail=200 + +logs-dev: + $(COMPOSE_DEV) logs -f --tail=200 + +ps: + $(COMPOSE_PROD) ps + +ps-dev: + $(COMPOSE_DEV) ps + +migrate: + $(COMPOSE_PROD) exec backend sh -c 'goose -dir /migrations postgres "$$DATABASE_URL" up' + +migrate-dev: + $(COMPOSE_DEV) exec backend sh -c 'cd /app && goose -dir migrations postgres "$$DATABASE_URL" up' + +psql: + $(COMPOSE_DEV) exec postgres psql -U $${POSTGRES_USER:-monitor} -d $${POSTGRES_DB:-monitor} + +redis-cli: + $(COMPOSE_DEV) exec redis redis-cli + +backend-shell: + $(COMPOSE_DEV) exec backend sh + +frontend-shell: + $(COMPOSE_DEV) exec frontend sh + +test: + cd backend && go test -race -count=1 ./... + +fmt: + cd backend && gofumpt -w . && goimports -w . + +tidy: + cd backend && go mod tidy + +build-prod: + $(COMPOSE_PROD) build + +stop: + $(COMPOSE_DEV) stop ; $(COMPOSE_PROD) stop + +clean: stop + $(COMPOSE_DEV) down -v + $(COMPOSE_PROD) down -v diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/cloudflared.compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/cloudflared.compose.yml new file mode 100644 index 00000000..d84e01f1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/cloudflared.compose.yml @@ -0,0 +1,22 @@ +# ©AngelaMos | 2026 +# cloudflared.compose.yml + +services: + cloudflared: + image: cloudflare/cloudflared:latest + container_name: ${APP_NAME:-monitor}-tunnel + command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - backend + depends_on: + nginx: + condition: service_started + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + restart: unless-stopped diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml new file mode 100644 index 00000000..b3d9d08c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml @@ -0,0 +1,130 @@ +# ©AngelaMos | 2026 +# compose.yml + +name: ${APP_NAME:-monitor} + +services: + nginx: + build: + context: . + dockerfile: conf/docker/prod/vite.docker + container_name: ${APP_NAME:-monitor}-nginx + ports: + - "${NGINX_HOST_PORT:-8430}:80" + depends_on: + backend: + condition: service_healthy + networks: + - frontend + - backend + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + reservations: + cpus: '0.25' + memory: 64M + restart: unless-stopped + + backend: + build: + context: . + dockerfile: conf/docker/prod/go.docker + container_name: ${APP_NAME:-monitor}-backend + expose: + - "8080" + env_file: + - .env + environment: + - APP_ENVIRONMENT=production + - DATABASE_URL=postgres://${POSTGRES_USER:-monitor}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-monitor}?sslmode=disable + - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + deploy: + resources: + limits: + cpus: '2.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 256M + healthcheck: + test: ["CMD", "/app", "-healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + restart: unless-stopped + + postgres: + image: postgres:17-alpine + container_name: ${APP_NAME:-monitor}-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-monitor} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-monitor} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - backend + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-monitor} -d ${POSTGRES_DB:-monitor}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-monitor}-redis + command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} + volumes: + - redisdata:/data + networks: + - backend + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + healthcheck: + test: ["CMD-SHELL", "redis-cli ${REDIS_PASSWORD:+-a ${REDIS_PASSWORD}} ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +networks: + frontend: + driver: bridge + ipam: + config: + - subnet: 10.203.83.0/24 + backend: + driver: bridge + ipam: + config: + - subnet: 10.203.84.0/24 + +volumes: + pgdata: + redisdata: diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml new file mode 100644 index 00000000..465186c6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml @@ -0,0 +1,126 @@ +# ©AngelaMos | 2026 +# dev.compose.yml + +name: ${APP_NAME:-monitor}-dev + +services: + nginx: + image: nginx:1.27-alpine + container_name: ${APP_NAME:-monitor}-nginx-dev + ports: + - "${NGINX_HOST_PORT:-8430}:80" + volumes: + - ./conf/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./conf/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + frontend: + condition: service_started + networks: + - frontend + - backend + restart: unless-stopped + + backend: + build: + context: . + dockerfile: conf/docker/dev/go.docker + container_name: ${APP_NAME:-monitor}-backend-dev + ports: + - "${BACKEND_HOST_PORT:-5430}:8080" + volumes: + - ./backend:/app + - go-mod-cache:/go/pkg/mod + env_file: + - .env + environment: + - APP_ENVIRONMENT=development + - DATABASE_URL=postgres://${POSTGRES_USER:-monitor}:${POSTGRES_PASSWORD:-monitor}@postgres:5432/${POSTGRES_DB:-monitor}?sslmode=disable + - REDIS_URL=redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/api/v1/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + frontend: + build: + context: . + dockerfile: conf/docker/dev/vite.docker + container_name: ${APP_NAME:-monitor}-frontend-dev + ports: + - "${FRONTEND_HOST_PORT:-3430}:5173" + volumes: + - ./frontend:/app + - frontend-node-modules:/app/node_modules + environment: + - VITE_API_URL=${VITE_API_URL:-/api} + networks: + - frontend + restart: unless-stopped + + postgres: + image: postgres:17-alpine + container_name: ${APP_NAME:-monitor}-postgres-dev + ports: + - "${POSTGRES_HOST_PORT:-5431}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-monitor} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-monitor} + POSTGRES_DB: ${POSTGRES_DB:-monitor} + volumes: + - pgdata-dev:/var/lib/postgresql/data + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-monitor} -d ${POSTGRES_DB:-monitor}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-monitor}-redis-dev + ports: + - "${REDIS_HOST_PORT:-6430}:6379" + command: redis-server --appendonly yes + volumes: + - redisdata-dev:/data + networks: + - backend + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +networks: + frontend: + driver: bridge + ipam: + config: + - subnet: 10.201.71.0/24 + backend: + driver: bridge + ipam: + config: + - subnet: 10.201.72.0/24 + +volumes: + pgdata-dev: + redisdata-dev: + go-mod-cache: + frontend-node-modules: From a627161b338ed9354a1ab0b229e73df832a1d9c3 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 06:21:01 -0400 Subject: [PATCH 004/109] refactor(monitor/backend): rename module to monitor-the-situation/backend --- .../backend/.golangci.yml | 2 +- .../backend/cmd/api/main.go | 16 ++++++++-------- .../backend/go.mod | 2 +- .../backend/internal/admin/handler.go | 2 +- .../backend/internal/auth/handler.go | 4 ++-- .../backend/internal/auth/jwt.go | 6 +++--- .../backend/internal/auth/repository.go | 2 +- .../backend/internal/auth/service.go | 2 +- .../backend/internal/core/database.go | 2 +- .../backend/internal/core/redis.go | 2 +- .../backend/internal/core/telemetry.go | 2 +- .../backend/internal/middleware/auth.go | 2 +- .../backend/internal/middleware/headers.go | 2 +- .../backend/internal/server/server.go | 4 ++-- .../backend/internal/user/handler.go | 4 ++-- .../backend/internal/user/repository.go | 2 +- .../backend/internal/user/service.go | 4 ++-- 17 files changed, 30 insertions(+), 30 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml index 679c3e2c..961b2028 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml @@ -102,7 +102,7 @@ formatters: reformat-tags: true goimports: local-prefixes: - - github.com/carterperez-dev/templates/go-backend + - github.com/carterperez-dev/monitor-the-situation/backend gci: sections: - standard diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index 150d2b52..7180e87f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -14,14 +14,14 @@ import ( "github.com/go-chi/chi/v5" - "github.com/carterperez-dev/templates/go-backend/internal/admin" - "github.com/carterperez-dev/templates/go-backend/internal/auth" - "github.com/carterperez-dev/templates/go-backend/internal/config" - "github.com/carterperez-dev/templates/go-backend/internal/core" - "github.com/carterperez-dev/templates/go-backend/internal/health" - "github.com/carterperez-dev/templates/go-backend/internal/middleware" - "github.com/carterperez-dev/templates/go-backend/internal/server" - "github.com/carterperez-dev/templates/go-backend/internal/user" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/admin" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/server" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/user" ) const ( diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index e5981e90..6f920fb6 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -1,4 +1,4 @@ -module github.com/carterperez-dev/templates/go-backend +module github.com/carterperez-dev/monitor-the-situation/backend go 1.25.0 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go index 4f75ab22..2bc5bf94 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/admin/handler.go @@ -12,7 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/redis/go-redis/v9" - "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) type AuthService interface { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go index 16b3b6bd..c8e5e46c 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go @@ -13,8 +13,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-playground/validator/v10" - "github.com/carterperez-dev/templates/go-backend/internal/core" - "github.com/carterperez-dev/templates/go-backend/internal/middleware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" ) type Handler struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go index 026cf056..d5774835 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go @@ -20,9 +20,9 @@ import ( "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/carterperez-dev/templates/go-backend/internal/config" - "github.com/carterperez-dev/templates/go-backend/internal/core" - "github.com/carterperez-dev/templates/go-backend/internal/middleware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" ) type JWTManager struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go index 2abb0360..f4f86605 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/repository.go @@ -10,7 +10,7 @@ import ( "fmt" "time" - "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) type Repository interface { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go index 3629051c..3762724e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go @@ -12,7 +12,7 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" - "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) var ( diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go index 766af7f8..470f711c 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/database.go @@ -13,7 +13,7 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" "github.com/jmoiron/sqlx" - "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" ) type Database struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go index 822b6cc9..f7f86927 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/redis.go @@ -10,7 +10,7 @@ import ( "github.com/redis/go-redis/v9" - "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" ) type Redis struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go index 950bba50..a03220b1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go @@ -19,7 +19,7 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" - "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" ) type Telemetry struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go index 0f9fb5f2..1ca066c3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go @@ -9,7 +9,7 @@ import ( "net/http" "strings" - "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) const ( diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go index 332d48f3..121e4dc9 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/headers.go @@ -8,7 +8,7 @@ import ( "strconv" "strings" - "github.com/carterperez-dev/templates/go-backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" ) func SecurityHeaders(isProduction bool) func(http.Handler) http.Handler { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go index 58d5cbac..1c39db94 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/server/server.go @@ -14,8 +14,8 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" - "github.com/carterperez-dev/templates/go-backend/internal/config" - "github.com/carterperez-dev/templates/go-backend/internal/health" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" ) type Server struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go index 8a21889b..c4b5f73c 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go @@ -12,8 +12,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-playground/validator/v10" - "github.com/carterperez-dev/templates/go-backend/internal/core" - "github.com/carterperez-dev/templates/go-backend/internal/middleware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" ) type Handler struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go index a255b08e..39c00012 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go @@ -12,7 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" - "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) type Repository interface { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go index 699d24d5..f772e6fd 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go @@ -10,8 +10,8 @@ import ( "github.com/google/uuid" - "github.com/carterperez-dev/templates/go-backend/internal/auth" - "github.com/carterperez-dev/templates/go-backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) type Service struct { From 618aaf43e8399a333747386aeeba9e4f1d56a511 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 06:27:23 -0400 Subject: [PATCH 005/109] deps(monitor/backend): add coder/websocket, gobreaker/v2, errgroup, prometheus, testcontainers --- .../backend/go.mod | 26 ++++---- .../backend/go.sum | 60 +++++++++---------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index 6f920fb6..1f8967f3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -14,12 +14,12 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.1.2 github.com/lestrrat-go/jwx/v3 v3.0.12 - github.com/redis/go-redis/v9 v9.7.0 - go.opentelemetry.io/otel v1.33.0 + github.com/redis/go-redis/v9 v9.7.3 + go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 - go.opentelemetry.io/otel/sdk v1.33.0 - go.opentelemetry.io/otel/trace v1.33.0 - golang.org/x/crypto v0.43.0 + go.opentelemetry.io/otel/sdk v1.35.0 + go.opentelemetry.io/otel/trace v1.41.0 + golang.org/x/crypto v0.48.0 golang.org/x/time v0.14.0 google.golang.org/grpc v1.68.1 ) @@ -31,7 +31,7 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -54,16 +54,16 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/valyala/fastjson v1.6.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect - go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect - golang.org/x/net v0.45.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/protobuf v1.35.2 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 0a9a9619..2cc02b59 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -22,8 +22,8 @@ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcP github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -44,8 +44,8 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= @@ -102,10 +102,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= +github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -117,36 +117,36 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= @@ -155,8 +155,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 73c56333e6dddb3686a64a486b8abb1aebb0dc46 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 06:28:51 -0400 Subject: [PATCH 006/109] feat(monitor/db): goose migrations 0001-0003 (alerts, panel data, collector state) --- .../backend/migrations/0001_alerts.sql | 110 +++++++++++++ .../backend/migrations/0002_panel_data.sql | 151 ++++++++++++++++++ .../migrations/0003_collector_state.sql | 20 +++ 3 files changed, 281 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_alerts.sql create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_panel_data.sql create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_collector_state.sql diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_alerts.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_alerts.sql new file mode 100644 index 00000000..a6c906af --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_alerts.sql @@ -0,0 +1,110 @@ +-- ©AngelaMos | 2026 +-- 0001_alerts.sql + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE alert_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + topic text NOT NULL, + predicate text NOT NULL, + cooldown_sec integer NOT NULL DEFAULT 300, + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX idx_alert_rules_user ON alert_rules(user_id) WHERE enabled = true; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE alert_channels ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type text NOT NULL, + label text NOT NULL, + config_enc bytea NOT NULL, + nonce bytea NOT NULL, + invalid boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX idx_alert_channels_user ON alert_channels(user_id); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE alert_rule_channels ( + rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + channel_id uuid NOT NULL REFERENCES alert_channels(id) ON DELETE CASCADE, + PRIMARY KEY (rule_id, channel_id) +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE alert_history ( + id bigserial PRIMARY KEY, + rule_id uuid NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + fired_at timestamptz NOT NULL DEFAULT now(), + payload jsonb NOT NULL, + delivered_to text[] NOT NULL DEFAULT '{}', + delivery_errors jsonb +); +CREATE INDEX idx_alert_history_user_time ON alert_history(user_id, fired_at DESC); +CREATE INDEX idx_alert_history_fired_brin ON alert_history USING BRIN (fired_at); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE telegram_webhooks ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + webhook_uuid uuid NOT NULL UNIQUE, + secret_token text NOT NULL, + bot_token_enc bytea NOT NULL, + bot_token_nonce bytea NOT NULL, + chat_id bigint, + pending_link boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE user_preferences ( + user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + watched_country text, + watched_asns integer[] NOT NULL DEFAULT '{}', + observer_lat double precision, + observer_lon double precision, + observer_alt_m double precision, + digest_enabled boolean NOT NULL DEFAULT false, + digest_local_tz text NOT NULL DEFAULT 'UTC', + digest_local_time time NOT NULL DEFAULT '08:00', + digest_window_hours integer NOT NULL DEFAULT 24, + digest_channel_ids uuid[] NOT NULL DEFAULT '{}', + chime_enabled boolean NOT NULL DEFAULT true, + chime_audio_object text, + presentation_default boolean NOT NULL DEFAULT false, + updated_at timestamptz NOT NULL DEFAULT now() +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE digest_runs ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + fired_date date NOT NULL, + fired_at timestamptz NOT NULL DEFAULT now(), + status text NOT NULL, + error text, + PRIMARY KEY (user_id, fired_date) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS digest_runs; +DROP TABLE IF EXISTS user_preferences; +DROP TABLE IF EXISTS telegram_webhooks; +DROP TABLE IF EXISTS alert_history; +DROP TABLE IF EXISTS alert_rule_channels; +DROP TABLE IF EXISTS alert_channels; +DROP TABLE IF EXISTS alert_rules; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_panel_data.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_panel_data.sql new file mode 100644 index 00000000..3bcd3169 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_panel_data.sql @@ -0,0 +1,151 @@ +-- ©AngelaMos | 2026 +-- 0002_panel_data.sql + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE cve_events ( + cve_id text PRIMARY KEY, + published timestamptz, + last_modified timestamptz NOT NULL, + severity text, + cvss_score numeric(3,1), + epss_score numeric(5,4), + epss_percentile numeric(5,4), + in_kev boolean NOT NULL DEFAULT false, + payload jsonb NOT NULL +); +CREATE INDEX idx_cve_lastmod_brin ON cve_events USING BRIN (last_modified); +CREATE INDEX idx_cve_severity_lastmod ON cve_events (severity, last_modified DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE kev_entries ( + cve_id text PRIMARY KEY, + vendor text, + product text, + vulnerability_name text, + date_added date NOT NULL, + due_date date, + ransomware_use text, + payload jsonb NOT NULL +); +CREATE INDEX idx_kev_dateadded ON kev_entries (date_added DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE earthquakes ( + id text PRIMARY KEY, + occurred_at timestamptz NOT NULL, + mag numeric(3,1) NOT NULL, + place text, + geom_lon double precision NOT NULL, + geom_lat double precision NOT NULL, + depth_km double precision, + payload jsonb NOT NULL +); +CREATE INDEX idx_quakes_time_brin ON earthquakes USING BRIN (occurred_at); +CREATE INDEX idx_quakes_mag ON earthquakes (mag DESC, occurred_at DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE ransomware_victims ( + id text PRIMARY KEY, + post_title text NOT NULL, + group_name text NOT NULL, + discovered_at timestamptz NOT NULL, + country text, + sector text, + payload jsonb NOT NULL +); +CREATE INDEX idx_rans_time_brin ON ransomware_victims USING BRIN (discovered_at); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE btc_eth_ticks ( + symbol text NOT NULL, + ts timestamptz NOT NULL, + price numeric(18,8) NOT NULL, + volume_24h numeric(20,8), + PRIMARY KEY (symbol, ts) +); +CREATE INDEX idx_ticks_time_brin ON btc_eth_ticks USING BRIN (ts); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE btc_eth_minute ( + symbol text NOT NULL, + minute timestamptz NOT NULL, + open numeric(18,8) NOT NULL, + high numeric(18,8) NOT NULL, + low numeric(18,8) NOT NULL, + close numeric(18,8) NOT NULL, + volume numeric(20,8), + PRIMARY KEY (symbol, minute) +); +CREATE INDEX idx_minute_time_brin ON btc_eth_minute USING BRIN (minute); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE outage_events ( + id text PRIMARY KEY, + started_at timestamptz NOT NULL, + ended_at timestamptz, + locations text[] NOT NULL DEFAULT '{}', + asns integer[] NOT NULL DEFAULT '{}', + cause text, + outage_type text, + payload jsonb NOT NULL +); +CREATE INDEX idx_outage_start_brin ON outage_events USING BRIN (started_at); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE bgp_hijack_events ( + id bigint PRIMARY KEY, + detected_at timestamptz NOT NULL, + started_at timestamptz NOT NULL, + duration_sec integer, + confidence smallint, + hijacker_asn integer, + victim_asns integer[] NOT NULL DEFAULT '{}', + prefixes cidr[] NOT NULL DEFAULT '{}', + payload jsonb NOT NULL +); +CREATE INDEX idx_hijack_time_brin ON bgp_hijack_events USING BRIN (detected_at); +CREATE INDEX idx_hijack_conf_time ON bgp_hijack_events (confidence DESC, detected_at DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE dshield_snapshots ( + ts timestamptz NOT NULL, + kind text NOT NULL, + payload jsonb NOT NULL, + PRIMARY KEY (ts, kind) +); +CREATE INDEX idx_dshield_ts_brin ON dshield_snapshots USING BRIN (ts); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE world_events ( + id text PRIMARY KEY, + source text NOT NULL, + occurred_at timestamptz NOT NULL, + headline text NOT NULL, + payload jsonb NOT NULL +); +CREATE INDEX idx_world_time_brin ON world_events USING BRIN (occurred_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS world_events; +DROP TABLE IF EXISTS dshield_snapshots; +DROP TABLE IF EXISTS bgp_hijack_events; +DROP TABLE IF EXISTS outage_events; +DROP TABLE IF EXISTS btc_eth_minute; +DROP TABLE IF EXISTS btc_eth_ticks; +DROP TABLE IF EXISTS ransomware_victims; +DROP TABLE IF EXISTS earthquakes; +DROP TABLE IF EXISTS kev_entries; +DROP TABLE IF EXISTS cve_events; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_collector_state.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_collector_state.sql new file mode 100644 index 00000000..29acdd36 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_collector_state.sql @@ -0,0 +1,20 @@ +-- ©AngelaMos | 2026 +-- 0003_collector_state.sql + +-- +goose Up +-- +goose StatementBegin +CREATE TABLE collector_state ( + name text PRIMARY KEY, + state text NOT NULL, + last_success_at timestamptz, + last_error_at timestamptz, + last_error text, + last_event_count bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS collector_state; +-- +goose StatementEnd From bc9c2d06ebd6ab5e52585d397289709909a8a267 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 07:03:47 -0400 Subject: [PATCH 007/109] feat(monitor/backend): events, ratelimit, bus core packages with TDD --- .../backend/go.mod | 5 + .../backend/go.sum | 2 + .../backend/internal/bus/bus.go | 85 ++++++++++++++++ .../backend/internal/bus/bus_test.go | 99 +++++++++++++++++++ .../backend/internal/bus/persister.go | 23 +++++ .../backend/internal/events/event.go | 13 +++ .../backend/internal/events/event_test.go | 48 +++++++++ .../backend/internal/events/topic.go | 60 +++++++++++ .../backend/internal/ratelimit/limiter.go | 70 +++++++++++++ .../internal/ratelimit/limiter_test.go | 65 ++++++++++++ 10 files changed, 470 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/persister.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/topic.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index 1f8967f3..72ee3fd4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -15,6 +15,8 @@ require ( github.com/knadh/koanf/v2 v2.1.2 github.com/lestrrat-go/jwx/v3 v3.0.12 github.com/redis/go-redis/v9 v9.7.3 + github.com/sony/gobreaker/v2 v2.4.0 + github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 go.opentelemetry.io/otel/sdk v1.35.0 @@ -27,6 +29,7 @@ require ( require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -52,6 +55,7 @@ require ( github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/valyala/fastjson v1.6.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -66,4 +70,5 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 2cc02b59..7d9a840f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -108,6 +108,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= +github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go new file mode 100644 index 00000000..6ea8445c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go @@ -0,0 +1,85 @@ +// ©AngelaMos | 2026 +// bus.go + +package bus + +import ( + "context" + "encoding/json" + "log/slog" + "sync/atomic" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const defaultBufferSize = 512 + +type Config struct { + BufferSize int + Persister Persister + Broadcaster Broadcaster + Logger *slog.Logger +} + +type Bus struct { + ch chan events.Event + persister Persister + broadcaster Broadcaster + logger *slog.Logger + dropped atomic.Uint64 +} + +func New(cfg Config) *Bus { + if cfg.BufferSize <= 0 { + cfg.BufferSize = defaultBufferSize + } + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Bus{ + ch: make(chan events.Event, cfg.BufferSize), + persister: cfg.Persister, + broadcaster: cfg.Broadcaster, + logger: logger, + } +} + +func (b *Bus) Emit(ev events.Event) { + select { + case b.ch <- ev: + default: + b.dropped.Add(1) + b.logger.Warn("event bus full, dropped", + "topic", ev.Topic, "source", ev.Source) + } +} + +func (b *Bus) DroppedCount() uint64 { + return b.dropped.Load() +} + +func (b *Bus) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case ev := <-b.ch: + if b.persister != nil { + if err := b.persister.Save(ctx, ev); err != nil { + b.logger.Error("persist event failed", + "err", err, "topic", ev.Topic) + } + } + if b.broadcaster != nil { + payload, err := json.Marshal(ev.Payload) + if err != nil { + b.logger.Error("marshal payload failed", + "err", err, "topic", ev.Topic) + continue + } + b.broadcaster.Broadcast(string(ev.Topic), payload) + } + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go new file mode 100644 index 00000000..f2115a06 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go @@ -0,0 +1,99 @@ +// ©AngelaMos | 2026 +// bus_test.go + +package bus_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/bus" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type capturePersister struct { + mu sync.Mutex + saved []events.Event +} + +func (c *capturePersister) Save(_ context.Context, ev events.Event) error { + c.mu.Lock() + defer c.mu.Unlock() + c.saved = append(c.saved, ev) + return nil +} + +func (c *capturePersister) Count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.saved) +} + +type captureBroadcaster struct { + mu sync.Mutex + sent []bus.Broadcast +} + +func (b *captureBroadcaster) Broadcast(topic string, payload []byte) { + b.mu.Lock() + defer b.mu.Unlock() + b.sent = append(b.sent, bus.Broadcast{Topic: topic, Payload: payload}) +} + +func (b *captureBroadcaster) Count() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.sent) +} + +func TestBus_EmitFlowsThroughDispatcher(t *testing.T) { + persist := &capturePersister{} + cast := &captureBroadcaster{} + + b := bus.New(bus.Config{ + BufferSize: 16, + Persister: persist, + Broadcaster: cast, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + + now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + b.Emit(events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: now, + Source: "heartbeat", + Payload: map[string]string{"ts": now.Format(time.RFC3339Nano)}, + }) + + require.Eventually(t, func() bool { + return persist.Count() == 1 && cast.Count() == 1 + }, 2*time.Second, 10*time.Millisecond) + + cancel() + require.ErrorIs(t, <-done, context.Canceled) +} + +func TestBus_DropOnFullBuffer(t *testing.T) { + persist := &capturePersister{} + cast := &captureBroadcaster{} + + b := bus.New(bus.Config{ + BufferSize: 1, + Persister: persist, + Broadcaster: cast, + }) + + for i := 0; i < 50; i++ { + b.Emit(events.Event{Topic: events.TopicHeartbeat, Source: "stress"}) + } + require.Greater(t, b.DroppedCount(), uint64(0)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/persister.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/persister.go new file mode 100644 index 00000000..1472faa5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/persister.go @@ -0,0 +1,23 @@ +// ©AngelaMos | 2026 +// persister.go + +package bus + +import ( + "context" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type Persister interface { + Save(ctx context.Context, ev events.Event) error +} + +type Broadcaster interface { + Broadcast(topic string, payload []byte) +} + +type Broadcast struct { + Topic string + Payload []byte +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event.go new file mode 100644 index 00000000..eb07a78b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event.go @@ -0,0 +1,13 @@ +// ©AngelaMos | 2026 +// event.go + +package events + +import "time" + +type Event struct { + Topic Topic `json:"topic"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Payload any `json:"payload"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event_test.go new file mode 100644 index 00000000..88072321 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/event_test.go @@ -0,0 +1,48 @@ +// ©AngelaMos | 2026 +// event_test.go + +package events_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +func TestEvent_JSONRoundTrip(t *testing.T) { + now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + original := events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: now, + Source: "heartbeat", + Payload: map[string]any{"ts": now.Format(time.RFC3339Nano)}, + } + + raw, err := json.Marshal(original) + require.NoError(t, err) + + var decoded events.Event + require.NoError(t, json.Unmarshal(raw, &decoded)) + + require.Equal(t, original.Topic, decoded.Topic) + require.Equal(t, original.Source, decoded.Source) + require.True(t, original.Timestamp.Equal(decoded.Timestamp)) +} + +func TestTopic_IsValid(t *testing.T) { + require.True(t, events.TopicHeartbeat.IsValid()) + require.True(t, events.TopicCVENew.IsValid()) + require.False(t, events.Topic("bogus").IsValid()) +} + +func TestAllTopics_AllValid(t *testing.T) { + all := events.AllTopics() + require.NotEmpty(t, all) + for _, top := range all { + require.True(t, top.IsValid(), "topic %q should be valid", top) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/topic.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/topic.go new file mode 100644 index 00000000..27e2c603 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/events/topic.go @@ -0,0 +1,60 @@ +// ©AngelaMos | 2026 +// topic.go + +package events + +type Topic string + +const ( + TopicHeartbeat Topic = "heartbeat" + TopicScanFirehose Topic = "scan_firehose" + TopicInternetOutage Topic = "internet_outage" + TopicBGPHijack Topic = "bgp_hijack" + TopicCVENew Topic = "cve_new" + TopicCVEUpdated Topic = "cve_updated" + TopicEPSS Topic = "epss" + TopicKEVAdded Topic = "kev_added" + TopicRansomwareVictim Topic = "ransomware_victim" + TopicCoinbasePrice Topic = "coinbase_price" + TopicEarthquake Topic = "earthquake" + TopicSpaceWeather Topic = "space_weather" + TopicWikipediaITN Topic = "wiki_itn" + TopicGDELTSpike Topic = "gdelt_spike" + TopicISSPosition Topic = "iss_position" + TopicCollectorState Topic = "collector_state" +) + +func (t Topic) String() string { return string(t) } + +func (t Topic) IsValid() bool { + switch t { + case TopicHeartbeat, TopicScanFirehose, TopicInternetOutage, TopicBGPHijack, + TopicCVENew, TopicCVEUpdated, TopicEPSS, TopicKEVAdded, + TopicRansomwareVictim, TopicCoinbasePrice, TopicEarthquake, + TopicSpaceWeather, TopicWikipediaITN, TopicGDELTSpike, + TopicISSPosition, TopicCollectorState: + return true + } + return false +} + +func AllTopics() []Topic { + return []Topic{ + TopicHeartbeat, + TopicScanFirehose, + TopicInternetOutage, + TopicBGPHijack, + TopicCVENew, + TopicCVEUpdated, + TopicEPSS, + TopicKEVAdded, + TopicRansomwareVictim, + TopicCoinbasePrice, + TopicEarthquake, + TopicSpaceWeather, + TopicWikipediaITN, + TopicGDELTSpike, + TopicISSPosition, + TopicCollectorState, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go new file mode 100644 index 00000000..5e842475 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go @@ -0,0 +1,70 @@ +// ©AngelaMos | 2026 +// limiter.go + +package ratelimit + +import ( + "context" + "log/slog" + "time" + + "github.com/sony/gobreaker/v2" + "golang.org/x/time/rate" +) + +type Config struct { + Name string + Rate rate.Limit + Burst int + ConsecutiveFailureBudget uint32 + BreakerTimeout time.Duration + OnStateChange func(name string, from, to gobreaker.State) +} + +type Limiter[T any] struct { + name string + rl *rate.Limiter + cb *gobreaker.CircuitBreaker[T] +} + +func New[T any](cfg Config) *Limiter[T] { + if cfg.ConsecutiveFailureBudget == 0 { + cfg.ConsecutiveFailureBudget = 5 + } + if cfg.BreakerTimeout == 0 { + cfg.BreakerTimeout = 30 * time.Second + } + settings := gobreaker.Settings{ + Name: cfg.Name, + Timeout: cfg.BreakerTimeout, + ReadyToTrip: func(c gobreaker.Counts) bool { + return c.ConsecutiveFailures >= cfg.ConsecutiveFailureBudget + }, + OnStateChange: func(name string, from, to gobreaker.State) { + slog.Warn("circuit breaker state change", + "name", name, "from", from.String(), "to", to.String()) + if cfg.OnStateChange != nil { + cfg.OnStateChange(name, from, to) + } + }, + } + return &Limiter[T]{ + name: cfg.Name, + rl: rate.NewLimiter(cfg.Rate, cfg.Burst), + cb: gobreaker.NewCircuitBreaker[T](settings), + } +} + +func (l *Limiter[T]) Do(ctx context.Context, fn func(context.Context) (T, error)) (T, error) { + var zero T + if err := l.rl.Wait(ctx); err != nil { + return zero, err + } + return l.cb.Execute(func() (T, error) { return fn(ctx) }) +} + +func (l *Limiter[T]) AdjustRate(newRate rate.Limit) { + l.rl.SetLimit(newRate) +} + +func (l *Limiter[T]) Name() string { return l.name } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go new file mode 100644 index 00000000..8299b139 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go @@ -0,0 +1,65 @@ +// ©AngelaMos | 2026 +// limiter_test.go + +package ratelimit_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/sony/gobreaker/v2" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ratelimit" +) + +func TestLimiter_AllowsAtNominalRate(t *testing.T) { + lim := ratelimit.New[string](ratelimit.Config{ + Name: "test-allow", + Rate: rate.Every(20 * time.Millisecond), + Burst: 4, + ConsecutiveFailureBudget: 5, + BreakerTimeout: 100 * time.Millisecond, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + calls := 0 + for i := 0; i < 4; i++ { + _, err := lim.Do(ctx, func(_ context.Context) (string, error) { + calls++ + return "ok", nil + }) + require.NoError(t, err) + } + require.Equal(t, 4, calls) +} + +func TestLimiter_OpensBreakerOnRepeatedFailures(t *testing.T) { + lim := ratelimit.New[string](ratelimit.Config{ + Name: "test-fail", + Rate: rate.Every(2 * time.Millisecond), + Burst: 10, + ConsecutiveFailureBudget: 3, + BreakerTimeout: 50 * time.Millisecond, + }) + + ctx := context.Background() + failure := errors.New("upstream broken") + + for i := 0; i < 3; i++ { + _, _ = lim.Do(ctx, func(_ context.Context) (string, error) { return "", failure }) + } + + called := false + _, err := lim.Do(ctx, func(_ context.Context) (string, error) { + called = true + return "", nil + }) + require.False(t, called, "should not have called function — breaker should be open") + require.ErrorIs(t, err, gobreaker.ErrOpenState) +} From a7865533cdf9551abaeb0cb5b69872e6710c2f5b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 07:09:28 -0400 Subject: [PATCH 008/109] feat(monitor/backend): ws hub, snapshot store, heartbeat collector with TDD --- .../backend/go.mod | 39 +++++ .../backend/go.sum | 97 ++++++++++++ .../collectors/heartbeat/collector.go | 63 ++++++++ .../collectors/heartbeat/collector_test.go | 72 +++++++++ .../backend/internal/snapshot/handler.go | 29 ++++ .../backend/internal/snapshot/persister.go | 24 +++ .../internal/snapshot/snapshot_test.go | 75 ++++++++++ .../backend/internal/snapshot/store.go | 46 ++++++ .../backend/internal/ws/broadcaster.go | 14 ++ .../backend/internal/ws/envelope.go | 34 +++++ .../backend/internal/ws/envelope_test.go | 33 +++++ .../backend/internal/ws/handler.go | 49 ++++++ .../backend/internal/ws/hub.go | 139 ++++++++++++++++++ .../backend/internal/ws/hub_test.go | 123 ++++++++++++++++ .../backend/internal/ws/subscriber.go | 24 +++ 15 files changed, 861 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/broadcaster.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index 72ee3fd4..fac26666 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -3,6 +3,7 @@ module github.com/carterperez-dev/monitor-the-situation/backend go 1.25.0 require ( + github.com/coder/websocket v1.8.14 github.com/go-chi/chi/v5 v5.2.3 github.com/go-playground/validator/v10 v10.23.0 github.com/go-redis/redis_rate/v10 v10.0.1 @@ -17,6 +18,7 @@ require ( github.com/redis/go-redis/v9 v9.7.3 github.com/sony/gobreaker/v2 v2.4.0 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 go.opentelemetry.io/otel/sdk v1.35.0 @@ -27,15 +29,29 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect @@ -44,6 +60,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect @@ -53,12 +70,34 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/segmentio/asm v1.2.1 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/fastjson v1.6.4 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 7d9a840f..6625ecd7 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -1,5 +1,13 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -8,6 +16,20 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -15,6 +37,16 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -26,6 +58,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -44,6 +78,7 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -60,6 +95,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -94,33 +131,81 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= +github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= @@ -131,6 +216,8 @@ go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCn go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= @@ -145,12 +232,18 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= @@ -165,3 +258,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector.go new file mode 100644 index 00000000..4c9c0333 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector.go @@ -0,0 +1,63 @@ +// ©AngelaMos | 2026 +// collector.go + +package heartbeat + +import ( + "context" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const defaultInterval = 5 * time.Second + +type Emitter interface { + Emit(ev events.Event) +} + +type Config struct { + Interval time.Duration + Emitter Emitter +} + +type Collector struct { + cfg Config +} + +func New(cfg Config) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultInterval + } + return &Collector{cfg: cfg} +} + +func (c *Collector) Name() string { return "heartbeat" } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.emitNow() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.emitNow() + } + } +} + +func (c *Collector) emitNow() { + now := time.Now().UTC() + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: now, + Source: c.Name(), + Payload: map[string]string{ + "ts": now.Format(time.RFC3339Nano), + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go new file mode 100644 index 00000000..5be60a8b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package heartbeat_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type collectingEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (c *collectingEmitter) Emit(ev events.Event) { + c.mu.Lock() + defer c.mu.Unlock() + c.events = append(c.events, ev) +} + +func (c *collectingEmitter) Count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.events) +} + +func (c *collectingEmitter) Last() events.Event { + c.mu.Lock() + defer c.mu.Unlock() + return c.events[len(c.events)-1] +} + +func TestCollector_EmitsAtInterval(t *testing.T) { + emitter := &collectingEmitter{} + c := heartbeat.New(heartbeat.Config{ + Interval: 50 * time.Millisecond, + Emitter: emitter, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + err := c.Run(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + + require.GreaterOrEqual(t, emitter.Count(), 3) + last := emitter.Last() + require.Equal(t, events.TopicHeartbeat, last.Topic) + require.Equal(t, "heartbeat", last.Source) +} + +func TestCollector_EmitsImmediatelyOnStart(t *testing.T) { + emitter := &collectingEmitter{} + c := heartbeat.New(heartbeat.Config{ + Interval: time.Hour, + Emitter: emitter, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _ = c.Run(ctx) + require.Equal(t, 1, emitter.Count(), "should emit one tick immediately on start") +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go new file mode 100644 index 00000000..9e43a403 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go @@ -0,0 +1,29 @@ +// ©AngelaMos | 2026 +// handler.go + +package snapshot + +import ( + "encoding/json" + "net/http" +) + +type Handler struct { + store *Store +} + +func NewHandler(store *Store) *Handler { return &Handler{store: store} } + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + all, err := h.store.GetAll(r.Context()) + if err != nil { + http.Error(w, "snapshot read failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + if err := json.NewEncoder(w).Encode(all); err != nil { + http.Error(w, "encode failed", http.StatusInternalServerError) + return + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go new file mode 100644 index 00000000..bd694aef --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// persister.go + +package snapshot + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type StorePersister struct { + Store *Store +} + +func (s StorePersister) Save(ctx context.Context, ev events.Event) error { + raw, err := json.Marshal(ev.Payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + return s.Store.PutLatest(ctx, ev.Topic, raw) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go new file mode 100644 index 00000000..c70e9f74 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go @@ -0,0 +1,75 @@ +// ©AngelaMos | 2026 +// snapshot_test.go + +package snapshot_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + tcredis "github.com/testcontainers/testcontainers-go/modules/redis" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot" +) + +func startRedis(t *testing.T) *redis.Client { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + c, err := tcredis.Run(ctx, "redis:7-alpine") + require.NoError(t, err) + t.Cleanup(func() { + _ = c.Terminate(context.Background()) + }) + + endpoint, err := c.Endpoint(ctx, "") + require.NoError(t, err) + rdb := redis.NewClient(&redis.Options{Addr: endpoint}) + t.Cleanup(func() { _ = rdb.Close() }) + + return rdb +} + +func TestStore_PutLatestAndGetAll(t *testing.T) { + rdb := startRedis(t) + store := snapshot.NewStore(rdb) + + ctx := context.Background() + require.NoError(t, store.PutLatest(ctx, events.TopicHeartbeat, json.RawMessage(`{"ts":"2026-05-01T00:00:00Z"}`))) + require.NoError(t, store.PutLatest(ctx, events.TopicCVENew, json.RawMessage(`{"id":"CVE-2026-9999"}`))) + + all, err := store.GetAll(ctx) + require.NoError(t, err) + require.Contains(t, all, string(events.TopicHeartbeat)) + require.Contains(t, all, string(events.TopicCVENew)) + + var hb map[string]string + require.NoError(t, json.Unmarshal(all[string(events.TopicHeartbeat)], &hb)) + require.Equal(t, "2026-05-01T00:00:00Z", hb["ts"]) +} + +func TestStorePersister_SavesEventPayload(t *testing.T) { + rdb := startRedis(t) + store := snapshot.NewStore(rdb) + persister := snapshot.StorePersister{Store: store} + + ctx := context.Background() + ev := events.Event{ + Topic: events.TopicHeartbeat, + Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + Source: "heartbeat", + Payload: map[string]string{"ts": "2026-05-01T00:00:00Z"}, + } + require.NoError(t, persister.Save(ctx, ev)) + + all, err := store.GetAll(ctx) + require.NoError(t, err) + require.Contains(t, all, string(events.TopicHeartbeat)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go new file mode 100644 index 00000000..b4968f3c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// store.go + +package snapshot + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const keyPrefix = "state:" + +type Store struct { + rdb *redis.Client +} + +func NewStore(rdb *redis.Client) *Store { return &Store{rdb: rdb} } + +func (s *Store) PutLatest(ctx context.Context, topic events.Topic, payload json.RawMessage) error { + if err := s.rdb.Set(ctx, keyPrefix+string(topic), []byte(payload), 0).Err(); err != nil { + return fmt.Errorf("redis set %s: %w", topic, err) + } + return nil +} + +func (s *Store) GetAll(ctx context.Context) (map[string]json.RawMessage, error) { + keys, err := s.rdb.Keys(ctx, keyPrefix+"*").Result() + if err != nil { + return nil, err + } + out := make(map[string]json.RawMessage, len(keys)) + for _, k := range keys { + v, err := s.rdb.Get(ctx, k).Bytes() + if err != nil { + continue + } + topic := k[len(keyPrefix):] + out[topic] = json.RawMessage(v) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/broadcaster.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/broadcaster.go new file mode 100644 index 00000000..24d99dbc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/broadcaster.go @@ -0,0 +1,14 @@ +// ©AngelaMos | 2026 +// broadcaster.go + +package ws + +import "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + +type HubBroadcaster struct { + Hub *Hub +} + +func (b HubBroadcaster) Broadcast(topic string, payload []byte) { + b.Hub.Broadcast(events.Topic(topic), payload) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope.go new file mode 100644 index 00000000..0f4b4c65 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope.go @@ -0,0 +1,34 @@ +// ©AngelaMos | 2026 +// envelope.go + +package ws + +import ( + "bytes" + "encoding/json" + "time" +) + +func EncodeEnvelope(channel string, payload []byte) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString(`{"ch":`) + chRaw, err := json.Marshal(channel) + if err != nil { + return nil, err + } + buf.Write(chRaw) + buf.WriteString(`,"data":`) + if len(payload) == 0 { + buf.WriteString("null") + } else { + buf.Write(payload) + } + buf.WriteString(`,"ts":`) + tsRaw, err := json.Marshal(time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + buf.Write(tsRaw) + buf.WriteByte('}') + return buf.Bytes(), nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go new file mode 100644 index 00000000..675acb49 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go @@ -0,0 +1,33 @@ +// ©AngelaMos | 2026 +// envelope_test.go + +package ws_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" +) + +func TestEncodeEnvelope(t *testing.T) { + out, err := ws.EncodeEnvelope("heartbeat", []byte(`{"ts":"2026-05-01T00:00:00Z"}`)) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + require.Equal(t, "heartbeat", got["ch"]) + require.Contains(t, got, "data") + require.Contains(t, got, "ts") +} + +func TestEncodeEnvelope_NilPayload(t *testing.T) { + out, err := ws.EncodeEnvelope("heartbeat", nil) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(out, &got)) + require.Nil(t, got["data"]) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go new file mode 100644 index 00000000..257a870a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go @@ -0,0 +1,49 @@ +// ©AngelaMos | 2026 +// handler.go + +package ws + +import ( + "net/http" + "strings" + + cdrws "github.com/coder/websocket" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type Handler struct { + hub *Hub +} + +func NewHandler(hub *Hub) *Handler { return &Handler{hub: hub} } + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{ + InsecureSkipVerify: false, + }) + if err != nil { + return + } + + topics := parseTopics(r.URL.Query().Get("topics")) + _ = h.hub.Serve(r.Context(), c, topics) +} + +func parseTopics(raw string) []events.Topic { + if raw == "" { + return events.AllTopics() + } + parts := strings.Split(raw, ",") + out := make([]events.Topic, 0, len(parts)) + for _, p := range parts { + t := events.Topic(strings.TrimSpace(p)) + if t.IsValid() { + out = append(out, t) + } + } + if len(out) == 0 { + return events.AllTopics() + } + return out +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go new file mode 100644 index 00000000..4c7c9440 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go @@ -0,0 +1,139 @@ +// ©AngelaMos | 2026 +// hub.go + +package ws + +import ( + "context" + "log/slog" + "sync" + "time" + + cdrws "github.com/coder/websocket" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + defaultSubscriberBuf = 16 + defaultPingInterval = 30 * time.Second + defaultPingTimeout = 10 * time.Second + defaultWriteTimeout = 5 * time.Second +) + +type HubConfig struct { + SubscriberBufferSize int + PingInterval time.Duration + PingTimeout time.Duration + WriteTimeout time.Duration + Logger *slog.Logger +} + +type Hub struct { + mu sync.Mutex + subs map[*subscriber]struct{} + bufSize int + pingInterval time.Duration + pingTimeout time.Duration + writeTimeout time.Duration + logger *slog.Logger +} + +func NewHub(cfg HubConfig) *Hub { + if cfg.SubscriberBufferSize <= 0 { + cfg.SubscriberBufferSize = defaultSubscriberBuf + } + if cfg.PingInterval <= 0 { + cfg.PingInterval = defaultPingInterval + } + if cfg.PingTimeout <= 0 { + cfg.PingTimeout = defaultPingTimeout + } + if cfg.WriteTimeout <= 0 { + cfg.WriteTimeout = defaultWriteTimeout + } + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Hub{ + subs: make(map[*subscriber]struct{}), + bufSize: cfg.SubscriberBufferSize, + pingInterval: cfg.PingInterval, + pingTimeout: cfg.PingTimeout, + writeTimeout: cfg.WriteTimeout, + logger: logger, + } +} + +func (h *Hub) Broadcast(topic events.Topic, payload []byte) { + env, err := EncodeEnvelope(string(topic), payload) + if err != nil { + h.logger.Error("encode envelope", "err", err, "topic", topic) + return + } + h.mu.Lock() + defer h.mu.Unlock() + for sub := range h.subs { + if _, ok := sub.topics[topic]; !ok { + continue + } + select { + case sub.msgs <- env: + default: + go sub.closeSlow() + } + } +} + +func (h *Hub) Serve(ctx context.Context, c *cdrws.Conn, topics []events.Topic) error { + sub := newSubscriber(topics, h.bufSize, func() { + _ = c.Close(cdrws.StatusPolicyViolation, "slow consumer") + }) + h.add(sub) + defer h.remove(sub) + + connCtx := c.CloseRead(ctx) + pingT := time.NewTicker(h.pingInterval) + defer pingT.Stop() + + for { + select { + case msg := <-sub.msgs: + wctx, cancel := context.WithTimeout(connCtx, h.writeTimeout) + err := c.Write(wctx, cdrws.MessageText, msg) + cancel() + if err != nil { + return err + } + case <-pingT.C: + pctx, cancel := context.WithTimeout(connCtx, h.pingTimeout) + err := c.Ping(pctx) + cancel() + if err != nil { + return err + } + case <-connCtx.Done(): + _ = c.Close(cdrws.StatusNormalClosure, "") + return connCtx.Err() + } + } +} + +func (h *Hub) add(sub *subscriber) { + h.mu.Lock() + defer h.mu.Unlock() + h.subs[sub] = struct{}{} +} + +func (h *Hub) remove(sub *subscriber) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.subs, sub) +} + +func (h *Hub) SubscriberCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.subs) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go new file mode 100644 index 00000000..b773824d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go @@ -0,0 +1,123 @@ +// ©AngelaMos | 2026 +// hub_test.go + +package ws_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + cdrws "github.com/coder/websocket" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" +) + +func TestHub_SubscribeAndReceive(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true}) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"x"}`)) + + _, msg, err := conn.Read(ctx) + require.NoError(t, err) + require.Contains(t, string(msg), `"ch":"heartbeat"`) +} + +func TestHub_TopicFiltering(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true}) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + hub.Broadcast(events.TopicCVENew, []byte(`{"id":"CVE-2026-0001"}`)) + hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"y"}`)) + + readCtx, readCancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer readCancel() + + _, msg, err := conn.Read(readCtx) + require.NoError(t, err) + require.Contains(t, string(msg), `"ch":"heartbeat"`) + require.NotContains(t, string(msg), "CVE-2026-0001") +} + +func TestHub_SlowConsumerClosed(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{SubscriberBufferSize: 2}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true}) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + for i := 0; i < 200; i++ { + hub.Broadcast(events.TopicHeartbeat, []byte(`{"i":1}`)) + } + + var ce cdrws.CloseError + for { + _, _, err = conn.Read(ctx) + if err == nil { + continue + } + if errors.As(err, &ce) { + break + } + t.Fatalf("expected close error, got %v", err) + } + require.Equal(t, cdrws.StatusPolicyViolation, ce.Code) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go new file mode 100644 index 00000000..851b2a31 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// subscriber.go + +package ws + +import "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + +type subscriber struct { + topics map[events.Topic]struct{} + msgs chan []byte + closeSlow func() +} + +func newSubscriber(topics []events.Topic, bufSize int, closeSlow func()) *subscriber { + t := make(map[events.Topic]struct{}, len(topics)) + for _, top := range topics { + t[top] = struct{}{} + } + return &subscriber{ + topics: t, + msgs: make(chan []byte, bufSize), + closeSlow: closeSlow, + } +} From a67ef20680591ca3e67e4a259ab6a34860eae7dd Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 13:53:16 -0400 Subject: [PATCH 009/109] feat(monitor/main): wire bus + hub + snapshot + heartbeat under errgroup; expose /v1/snapshot, /v1/ws, /v1/healthz --- .../backend/cmd/api/main.go | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index 7180e87f..a4eac3f3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -5,6 +5,7 @@ package main import ( "context" + "errors" "flag" "log/slog" "os" @@ -13,15 +14,20 @@ import ( "time" "github.com/go-chi/chi/v5" + "golang.org/x/sync/errgroup" "github.com/carterperez-dev/monitor-the-situation/backend/internal/admin" "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/bus" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat" "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" "github.com/carterperez-dev/monitor-the-situation/backend/internal/server" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot" "github.com/carterperez-dev/monitor-the-situation/backend/internal/user" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" ) const ( @@ -117,6 +123,29 @@ func run(configPath string) error { RedisPing: redis.Ping, }) + snapStore := snapshot.NewStore(redis.Client) + snapHandler := snapshot.NewHandler(snapStore) + + hub := ws.NewHub(ws.HubConfig{Logger: logger}) + wsHandler := ws.NewHandler(hub) + + eventBus := bus.New(bus.Config{ + BufferSize: 512, + Persister: snapshot.StorePersister{Store: snapStore}, + Broadcaster: ws.HubBroadcaster{Hub: hub}, + Logger: logger, + }) + + beat := heartbeat.New(heartbeat.Config{ + Interval: 5 * time.Second, + Emitter: eventBus, + }) + + collectorGroup, collectorCtx := errgroup.WithContext(ctx) + collectorGroup.Go(func() error { return eventBus.Run(collectorCtx) }) + collectorGroup.Go(func() error { return beat.Run(collectorCtx) }) + logger.Info("collectors started", "count", 1) + srv := server.New(server.Config{ ServerConfig: cfg.Server, HealthHandler: healthHandler, @@ -147,6 +176,12 @@ func run(configPath string) error { adminOnly := middleware.RequireAdmin router.Route("/v1", func(r chi.Router) { + r.Get("/healthz", healthHandler.Liveness) + r.Get("/readyz", healthHandler.Readiness) + + r.Get("/snapshot", snapHandler.ServeHTTP) + r.Get("/ws", wsHandler.ServeHTTP) + authHandler.RegisterRoutes(r, authenticator) r.Post("/users", authHandler.Register) @@ -184,6 +219,12 @@ func run(configPath string) error { } } + if err := collectorGroup.Wait(); err != nil && + !errors.Is(err, context.Canceled) && + !errors.Is(err, context.DeadlineExceeded) { + logger.Error("collector group exit", "error", err) + } + if err := redis.Close(); err != nil { logger.Error("redis close error", "error", err) } From ecaacc48faa325fb69f87549816090600341f60f Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 14:53:28 -0400 Subject: [PATCH 010/109] chore(monitor): replace Makefile with Justfile (project convention) --- .../monitor-the-situation-dashboard/Justfile | 231 ++++++++++++++++++ .../monitor-the-situation-dashboard/Makefile | 107 -------- 2 files changed, 231 insertions(+), 107 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/Justfile delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/Makefile diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile b/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile new file mode 100644 index 00000000..ffc21484 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile @@ -0,0 +1,231 @@ +# ©AngelaMos | 2026 +# Justfile + +set export +set shell := ["bash", "-uc"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` +binary := "monitor" + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Linting and Formatting +# ============================================================================= + +[group('lint')] +lint *ARGS: + cd backend && golangci-lint run --timeout=5m {{ARGS}} + +[group('lint')] +lint-fix: + cd backend && golangci-lint run --timeout=5m --fix + +[group('lint')] +format: + cd backend && gofumpt -w . && goimports -w . + +[group('lint')] +tidy: + cd backend && go mod tidy + +[group('lint')] +vet: + cd backend && go vet ./... + +# ============================================================================= +# Testing +# ============================================================================= + +[group('test')] +test *ARGS: + cd backend && go test -race ./... {{ARGS}} + +[group('test')] +test-v *ARGS: + cd backend && go test -race -v ./... {{ARGS}} + +[group('test')] +cover: + cd backend && go test -race -cover ./... + +# ============================================================================= +# CI / Quality +# ============================================================================= + +[group('ci')] +ci: lint test + @echo "All checks passed." + +[group('ci')] +check: lint vet + +# ============================================================================= +# Development (host go) +# ============================================================================= + +[group('dev')] +run *ARGS: + cd backend && go run ./cmd/api {{ARGS}} + +[group('dev')] +dev-serve: + cd backend && go run ./cmd/api -config config.yaml + +# ============================================================================= +# Build (Production) +# ============================================================================= + +[group('prod')] +build: + cd backend && go build -ldflags="-s -w" -o ../bin/{{binary}} ./cmd/api + @echo "Built: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))" + +[group('prod')] +build-static: + cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o ../bin/{{binary}} ./cmd/api + @echo "Built static: bin/{{binary}} ($(du -h bin/{{binary}} | cut -f1))" + +[group('prod')] +build-debug: + cd backend && go build -o ../bin/{{binary}} ./cmd/api + +[group('prod')] +install: + cd backend && go install ./cmd/api + +# ============================================================================= +# Database +# ============================================================================= + +[group('db')] +migrate *ARGS: + docker compose -f compose.yml exec backend sh -c 'goose -dir /migrations postgres "$DATABASE_URL" up' {{ARGS}} + +[group('db')] +migrate-dev *ARGS: + docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" up' {{ARGS}} + +[group('db')] +migrate-down: + docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" down' + +[group('db')] +migrate-status: + docker compose -f dev.compose.yml exec backend sh -c 'cd /app && goose -dir migrations postgres "$DATABASE_URL" status' + +[group('db')] +psql: + docker compose -f dev.compose.yml exec postgres psql -U $${POSTGRES_USER:-monitor} -d $${POSTGRES_DB:-monitor} + +[group('db')] +redis-cli: + docker compose -f dev.compose.yml exec redis redis-cli + +# ============================================================================= +# Docker (production) +# ============================================================================= + +[group('docker')] +up *ARGS: + docker compose -f compose.yml up {{ARGS}} + +[group('docker')] +start *ARGS: + docker compose -f compose.yml up -d --build {{ARGS}} + +[group('docker')] +down *ARGS: + docker compose -f compose.yml down {{ARGS}} + +[group('docker')] +logs *SERVICE: + docker compose -f compose.yml logs -f {{SERVICE}} + +[group('docker')] +ps: + docker compose -f compose.yml ps + +[group('docker')] +build-prod: + docker compose -f compose.yml build + +# ============================================================================= +# Docker (development) +# ============================================================================= + +[group('docker')] +dev-up *ARGS: + docker compose -f dev.compose.yml up {{ARGS}} + +[group('docker')] +dev-start *ARGS: + docker compose -f dev.compose.yml up -d --build {{ARGS}} + +[group('docker')] +dev-down *ARGS: + docker compose -f dev.compose.yml down {{ARGS}} + +[group('docker')] +dev-logs *SERVICE: + docker compose -f dev.compose.yml logs -f {{SERVICE}} + +[group('docker')] +dev-ps: + docker compose -f dev.compose.yml ps + +[group('docker')] +dev-shell service='backend': + docker compose -f dev.compose.yml exec -it {{service}} /bin/sh + +[group('docker')] +dev-clean: + docker compose -f dev.compose.yml down -v + +# ============================================================================= +# Cloudflare Tunnel +# ============================================================================= + +[group('tunnel')] +tunnel-up *ARGS: + docker compose -f compose.yml -f cloudflared.compose.yml up {{ARGS}} + +[group('tunnel')] +tunnel-start *ARGS: + docker compose -f compose.yml -f cloudflared.compose.yml up -d --build {{ARGS}} + +[group('tunnel')] +tunnel-down *ARGS: + docker compose -f compose.yml -f cloudflared.compose.yml down {{ARGS}} + +[group('tunnel')] +tunnel-logs: + docker compose -f compose.yml -f cloudflared.compose.yml logs -f cloudflared + +# ============================================================================= +# Utilities +# ============================================================================= + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "Go: $(cd backend && go version | cut -d' ' -f3)" + @echo "OS: {{os()}} ({{arch()}})" + @echo "Module: $(head -1 backend/go.mod | cut -d' ' -f2)" + +[group('util')] +clean: + -rm -rf bin/ + -rm -rf backend/tmp/ + @echo "Cleaned build artifacts." + +[group('util')] +env-init: + @if [ ! -f .env ]; then cp .env.example .env && echo "created .env from .env.example — fill in secrets"; else echo ".env already exists"; fi diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/Makefile b/PROJECTS/advanced/monitor-the-situation-dashboard/Makefile deleted file mode 100644 index d929628f..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/Makefile +++ /dev/null @@ -1,107 +0,0 @@ -# ©AngelaMos | 2026 -# Makefile - -.DEFAULT_GOAL := help -SHELL := /bin/bash - -COMPOSE_PROD := docker compose -f compose.yml -COMPOSE_DEV := docker compose -f dev.compose.yml -COMPOSE_TUN := docker compose -f compose.yml -f cloudflared.compose.yml - -.PHONY: help up up-dev up-tunnel down down-dev logs logs-dev ps ps-dev migrate migrate-dev psql redis-cli backend-shell frontend-shell tidy fmt test build-prod stop clean - -help: - @echo 'Monitor the Situation — Make targets:' - @echo '' - @echo ' Production:' - @echo ' up start production stack (no tunnel)' - @echo ' up-tunnel start production stack + cloudflared' - @echo ' down stop production stack' - @echo ' logs tail production logs' - @echo ' ps status of production services' - @echo '' - @echo ' Development:' - @echo ' up-dev start dev stack (vite + air)' - @echo ' down-dev stop dev stack' - @echo ' logs-dev tail dev logs' - @echo ' ps-dev status of dev services' - @echo '' - @echo ' Database:' - @echo ' migrate run goose migrations against prod db' - @echo ' migrate-dev run goose migrations against dev db' - @echo ' psql psql shell into dev db' - @echo ' redis-cli redis-cli shell into dev redis' - @echo '' - @echo ' Backend:' - @echo ' backend-shell shell into the dev backend container' - @echo ' test run all backend tests with -race' - @echo ' fmt format Go code' - @echo ' tidy go mod tidy' - @echo '' - @echo ' Frontend:' - @echo ' frontend-shell shell into the dev frontend container' - @echo '' - -up: - $(COMPOSE_PROD) up -d --build - -up-tunnel: - $(COMPOSE_TUN) up -d --build - -up-dev: - $(COMPOSE_DEV) up -d --build - -down: - $(COMPOSE_PROD) down - -down-dev: - $(COMPOSE_DEV) down - -logs: - $(COMPOSE_PROD) logs -f --tail=200 - -logs-dev: - $(COMPOSE_DEV) logs -f --tail=200 - -ps: - $(COMPOSE_PROD) ps - -ps-dev: - $(COMPOSE_DEV) ps - -migrate: - $(COMPOSE_PROD) exec backend sh -c 'goose -dir /migrations postgres "$$DATABASE_URL" up' - -migrate-dev: - $(COMPOSE_DEV) exec backend sh -c 'cd /app && goose -dir migrations postgres "$$DATABASE_URL" up' - -psql: - $(COMPOSE_DEV) exec postgres psql -U $${POSTGRES_USER:-monitor} -d $${POSTGRES_DB:-monitor} - -redis-cli: - $(COMPOSE_DEV) exec redis redis-cli - -backend-shell: - $(COMPOSE_DEV) exec backend sh - -frontend-shell: - $(COMPOSE_DEV) exec frontend sh - -test: - cd backend && go test -race -count=1 ./... - -fmt: - cd backend && gofumpt -w . && goimports -w . - -tidy: - cd backend && go mod tidy - -build-prod: - $(COMPOSE_PROD) build - -stop: - $(COMPOSE_DEV) stop ; $(COMPOSE_PROD) stop - -clean: stop - $(COMPOSE_DEV) down -v - $(COMPOSE_PROD) down -v From 92202b1c165db4e422d8b3531a01887faaab57cd Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 20:13:00 -0400 Subject: [PATCH 011/109] fix(monitor): pick conflict-free host ports (8432/5432/4432/6432/3432); JWT keygen-on-boot, healthcheck path, baseline migration; ignore frontend/.pnpm-store; pre-commit excludes --- .pre-commit-config.yaml | 10 ++++ .../.env.example | 12 ++--- .../.gitignore | 1 + .../backend/cmd/api/main.go | 30 +++++++++++ .../migrations/0001_template_baseline.sql | 52 +++++++++++++++++++ .../{0001_alerts.sql => 0002_alerts.sql} | 0 ...002_panel_data.sql => 0003_panel_data.sql} | 0 ...tor_state.sql => 0004_collector_state.sql} | 0 .../compose.yml | 2 +- .../conf/docker/dev/vite.docker | 2 +- .../dev.compose.yml | 12 ++--- 11 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql rename PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/{0001_alerts.sql => 0002_alerts.sql} (100%) rename PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/{0002_panel_data.sql => 0003_panel_data.sql} (100%) rename PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/{0003_collector_state.sql => 0004_collector_state.sql} (100%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98df1251..20efe34a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -164,12 +164,22 @@ repos: rev: v6.0.0 hooks: - id: check-yaml + exclude: (\.pnpm-store|node_modules)/ - id: check-json + exclude: (\.pnpm-store|node_modules)/ - id: check-toml + exclude: (\.pnpm-store|node_modules)/ - id: check-ast + exclude: (\.pnpm-store|node_modules|\.venv)/ - id: check-symlinks + exclude: (\.pnpm-store|node_modules)/ - id: end-of-file-fixer + exclude: (\.pnpm-store|node_modules)/ - id: trailing-whitespace + exclude: (\.pnpm-store|node_modules)/ - id: check-illegal-windows-names + exclude: (\.pnpm-store|node_modules)/ - id: check-executables-have-shebangs + exclude: (\.pnpm-store|node_modules)/ - id: check-shebang-scripts-are-executable + exclude: (\.pnpm-store|node_modules)/ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example index 39b2128b..003d47e0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example @@ -2,13 +2,13 @@ # .env.example APP_NAME=monitor -PUBLIC_URL=http://localhost:8430 +PUBLIC_URL=http://localhost:8432 -NGINX_HOST_PORT=8430 -BACKEND_HOST_PORT=5430 -FRONTEND_HOST_PORT=3430 -POSTGRES_HOST_PORT=5431 -REDIS_HOST_PORT=6430 +NGINX_HOST_PORT=8432 +BACKEND_HOST_PORT=5432 +FRONTEND_HOST_PORT=3432 +POSTGRES_HOST_PORT=4432 +REDIS_HOST_PORT=6432 POSTGRES_USER=monitor POSTGRES_PASSWORD=changeme diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore index 48cf6544..df37de2a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore @@ -14,5 +14,6 @@ backend/keys/ frontend/dist/ frontend/node_modules/ frontend/.vite/ +frontend/.pnpm-store/ docs/ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index a4eac3f3..9fbf02db 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -10,6 +10,7 @@ import ( "log/slog" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -97,6 +98,10 @@ func run(configPath string) error { "pool_size", cfg.Redis.PoolSize, ) + if err := ensureJWTKeys(cfg.JWT, logger); err != nil { + return err + } + jwtManager, err := auth.NewJWTManager(cfg.JWT) if err != nil { return err @@ -237,6 +242,31 @@ func run(configPath string) error { return nil } +func ensureJWTKeys(cfg config.JWTConfig, logger *slog.Logger) error { + if _, err := os.Stat(cfg.PrivateKeyPath); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if dir := filepath.Dir(cfg.PrivateKeyPath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + if dir := filepath.Dir(cfg.PublicKeyPath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + + logger.Warn("JWT keys missing, generating", + "private", cfg.PrivateKeyPath, + "public", cfg.PublicKeyPath, + ) + return auth.GenerateKeyPair(cfg.PrivateKeyPath, cfg.PublicKeyPath) +} + func setupLogger(cfg config.LogConfig) *slog.Logger { var handler slog.Handler diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql new file mode 100644 index 00000000..1f06134e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql @@ -0,0 +1,52 @@ +-- ©AngelaMos | 2026 +-- 00000_template_baseline.sql + +-- +goose Up +-- +goose StatementBegin +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL UNIQUE, + password_hash text NOT NULL, + name text NOT NULL DEFAULT '', + role text NOT NULL DEFAULT 'user', + tier text NOT NULL DEFAULT 'free', + token_version integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); +CREATE INDEX idx_users_email ON users (email) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_role ON users (role) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_created ON users (created_at DESC); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE refresh_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + family_id uuid NOT NULL, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + is_used boolean NOT NULL DEFAULT false, + used_at timestamptz, + revoked_at timestamptz, + replaced_by_id uuid, + user_agent text NOT NULL DEFAULT '', + ip_address text NOT NULL DEFAULT '' +); +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id); +CREATE INDEX idx_refresh_tokens_family ON refresh_tokens (family_id); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens (token_hash); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS users; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_alerts.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql similarity index 100% rename from PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_alerts.sql rename to PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_panel_data.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql similarity index 100% rename from PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_panel_data.sql rename to PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_collector_state.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql similarity index 100% rename from PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_collector_state.sql rename to PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml index b3d9d08c..cde7224f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml @@ -10,7 +10,7 @@ services: dockerfile: conf/docker/prod/vite.docker container_name: ${APP_NAME:-monitor}-nginx ports: - - "${NGINX_HOST_PORT:-8430}:80" + - "${NGINX_HOST_PORT:-8432}:80" depends_on: backend: condition: service_healthy diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker index e3400dcb..4c09838b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/docker/dev/vite.docker @@ -11,4 +11,4 @@ ENV NODE_ENV=development EXPOSE 5173 -CMD ["pnpm", "dev", "--host", "0.0.0.0"] +CMD ["sh", "-c", "pnpm install && pnpm dev --host 0.0.0.0"] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml index 465186c6..e4bce9a0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml @@ -8,7 +8,7 @@ services: image: nginx:1.27-alpine container_name: ${APP_NAME:-monitor}-nginx-dev ports: - - "${NGINX_HOST_PORT:-8430}:80" + - "${NGINX_HOST_PORT:-8432}:80" volumes: - ./conf/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./conf/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro @@ -28,7 +28,7 @@ services: dockerfile: conf/docker/dev/go.docker container_name: ${APP_NAME:-monitor}-backend-dev ports: - - "${BACKEND_HOST_PORT:-5430}:8080" + - "${BACKEND_HOST_PORT:-5432}:8080" volumes: - ./backend:/app - go-mod-cache:/go/pkg/mod @@ -46,7 +46,7 @@ services: networks: - backend healthcheck: - test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/api/v1/healthz || exit 1"] + test: ["CMD-SHELL", "wget -q -O- http://localhost:8080/healthz || exit 1"] interval: 10s timeout: 5s retries: 5 @@ -59,7 +59,7 @@ services: dockerfile: conf/docker/dev/vite.docker container_name: ${APP_NAME:-monitor}-frontend-dev ports: - - "${FRONTEND_HOST_PORT:-3430}:5173" + - "${FRONTEND_HOST_PORT:-3432}:5173" volumes: - ./frontend:/app - frontend-node-modules:/app/node_modules @@ -73,7 +73,7 @@ services: image: postgres:17-alpine container_name: ${APP_NAME:-monitor}-postgres-dev ports: - - "${POSTGRES_HOST_PORT:-5431}:5432" + - "${POSTGRES_HOST_PORT:-4432}:5432" environment: POSTGRES_USER: ${POSTGRES_USER:-monitor} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-monitor} @@ -94,7 +94,7 @@ services: image: redis:7-alpine container_name: ${APP_NAME:-monitor}-redis-dev ports: - - "${REDIS_HOST_PORT:-6430}:6379" + - "${REDIS_HOST_PORT:-6432}:6379" command: redis-server --appendonly yes volumes: - redisdata-dev:/data From 4bf29da96ffa68638c3d8167a79d7caa62c9dd08 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 21:38:04 -0400 Subject: [PATCH 012/109] fix(monitor/nginx): strip /api prefix on WS proxy_pass so backend route /v1/ws is reached --- .../monitor-the-situation-dashboard/conf/nginx/dev.nginx | 2 +- .../monitor-the-situation-dashboard/conf/nginx/prod.nginx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx index 931b599f..9eaa0e54 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx @@ -15,7 +15,7 @@ server { } location /api/v1/ws { - proxy_pass http://backend; + proxy_pass http://backend/v1/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx index 8a80556f..17ead27a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx @@ -22,7 +22,7 @@ server { } location /api/v1/ws { - proxy_pass http://backend; + proxy_pass http://backend/v1/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; From 33d15a6ab66cdabbf669524b1e3b014c672809a7 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 21:38:59 -0400 Subject: [PATCH 013/109] docs(monitor): quickstart README --- .../monitor-the-situation-dashboard/README.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/README.md diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md new file mode 100644 index 00000000..a2fb49c9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md @@ -0,0 +1,65 @@ + + +# Monitor the Situation + +Operator-grade real-time situational awareness dashboard. Single-binary Go backend, React 19 frontend, Postgres + Redis, fronted by nginx and (optionally) a Cloudflare Tunnel. + +> The phrase "Monitoring the situation" is a Twitter/X meme from June 2025. This is the version that actually monitors the situation. + +## Stack + +| Layer | Tech | +| ----------- | ---------------------------------------------------------- | +| Backend | Go 1.22, chi router, `coder/websocket`, `goose` migrations | +| Frontend | React 19, Vite, TanStack Query, Zustand, MapLibre, D3 | +| Storage | Postgres 16 (BRIN-indexed time-series), Redis 7 | +| Ingress | nginx (dev + prod), Cloudflare Tunnel (prod) | +| Build / run | `just` recipes, multi-stage Docker, air for live reload | + +## Quickstart (development) + +```bash +cp .env.example .env +# fill .env: POSTGRES_PASSWORD, JWT_SECRET, NOTIFICATION_ENCRYPTION_KEY +just dev-start +just migrate-dev +open http://localhost:8432 +``` + +JWT signing keys auto-generate at `backend/keys/private.pem` on first boot. The dev stack binds host ports `8432` (nginx) / `5432` (backend) / `4432` (postgres) / `6432` (redis) / `3432` (vite). + +### Smoke checks + +```bash +curl -s http://localhost:8432/api/v1/healthz +curl -s http://localhost:8432/api/v1/snapshot | jq . +docker run --rm -i --network host ghcr.io/vi/websocat:latest \ + "ws://localhost:8432/api/v1/ws?topics=heartbeat" +``` + +## Production (Cloudflare Tunnel) + +```bash +cp .env.example .env +# fill production secrets including CLOUDFLARE_TUNNEL_TOKEN +just tunnel-start +just migrate +``` + +## Tests + +```bash +cd backend && go test -race ./... +``` + +## Layout + +``` +backend/ Go services (cmd/api, internal/{events,bus,ws,snapshot,collectors,...}) +frontend/ React 19 dashboard +conf/ nginx and per-environment Docker configs +migrations/ goose SQL migrations (mounted into the backend container) +``` From c5cc7951a3df3dc53757d90cdb5019d48ac7a03c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:25:37 -0400 Subject: [PATCH 014/109] feat(monitor/httpx): rate-limited HTTP client with 429/5xx retry honoring Retry-After --- .../backend/internal/httpx/client.go | 199 ++++++++++++++++++ .../backend/internal/httpx/client_test.go | 175 +++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go new file mode 100644 index 00000000..e214aba6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go @@ -0,0 +1,199 @@ +// ©AngelaMos | 2026 +// client.go + +package httpx + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/cenkalti/backoff/v4" + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ratelimit" +) + +const ( + defaultUserAgent = "monitor-the-situation/1.0" + defaultRequestTimeout = 30 * time.Second + defaultMaxRetryElapsed = 60 * time.Second + defaultInitialRetryBackoff = 500 * time.Millisecond + defaultMaxRetryBackoff = 10 * time.Second + defaultAPIKeyHeader = "X-API-Key" + clientErrorBodyLimit = 4096 +) + +type Config struct { + Name string + BaseURL string + Headers http.Header + BearerToken string + APIKey string + APIKeyHeader string + Rate rate.Limit + Burst int + ConsecutiveFailureBudget uint32 + BreakerTimeout time.Duration + RequestTimeout time.Duration + MaxRetryElapsed time.Duration + UserAgent string +} + +type Client struct { + cfg Config + limiter *ratelimit.Limiter[*http.Response] + hc *http.Client +} + +func New(cfg Config) *Client { + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = defaultRequestTimeout + } + if cfg.MaxRetryElapsed <= 0 { + cfg.MaxRetryElapsed = defaultMaxRetryElapsed + } + if cfg.UserAgent == "" { + cfg.UserAgent = defaultUserAgent + } + limiter := ratelimit.New[*http.Response](ratelimit.Config{ + Name: cfg.Name, + Rate: cfg.Rate, + Burst: cfg.Burst, + ConsecutiveFailureBudget: cfg.ConsecutiveFailureBudget, + BreakerTimeout: cfg.BreakerTimeout, + }) + return &Client{ + cfg: cfg, + limiter: limiter, + hc: &http.Client{Timeout: cfg.RequestTimeout}, + } +} + +func (c *Client) GetJSON(ctx context.Context, path string, query url.Values, out any) error { + resp, err := c.Get(ctx, path, query) + if err != nil { + return err + } + defer drainAndClose(resp) + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.Response, error) { + target, err := c.resolveURL(path, query) + if err != nil { + return nil, err + } + + bo := backoff.NewExponentialBackOff() + bo.MaxElapsedTime = c.cfg.MaxRetryElapsed + bo.InitialInterval = defaultInitialRetryBackoff + bo.MaxInterval = defaultMaxRetryBackoff + + var resp *http.Response + op := func() error { + r, opErr := c.limiter.Do(ctx, func(rctx context.Context) (*http.Response, error) { + return c.do(rctx, target) + }) + if opErr != nil { + return opErr + } + switch { + case r.StatusCode == http.StatusTooManyRequests: + retryAfter := parseRetryAfter(r.Header.Get("Retry-After")) + drainAndClose(r) + if retryAfter > 0 { + select { + case <-ctx.Done(): + return backoff.Permanent(ctx.Err()) + case <-time.After(retryAfter): + } + } + return errors.New("upstream 429") + case r.StatusCode >= 500 && r.StatusCode <= 599: + drainAndClose(r) + return fmt.Errorf("upstream %d", r.StatusCode) + case r.StatusCode >= 400: + body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit)) + drainAndClose(r) + return backoff.Permanent(fmt.Errorf("client error %d: %s", r.StatusCode, body)) + } + resp = r + return nil + } + if err := backoff.Retry(op, backoff.WithContext(bo, ctx)); err != nil { + return nil, err + } + return resp, nil +} + +func (c *Client) do(ctx context.Context, target string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.cfg.UserAgent) + for k, vs := range c.cfg.Headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + if c.cfg.BearerToken != "" { + req.Header.Set("Authorization", "Bearer "+c.cfg.BearerToken) + } + if c.cfg.APIKey != "" { + header := c.cfg.APIKeyHeader + if header == "" { + header = defaultAPIKeyHeader + } + req.Header.Set(header, c.cfg.APIKey) + } + return c.hc.Do(req) +} + +func (c *Client) resolveURL(path string, query url.Values) (string, error) { + base, err := url.Parse(c.cfg.BaseURL) + if err != nil { + return "", err + } + rel, err := url.Parse(path) + if err != nil { + return "", err + } + resolved := base.ResolveReference(rel) + if query != nil { + resolved.RawQuery = query.Encode() + } + return resolved.String(), nil +} + +func parseRetryAfter(v string) time.Duration { + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil { + return time.Duration(secs) * time.Second + } + if t, err := http.ParseTime(v); err == nil { + d := time.Until(t) + if d > 0 { + return d + } + } + return 0 +} + +func drainAndClose(r *http.Response) { + if r == nil || r.Body == nil { + return + } + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go new file mode 100644 index 00000000..837ba359 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go @@ -0,0 +1,175 @@ +// ©AngelaMos | 2026 +// client_test.go + +package httpx_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +func TestClient_GetJSONHonorsRateLimit(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "test", + BaseURL: srv.URL, + Rate: rate.Every(50 * time.Millisecond), + Burst: 1, + ConsecutiveFailureBudget: 5, + BreakerTimeout: time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + start := time.Now() + for i := 0; i < 3; i++ { + var out struct { + Ok bool `json:"ok"` + } + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.True(t, out.Ok) + } + require.GreaterOrEqual(t, time.Since(start), 100*time.Millisecond) + require.EqualValues(t, 3, hits.Load()) +} + +func TestClient_RetriesOn429WithRetryAfter(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits.Add(1) == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "retry", + BaseURL: srv.URL, + Rate: rate.Inf, + Burst: 10, + ConsecutiveFailureBudget: 5, + BreakerTimeout: time.Second, + MaxRetryElapsed: 3 * time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + var out struct { + Ok bool `json:"ok"` + } + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.True(t, out.Ok) + require.EqualValues(t, 2, hits.Load()) +} + +func TestClient_RetriesOn5xx(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits.Add(1) < 3 { + w.WriteHeader(http.StatusBadGateway) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "5xx", + BaseURL: srv.URL, + Rate: rate.Inf, + Burst: 10, + ConsecutiveFailureBudget: 10, + BreakerTimeout: time.Second, + MaxRetryElapsed: 5 * time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + var out struct { + Ok bool `json:"ok"` + } + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.EqualValues(t, 3, hits.Load()) +} + +func TestClient_PermanentErrorOn4xx(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`bad`)) + })) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "perm", + BaseURL: srv.URL, + Rate: rate.Inf, + Burst: 10, + ConsecutiveFailureBudget: 10, + BreakerTimeout: time.Second, + MaxRetryElapsed: 3 * time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + var out struct{} + err := c.GetJSON(ctx, "/", nil, &out) + require.Error(t, err) + require.EqualValues(t, 1, hits.Load()) +} + +func TestClient_AddsBearerAndAPIKeyHeaders(t *testing.T) { + var bearer, apiKey, ua string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bearer = r.Header.Get("Authorization") + apiKey = r.Header.Get("X-Custom-Key") + ua = r.Header.Get("User-Agent") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := httpx.New(httpx.Config{ + Name: "headers", + BaseURL: srv.URL, + BearerToken: "bearer-abc", + APIKey: "key-xyz", + APIKeyHeader: "X-Custom-Key", + UserAgent: "monitor-test/1.0", + Rate: rate.Inf, + Burst: 1, + ConsecutiveFailureBudget: 5, + BreakerTimeout: time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + var out struct{} + require.NoError(t, c.GetJSON(ctx, "/", nil, &out)) + require.Equal(t, "Bearer bearer-abc", bearer) + require.Equal(t, "key-xyz", apiKey) + require.Equal(t, "monitor-test/1.0", ua) +} From 91d855f4efbe6b3baeeab00bec9691dc6e1652a5 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:26:57 -0400 Subject: [PATCH 015/109] feat(monitor/collectors/state): collector_state CRUD with healthy/degraded upserts --- .../backend/go.mod | 1 + .../backend/go.sum | 2 + .../backend/internal/collectors/state/repo.go | 94 +++++++++++++++ .../internal/collectors/state/repo_test.go | 110 ++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index fac26666..ca56185a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -92,6 +92,7 @@ require ( github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/fastjson v1.6.4 // indirect diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 6625ecd7..0d57c919 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -192,6 +192,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go new file mode 100644 index 00000000..4e8c633d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go @@ -0,0 +1,94 @@ +// ©AngelaMos | 2026 +// repo.go + +package state + +import ( + "context" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +type CollectorState string + +const ( + StateHealthy CollectorState = "healthy" + StateDegraded CollectorState = "degraded" + StateDown CollectorState = "down" +) + +type Row struct { + Name string `db:"name"` + State CollectorState `db:"state"` + LastSuccessAt *time.Time `db:"last_success_at"` + LastErrorAt *time.Time `db:"last_error_at"` + LastError string `db:"last_error"` + LastEventCount int64 `db:"last_event_count"` + UpdatedAt time.Time `db:"updated_at"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) RecordSuccess(ctx context.Context, name string, eventCount int64) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO collector_state (name, state, last_success_at, last_event_count, updated_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (name) DO UPDATE SET + state = EXCLUDED.state, + last_success_at = EXCLUDED.last_success_at, + last_event_count = collector_state.last_event_count + EXCLUDED.last_event_count, + updated_at = EXCLUDED.updated_at`, + name, StateHealthy, eventCount, + ) + if err != nil { + return fmt.Errorf("upsert healthy %s: %w", name, err) + } + return nil +} + +func (r *Repo) RecordError(ctx context.Context, name, errMsg string) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO collector_state (name, state, last_error_at, last_error, updated_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (name) DO UPDATE SET + state = EXCLUDED.state, + last_error_at = EXCLUDED.last_error_at, + last_error = EXCLUDED.last_error, + updated_at = EXCLUDED.updated_at`, + name, StateDegraded, errMsg, + ) + if err != nil { + return fmt.Errorf("upsert degraded %s: %w", name, err) + } + return nil +} + +func (r *Repo) Get(ctx context.Context, name string) (Row, error) { + var row Row + err := r.db.GetContext(ctx, &row, ` + SELECT name, state, last_success_at, last_error_at, last_error, + last_event_count, updated_at + FROM collector_state WHERE name = $1`, name) + if err != nil { + return Row{}, fmt.Errorf("get state %s: %w", name, err) + } + return row, nil +} + +func (r *Repo) All(ctx context.Context) ([]Row, error) { + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT name, state, last_success_at, last_error_at, last_error, + last_event_count, updated_at + FROM collector_state ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("select all collector_state: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go new file mode 100644 index 00000000..0b186c40 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go @@ -0,0 +1,110 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package state_test + +import ( + "context" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/state" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, + "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE collector_state ( + name text PRIMARY KEY, + state text NOT NULL, + last_success_at timestamptz, + last_error_at timestamptz, + last_error text NOT NULL DEFAULT '', + last_event_count bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now() + )`) + require.NoError(t, err) + + return db +} + +func TestRepo_RecordSuccessThenError(t *testing.T) { + db := setupDB(t) + repo := state.NewRepo(db) + ctx := context.Background() + + require.NoError(t, repo.RecordSuccess(ctx, "dshield", 12)) + + got, err := repo.Get(ctx, "dshield") + require.NoError(t, err) + require.Equal(t, "dshield", got.Name) + require.Equal(t, state.StateHealthy, got.State) + require.EqualValues(t, 12, got.LastEventCount) + require.NotNil(t, got.LastSuccessAt) + require.WithinDuration(t, time.Now(), *got.LastSuccessAt, 5*time.Second) + + require.NoError(t, repo.RecordError(ctx, "dshield", "upstream 503")) + + got, err = repo.Get(ctx, "dshield") + require.NoError(t, err) + require.Equal(t, state.StateDegraded, got.State) + require.Equal(t, "upstream 503", got.LastError) + require.NotNil(t, got.LastErrorAt) + require.WithinDuration(t, time.Now(), *got.LastErrorAt, 5*time.Second) +} + +func TestRepo_SuccessAccumulatesCount(t *testing.T) { + db := setupDB(t) + repo := state.NewRepo(db) + ctx := context.Background() + + require.NoError(t, repo.RecordSuccess(ctx, "kev", 3)) + require.NoError(t, repo.RecordSuccess(ctx, "kev", 5)) + + got, err := repo.Get(ctx, "kev") + require.NoError(t, err) + require.EqualValues(t, 8, got.LastEventCount) +} + +func TestRepo_AllReturnsRowsSorted(t *testing.T) { + db := setupDB(t) + repo := state.NewRepo(db) + ctx := context.Background() + + require.NoError(t, repo.RecordSuccess(ctx, "ransomware", 1)) + require.NoError(t, repo.RecordSuccess(ctx, "cve", 1)) + require.NoError(t, repo.RecordSuccess(ctx, "kev", 1)) + + rows, err := repo.All(ctx) + require.NoError(t, err) + require.Len(t, rows, 3) + require.Equal(t, "cve", rows[0].Name) + require.Equal(t, "kev", rows[1].Name) + require.Equal(t, "ransomware", rows[2].Name) +} From a80664b32ae3c42988c6b3e179669917df77ca42 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:28:42 -0400 Subject: [PATCH 016/109] feat(monitor/collectors/dshield): snapshot repository (per-kind upsert + read-latest) --- .../internal/collectors/dshield/repo.go | 50 ++++++++ .../internal/collectors/dshield/repo_test.go | 108 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go new file mode 100644 index 00000000..0137e973 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go @@ -0,0 +1,50 @@ +// ©AngelaMos | 2026 +// repo.go + +package dshield + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +type Snapshot struct { + TS time.Time `db:"ts"` + Kind string `db:"kind"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) PutSnapshot(ctx context.Context, ts time.Time, kind string, payload json.RawMessage) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO dshield_snapshots (ts, kind, payload) + VALUES ($1, $2, $3) + ON CONFLICT (ts, kind) DO UPDATE SET payload = EXCLUDED.payload`, + ts, kind, []byte(payload), + ) + if err != nil { + return fmt.Errorf("put dshield snapshot %s: %w", kind, err) + } + return nil +} + +func (r *Repo) LatestByKind(ctx context.Context, kind string, limit int) ([]Snapshot, error) { + var rows []Snapshot + err := r.db.SelectContext(ctx, &rows, ` + SELECT ts, kind, payload FROM dshield_snapshots + WHERE kind = $1 + ORDER BY ts DESC LIMIT $2`, kind, limit) + if err != nil { + return nil, fmt.Errorf("latest dshield by kind: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go new file mode 100644 index 00000000..5595a0c3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go @@ -0,0 +1,108 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package dshield_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE dshield_snapshots ( + ts timestamptz NOT NULL, + kind text NOT NULL, + payload jsonb NOT NULL, + PRIMARY KEY (ts, kind) + )`) + require.NoError(t, err) + return db +} + +func TestRepo_PutSnapshotAndReadLatest(t *testing.T) { + db := setupDB(t) + repo := dshield.NewRepo(db) + ctx := context.Background() + + ts := time.Now().UTC().Truncate(time.Second) + payload := json.RawMessage(`[{"port":"22","records":1234}]`) + + require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", payload)) + + rows, err := repo.LatestByKind(ctx, "topports", 1) + require.NoError(t, err) + require.Len(t, rows, 1) + require.True(t, ts.Equal(rows[0].TS), "expected %s == %s", ts, rows[0].TS) + require.Equal(t, "topports", rows[0].Kind) + require.JSONEq(t, string(payload), string(rows[0].Payload)) +} + +func TestRepo_PutSnapshotIsIdempotentOnConflict(t *testing.T) { + db := setupDB(t) + repo := dshield.NewRepo(db) + ctx := context.Background() + + ts := time.Now().UTC().Truncate(time.Second) + first := json.RawMessage(`[{"port":"22"}]`) + second := json.RawMessage(`[{"port":"22","records":99}]`) + + require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", first)) + require.NoError(t, repo.PutSnapshot(ctx, ts, "topports", second)) + + rows, err := repo.LatestByKind(ctx, "topports", 1) + require.NoError(t, err) + require.Len(t, rows, 1) + require.JSONEq(t, string(second), string(rows[0].Payload)) +} + +func TestRepo_LatestByKindIsOrderedDesc(t *testing.T) { + db := setupDB(t) + repo := dshield.NewRepo(db) + ctx := context.Background() + + t1 := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second) + t2 := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second) + t3 := time.Now().UTC().Truncate(time.Second) + + require.NoError(t, repo.PutSnapshot(ctx, t1, "sources", json.RawMessage(`[{"i":1}]`))) + require.NoError(t, repo.PutSnapshot(ctx, t3, "sources", json.RawMessage(`[{"i":3}]`))) + require.NoError(t, repo.PutSnapshot(ctx, t2, "sources", json.RawMessage(`[{"i":2}]`))) + + rows, err := repo.LatestByKind(ctx, "sources", 3) + require.NoError(t, err) + require.Len(t, rows, 3) + require.True(t, t3.Equal(rows[0].TS)) + require.True(t, t2.Equal(rows[1].TS)) + require.True(t, t1.Equal(rows[2].TS)) +} From 808eb1b89262f7333acd2b8145bf6c5e4fb196b4 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:31:01 -0400 Subject: [PATCH 017/109] feat(monitor/collectors/dshield): http client + frozen fixtures (topports, topips, dailysummary) --- .../internal/collectors/dshield/client.go | 85 +++++++++++++++++ .../collectors/dshield/client_test.go | 93 +++++++++++++++++++ .../dshield/testdata/dailysummary.json | 1 + .../collectors/dshield/testdata/topips.json | 1 + .../collectors/dshield/testdata/topports.json | 1 + 5 files changed, 181 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/dailysummary.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topips.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topports.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go new file mode 100644 index 00000000..a68c17bc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go @@ -0,0 +1,85 @@ +// ©AngelaMos | 2026 +// client.go + +package dshield + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + KindTopPorts = "topports" + KindTopIPs = "topips" + KindDailySummary = "dailysummary" + + defaultTopN = 10 + defaultDShieldRate = 2 * time.Second + defaultDShieldBudget = 5 + defaultBreakerWindow = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string + TopN int +} + +type Client struct { + hx *httpx.Client + topN int +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://isc.sans.edu" + } + if cfg.TopN <= 0 { + cfg.TopN = defaultTopN + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "dshield", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultDShieldRate), + Burst: 1, + ConsecutiveFailureBudget: defaultDShieldBudget, + BreakerTimeout: defaultBreakerWindow, + }), + topN: cfg.TopN, + } +} + +type SnapshotPayload struct { + Kind string + Payload json.RawMessage +} + +func (c *Client) FetchAll(ctx context.Context) ([]SnapshotPayload, error) { + yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + + endpoints := []struct { + Kind string + Path string + }{ + {KindTopPorts, "/api/topports/records/" + strconv.Itoa(c.topN) + "/" + yesterday + "?json"}, + {KindTopIPs, "/api/topips/?json"}, + {KindDailySummary, "/api/dailysummary/" + yesterday + "/?json"}, + } + + out := make([]SnapshotPayload, 0, len(endpoints)) + for _, ep := range endpoints { + var raw json.RawMessage + if err := c.hx.GetJSON(ctx, ep.Path, nil, &raw); err != nil { + return nil, fmt.Errorf("dshield %s: %w", ep.Kind, err) + } + out = append(out, SnapshotPayload{Kind: ep.Kind, Payload: raw}) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go new file mode 100644 index 00000000..74881918 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go @@ -0,0 +1,93 @@ +// ©AngelaMos | 2026 +// client_test.go + +package dshield_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" +) + +func newFixtureServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/topports/", func(w http.ResponseWriter, r *http.Request) { + require.Contains(t, r.URL.Path, "/api/topports/records/") + body, err := os.ReadFile(filepath.Join("testdata", "topports.json")) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + mux.HandleFunc("/api/topips/", func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile(filepath.Join("testdata", "topips.json")) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + mux.HandleFunc("/api/dailysummary/", func(w http.ResponseWriter, r *http.Request) { + require.True(t, strings.HasPrefix(r.URL.Path, "/api/dailysummary/")) + body, err := os.ReadFile(filepath.Join("testdata", "dailysummary.json")) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + return httptest.NewServer(mux) +} + +func TestClient_FetchAllReturnsThreeKinds(t *testing.T) { + srv := newFixtureServer(t) + defer srv.Close() + + c := dshield.NewClient(dshield.ClientConfig{BaseURL: srv.URL, TopN: 10}) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + snaps, err := c.FetchAll(ctx) + require.NoError(t, err) + require.Len(t, snaps, 3) + + kinds := map[string]bool{} + for _, s := range snaps { + kinds[s.Kind] = true + require.NotEmpty(t, s.Payload, "kind %s payload empty", s.Kind) + } + require.True(t, kinds[dshield.KindTopPorts]) + require.True(t, kinds[dshield.KindTopIPs]) + require.True(t, kinds[dshield.KindDailySummary]) +} + +func TestClient_FetchAllUsesYesterdayDate(t *testing.T) { + var topPortsPath, dailyPath string + mux := http.NewServeMux() + mux.HandleFunc("/api/topports/", func(w http.ResponseWriter, r *http.Request) { + topPortsPath = r.URL.Path + _, _ = w.Write([]byte(`{}`)) + }) + mux.HandleFunc("/api/topips/", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[]`)) + }) + mux.HandleFunc("/api/dailysummary/", func(w http.ResponseWriter, r *http.Request) { + dailyPath = r.URL.Path + _, _ = w.Write([]byte(`[]`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := dshield.NewClient(dshield.ClientConfig{BaseURL: srv.URL, TopN: 5}) + _, _ = c.FetchAll(context.Background()) + + yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + require.Contains(t, topPortsPath, "/records/5/"+yesterday) + require.Contains(t, dailyPath, "/dailysummary/"+yesterday) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/dailysummary.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/dailysummary.json new file mode 100644 index 00000000..59f2fafc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/dailysummary.json @@ -0,0 +1 @@ +[{"date":"2026-05-01","records":17196921,"sources":146555,"targets":871}] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topips.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topips.json new file mode 100644 index 00000000..32610be1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topips.json @@ -0,0 +1 @@ +[{"rank":1,"source":"13.94.254.200","reports":319739,"targets":1},{"rank":2,"source":"194.224.249.214","reports":309508,"targets":1},{"rank":3,"source":"52.157.207.201","reports":194780,"targets":1},{"rank":4,"source":"89.248.163.109","reports":96279,"targets":150},{"rank":5,"source":"143.244.186.35","reports":92007,"targets":8},{"rank":6,"source":"18.116.198.191","reports":59907,"targets":1},{"rank":7,"source":"152.89.198.163","reports":41378,"targets":478},{"rank":8,"source":"152.89.198.103","reports":41346,"targets":479},{"rank":9,"source":"152.89.198.187","reports":41332,"targets":477},{"rank":10,"source":"185.122.204.71","reports":41221,"targets":479}] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topports.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topports.json new file mode 100644 index 00000000..e63e7484 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/testdata/topports.json @@ -0,0 +1 @@ +{"0":{"rank":1,"targetport":23,"records":5756817,"targets":316,"sources":52950},"1":{"rank":2,"targetport":80,"records":1151422,"targets":316,"sources":9421},"2":{"rank":3,"targetport":5555,"records":1106744,"targets":298,"sources":3668},"3":{"rank":4,"targetport":8080,"records":978049,"targets":309,"sources":7062},"4":{"rank":5,"targetport":22,"records":514078,"targets":326,"sources":9113},"5":{"rank":6,"targetport":2222,"records":371960,"targets":318,"sources":7388},"6":{"rank":7,"targetport":51223,"records":313702,"targets":15,"sources":1293},"7":{"rank":8,"targetport":8000,"records":241520,"targets":323,"sources":6078},"8":{"rank":9,"targetport":443,"records":231949,"targets":432,"sources":8308},"9":{"rank":10,"targetport":68,"records":215704,"targets":185,"sources":209},"date":"2026-05-01","limit":10} From fd861d587e0aae14a82fc0f2a7c2bfc995d688f1 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:32:10 -0400 Subject: [PATCH 018/109] feat(monitor/collectors/dshield): hourly tick collector emitting scan_firehose --- .../internal/collectors/dshield/collector.go | 117 ++++++++++++ .../collectors/dshield/collector_test.go | 177 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go new file mode 100644 index 00000000..ff2c4827 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go @@ -0,0 +1,117 @@ +// ©AngelaMos | 2026 +// collector.go + +package dshield + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "dshield" + defaultInterval = time.Hour +) + +type Fetcher interface { + FetchAll(ctx context.Context) ([]SnapshotPayload, error) +} + +type Persister interface { + PutSnapshot(ctx context.Context, ts time.Time, kind string, payload json.RawMessage) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Persister Persister + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + snaps, err := c.cfg.Fetcher.FetchAll(ctx) + if err != nil { + c.logger.Warn("dshield fetch failed", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + now := time.Now().UTC() + tsRaw, err := json.Marshal(now.Format(time.RFC3339Nano)) + if err != nil { + c.logger.Error("dshield marshal ts", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + merged := map[string]json.RawMessage{"ts": tsRaw} + for _, s := range snaps { + if perr := c.cfg.Persister.PutSnapshot(ctx, now, s.Kind, s.Payload); perr != nil { + c.logger.Warn("dshield persist failed", "kind", s.Kind, "err", perr) + } + merged[s.Kind] = s.Payload + } + + body, err := json.Marshal(merged) + if err != nil { + c.logger.Error("dshield marshal merged", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicScanFirehose, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(body), + }) + _ = c.cfg.State.RecordSuccess(ctx, Name, 1) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go new file mode 100644 index 00000000..4e43288a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go @@ -0,0 +1,177 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package dshield_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + mu sync.Mutex + calls int + err error +} + +func (f *fakeFetcher) FetchAll(_ context.Context) ([]dshield.SnapshotPayload, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.err != nil { + return nil, f.err + } + return []dshield.SnapshotPayload{ + {Kind: dshield.KindTopPorts, Payload: json.RawMessage(`[{"port":22}]`)}, + {Kind: dshield.KindTopIPs, Payload: json.RawMessage(`[{"ip":"1.2.3.4"}]`)}, + {Kind: dshield.KindDailySummary, Payload: json.RawMessage(`[{"records":42}]`)}, + }, nil +} + +func (f *fakeFetcher) Calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +type persistedRow struct { + Kind string + Body json.RawMessage +} + +type fakePersister struct { + mu sync.Mutex + rows []persistedRow +} + +func (p *fakePersister) PutSnapshot(_ context.Context, _ time.Time, kind string, body json.RawMessage) error { + p.mu.Lock() + defer p.mu.Unlock() + p.rows = append(p.rows, persistedRow{Kind: kind, Body: body}) + return nil +} + +func (p *fakePersister) Rows() []persistedRow { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]persistedRow, len(p.rows)) + copy(out, p.rows) + return out +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type recordingState struct { + mu sync.Mutex + successes int + successN int64 + failures int + lastErr string +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, n int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + s.successN += n + return nil +} + +func (s *recordingState) RecordError(_ context.Context, _, msg string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + s.lastErr = msg + return nil +} + +func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) { + ftch := &fakeFetcher{} + prst := &fakePersister{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := dshield.NewCollector(dshield.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Persister: prst, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, ftch.Calls(), 2) + require.GreaterOrEqual(t, len(prst.Rows()), 6) + + evs := emt.Events() + require.GreaterOrEqual(t, len(evs), 2) + for _, ev := range evs { + require.Equal(t, events.TopicScanFirehose, ev.Topic) + require.Equal(t, dshield.Name, ev.Source) + body, ok := ev.Payload.(json.RawMessage) + require.True(t, ok, "payload must be json.RawMessage") + + var merged map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &merged)) + require.Contains(t, merged, dshield.KindTopPorts) + require.Contains(t, merged, dshield.KindTopIPs) + require.Contains(t, merged, dshield.KindDailySummary) + require.Contains(t, merged, "ts") + } + + require.Greater(t, st.successes, 0) + require.Equal(t, 0, st.failures) +} + +func TestCollector_FetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + prst := &fakePersister{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := dshield.NewCollector(dshield.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Persister: prst, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, len(prst.Rows())) + require.Equal(t, 0, len(emt.Events())) + require.Greater(t, st.failures, 0) + require.Contains(t, st.lastErr, "upstream 503") +} From ada39ef6d9ecd33c0fc2c3bf92b81518ede0a9b6 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:34:03 -0400 Subject: [PATCH 019/109] feat(monitor/collectors/cfradar): outages + hijacks repository with diff lookups --- .../backend/go.mod | 1 + .../backend/go.sum | 2 + .../internal/collectors/cfradar/repo.go | 125 ++++++++++++++++ .../internal/collectors/cfradar/repo_test.go | 136 ++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index ca56185a..01de56e5 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -70,6 +70,7 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect + github.com/lib/pq v1.12.3 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 0d57c919..3a4aa362 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -131,6 +131,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go new file mode 100644 index 00000000..9d1a1600 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go @@ -0,0 +1,125 @@ +// ©AngelaMos | 2026 +// repo.go + +package cfradar + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type OutageRow struct { + ID string `db:"id"` + StartedAt time.Time `db:"started_at"` + EndedAt *time.Time `db:"ended_at"` + Locations pq.StringArray `db:"locations"` + ASNs pq.Int32Array `db:"asns"` + Cause string `db:"cause"` + OutageType string `db:"outage_type"` + Payload json.RawMessage `db:"payload"` +} + +type HijackRow struct { + ID int64 `db:"id"` + DetectedAt time.Time `db:"detected_at"` + StartedAt time.Time `db:"started_at"` + DurationSec int32 `db:"duration_sec"` + Confidence int16 `db:"confidence"` + HijackerASN int32 `db:"hijacker_asn"` + VictimASNs pq.Int32Array `db:"victim_asns"` + Prefixes []string `db:"-"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error { + if o.Locations == nil { + o.Locations = pq.StringArray{} + } + if o.ASNs == nil { + o.ASNs = pq.Int32Array{} + } + _, err := r.db.ExecContext(ctx, ` + INSERT INTO outage_events (id, started_at, ended_at, locations, asns, cause, outage_type, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + ended_at = EXCLUDED.ended_at, + locations = EXCLUDED.locations, + asns = EXCLUDED.asns, + cause = EXCLUDED.cause, + outage_type = EXCLUDED.outage_type, + payload = EXCLUDED.payload`, + o.ID, o.StartedAt, o.EndedAt, o.Locations, o.ASNs, o.Cause, o.OutageType, []byte(o.Payload), + ) + if err != nil { + return fmt.Errorf("upsert outage %s: %w", o.ID, err) + } + return nil +} + +func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error { + if h.VictimASNs == nil { + h.VictimASNs = pq.Int32Array{} + } + if h.Prefixes == nil { + h.Prefixes = []string{} + } + _, err := r.db.ExecContext(ctx, ` + INSERT INTO bgp_hijack_events + (id, detected_at, started_at, duration_sec, confidence, hijacker_asn, victim_asns, prefixes, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::cidr[], $9) + ON CONFLICT (id) DO UPDATE SET + detected_at = EXCLUDED.detected_at, + duration_sec = EXCLUDED.duration_sec, + confidence = EXCLUDED.confidence, + payload = EXCLUDED.payload`, + h.ID, h.DetectedAt, h.StartedAt, h.DurationSec, h.Confidence, h.HijackerASN, + h.VictimASNs, pq.Array(h.Prefixes), []byte(h.Payload), + ) + if err != nil { + return fmt.Errorf("upsert hijack %d: %w", h.ID, err) + } + return nil +} + +func (r *Repo) KnownOutageIDs(ctx context.Context, ids []string) (map[string]bool, error) { + if len(ids) == 0 { + return map[string]bool{}, nil + } + var found []string + if err := r.db.SelectContext(ctx, &found, + `SELECT id FROM outage_events WHERE id = ANY($1::text[])`, pq.Array(ids)); err != nil { + return nil, fmt.Errorf("known outage ids: %w", err) + } + out := make(map[string]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, error) { + if len(ids) == 0 { + return map[int64]bool{}, nil + } + var found []int64 + if err := r.db.SelectContext(ctx, &found, + `SELECT id FROM bgp_hijack_events WHERE id = ANY($1::bigint[])`, pq.Array(ids)); err != nil { + return nil, fmt.Errorf("known hijack ids: %w", err) + } + out := make(map[int64]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo_test.go new file mode 100644 index 00000000..52559c7b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo_test.go @@ -0,0 +1,136 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package cfradar_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE outage_events ( + id text PRIMARY KEY, + started_at timestamptz NOT NULL, + ended_at timestamptz, + locations text[] NOT NULL DEFAULT '{}', + asns integer[] NOT NULL DEFAULT '{}', + cause text NOT NULL DEFAULT '', + outage_type text NOT NULL DEFAULT '', + payload jsonb NOT NULL + ); + CREATE TABLE bgp_hijack_events ( + id bigint PRIMARY KEY, + detected_at timestamptz NOT NULL, + started_at timestamptz NOT NULL, + duration_sec integer NOT NULL DEFAULT 0, + confidence smallint NOT NULL DEFAULT 0, + hijacker_asn integer NOT NULL DEFAULT 0, + victim_asns integer[] NOT NULL DEFAULT '{}', + prefixes cidr[] NOT NULL DEFAULT '{}', + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_UpsertOutageIsIdempotent(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + row := cfradar.OutageRow{ + ID: "out-1", + StartedAt: time.Now().UTC().Add(-time.Hour), + Locations: pq.StringArray{"DE", "AT"}, + ASNs: pq.Int32Array{15169}, + Cause: "fiber-cut", + OutageType: "regional", + Payload: json.RawMessage(`{"id":"out-1"}`), + } + require.NoError(t, repo.UpsertOutage(ctx, row)) + require.NoError(t, repo.UpsertOutage(ctx, row)) +} + +func TestRepo_UpsertHijackAndDiff(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + row := cfradar.HijackRow{ + ID: 7700001, + DetectedAt: time.Now().UTC(), + StartedAt: time.Now().UTC().Add(-2 * time.Minute), + Confidence: 9, + HijackerASN: 64500, + VictimASNs: pq.Int32Array{15169, 13335}, + Prefixes: []string{"203.0.113.0/24"}, + Payload: json.RawMessage(`{"id":7700001}`), + } + require.NoError(t, repo.UpsertHijack(ctx, row)) + + known, err := repo.KnownHijackIDs(ctx, []int64{7700001, 7700002}) + require.NoError(t, err) + require.Equal(t, map[int64]bool{7700001: true}, known) +} + +func TestRepo_KnownOutageIDs(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + require.NoError(t, repo.UpsertOutage(ctx, cfradar.OutageRow{ + ID: "out-known", StartedAt: time.Now().UTC(), + Payload: json.RawMessage(`{}`), + })) + + known, err := repo.KnownOutageIDs(ctx, []string{"out-known", "out-new"}) + require.NoError(t, err) + require.Equal(t, map[string]bool{"out-known": true}, known) +} + +func TestRepo_KnownIDsHandlesEmptySlice(t *testing.T) { + db := setupDB(t) + repo := cfradar.NewRepo(db) + ctx := context.Background() + + out, err := repo.KnownOutageIDs(ctx, nil) + require.NoError(t, err) + require.Empty(t, out) + + hij, err := repo.KnownHijackIDs(ctx, nil) + require.NoError(t, err) + require.Empty(t, hij) +} From 428610b663f9e2d6389ddaab36f7517e979b5bf5 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:35:26 -0400 Subject: [PATCH 020/109] feat(monitor/collectors/cfradar): radar client (outages + bgp hijacks) with bearer auth --- .../internal/collectors/cfradar/client.go | 126 ++++++++++++++++++ .../collectors/cfradar/client_test.go | 93 +++++++++++++ .../collectors/cfradar/testdata/hijacks.json | 29 ++++ .../collectors/cfradar/testdata/outages.json | 27 ++++ 4 files changed, 275 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go new file mode 100644 index 00000000..deb73273 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go @@ -0,0 +1,126 @@ +// ©AngelaMos | 2026 +// client.go + +package cfradar + +import ( + "context" + "fmt" + "net/url" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultCFBaseURL = "https://api.cloudflare.com" + defaultCFRate = 250 * time.Millisecond + defaultCFBurst = 5 + defaultCFBudget = 5 + defaultCFBreaker = 60 * time.Second + pathRadarOutages = "/client/v4/radar/annotations/outages" + pathRadarHijacks = "/client/v4/radar/bgp/hijacks/events" + defaultDateRange = "1d" +) + +type ClientConfig struct { + BaseURL string + BearerToken string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultCFBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "cfradar", + BaseURL: cfg.BaseURL, + BearerToken: cfg.BearerToken, + Rate: rate.Every(defaultCFRate), + Burst: defaultCFBurst, + ConsecutiveFailureBudget: defaultCFBudget, + BreakerTimeout: defaultCFBreaker, + }), + } +} + +type OutageEnvelope struct { + Success bool `json:"success"` + Result OutageResultBody `json:"result"` + Errors []ErrorEntry `json:"errors"` +} + +type OutageResultBody struct { + Annotations []OutageAnnotation `json:"annotations"` +} + +type OutageAnnotation struct { + ID string `json:"id"` + StartDate time.Time `json:"startDate"` + EndDate *time.Time `json:"endDate"` + Locations []string `json:"locations"` + ASNs []int32 `json:"asns"` + Reason string `json:"reason"` + OutageType string `json:"outageType"` +} + +type HijackEnvelope struct { + Success bool `json:"success"` + Result HijackBody `json:"result"` + Errors []ErrorEntry `json:"errors"` +} + +type HijackBody struct { + Events []HijackEvent `json:"events"` +} + +type HijackEvent struct { + ID int64 `json:"id"` + DetectedAt time.Time `json:"detectedAt"` + StartedAt time.Time `json:"startedAt"` + DurationSec int32 `json:"duration"` + Confidence int16 `json:"confidenceScore"` + HijackerASN int32 `json:"hijackerAsn"` + VictimASNs []int32 `json:"victimAsns"` + Prefixes []string `json:"prefixes"` +} + +type ErrorEntry struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (c *Client) FetchOutages(ctx context.Context) (OutageResultBody, error) { + q := url.Values{"dateRange": []string{defaultDateRange}} + var env OutageEnvelope + if err := c.hx.GetJSON(ctx, pathRadarOutages, q, &env); err != nil { + return OutageResultBody{}, fmt.Errorf("fetch outages: %w", err) + } + if !env.Success { + return OutageResultBody{}, fmt.Errorf("radar outages: success=false errors=%v", env.Errors) + } + return env.Result, nil +} + +func (c *Client) FetchHijacks(ctx context.Context, minConfidence int) (HijackBody, error) { + q := url.Values{ + "dateRange": []string{defaultDateRange}, + "minConfidence": []string{strconv.Itoa(minConfidence)}, + } + var env HijackEnvelope + if err := c.hx.GetJSON(ctx, pathRadarHijacks, q, &env); err != nil { + return HijackBody{}, fmt.Errorf("fetch hijacks: %w", err) + } + if !env.Success { + return HijackBody{}, fmt.Errorf("radar hijacks: success=false errors=%v", env.Errors) + } + return env.Result, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go new file mode 100644 index 00000000..797520bd --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go @@ -0,0 +1,93 @@ +// ©AngelaMos | 2026 +// client_test.go + +package cfradar_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" +) + +func newRadarServer(t *testing.T, authHits *atomic.Int32) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/client/v4/radar/annotations/outages", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer test-token" { + authHits.Add(1) + } + body, err := os.ReadFile("testdata/outages.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + mux.HandleFunc("/client/v4/radar/bgp/hijacks/events", func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/hijacks.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + return httptest.NewServer(mux) +} + +func TestClient_FetchOutagesSendsBearerAndDecodes(t *testing.T) { + var hits atomic.Int32 + srv := newRadarServer(t, &hits) + defer srv.Close() + + c := cfradar.NewClient(cfradar.ClientConfig{ + BaseURL: srv.URL, + BearerToken: "test-token", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + body, err := c.FetchOutages(ctx) + require.NoError(t, err) + require.Len(t, body.Annotations, 2) + require.Equal(t, "outage-2026-04-30-DE-DTAG", body.Annotations[0].ID) + require.Equal(t, []string{"DE"}, body.Annotations[0].Locations) + require.Equal(t, []int32{3320}, body.Annotations[0].ASNs) + require.EqualValues(t, 1, hits.Load()) +} + +func TestClient_FetchHijacksDecodes(t *testing.T) { + var hits atomic.Int32 + srv := newRadarServer(t, &hits) + defer srv.Close() + + c := cfradar.NewClient(cfradar.ClientConfig{ + BaseURL: srv.URL, + BearerToken: "test-token", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + body, err := c.FetchHijacks(ctx, 7) + require.NoError(t, err) + require.Len(t, body.Events, 2) + require.EqualValues(t, 7700001, body.Events[0].ID) + require.EqualValues(t, 9, body.Events[0].Confidence) + require.Equal(t, []string{"203.0.113.0/24"}, body.Events[0].Prefixes) +} + +func TestClient_FetchOutagesFailsOnSuccessFalse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"success":false,"errors":[{"message":"unauthorized"}]}`)) + })) + defer srv.Close() + + c := cfradar.NewClient(cfradar.ClientConfig{BaseURL: srv.URL, BearerToken: "x"}) + _, err := c.FetchOutages(context.Background()) + require.Error(t, err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json new file mode 100644 index 00000000..7835afe0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/hijacks.json @@ -0,0 +1,29 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "events": [ + { + "id": 7700001, + "detectedAt": "2026-05-01T17:23:14Z", + "startedAt": "2026-05-01T17:21:50Z", + "duration": 600, + "confidenceScore": 9, + "hijackerAsn": 64500, + "victimAsns": [15169, 13335], + "prefixes": ["203.0.113.0/24"] + }, + { + "id": 7700002, + "detectedAt": "2026-05-01T18:02:09Z", + "startedAt": "2026-05-01T18:00:00Z", + "duration": 180, + "confidenceScore": 7, + "hijackerAsn": 64512, + "victimAsns": [3356], + "prefixes": ["198.51.100.0/24", "198.51.101.0/24"] + } + ] + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json new file mode 100644 index 00000000..8b03d2ad --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/testdata/outages.json @@ -0,0 +1,27 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "annotations": [ + { + "id": "outage-2026-04-30-DE-DTAG", + "startDate": "2026-04-30T14:00:00Z", + "endDate": "2026-04-30T15:30:00Z", + "locations": ["DE"], + "asns": [3320], + "reason": "fiber cut, regional infrastructure", + "outageType": "REGIONAL" + }, + { + "id": "outage-2026-05-01-IR-MCI", + "startDate": "2026-05-01T09:15:00Z", + "endDate": null, + "locations": ["IR"], + "asns": [197207], + "reason": "government-directed shutdown", + "outageType": "NATION_WIDE" + } + ] + } +} From ee358e38f1a34e989f82f168c663f141cddc117e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:37:10 -0400 Subject: [PATCH 021/109] feat(monitor/collectors/cfradar): 5m collector with diff-on-ID emitting outages + hijacks --- .../internal/collectors/cfradar/collector.go | 212 ++++++++++++++++++ .../collectors/cfradar/collector_test.go | 191 ++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go new file mode 100644 index 00000000..d8c9c09a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go @@ -0,0 +1,212 @@ +// ©AngelaMos | 2026 +// collector.go + +package cfradar + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/lib/pq" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "cfradar" + defaultCollectorInterval = 5 * time.Minute + defaultCollectorConfidence = 7 +) + +type Fetcher interface { + FetchOutages(ctx context.Context) (OutageResultBody, error) + FetchHijacks(ctx context.Context, minConfidence int) (HijackBody, error) +} + +type Repository interface { + UpsertOutage(ctx context.Context, o OutageRow) error + UpsertHijack(ctx context.Context, h HijackRow) error + KnownOutageIDs(ctx context.Context, ids []string) (map[string]bool, error) + KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + MinConfidence int + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultCollectorInterval + } + if cfg.MinConfidence <= 0 { + cfg.MinConfidence = defaultCollectorConfidence + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + emitted := int64(0) + hadError := false + + if n, err := c.tickOutages(ctx); err != nil { + c.logger.Warn("cfradar outages tick failed", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + emitted += n + } + + if n, err := c.tickHijacks(ctx); err != nil { + c.logger.Warn("cfradar hijacks tick failed", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + emitted += n + } + + if !hadError { + _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + } +} + +func (c *Collector) tickOutages(ctx context.Context) (int64, error) { + body, err := c.cfg.Fetcher.FetchOutages(ctx) + if err != nil { + return 0, err + } + + ids := make([]string, 0, len(body.Annotations)) + for _, a := range body.Annotations { + ids = append(ids, a.ID) + } + + known, err := c.cfg.Repo.KnownOutageIDs(ctx, ids) + if err != nil { + return 0, err + } + + now := time.Now().UTC() + emitted := int64(0) + for _, a := range body.Annotations { + if known[a.ID] { + continue + } + rawBytes, _ := json.Marshal(a) + raw := json.RawMessage(rawBytes) + row := OutageRow{ + ID: a.ID, + StartedAt: a.StartDate, + EndedAt: a.EndDate, + Locations: pq.StringArray(a.Locations), + ASNs: pq.Int32Array(a.ASNs), + Cause: a.Reason, + OutageType: a.OutageType, + Payload: raw, + } + if uerr := c.cfg.Repo.UpsertOutage(ctx, row); uerr != nil { + c.logger.Warn("upsert outage", "id", a.ID, "err", uerr) + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicInternetOutage, + Timestamp: now, + Source: Name, + Payload: raw, + }) + emitted++ + } + return emitted, nil +} + +func (c *Collector) tickHijacks(ctx context.Context) (int64, error) { + body, err := c.cfg.Fetcher.FetchHijacks(ctx, c.cfg.MinConfidence) + if err != nil { + return 0, err + } + + ids := make([]int64, 0, len(body.Events)) + for _, e := range body.Events { + ids = append(ids, e.ID) + } + + known, err := c.cfg.Repo.KnownHijackIDs(ctx, ids) + if err != nil { + return 0, err + } + + now := time.Now().UTC() + emitted := int64(0) + for _, e := range body.Events { + if known[e.ID] { + continue + } + rawBytes, _ := json.Marshal(e) + raw := json.RawMessage(rawBytes) + row := HijackRow{ + ID: e.ID, + DetectedAt: e.DetectedAt, + StartedAt: e.StartedAt, + DurationSec: e.DurationSec, + Confidence: e.Confidence, + HijackerASN: e.HijackerASN, + VictimASNs: pq.Int32Array(e.VictimASNs), + Prefixes: e.Prefixes, + Payload: raw, + } + if uerr := c.cfg.Repo.UpsertHijack(ctx, row); uerr != nil { + c.logger.Warn("upsert hijack", "id", e.ID, "err", uerr) + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicBGPHijack, + Timestamp: now, + Source: Name, + Payload: raw, + }) + emitted++ + } + return emitted, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go new file mode 100644 index 00000000..c0f93853 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go @@ -0,0 +1,191 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package cfradar_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + outages cfradar.OutageResultBody + hijacks cfradar.HijackBody +} + +func (f *fakeFetcher) FetchOutages(context.Context) (cfradar.OutageResultBody, error) { + return f.outages, nil +} + +func (f *fakeFetcher) FetchHijacks(context.Context, int) (cfradar.HijackBody, error) { + return f.hijacks, nil +} + +type fakeRepo struct { + mu sync.Mutex + outageUpserts int + hijackUpserts int + knownOutages map[string]bool + knownHijacks map[int64]bool +} + +func (r *fakeRepo) UpsertOutage(_ context.Context, _ cfradar.OutageRow) error { + r.mu.Lock() + defer r.mu.Unlock() + r.outageUpserts++ + return nil +} + +func (r *fakeRepo) UpsertHijack(_ context.Context, _ cfradar.HijackRow) error { + r.mu.Lock() + defer r.mu.Unlock() + r.hijackUpserts++ + return nil +} + +func (r *fakeRepo) KnownOutageIDs(_ context.Context, ids []string) (map[string]bool, error) { + out := make(map[string]bool) + for _, id := range ids { + if r.knownOutages[id] { + out[id] = true + } + } + return out, nil +} + +func (r *fakeRepo) KnownHijackIDs(_ context.Context, ids []int64) (map[int64]bool, error) { + out := make(map[int64]bool) + for _, id := range ids { + if r.knownHijacks[id] { + out[id] = true + } + } + return out, nil +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type noopState struct{} + +func (noopState) RecordSuccess(context.Context, string, int64) error { return nil } +func (noopState) RecordError(context.Context, string, string) error { return nil } + +func TestCollector_OnlyEmitsNetNew(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + outages: cfradar.OutageResultBody{Annotations: []cfradar.OutageAnnotation{ + {ID: "out-known", StartDate: now}, + {ID: "out-new", StartDate: now}, + }}, + hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ + {ID: 100, DetectedAt: now, StartedAt: now, Confidence: 9}, + {ID: 200, DetectedAt: now, StartedAt: now, Confidence: 8}, + }}, + } + repo := &fakeRepo{ + knownOutages: map[string]bool{"out-known": true}, + knownHijacks: map[int64]bool{200: true}, + } + emt := &fakeEmitter{} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 30 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: noopState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + evs := emt.Events() + + var outageEvents, hijackEvents int + for _, ev := range evs { + switch ev.Topic { + case events.TopicInternetOutage: + outageEvents++ + body, _ := json.Marshal(ev.Payload) + require.Contains(t, string(body), "out-new") + require.NotContains(t, string(body), "out-known") + case events.TopicBGPHijack: + hijackEvents++ + body, _ := json.Marshal(ev.Payload) + require.Contains(t, string(body), "100") + } + } + require.GreaterOrEqual(t, outageEvents, 1) + require.GreaterOrEqual(t, hijackEvents, 1) +} + +func TestCollector_RepeatedTickIsIdempotent(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + outages: cfradar.OutageResultBody{Annotations: []cfradar.OutageAnnotation{ + {ID: "out-x", StartDate: now}, + }}, + } + emit := &fakeEmitter{} + + known := map[string]bool{} + repo := &fakeRepo{knownOutages: known} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 20 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emit, + State: noopState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + known["out-x"] = true + + emit2 := &fakeEmitter{} + c2 := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 20 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emit2, + State: noopState{}, + }) + ctx2, cancel2 := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel2() + _ = c2.Run(ctx2) + + for _, ev := range emit2.Events() { + require.NotEqual(t, events.TopicInternetOutage, ev.Topic, "should not re-emit known outage") + } +} From 2a61953cb963619e9bdb44aa3fd93ddf51fae271 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:38:08 -0400 Subject: [PATCH 022/109] feat(monitor/collectors/cve): cve_events repository with EPSS-only patch --- .../backend/internal/collectors/cve/repo.go | 84 +++++++++++ .../internal/collectors/cve/repo_test.go | 135 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go new file mode 100644 index 00000000..0e546357 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go @@ -0,0 +1,84 @@ +// ©AngelaMos | 2026 +// repo.go + +package cve + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +type Row struct { + CveID string `db:"cve_id"` + Published time.Time `db:"published"` + LastModified time.Time `db:"last_modified"` + Severity string `db:"severity"` + CVSS float64 `db:"cvss_score"` + EPSSScore *float64 `db:"epss_score"` + EPSSPercentile *float64 `db:"epss_percentile"` + InKEV bool `db:"in_kev"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Upsert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO cve_events (cve_id, published, last_modified, severity, cvss_score, payload) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (cve_id) DO UPDATE SET + last_modified = EXCLUDED.last_modified, + severity = EXCLUDED.severity, + cvss_score = EXCLUDED.cvss_score, + payload = EXCLUDED.payload`, + row.CveID, row.Published, row.LastModified, row.Severity, row.CVSS, []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("upsert cve %s: %w", row.CveID, err) + } + return nil +} + +func (r *Repo) UpdateEPSS(ctx context.Context, cveID string, score, percentile float64) error { + _, err := r.db.ExecContext(ctx, + `UPDATE cve_events SET epss_score = $2, epss_percentile = $3 WHERE cve_id = $1`, + cveID, score, percentile, + ) + if err != nil { + return fmt.Errorf("update epss %s: %w", cveID, err) + } + return nil +} + +func (r *Repo) Get(ctx context.Context, cveID string) (Row, error) { + var row Row + err := r.db.GetContext(ctx, &row, ` + SELECT cve_id, published, last_modified, severity, cvss_score, + epss_score, epss_percentile, in_kev, payload + FROM cve_events WHERE cve_id = $1`, cveID) + if err != nil { + return Row{}, fmt.Errorf("get cve %s: %w", cveID, err) + } + return row, nil +} + +func (r *Repo) RecentByLastModified(ctx context.Context, limit int) ([]Row, error) { + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT cve_id, published, last_modified, severity, cvss_score, + epss_score, epss_percentile, in_kev, payload + FROM cve_events + ORDER BY last_modified DESC LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent cves: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo_test.go new file mode 100644 index 00000000..28c171aa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo_test.go @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package cve_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE cve_events ( + cve_id text PRIMARY KEY, + published timestamptz, + last_modified timestamptz NOT NULL, + severity text NOT NULL DEFAULT '', + cvss_score numeric(3,1) NOT NULL DEFAULT 0, + epss_score numeric(5,4), + epss_percentile numeric(5,4), + in_kev boolean NOT NULL DEFAULT false, + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_UpsertThenEPSSUpdate(t *testing.T) { + db := setupDB(t) + repo := cve.NewRepo(db) + ctx := context.Background() + + row := cve.Row{ + CveID: "CVE-2026-0001", + Published: time.Now().UTC().Add(-time.Hour), + LastModified: time.Now().UTC(), + Severity: "CRITICAL", + CVSS: 9.8, + Payload: json.RawMessage(`{"id":"CVE-2026-0001"}`), + } + require.NoError(t, repo.Upsert(ctx, row)) + + require.NoError(t, repo.UpdateEPSS(ctx, "CVE-2026-0001", 0.9512, 0.9988)) + + got, err := repo.Get(ctx, "CVE-2026-0001") + require.NoError(t, err) + require.NotNil(t, got.EPSSScore) + require.InDelta(t, 0.9512, *got.EPSSScore, 0.0001) + require.NotNil(t, got.EPSSPercentile) + require.InDelta(t, 0.9988, *got.EPSSPercentile, 0.0001) + require.Equal(t, "CRITICAL", got.Severity) + require.InDelta(t, 9.8, got.CVSS, 0.0001) +} + +func TestRepo_UpsertOnConflictUpdatesLastModified(t *testing.T) { + db := setupDB(t) + repo := cve.NewRepo(db) + ctx := context.Background() + + first := cve.Row{ + CveID: "CVE-2026-0002", + Published: time.Now().UTC().Add(-2 * time.Hour), + LastModified: time.Now().UTC().Add(-time.Hour), + Severity: "MEDIUM", + CVSS: 5.0, + Payload: json.RawMessage(`{"v":1}`), + } + require.NoError(t, repo.Upsert(ctx, first)) + + updated := first + updated.LastModified = time.Now().UTC() + updated.Severity = "HIGH" + updated.CVSS = 7.5 + updated.Payload = json.RawMessage(`{"v":2}`) + require.NoError(t, repo.Upsert(ctx, updated)) + + got, err := repo.Get(ctx, "CVE-2026-0002") + require.NoError(t, err) + require.Equal(t, "HIGH", got.Severity) + require.InDelta(t, 7.5, got.CVSS, 0.0001) +} + +func TestRepo_RecentByLastModifiedOrdered(t *testing.T) { + db := setupDB(t) + repo := cve.NewRepo(db) + ctx := context.Background() + + now := time.Now().UTC() + for i, name := range []string{"CVE-A", "CVE-B", "CVE-C"} { + require.NoError(t, repo.Upsert(ctx, cve.Row{ + CveID: name, + Published: now.Add(-time.Duration(10-i) * time.Hour), + LastModified: now.Add(-time.Duration(10-i) * time.Hour), + Severity: "LOW", + Payload: json.RawMessage(`{}`), + })) + } + + rows, err := repo.RecentByLastModified(ctx, 3) + require.NoError(t, err) + require.Len(t, rows, 3) + require.Equal(t, "CVE-C", rows[0].CveID) + require.Equal(t, "CVE-B", rows[1].CveID) + require.Equal(t, "CVE-A", rows[2].CveID) +} From f5e8cf55481f0249e468f669d396216b5ad3ca69 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:39:29 -0400 Subject: [PATCH 023/109] feat(monitor/collectors/cve): NVD CVE 2.0 client (apiKey header, last-modified window, NVDTime) --- .../internal/collectors/cve/nvd_client.go | 133 ++++++++++++++++++ .../collectors/cve/nvd_client_test.go | 78 ++++++++++ .../cve/testdata/nvd_2h_window.json | 1 + 3 files changed, 212 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/nvd_2h_window.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go new file mode 100644 index 00000000..8457d0cc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go @@ -0,0 +1,133 @@ +// ©AngelaMos | 2026 +// nvd_client.go + +package cve + +import ( + "context" + "fmt" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultNVDBaseURL = "https://services.nvd.nist.gov" + pathNVDCVE2 = "/rest/json/cves/2.0" + defaultNVDRate = 600 * time.Millisecond + defaultNVDBurst = 5 + defaultNVDBudget = 5 + defaultNVDBreakerWin = 120 * time.Second + nvdTimeFormat = "2006-01-02T15:04:05.000" + nvdAPIKeyHeader = "apiKey" +) + +type NVDClientConfig struct { + BaseURL string + APIKey string +} + +type NVDClient struct { + hx *httpx.Client +} + +func NewNVDClient(cfg NVDClientConfig) *NVDClient { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultNVDBaseURL + } + return &NVDClient{ + hx: httpx.New(httpx.Config{ + Name: "nvd", + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + APIKeyHeader: nvdAPIKeyHeader, + Rate: rate.Every(defaultNVDRate), + Burst: defaultNVDBurst, + ConsecutiveFailureBudget: defaultNVDBudget, + BreakerTimeout: defaultNVDBreakerWin, + }), + } +} + +type NVDResponse struct { + ResultsPerPage int `json:"resultsPerPage"` + StartIndex int `json:"startIndex"` + TotalResults int `json:"totalResults"` + Vulnerabilities []NVDVulnRoot `json:"vulnerabilities"` +} + +type NVDVulnRoot struct { + CVE NVDCVE `json:"cve"` +} + +type NVDCVE struct { + ID string `json:"id"` + Published NVDTime `json:"published"` + LastModified NVDTime `json:"lastModified"` + Metrics NVDMetrics `json:"metrics"` +} + +type NVDTime struct { + time.Time +} + +func (t *NVDTime) UnmarshalJSON(b []byte) error { + s := string(b) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + s = s[1 : len(s)-1] + } + if s == "" || s == "null" { + return nil + } + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.999", + "2006-01-02T15:04:05", + } { + if parsed, err := time.Parse(layout, s); err == nil { + t.Time = parsed.UTC() + return nil + } + } + return fmt.Errorf("nvd time: unrecognized format %q", s) +} + +type NVDMetrics struct { + CVSSv31 []NVDMetricEntry `json:"cvssMetricV31"` + CVSSv30 []NVDMetricEntry `json:"cvssMetricV30"` +} + +type NVDMetricEntry struct { + CVSSData NVDCVSSData `json:"cvssData"` +} + +type NVDCVSSData struct { + BaseScore float64 `json:"baseScore"` + BaseSeverity string `json:"baseSeverity"` +} + +func (c *NVDClient) Fetch(ctx context.Context, start, end time.Time) (NVDResponse, error) { + q := url.Values{ + "lastModStartDate": []string{start.UTC().Format(nvdTimeFormat)}, + "lastModEndDate": []string{end.UTC().Format(nvdTimeFormat)}, + } + var resp NVDResponse + if err := c.hx.GetJSON(ctx, pathNVDCVE2, q, &resp); err != nil { + return NVDResponse{}, fmt.Errorf("nvd fetch: %w", err) + } + return resp, nil +} + +func (v NVDVulnRoot) PrimarySeverity() (float64, string) { + if len(v.CVE.Metrics.CVSSv31) > 0 { + return v.CVE.Metrics.CVSSv31[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv31[0].CVSSData.BaseSeverity + } + if len(v.CVE.Metrics.CVSSv30) > 0 { + return v.CVE.Metrics.CVSSv30[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv30[0].CVSSData.BaseSeverity + } + return 0, "" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go new file mode 100644 index 00000000..7689e5a5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go @@ -0,0 +1,78 @@ +// ©AngelaMos | 2026 +// nvd_client_test.go + +package cve_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" +) + +func TestNVDClient_FetchSendsAPIKeyAndDecodes(t *testing.T) { + var sawKey, sawStart, sawEnd string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawKey = r.Header.Get("apiKey") + sawStart = r.URL.Query().Get("lastModStartDate") + sawEnd = r.URL.Query().Get("lastModEndDate") + body, err := os.ReadFile("testdata/nvd_2h_window.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := cve.NewNVDClient(cve.NVDClientConfig{BaseURL: srv.URL, APIKey: "test-key"}) + + end := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + start := end.Add(-2 * time.Hour) + + resp, err := c.Fetch(context.Background(), start, end) + require.NoError(t, err) + require.NotEmpty(t, resp.Vulnerabilities) + require.Equal(t, "test-key", sawKey) + require.NotEmpty(t, sawStart) + require.NotEmpty(t, sawEnd) +} + +func TestNVDClient_PrimarySeverityFromV31(t *testing.T) { + v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ + ID: "CVE-2026-X", + Metrics: cve.NVDMetrics{ + CVSSv31: []cve.NVDMetricEntry{ + {CVSSData: cve.NVDCVSSData{BaseScore: 9.8, BaseSeverity: "CRITICAL"}}, + }, + }, + }} + score, sev := v.PrimarySeverity() + require.InDelta(t, 9.8, score, 0.0001) + require.Equal(t, "CRITICAL", sev) +} + +func TestNVDClient_PrimarySeverityFallsBackToV30(t *testing.T) { + v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ + ID: "CVE-2018-X", + Metrics: cve.NVDMetrics{ + CVSSv30: []cve.NVDMetricEntry{ + {CVSSData: cve.NVDCVSSData{BaseScore: 7.5, BaseSeverity: "HIGH"}}, + }, + }, + }} + score, sev := v.PrimarySeverity() + require.InDelta(t, 7.5, score, 0.0001) + require.Equal(t, "HIGH", sev) +} + +func TestNVDClient_PrimarySeverityZeroWhenMissing(t *testing.T) { + v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ID: "CVE-X"}} + score, sev := v.PrimarySeverity() + require.Zero(t, score) + require.Empty(t, sev) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/nvd_2h_window.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/nvd_2h_window.json new file mode 100644 index 00000000..965be798 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/nvd_2h_window.json @@ -0,0 +1 @@ +{"resultsPerPage":9,"startIndex":0,"totalResults":9,"format":"NVD_CVE","version":"2.0","timestamp":"2026-05-02T02:39:09.642","vulnerabilities":[{"cve":{"id":"CVE-2025-54236","sourceIdentifier":"psirt@adobe.com","published":"2025-09-09T14:15:46.563","lastModified":"2026-04-22T19:00:02.080","vulnStatus":"Analyzed","cveTags":[],"descriptions":[{"lang":"en","value":"Adobe Commerce versions 2.4.9-alpha2, 2.4.8-p2, 2.4.7-p7, 2.4.6-p12, 2.4.5-p14, 2.4.4-p15 and earlier are affected by an Improper Input Validation vulnerability. A successful attacker can abuse this to achieve session takeover, increasing the confidentiality, and integrity impact to high. Exploitation of this issue does not require user interaction."}],"metrics":{"cvssMetricV31":[{"source":"psirt@adobe.com","type":"Secondary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N","baseScore":9.1,"baseSeverity":"CRITICAL","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"NONE","userInteraction":"NONE","scope":"UNCHANGED","confidentialityImpact":"HIGH","integrityImpact":"HIGH","availabilityImpact":"NONE"},"exploitabilityScore":3.9,"impactScore":5.2}]},"cisaExploitAdd":"2025-10-24","cisaActionDue":"2025-11-14","cisaRequiredAction":"Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.","cisaVulnerabilityName":"Adobe Commerce and?Magento Improper Input Validation Vulnerability","weaknesses":[{"source":"psirt@adobe.com","type":"Secondary","description":[{"lang":"en","value":"CWE-20"}]}],"configurations":[{"nodes":[{"operator":"OR","negate":false,"cpeMatch":[{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:-:*:*:*:*:*:*","matchCriteriaId":"D258D9EF-94FB-41F0-A7A5-7F66FA7A0055"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p1:*:*:*:*:*:*","matchCriteriaId":"4E5CF6F0-2388-4D3F-8FE1-43B8AF148564"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p10:*:*:*:*:*:*","matchCriteriaId":"1C3D7164-1C5F-40BC-9EEC-B0E00CD45808"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p11:*:*:*:*:*:*","matchCriteriaId":"68AAE162-5957-42AF-BE20-40F341837FAC"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p12:*:*:*:*:*:*","matchCriteriaId":"D9D01159-3309-4F6B-93B0-2D89DDD33DEE"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p13:*:*:*:*:*:*","matchCriteriaId":"91736E79-D8E7-4AF2-8E01-A7B4EB8AD6F4"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p14:*:*:*:*:*:*","matchCriteriaId":"8412C043-64E7-4DFF-A303-13A6FE113BFB"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p15:*:*:*:*:*:*","matchCriteriaId":"BBDA2BCF-E784-4CF3-B30D-6FF5BEE2055F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p2:*:*:*:*:*:*","matchCriteriaId":"D6D6F1A7-ABB5-4EDC-9EA8-98B74518847A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p3:*:*:*:*:*:*","matchCriteriaId":"CFEBDDF2-6443-4482-83B2-3CD272CF599F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p4:*:*:*:*:*:*","matchCriteriaId":"6661093F-8D22-450F-BC6C-A8894A52E6A9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p5:*:*:*:*:*:*","matchCriteriaId":"2515DA6D-2E74-4A05-BD29-FEEF3322BCB6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p6:*:*:*:*:*:*","matchCriteriaId":"69A1F1F7-E53C-40F3-B3D9-DC011FC353BF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p7:*:*:*:*:*:*","matchCriteriaId":"6A56E96C-6CE5-442C-AA88-F0059B02B5E7"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p8:*:*:*:*:*:*","matchCriteriaId":"8867F510-201C-4199-8554-53DE156CE669"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.4:p9:*:*:*:*:*:*","matchCriteriaId":"23988132-DD4E-4968-B6B8-954122F76081"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:-:*:*:*:*:*:*","matchCriteriaId":"9B07F7B2-E915-4EFF-8FFC-91143CEF082E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p1:*:*:*:*:*:*","matchCriteriaId":"7F5E9DB6-1386-4274-8270-2FE0F0CAF7FD"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p10:*:*:*:*:*:*","matchCriteriaId":"5764CC97-C866-415D-A3A1-5B5B9E1C06A6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p11:*:*:*:*:*:*","matchCriteriaId":"E82D10D8-2894-4E5B-B47B-F00964DD5CDE"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p12:*:*:*:*:*:*","matchCriteriaId":"B044F2D9-E888-4852-8A40-DCE688860ED3"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p13:*:*:*:*:*:*","matchCriteriaId":"6423C754-36F9-4680-9211-60940ED63E79"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p14:*:*:*:*:*:*","matchCriteriaId":"3472064A-8C79-436B-965A-96834AE8D346"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p2:*:*:*:*:*:*","matchCriteriaId":"8605E4E6-0F7D-42C8-B35B-2349A0BEFC69"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p3:*:*:*:*:*:*","matchCriteriaId":"B6318F97-E59A-4425-8DC7-045C78A644F8"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p4:*:*:*:*:*:*","matchCriteriaId":"324A573E-DBC8-42A0-8CB8-EDD8FBAB7115"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p5:*:*:*:*:*:*","matchCriteriaId":"54151A00-CFB8-4E6A-8E74-497CB67BF7E2"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p6:*:*:*:*:*:*","matchCriteriaId":"6DF0E74D-9293-4209-97D1-A3BA13C3DDE9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p7:*:*:*:*:*:*","matchCriteriaId":"8922D646-1A97-47ED-91C6-5A426781C98A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p8:*:*:*:*:*:*","matchCriteriaId":"952787C6-9BF1-49FB-9824-1236678E1902"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.5:p9:*:*:*:*:*:*","matchCriteriaId":"898A8679-3C46-4718-9EDF-583ADDFCF2EC"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:-:*:*:*:*:*:*","matchCriteriaId":"7C7AFBB1-F9C9-4BDE-BCEF-94C9F0AC6798"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p1:*:*:*:*:*:*","matchCriteriaId":"D6086841-C175-46A1-8414-71C6163A0E7A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p10:*:*:*:*:*:*","matchCriteriaId":"E57889CC-3E90-46AF-9CD6-3328DD501AD1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p11:*:*:*:*:*:*","matchCriteriaId":"47A86566-DE38-4032-947D-B6181F0BC120"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p12:*:*:*:*:*:*","matchCriteriaId":"B7D1D684-CE7E-4D6D-95B5-1F86A8DB6C66"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p2:*:*:*:*:*:*","matchCriteriaId":"D2E0DDD1-0F4A-4F96-B25D-40A39A1A535A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p3:*:*:*:*:*:*","matchCriteriaId":"A576B1B5-73A2-431E-998F-7E5458B51D6A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p4:*:*:*:*:*:*","matchCriteriaId":"0E05F4AC-2A28-47E3-96DE-0E31AF73CD43"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p5:*:*:*:*:*:*","matchCriteriaId":"3A9A62EE-1649-4815-8EC9-7AEF7949EB2F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p6:*:*:*:*:*:*","matchCriteriaId":"E58690F9-FA9C-42A0-B4CD-91FD1197A53E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p7:*:*:*:*:*:*","matchCriteriaId":"77D01D8B-1FBC-43CA-90F9-C89D9B4D18F0"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p8:*:*:*:*:*:*","matchCriteriaId":"8B83729E-80AF-47CE-A70C-32BF83024A40"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.6:p9:*:*:*:*:*:*","matchCriteriaId":"73D22D42-646D-4955-A6F9-9B7BA63DC0A9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:-:*:*:*:*:*:*","matchCriteriaId":"B5D04853-0C2F-47DD-A939-3A8F6E22CB7D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:b1:*:*:*:*:*:*","matchCriteriaId":"6EBB0608-034B-4F07-A59B-9E6A989BA260"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:b2:*:*:*:*:*:*","matchCriteriaId":"B3BF9B08-84E3-4974-9DEB-F4285995D796"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:beta3:*:*:*:*:*:*","matchCriteriaId":"7771BEDB-05E2-430E-B2A2-E2F7574B7114"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p1:*:*:*:*:*:*","matchCriteriaId":"2E05341A-C70C-4B3D-AF30-9520D6B97D30"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p2:*:*:*:*:*:*","matchCriteriaId":"4D98B52E-3B59-4327-AC7E-DDBB0ADA08F6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p3:*:*:*:*:*:*","matchCriteriaId":"95026AA9-A28B-4D94-BD77-7628429EBA30"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p4:*:*:*:*:*:*","matchCriteriaId":"83FD1220-7D46-42B2-8110-30A934144572"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p5:*:*:*:*:*:*","matchCriteriaId":"3F1439CE-8A3B-414A-B974-559209FF480C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p6:*:*:*:*:*:*","matchCriteriaId":"13726DEE-FFCB-447B-9FFF-136F132F2C4C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.7:p7:*:*:*:*:*:*","matchCriteriaId":"1A9443CE-AE1F-4D66-9C88-5E2E3FD28EE6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:-:*:*:*:*:*:*","matchCriteriaId":"1EE12F4B-5607-4790-A29B-EE23383BCC1A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:beta1:*:*:*:*:*:*","matchCriteriaId":"6D05A958-9749-486A-A149-C21647CDCADF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:beta2:*:*:*:*:*:*","matchCriteriaId":"C9E12B43-AD3E-48A2-9042-5586186CA3BE"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:p1:*:*:*:*:*:*","matchCriteriaId":"C267AF14-7BA8-4D1F-BCD9-BE3ED0DA3D25"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.8:p2:*:*:*:*:*:*","matchCriteriaId":"B4947C63-CFD9-437B-A09E-A197DCE40095"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.9:alpha1:*:*:*:*:*:*","matchCriteriaId":"37F32F70-7B2A-4BAB-B3F0-AFF5C04CCDED"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce:2.4.9:alpha2:*:*:*:*:*:*","matchCriteriaId":"8B6B7609-6A8E-4154-BC05-2A9099909684"}]}]},{"nodes":[{"operator":"OR","negate":false,"cpeMatch":[{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:-:*:*:*:*:*:*","matchCriteriaId":"C4667AA3-4CC9-41C0-8E0C-19B0FCE1CF79"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p1:*:*:*:*:*:*","matchCriteriaId":"E396FB4F-B20A-4BF9-8FBD-014A0F197F08"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p10:*:*:*:*:*:*","matchCriteriaId":"2ADE32D1-2845-4030-BE1F-ECE28189D0F9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p11:*:*:*:*:*:*","matchCriteriaId":"F2E771C9-86C4-455C-98D4-6F4FE7A9A822"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p12:*:*:*:*:*:*","matchCriteriaId":"491AB715-F62A-46DB-A56E-055CF7CB7BEF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p13:*:*:*:*:*:*","matchCriteriaId":"6FE364A8-4780-426F-9E8A-284A31FE2623"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p14:*:*:*:*:*:*","matchCriteriaId":"F9258027-8A6A-4C6A-BC6F-349B6E03D828"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p15:*:*:*:*:*:*","matchCriteriaId":"934C52C7-8751-481E-BAA7-F631C4E31F32"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p2:*:*:*:*:*:*","matchCriteriaId":"5677B7E2-FA07-4536-96A9-2C64BEFD3751"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p3:*:*:*:*:*:*","matchCriteriaId":"2DCD1522-6E27-474F-9FC6-413409D6AD55"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p4:*:*:*:*:*:*","matchCriteriaId":"B7968FCA-CCFD-4222-8FB8-E6E21107944F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p5:*:*:*:*:*:*","matchCriteriaId":"8C175A1F-7814-4C51-A7B7-AD5140F0688F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p6:*:*:*:*:*:*","matchCriteriaId":"E66CBFB3-40C3-474A-A3A3-12135F610814"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p7:*:*:*:*:*:*","matchCriteriaId":"F51DFA17-1875-41A9-B141-D89BB6238B3F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p8:*:*:*:*:*:*","matchCriteriaId":"5A4D10EF-9137-4DF5-A5DD-97907E8B4C02"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.3:p9:*:*:*:*:*:*","matchCriteriaId":"5CD0DC76-7181-4954-A59E-AB7BB47D0576"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:-:*:*:*:*:*:*","matchCriteriaId":"1C90C433-6655-4038-9AB3-0304C1AFF360"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p1:*:*:*:*:*:*","matchCriteriaId":"374E7EDD-512A-4633-A136-01A656935334"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p10:*:*:*:*:*:*","matchCriteriaId":"89BAB227-03E6-4776-ADE4-9D9CB666EFD9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p11:*:*:*:*:*:*","matchCriteriaId":"0E5ACABA-D6D6-4F29-A9DD-5A04A44ABE64"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p12:*:*:*:*:*:*","matchCriteriaId":"FA80AFCE-2663-46C0-AEC0-C16C8E675E6A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p13:*:*:*:*:*:*","matchCriteriaId":"EB9955CA-7E7B-40D3-A85D-58BB0D9AC897"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p14:*:*:*:*:*:*","matchCriteriaId":"5D0A17AC-D433-47C2-A1AC-88291DCCECCD"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p2:*:*:*:*:*:*","matchCriteriaId":"0E9D364A-C858-4160-8B8B-33ECF94796D9"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p3:*:*:*:*:*:*","matchCriteriaId":"61559E50-581E-40FF-9FD4-10192ECFCD04"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p4:*:*:*:*:*:*","matchCriteriaId":"DE3BFB41-5633-4167-B1EA-9E958BCE9DC2"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p5:*:*:*:*:*:*","matchCriteriaId":"F2C525D2-837D-486A-8B38-5634AE2ECE2B"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p6:*:*:*:*:*:*","matchCriteriaId":"6F220229-F2DF-4C9D-90A6-8B09F8BE3391"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p7:*:*:*:*:*:*","matchCriteriaId":"63AB9506-3F8E-4C2E-A859-2380431C15A6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p8:*:*:*:*:*:*","matchCriteriaId":"51B76658-EA6B-4AC9-9D9C-374C5308D069"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.3.4:p9:*:*:*:*:*:*","matchCriteriaId":"6E94B136-7A2C-47F0-BCE4-6BB8E776A305"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:-:*:*:*:*:*:*","matchCriteriaId":"15C638A8-EFE0-47DB-B1F9-34093AF0FC17"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p1:*:*:*:*:*:*","matchCriteriaId":"CB863404-A9D7-4692-AB43-08945E669928"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p2:*:*:*:*:*:*","matchCriteriaId":"D8CFA8F4-D57D-4D0F-88D5-00A72E3AD8DA"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p3:*:*:*:*:*:*","matchCriteriaId":"A21F608C-C356-47B8-8FBB-DB28BABFC4C6"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p4:*:*:*:*:*:*","matchCriteriaId":"E14195F1-5016-46BE-A614-6FB4E312FC93"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p5:*:*:*:*:*:*","matchCriteriaId":"9C360EA8-B18F-4327-90EF-7EED2892BE4F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p6:*:*:*:*:*:*","matchCriteriaId":"500E3A54-D7C7-4887-9EA6-7DF85389A831"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.4.2:p7:*:*:*:*:*:*","matchCriteriaId":"ED6FFC1D-E921-4FF7-9928-015630613FE1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.2:-:*:*:*:*:*:*","matchCriteriaId":"D855D141-7876-4F5A-91BE-6350DD379879"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.2:p1:*:*:*:*:*:*","matchCriteriaId":"79CBDF59-EB84-44D3-81CF-5CBF943B411E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.2:p2:*:*:*:*:*:*","matchCriteriaId":"2117B163-D88E-4EB4-AEA7-F27FB732BD48"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.3:alpha1:*:*:*:*:*:*","matchCriteriaId":"508EE0EF-D54A-4834-84AB-FFC62040FDAB"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:commerce_b2b:1.5.3:alpha2:*:*:*:*:*:*","matchCriteriaId":"4D7C6592-33B0-4586-8178-E8F4EB837B7F"}]}]},{"nodes":[{"operator":"OR","negate":false,"cpeMatch":[{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:-:*:*:open_source:*:*:*","matchCriteriaId":"7A41C717-4B9F-4972-ABA3-2294EEC20F3E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p1:*:*:open_source:*:*:*","matchCriteriaId":"3FA80BBC-2DF2-46E1-84CE-8A899415114E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p10:*:*:open_source:*:*:*","matchCriteriaId":"783E4AF1-52F3-446B-B003-8079EDA78CBF"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p11:*:*:open_source:*:*:*","matchCriteriaId":"08B7898F-E25A-4D16-A007-6D4543E80C58"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p12:*:*:open_source:*:*:*","matchCriteriaId":"313CB0C1-2E8C-46AC-B72B-AFA9E0A6E064"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p13:*:*:open_source:*:*:*","matchCriteriaId":"E99C1F27-68C9-481F-B01D-8B58B0AFB437"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p14:*:*:open_source:*:*:*","matchCriteriaId":"D4A3F4C7-8784-43BD-A11B-E66872DD8812"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p2:*:*:open_source:*:*:*","matchCriteriaId":"510B1840-AE77-4BDD-9C09-26C64CC8FC81"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p3:*:*:open_source:*:*:*","matchCriteriaId":"FA1EDF58-8384-48C4-A584-54D24F6F7973"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p4:*:*:open_source:*:*:*","matchCriteriaId":"9D2D9715-3A6B-4BE0-B1C5-8D19A683A083"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p5:*:*:open_source:*:*:*","matchCriteriaId":"1C99B578-5DD6-476D-BB75-4DCAD7F79535"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p6:*:*:open_source:*:*:*","matchCriteriaId":"7C1B2897-79A5-4A5B-9137-7A4B6B85AA84"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p7:*:*:open_source:*:*:*","matchCriteriaId":"B9E8299D-FA97-483A-8E1B-BA7B869E467D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p8:*:*:open_source:*:*:*","matchCriteriaId":"9A1B92EC-E83A-43B3-8F14-5C1A52B579B1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.5:p9:*:*:open_source:*:*:*","matchCriteriaId":"E5F2B6F1-AE8F-4AEE-9AB3-080976AE48B7"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:-:*:*:open_source:*:*:*","matchCriteriaId":"789BD987-9DAD-4EAE-93DE-0E267D54F124"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p1:*:*:open_source:*:*:*","matchCriteriaId":"A3F113C0-00C5-4BC2-B42B-8AE3756252F2"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p10:*:*:open_source:*:*:*","matchCriteriaId":"AE842CC8-7795-4238-B727-0BA2FFFBF62C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p11:*:*:open_source:*:*:*","matchCriteriaId":"AE724531-422D-4ABB-98F5-2C0B1BBEF031"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p12:*:*:open_source:*:*:*","matchCriteriaId":"BB499397-0E40-45B0-A7E9-BEFCC909DD07"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p2:*:*:open_source:*:*:*","matchCriteriaId":"02592D65-2D2C-460A-A970-8A18F9B156ED"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p3:*:*:open_source:*:*:*","matchCriteriaId":"457B89CF-C75E-4ED6-8603-9C52BA462A9E"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p4:*:*:open_source:*:*:*","matchCriteriaId":"A572A2DC-2DAB-4ABE-8FC2-5AF2340C826F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p5:*:*:open_source:*:*:*","matchCriteriaId":"2A2DD9C6-BAF5-4DF5-9C14-3478923B2019"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p6:*:*:open_source:*:*:*","matchCriteriaId":"BA9CFC70-24CF-4DFA-AEF9-9B5A9DAF837D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p7:*:*:open_source:*:*:*","matchCriteriaId":"2AA0B806-ABB8-4C18-9F9C-8291BE208F52"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p8:*:*:open_source:*:*:*","matchCriteriaId":"AA9D4DAB-7567-48D7-BE60-2A10B35CFF27"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.6:p9:*:*:open_source:*:*:*","matchCriteriaId":"A91E797D-63F6-4DE8-869C-AF0133DC6C03"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:-:*:*:open_source:*:*:*","matchCriteriaId":"0E06FE04-8844-4409-92D9-4972B47C921B"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:b1:*:*:open_source:*:*:*","matchCriteriaId":"99C620F3-40ED-4D7F-B6A1-205E948FD6F5"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:b2:*:*:open_source:*:*:*","matchCriteriaId":"FBCFE5FB-FAB7-4BF0-90AE-79F9590FD872"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:beta3:*:*:open_source:*:*:*","matchCriteriaId":"7EB4B9C5-513C-4039-8087-5E8880894318"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p1:*:*:open_source:*:*:*","matchCriteriaId":"9C77154A-DBFE-48C3-A274-03075A0DB040"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p2:*:*:open_source:*:*:*","matchCriteriaId":"F5AAC414-623C-444F-9BD5-EE0ACE2B2246"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p3:*:*:open_source:*:*:*","matchCriteriaId":"8292888D-B0B0-4DF3-8719-EA4CDCAB39D1"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p4:*:*:open_source:*:*:*","matchCriteriaId":"9830E074-FDCF-41E9-98C7-10C20424EF4C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p5:*:*:open_source:*:*:*","matchCriteriaId":"9D0C8648-B39E-47C7-AA5C-3AFED22F8D40"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p6:*:*:open_source:*:*:*","matchCriteriaId":"082F8B60-ECC5-4C55-BBFE-A0C8A3E95590"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.7:p7:*:*:open_source:*:*:*","matchCriteriaId":"A7B83AD4-3134-414A-80E3-106C3C0F975A"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:-:*:*:open_source:*:*:*","matchCriteriaId":"00E8284F-10CD-449C-AEF1-688B8287292F"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:beta1:*:*:open_source:*:*:*","matchCriteriaId":"59C10C74-FDB1-46EC-8F41-F3AC24AEFB7D"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:beta2:*:*:open_source:*:*:*","matchCriteriaId":"2957B390-52C5-48D7-A6D7-709BC76B9C69"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:p1:*:*:open_source:*:*:*","matchCriteriaId":"524F64B6-F7F7-4926-884F-E9448636007C"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.8:p2:*:*:open_source:*:*:*","matchCriteriaId":"9F56F919-69B6-4A77-B8CE-F13409542F14"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.9:alpha1:*:*:open_source:*:*:*","matchCriteriaId":"E34849F7-54EE-4E4C-9184-3DE9C30E12AA"},{"vulnerable":true,"criteria":"cpe:2.3:a:adobe:magento:2.4.9:alpha2:*:*:open_source:*:*:*","matchCriteriaId":"4E21DFF0-9F15-44D2-B78A-097BF3ACD752"}]}]}],"references":[{"url":"https://helpx.adobe.com/security/products/magento/apsb25-88.html","source":"psirt@adobe.com","tags":["Vendor Advisory"]},{"url":"https://experienceleague.adobe.com/en/docs/experience-cloud-kcs/kbarticles/ka-27397","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","tags":["Vendor Advisory"]},{"url":"https://nullsecurityx.codes/cve-2025-54236-sessionreaper-unauthenticated-rce-in-magento","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","tags":["Broken Link","Exploit","Third Party Advisory"]},{"url":"https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2025-54236","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0","tags":["US Government Resource"]}]}},{"cve":{"id":"CVE-2026-39804","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:16.853","lastModified":"2026-05-02T02:16:00.013","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated remote denial of service via memory exhaustion when WebSocket permessage-deflate compression is enabled.\n\n'Elixir.Bandit.WebSocket.PerMessageDeflate':inflate/2 in lib/bandit/websocket/permessage_deflate.ex calls :zlib.inflate/2 with no output-size cap, then materializes the entire decompressed payload as a single binary via IO.iodata_to_binary/1. The websocket_options.max_frame_size option only bounds the on-the-wire (compressed) frame size, not the decompressed output. A high-ratio compressed frame (e.g. uniform data at ~1024:1 ratio) can stay well under any wire-size limit while forcing GiB-scale heap allocations in the connection process before any application code runs.\n\nAn unauthenticated attacker who can open a WebSocket connection can send a single such frame to exhaust the BEAM node's memory and trigger an OOM kill.\n\nThis vulnerability requires both Bandit's server-level websocket_options.compress and the per-upgrade compress: true option passed to WebSockAdapter.upgrade/4 to be enabled. Stock Phoenix and LiveView applications are not affected as they default to compress: false.\n\nThis issue affects bandit: from 0.5.9 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":8.2,"baseSeverity":"HIGH","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"PRESENT","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"NONE","vulnAvailabilityImpact":"HIGH","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-770"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-39804.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/8156921a51e684a951221da7bc30a70a022f722e","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-frh3-6pv6-rc8j","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-39804","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-frh3-6pv6-rc8j","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-39805","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.037","lastModified":"2026-05-02T02:16:00.180","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Inconsistent Interpretation of HTTP Requests vulnerability in mtrudel bandit allows HTTP request smuggling via duplicate Content-Length headers.\n\n'Elixir.Bandit.Headers':get_content_length/1 in lib/bandit/headers.ex uses List.keyfind/3, which returns only the first matching header. When a request contains two Content-Length headers with different values, Bandit silently accepts it, uses the first value to read the body, and dispatches the remaining bytes as a second pipelined request on the same keep-alive connection. RFC 9112 §6.3 requires recipients to treat this as an unrecoverable framing error.\n\nWhen Bandit sits behind a proxy that picks the last Content-Length value and forwards the request rather than rejecting it, an unauthenticated attacker can smuggle requests past edge WAF rules, path-based ACLs, rate limiting, and audit logging.\n\nThis issue affects bandit: before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":6.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"PRESENT","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"LOW","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"NONE","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-444"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-39805.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/f2ca636eb6df385219957e8934e9fc6efa1630d1","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-c67r-gc9j-2qf7","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-39805","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-c67r-gc9j-2qf7","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-39807","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.180","lastModified":"2026-05-02T02:16:00.320","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Reliance on Untrusted Inputs in a Security Decision vulnerability in mtrudel bandit allows unauthenticated transport-state spoofing on plaintext HTTP connections.\n\n'Elixir.Bandit.Pipeline':determine_scheme/2 in lib/bandit/pipeline.ex returns the client-supplied URI scheme verbatim, ignoring the transport's secure? flag. HTTP/1.1 absolute-form request targets (e.g. GET https://victim/path HTTP/1.1) and the HTTP/2 :scheme pseudo-header are both attacker-controlled strings that flow through this function. Over a plaintext TCP connection, a client can declare https and Bandit will set conn.scheme = :https even though no TLS was negotiated.\n\nDownstream Plug consumers that branch on conn.scheme are silently misled: Plug.SSL's already-secure branch skips its HTTP→HTTPS redirect, cookies emitted with secure: true are sent over plaintext, audit logs record requests as having arrived over HTTPS, and CSRF/SameSite gating may make incorrect decisions.\n\nThis issue affects bandit: from 1.0.0 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":6.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"PRESENT","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"LOW","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"NONE","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-807"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-39807.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/45feea20dea8af7ffd7245271107b695c040e667","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-375f-4r2h-f99j","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-39807","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-375f-4r2h-f99j","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-42786","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.347","lastModified":"2026-05-02T02:16:00.467","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated remote denial of service via memory exhaustion.\n\nThe fragment reassembly path in 'Elixir.Bandit.WebSocket.Connection':handle_frame/3 in lib/bandit/websocket/connection.ex appends every incoming Continuation{fin: false} frame's payload to a per-connection iolist with no cumulative size cap. The existing max_frame_size option only bounds individual frames; a peer that streams an unbounded number of continuation frames without ever setting fin=1 grows BEAM heap linearly until the OS or a supervisor kills the process.\n\nBecause the accumulation happens before WebSock.handle_in/2 is called, the application has no opportunity to interpose a size check. Phoenix Channels and LiveView both run over WebSock on Bandit, so a stock Phoenix application exposes this surface as soon as it accepts socket connections.\n\nThis issue affects bandit: from 0.5.0 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":8.7,"baseSeverity":"HIGH","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"NONE","vulnAvailabilityImpact":"HIGH","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-770"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-42786.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/21612c7c7b1ce43eccd36d3af3a2299d23513667","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-pf94-94m9-536p","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-42786","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-pf94-94m9-536p","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-42788","sourceIdentifier":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","published":"2026-05-01T21:16:17.500","lastModified":"2026-05-02T02:16:00.610","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated memory exhaustion via oversized HTTP/2 frames.\n\n'Elixir.Bandit.HTTP2.Frame':deserialize/2 in lib/bandit/http2/frame.ex checks the SETTINGS_MAX_FRAME_SIZE limit only after pattern-matching payload::binary-size(length), which requires the entire frame body to be present in memory before either the accept or reject clause can fire. A peer that announces a frame length up to the 24-bit maximum (~16 MiB) causes the server to buffer that entire body before the size guard is evaluated, regardless of the max_frame_size negotiated during the HTTP/2 handshake (default 16 KiB per RFC 9113).\n\nAn unauthenticated attacker holding many concurrent connections can force the server to buffer far more memory than the negotiated frame size limit should permit, leading to memory pressure and potential denial of service.\n\nThis issue affects bandit: from 0.3.6 before 1.11.0."}],"metrics":{"cvssMetricV40":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":6.9,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"NONE","userInteraction":"NONE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"NONE","vulnAvailabilityImpact":"LOW","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"NOT_DEFINED","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}]},"weaknesses":[{"source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db","type":"Secondary","description":[{"lang":"en","value":"CWE-770"}]}],"references":[{"url":"https://cna.erlef.org/cves/CVE-2026-42788.html","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/commit/1e8e55966da9129016b73d32f0e1df4630e3b463","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-q6v9-r226-v65f","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-42788","source":"6b3ad84c-e1a6-4bf7-a703-f496b71e49db"},{"url":"https://github.com/mtrudel/bandit/security/advisories/GHSA-q6v9-r226-v65f","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-7596","sourceIdentifier":"cna@vuldb.com","published":"2026-05-01T21:16:18.300","lastModified":"2026-05-02T02:16:00.947","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"A vulnerability has been found in nextlevelbuilder ui-ux-pro-max-skill up to 2.5.0. Affected by this issue is the function data.get of the file .claude/skills/design-system/scripts/generate-slide.py of the component Slide Generator. Such manipulation leads to cross site scripting. The attack may be performed from remote. The exploit has been disclosed to the public and may be used. The project was informed of the problem early through a pull request but has not reacted yet."}],"metrics":{"cvssMetricV40":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":2.1,"baseSeverity":"LOW","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"NONE","userInteraction":"PASSIVE","vulnConfidentialityImpact":"NONE","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"NONE","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"PROOF_OF_CONCEPT","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}],"cvssMetricV31":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N","baseScore":4.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"NONE","userInteraction":"REQUIRED","scope":"UNCHANGED","confidentialityImpact":"NONE","integrityImpact":"LOW","availabilityImpact":"NONE"},"exploitabilityScore":2.8,"impactScore":1.4}],"cvssMetricV2":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"2.0","vectorString":"AV:N/AC:L/Au:N/C:N/I:P/A:N","baseScore":5.0,"accessVector":"NETWORK","accessComplexity":"LOW","authentication":"NONE","confidentialityImpact":"NONE","integrityImpact":"PARTIAL","availabilityImpact":"NONE"},"baseSeverity":"MEDIUM","exploitabilityScore":10.0,"impactScore":2.9,"acInsufInfo":false,"obtainAllPrivilege":false,"obtainUserPrivilege":false,"obtainOtherPrivilege":false,"userInteractionRequired":false}]},"weaknesses":[{"source":"cna@vuldb.com","type":"Secondary","description":[{"lang":"en","value":"CWE-79"},{"lang":"en","value":"CWE-94"}]}],"references":[{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/","source":"cna@vuldb.com"},{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/issues/247","source":"cna@vuldb.com"},{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/pull/274","source":"cna@vuldb.com"},{"url":"https://vuldb.com/submit/805510","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360549","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360549/cti","source":"cna@vuldb.com"},{"url":"https://github.com/nextlevelbuilder/ui-ux-pro-max-skill/issues/247","source":"134c704f-9b21-4f2e-91b3-4a467353bcc0"}]}},{"cve":{"id":"CVE-2026-7600","sourceIdentifier":"cna@vuldb.com","published":"2026-05-02T01:16:00.903","lastModified":"2026-05-02T01:16:00.903","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"A flaw has been found in ArtMin96 yii2-mcp-server 1.0.2. This impacts the function yii_command_help/yii_execute_command of the file src/index.ts of the component MCP Interface. Executing a manipulation can lead to os command injection. The attack can be executed remotely. The exploit has been published and may be used. The project was informed of the problem early through an issue report but has not responded yet."}],"metrics":{"cvssMetricV40":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"4.0","vectorString":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","baseScore":2.1,"baseSeverity":"LOW","attackVector":"NETWORK","attackComplexity":"LOW","attackRequirements":"NONE","privilegesRequired":"LOW","userInteraction":"NONE","vulnConfidentialityImpact":"LOW","vulnIntegrityImpact":"LOW","vulnAvailabilityImpact":"LOW","subConfidentialityImpact":"NONE","subIntegrityImpact":"NONE","subAvailabilityImpact":"NONE","exploitMaturity":"PROOF_OF_CONCEPT","confidentialityRequirement":"NOT_DEFINED","integrityRequirement":"NOT_DEFINED","availabilityRequirement":"NOT_DEFINED","modifiedAttackVector":"NOT_DEFINED","modifiedAttackComplexity":"NOT_DEFINED","modifiedAttackRequirements":"NOT_DEFINED","modifiedPrivilegesRequired":"NOT_DEFINED","modifiedUserInteraction":"NOT_DEFINED","modifiedVulnConfidentialityImpact":"NOT_DEFINED","modifiedVulnIntegrityImpact":"NOT_DEFINED","modifiedVulnAvailabilityImpact":"NOT_DEFINED","modifiedSubConfidentialityImpact":"NOT_DEFINED","modifiedSubIntegrityImpact":"NOT_DEFINED","modifiedSubAvailabilityImpact":"NOT_DEFINED","Safety":"NOT_DEFINED","Automatable":"NOT_DEFINED","Recovery":"NOT_DEFINED","valueDensity":"NOT_DEFINED","vulnerabilityResponseEffort":"NOT_DEFINED","providerUrgency":"NOT_DEFINED"}}],"cvssMetricV31":[{"source":"cna@vuldb.com","type":"Primary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L","baseScore":6.3,"baseSeverity":"MEDIUM","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"LOW","userInteraction":"NONE","scope":"UNCHANGED","confidentialityImpact":"LOW","integrityImpact":"LOW","availabilityImpact":"LOW"},"exploitabilityScore":2.8,"impactScore":3.4}],"cvssMetricV2":[{"source":"cna@vuldb.com","type":"Secondary","cvssData":{"version":"2.0","vectorString":"AV:N/AC:L/Au:S/C:P/I:P/A:P","baseScore":6.5,"accessVector":"NETWORK","accessComplexity":"LOW","authentication":"SINGLE","confidentialityImpact":"PARTIAL","integrityImpact":"PARTIAL","availabilityImpact":"PARTIAL"},"baseSeverity":"MEDIUM","exploitabilityScore":8.0,"impactScore":6.4,"acInsufInfo":false,"obtainAllPrivilege":false,"obtainUserPrivilege":false,"obtainOtherPrivilege":false,"userInteractionRequired":false}]},"weaknesses":[{"source":"cna@vuldb.com","type":"Primary","description":[{"lang":"en","value":"CWE-77"},{"lang":"en","value":"CWE-78"}]}],"references":[{"url":"https://github.com/ArtMin96/yii2-mcp-server/","source":"cna@vuldb.com"},{"url":"https://github.com/ArtMin96/yii2-mcp-server/issues/3","source":"cna@vuldb.com"},{"url":"https://github.com/BruceJqs/public_exp/issues/29","source":"cna@vuldb.com"},{"url":"https://vuldb.com/submit/805613","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360557","source":"cna@vuldb.com"},{"url":"https://vuldb.com/vuln/360557/cti","source":"cna@vuldb.com"}]}},{"cve":{"id":"CVE-2026-43824","sourceIdentifier":"cve@mitre.org","published":"2026-05-02T02:16:00.747","lastModified":"2026-05-02T02:16:00.747","vulnStatus":"Received","cveTags":[],"descriptions":[{"lang":"en","value":"In Argo CD 3.2.0 before 3.2.11 and 3.3.0 before 3.3.9, ServerSideDiff allows reading cleartext Kubernetes Secret data."}],"metrics":{"cvssMetricV31":[{"source":"cve@mitre.org","type":"Secondary","cvssData":{"version":"3.1","vectorString":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","baseScore":7.7,"baseSeverity":"HIGH","attackVector":"NETWORK","attackComplexity":"LOW","privilegesRequired":"LOW","userInteraction":"NONE","scope":"CHANGED","confidentialityImpact":"HIGH","integrityImpact":"NONE","availabilityImpact":"NONE"},"exploitabilityScore":3.1,"impactScore":4.0}]},"weaknesses":[{"source":"cve@mitre.org","type":"Primary","description":[{"lang":"en","value":"CWE-212"}]}],"references":[{"url":"https://github.com/argoproj/argo-cd/security/advisories/GHSA-3v3m-wc6v-x4x3","source":"cve@mitre.org"}]}}]} From 35da1f45a69f61edb45fe43a03104f28032930fe Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:40:15 -0400 Subject: [PATCH 024/109] feat(monitor/collectors/cve): FIRST EPSS client with 100-id batching --- .../internal/collectors/cve/epss_client.go | 86 +++++++++++++++++++ .../collectors/cve/epss_client_test.go | 64 ++++++++++++++ .../collectors/cve/testdata/epss_batch.json | 1 + 3 files changed, 151 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go new file mode 100644 index 00000000..7dc5cf7f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go @@ -0,0 +1,86 @@ +// ©AngelaMos | 2026 +// epss_client.go + +package cve + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + epssBatchSize = 100 + defaultEPSSBaseURL = "https://api.first.org" + pathEPSS = "/data/v1/epss" + defaultEPSSRate = 500 * time.Millisecond + defaultEPSSBurst = 5 + defaultEPSSBudget = 5 + defaultEPSSBreakerWin = 60 * time.Second +) + +type EPSSClientConfig struct { + BaseURL string +} + +type EPSSClient struct { + hx *httpx.Client +} + +func NewEPSSClient(cfg EPSSClientConfig) *EPSSClient { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultEPSSBaseURL + } + return &EPSSClient{ + hx: httpx.New(httpx.Config{ + Name: "epss", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultEPSSRate), + Burst: defaultEPSSBurst, + ConsecutiveFailureBudget: defaultEPSSBudget, + BreakerTimeout: defaultEPSSBreakerWin, + }), + } +} + +type EPSSScore struct { + CveID string `json:"cve"` + Score float64 `json:"-"` + Percentile float64 `json:"-"` + ScoreRaw string `json:"epss"` + PctileRaw string `json:"percentile"` + Date string `json:"date"` +} + +type EPSSResponse struct { + Data []EPSSScore `json:"data"` +} + +func (c *EPSSClient) LookupBatch(ctx context.Context, cveIDs []string) (map[string]EPSSScore, error) { + out := map[string]EPSSScore{} + for i := 0; i < len(cveIDs); i += epssBatchSize { + end := i + epssBatchSize + if end > len(cveIDs) { + end = len(cveIDs) + } + chunk := cveIDs[i:end] + q := url.Values{"cve": []string{strings.Join(chunk, ",")}} + var resp EPSSResponse + if err := c.hx.GetJSON(ctx, pathEPSS, q, &resp); err != nil { + return nil, fmt.Errorf("epss batch [%d:%d]: %w", i, end, err) + } + for _, s := range resp.Data { + s.Score, _ = strconv.ParseFloat(s.ScoreRaw, 64) + s.Percentile, _ = strconv.ParseFloat(s.PctileRaw, 64) + out[s.CveID] = s + } + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go new file mode 100644 index 00000000..638adf56 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go @@ -0,0 +1,64 @@ +// ©AngelaMos | 2026 +// epss_client_test.go + +package cve_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" +) + +func TestEPSSClient_BatchLookupDecodesScores(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ids := r.URL.Query().Get("cve") + require.True(t, strings.Contains(ids, "CVE-2024-3094")) + body, err := os.ReadFile("testdata/epss_batch.json") + require.NoError(t, err) + _, _ = w.Write(body) + })) + defer srv.Close() + + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) + scores, err := c.LookupBatch(context.Background(), []string{"CVE-2024-3094", "CVE-2024-21413"}) + require.NoError(t, err) + require.NotEmpty(t, scores) + + got := scores["CVE-2024-3094"] + require.InDelta(t, 0.84588, got.Score, 0.0001) + require.InDelta(t, 0.99335, got.Percentile, 0.0001) +} + +func TestEPSSClient_ChunksOver100PerRequest(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) + + ids := make([]string, 250) + for i := range ids { + ids[i] = "CVE-2024-X" + } + _, err := c.LookupBatch(context.Background(), ids) + require.NoError(t, err) + require.EqualValues(t, 3, hits.Load()) +} + +func TestEPSSClient_EmptyInputReturnsEmptyMap(t *testing.T) { + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: "http://invalid"}) + out, err := c.LookupBatch(context.Background(), nil) + require.NoError(t, err) + require.Empty(t, out) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json new file mode 100644 index 00000000..ce667a5f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json @@ -0,0 +1 @@ +{"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,"data":[{"cve":"CVE-2024-3094","epss":"0.845880000","percentile":"0.993350000","date":"2026-05-01"},{"cve":"CVE-2024-21413","epss":"0.929920000","percentile":"0.997830000","date":"2026-05-01"}]} From 5ad1ff8e1432aaef1d12118b7c7b88941a9ae2dd Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:41:24 -0400 Subject: [PATCH 025/109] feat(monitor/collectors/cve): 2h CVE collector (NVD pull + EPSS enrichment per tick) --- .../internal/collectors/cve/collector.go | 146 +++++++++++++++ .../internal/collectors/cve/collector_test.go | 172 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go new file mode 100644 index 00000000..b90b2eb0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go @@ -0,0 +1,146 @@ +// ©AngelaMos | 2026 +// collector.go + +package cve + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "cve" + defaultCVEInterval = 2 * time.Hour + defaultCVEWindow = 2 * time.Hour +) + +type NVDFetcher interface { + Fetch(ctx context.Context, start, end time.Time) (NVDResponse, error) +} + +type EPSSFetcher interface { + LookupBatch(ctx context.Context, cveIDs []string) (map[string]EPSSScore, error) +} + +type Repository interface { + Upsert(ctx context.Context, row Row) error + UpdateEPSS(ctx context.Context, cveID string, score, percentile float64) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + Window time.Duration + NVD NVDFetcher + EPSS EPSSFetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultCVEInterval + } + if cfg.Window <= 0 { + cfg.Window = defaultCVEWindow + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + end := time.Now().UTC() + start := end.Add(-c.cfg.Window) + + resp, err := c.cfg.NVD.Fetch(ctx, start, end) + if err != nil { + c.logger.Warn("nvd fetch", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(resp.Vulnerabilities)) + rows := make([]Row, 0, len(resp.Vulnerabilities)) + for _, v := range resp.Vulnerabilities { + score, severity := v.PrimarySeverity() + raw, _ := json.Marshal(v) + row := Row{ + CveID: v.CVE.ID, + Published: v.CVE.Published.Time, + LastModified: v.CVE.LastModified.Time, + Severity: severity, + CVSS: score, + Payload: raw, + } + rows = append(rows, row) + ids = append(ids, v.CVE.ID) + } + + scores, err := c.cfg.EPSS.LookupBatch(ctx, ids) + if err != nil { + c.logger.Warn("epss lookup", "err", err) + } + + emitted := int64(0) + for _, row := range rows { + if err := c.cfg.Repo.Upsert(ctx, row); err != nil { + c.logger.Warn("upsert cve", "id", row.CveID, "err", err) + continue + } + if s, ok := scores[row.CveID]; ok { + if err := c.cfg.Repo.UpdateEPSS(ctx, row.CveID, s.Score, s.Percentile); err != nil { + c.logger.Warn("update epss", "id", row.CveID, "err", err) + } + row.EPSSScore = &s.Score + row.EPSSPercentile = &s.Percentile + } + body, _ := json.Marshal(row) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicCVENew, + Timestamp: end, + Source: Name, + Payload: json.RawMessage(body), + }) + emitted++ + } + _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go new file mode 100644 index 00000000..d7eea8e1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go @@ -0,0 +1,172 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package cve_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type stubNVD struct { + resp cve.NVDResponse + err error +} + +func (s *stubNVD) Fetch(context.Context, time.Time, time.Time) (cve.NVDResponse, error) { + return s.resp, s.err +} + +type stubEPSS struct { + scores map[string]cve.EPSSScore + err error +} + +func (s *stubEPSS) LookupBatch(context.Context, []string) (map[string]cve.EPSSScore, error) { + return s.scores, s.err +} + +type stubCVERepo struct { + mu sync.Mutex + upserts map[string]cve.Row + patches map[string][2]float64 +} + +func newStubCVERepo() *stubCVERepo { + return &stubCVERepo{ + upserts: map[string]cve.Row{}, + patches: map[string][2]float64{}, + } +} + +func (r *stubCVERepo) Upsert(_ context.Context, row cve.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.upserts[row.CveID] = row + return nil +} + +func (r *stubCVERepo) UpdateEPSS(_ context.Context, id string, score, pct float64) error { + r.mu.Lock() + defer r.mu.Unlock() + r.patches[id] = [2]float64{score, pct} + return nil +} + +func (r *stubCVERepo) Upserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.upserts) +} + +func (r *stubCVERepo) Patches() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.patches) +} + +type stubCVEEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *stubCVEEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *stubCVEEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type stubCVEState struct{} + +func (stubCVEState) RecordSuccess(context.Context, string, int64) error { return nil } +func (stubCVEState) RecordError(context.Context, string, string) error { return nil } + +func TestCVECollector_FetchesEnrichesAndEmits(t *testing.T) { + now := time.Now().UTC() + nvd := &stubNVD{resp: cve.NVDResponse{ + Vulnerabilities: []cve.NVDVulnRoot{ + {CVE: cve.NVDCVE{ + ID: "CVE-2026-0001", Published: cve.NVDTime{Time: now.Add(-time.Hour)}, + LastModified: cve.NVDTime{Time: now}, + Metrics: cve.NVDMetrics{CVSSv31: []cve.NVDMetricEntry{ + {CVSSData: cve.NVDCVSSData{BaseScore: 9.8, BaseSeverity: "CRITICAL"}}, + }}, + }}, + {CVE: cve.NVDCVE{ + ID: "CVE-2026-0002", Published: cve.NVDTime{Time: now.Add(-time.Hour)}, + LastModified: cve.NVDTime{Time: now}, + }}, + }, + }} + epss := &stubEPSS{scores: map[string]cve.EPSSScore{ + "CVE-2026-0001": {CveID: "CVE-2026-0001", Score: 0.9, Percentile: 0.99}, + "CVE-2026-0002": {CveID: "CVE-2026-0002", Score: 0.1, Percentile: 0.5}, + }} + repo := newStubCVERepo() + emt := &stubCVEEmitter{} + + c := cve.NewCollector(cve.CollectorConfig{ + Interval: 30 * time.Millisecond, + NVD: nvd, + EPSS: epss, + Repo: repo, + Emitter: emt, + State: stubCVEState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Upserts(), 2) + require.GreaterOrEqual(t, repo.Patches(), 2) + + evs := emt.Events() + require.GreaterOrEqual(t, len(evs), 2) + for _, ev := range evs { + require.Equal(t, events.TopicCVENew, ev.Topic) + require.Equal(t, cve.Name, ev.Source) + raw, ok := ev.Payload.(json.RawMessage) + require.True(t, ok) + require.Contains(t, string(raw), "CVE-2026-") + } +} + +func TestCVECollector_NVDFetchErrorRecordsState(t *testing.T) { + nvd := &stubNVD{err: context.DeadlineExceeded} + repo := newStubCVERepo() + emt := &stubCVEEmitter{} + + c := cve.NewCollector(cve.CollectorConfig{ + Interval: 20 * time.Millisecond, + NVD: nvd, + EPSS: &stubEPSS{}, + Repo: repo, + Emitter: emt, + State: stubCVEState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Zero(t, repo.Upserts()) + require.Zero(t, repo.Patches()) + require.Zero(t, len(emt.Events())) +} From 701bc8e790403eac5af45141e208327ad788c00d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:42:18 -0400 Subject: [PATCH 026/109] feat(monitor/collectors/kev): kev_entries repository with diff lookup --- .../backend/internal/collectors/kev/repo.go | 77 ++++++++++++ .../internal/collectors/kev/repo_test.go | 115 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go new file mode 100644 index 00000000..75edabc3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go @@ -0,0 +1,77 @@ +// ©AngelaMos | 2026 +// repo.go + +package kev + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type Row struct { + CveID string `db:"cve_id"` + Vendor string `db:"vendor"` + Product string `db:"product"` + VulnerabilityName string `db:"vulnerability_name"` + DateAdded time.Time `db:"date_added"` + DueDate *time.Time `db:"due_date"` + RansomwareUse string `db:"ransomware_use"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Insert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO kev_entries + (cve_id, vendor, product, vulnerability_name, date_added, due_date, ransomware_use, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (cve_id) DO UPDATE SET + vendor = EXCLUDED.vendor, + product = EXCLUDED.product, + vulnerability_name = EXCLUDED.vulnerability_name, + date_added = EXCLUDED.date_added, + due_date = EXCLUDED.due_date, + ransomware_use = EXCLUDED.ransomware_use, + payload = EXCLUDED.payload`, + row.CveID, row.Vendor, row.Product, row.VulnerabilityName, + row.DateAdded, row.DueDate, row.RansomwareUse, []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("insert kev %s: %w", row.CveID, err) + } + return nil +} + +func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) { + if len(ids) == 0 { + return map[string]bool{}, nil + } + var found []string + if err := r.db.SelectContext(ctx, &found, + `SELECT cve_id FROM kev_entries WHERE cve_id = ANY($1::text[])`, pq.Array(ids)); err != nil { + return nil, fmt.Errorf("kev known ids: %w", err) + } + out := make(map[string]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) Count(ctx context.Context) (int64, error) { + var n int64 + if err := r.db.GetContext(ctx, &n, `SELECT count(*) FROM kev_entries`); err != nil { + return 0, fmt.Errorf("kev count: %w", err) + } + return n, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go new file mode 100644 index 00000000..2cda2b07 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go @@ -0,0 +1,115 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package kev_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE kev_entries ( + cve_id text PRIMARY KEY, + vendor text NOT NULL DEFAULT '', + product text NOT NULL DEFAULT '', + vulnerability_name text NOT NULL DEFAULT '', + date_added date NOT NULL, + due_date date, + ransomware_use text NOT NULL DEFAULT '', + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_InsertAndKnownIDs(t *testing.T) { + db := setupDB(t) + repo := kev.NewRepo(db) + ctx := context.Background() + added := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC) + + require.NoError(t, repo.Insert(ctx, kev.Row{ + CveID: "CVE-2024-3094", + Vendor: "JiaT75", + Product: "xz", + VulnerabilityName: "Backdoor", + DateAdded: added, + Payload: json.RawMessage(`{"cveID":"CVE-2024-3094"}`), + })) + + known, err := repo.KnownIDs(ctx, []string{"CVE-2024-3094", "CVE-2024-MISSING"}) + require.NoError(t, err) + require.True(t, known["CVE-2024-3094"]) + require.False(t, known["CVE-2024-MISSING"]) +} + +func TestRepo_InsertIsIdempotent(t *testing.T) { + db := setupDB(t) + repo := kev.NewRepo(db) + ctx := context.Background() + + row := kev.Row{ + CveID: "CVE-2024-X", + Vendor: "v1", + DateAdded: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), + Payload: json.RawMessage(`{}`), + } + require.NoError(t, repo.Insert(ctx, row)) + + updated := row + updated.Vendor = "v2" + require.NoError(t, repo.Insert(ctx, updated)) + + count, err := repo.Count(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, count) +} + +func TestRepo_CountReflectsTotal(t *testing.T) { + db := setupDB(t) + repo := kev.NewRepo(db) + ctx := context.Background() + added := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + + for _, id := range []string{"CVE-A", "CVE-B", "CVE-C"} { + require.NoError(t, repo.Insert(ctx, kev.Row{ + CveID: id, DateAdded: added, Payload: json.RawMessage(`{}`), + })) + } + + count, err := repo.Count(ctx) + require.NoError(t, err) + require.EqualValues(t, 3, count) +} From 7395f1064fd329897831d78210ead3899aea8249 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:43:11 -0400 Subject: [PATCH 027/109] feat(monitor/collectors/kev): CISA KEV catalog client --- .../backend/internal/collectors/kev/client.go | 80 +++++ .../internal/collectors/kev/client_test.go | 43 +++ .../collectors/kev/testdata/kev_catalog.json | 311 ++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/testdata/kev_catalog.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client.go new file mode 100644 index 00000000..7cd35b6d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client.go @@ -0,0 +1,80 @@ +// ©AngelaMos | 2026 +// client.go + +package kev + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultKEVURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" + defaultKEVRate = time.Second + defaultKEVBudget = 5 + defaultKEVBreakerWin = 60 * time.Second +) + +type ClientConfig struct { + URL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.URL == "" { + cfg.URL = defaultKEVURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "kev", + BaseURL: cfg.URL, + Rate: rate.Every(defaultKEVRate), + Burst: 1, + ConsecutiveFailureBudget: defaultKEVBudget, + BreakerTimeout: defaultKEVBreakerWin, + }), + } +} + +type Catalog struct { + Title string `json:"title"` + CatalogVersion string `json:"catalogVersion"` + DateReleased string `json:"dateReleased"` + Count int `json:"count"` + Vulnerabilities []Vulnerability `json:"vulnerabilities"` +} + +type Vulnerability struct { + CveID string `json:"cveID"` + VendorProject string `json:"vendorProject"` + Product string `json:"product"` + VulnerabilityName string `json:"vulnerabilityName"` + DateAdded string `json:"dateAdded"` + DueDate string `json:"dueDate"` + KnownRansomwareCampaignUse string `json:"knownRansomwareCampaignUse"` + ShortDescription string `json:"shortDescription"` + RequiredAction string `json:"requiredAction"` +} + +func (c *Client) FetchCatalog(ctx context.Context) (Catalog, error) { + resp, err := c.hx.Get(ctx, "", nil) + if err != nil { + return Catalog{}, fmt.Errorf("fetch kev catalog: %w", err) + } + defer resp.Body.Close() + + var cat Catalog + if err := json.NewDecoder(resp.Body).Decode(&cat); err != nil { + return Catalog{}, fmt.Errorf("decode kev: %w", err) + } + return cat, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go new file mode 100644 index 00000000..fc32d4ea --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// client_test.go + +package kev_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" +) + +func TestKEVClient_FetchCatalogDecodes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/kev_catalog.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := kev.NewClient(kev.ClientConfig{URL: srv.URL}) + cat, err := c.FetchCatalog(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, cat.Vulnerabilities) + require.NotEmpty(t, cat.Vulnerabilities[0].CveID) +} + +func TestKEVClient_RejectsServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + c := kev.NewClient(kev.ClientConfig{URL: srv.URL}) + _, err := c.FetchCatalog(context.Background()) + require.Error(t, err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/testdata/kev_catalog.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/testdata/kev_catalog.json new file mode 100644 index 00000000..e4ce84f2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/testdata/kev_catalog.json @@ -0,0 +1,311 @@ +{ + "title": "CISA Catalog of Known Exploited Vulnerabilities", + "catalogVersion": "2026.05.01", + "dateReleased": "2026-05-01T18:00:49.4551Z", + "count": 20, + "vulnerabilities": [ + { + "cveID": "CVE-2026-31431", + "vendorProject": "Linux", + "product": "Kernel", + "vulnerabilityName": "Linux Kernel Incorrect Resource Transfer Between Spheres Vulnerability", + "dateAdded": "2026-05-01", + "shortDescription": "Linux Kernel contains an incorrect resource transfer between spheres vulnerability that could allow for privilege escalation.", + "requiredAction": "\"Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-15", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://lore.kernel.org/linux-cve-announce/2026042214-CVE-2026-31431-3d65@gregkh/; https://xint.io/blog/copy-fail-linux-distributions#the-fix-6 ; https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/about/ ; https://nvd.nist.gov/vuln/detail/CVE-2026-31431", + "cwes": [ + "CWE-699" + ] + }, + { + "cveID": "CVE-2026-41940", + "vendorProject": "WebPros", + "product": "cPanel & WHM and WP2 (WordPress Squared)", + "vulnerabilityName": "WebPros cPanel & WHM and WP2 (WordPress Squared) Missing Authentication for Critical Function Vulnerability", + "dateAdded": "2026-04-30", + "shortDescription": "WebPros cPanel & WHM (WebHost Manager) and WP2 (WordPress Squared) contain an authentication bypass vulnerability in the login flow that allows unauthenticated remote attackers to gain unauthorized access to the control panel.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-03", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://support.cpanel.net/hc/en-us/articles/40073787579671-cPanel-WHM-Security-Update-04-28-2026 ; https://docs.cpanel.net/release-notes/release-notes/ ; https://docs.wpsquared.com/changelogs/versions/changelog/#13617 ; https://nvd.nist.gov/vuln/detail/CVE-2026-41940\"", + "cwes": [ + "CWE-306" + ] + }, + { + "cveID": "CVE-2024-1708", + "vendorProject": "ConnectWise", + "product": "ScreenConnect", + "vulnerabilityName": "ConnectWise ScreenConnect Path Traversal Vulnerability", + "dateAdded": "2026-04-28", + "shortDescription": "ConnectWise ScreenConnect contains a path traversal vulnerability which could allow an attacker to execute remote code or directly impact confidential data and critical systems.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-12", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://www.connectwise.com/company/trust/security-bulletins/connectwise-screenconnect-23.9.8 ; https://nvd.nist.gov/vuln/detail/CVE-2024-1708", + "cwes": [ + "CWE-22" + ] + }, + { + "cveID": "CVE-2026-32202", + "vendorProject": "Microsoft", + "product": "Windows", + "vulnerabilityName": "Microsoft Windows Protection Mechanism Failure Vulnerability", + "dateAdded": "2026-04-28", + "shortDescription": "Microsoft Windows Shell contains a protection mechanism failure vulnerability that allows an unauthorized attacker to perform spoofing over a network.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-12", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2026-32202 ; https://nvd.nist.gov/vuln/detail/CVE-2026-32202", + "cwes": [ + "CWE-693" + ] + }, + { + "cveID": "CVE-2025-29635", + "vendorProject": "D-Link", + "product": "DIR-823X", + "vulnerabilityName": "D-Link DIR-823X Command Injection Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "D-Link DIR-823X contains a command injection vulnerability that allows an authorized attacker to execute arbitrary commands on remote devices by sending a POST request to /goform/set_prohibiting via the corresponding function. The impacted product could be end-of-life (EoL) and/or end-of-service (EoS). Users should discontinue product utilization.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://supportannouncement.us.dlink.com/security/publication.aspx?name=SAP10469 ; https://nvd.nist.gov/vuln/detail/CVE-2025-29635", + "cwes": [ + "CWE-77" + ] + }, + { + "cveID": "CVE-2024-7399", + "vendorProject": "Samsung", + "product": "MagicINFO 9 Server", + "vulnerabilityName": "Samsung MagicINFO 9 Server Path Traversal Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "Samsung MagicINFO 9 Server contains a path traversal vulnerability that could allow an attacker to write arbitrary files as system authority.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://security.samsungtv.com/securityUpdates ; https://nvd.nist.gov/vuln/detail/CVE-2024-7399", + "cwes": [ + "CWE-22", + "CWE-434" + ] + }, + { + "cveID": "CVE-2024-57728", + "vendorProject": "SimpleHelp ", + "product": "SimpleHelp", + "vulnerabilityName": "SimpleHelp Path Traversal Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "SimpleHelp contains a path traversal vulnerability that allows admin users to upload arbitrary files anywhere on the file system by uploading a crafted zip file (i.e. zip slip). This can be exploited to execute arbitrary code on the host in the context of the SimpleHelp server user.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://simple-help.com/kb---security-vulnerabilities-01-2025#security-vulnerabilities-in-simplehelp-5-5-7-and-earlier ; https://nvd.nist.gov/vuln/detail/CVE-2024-57728", + "cwes": [ + "CWE-22" + ] + }, + { + "cveID": "CVE-2024-57726", + "vendorProject": "SimpleHelp ", + "product": "SimpleHelp", + "vulnerabilityName": "SimpleHelp Missing Authorization Vulnerability", + "dateAdded": "2026-04-24", + "shortDescription": "SimpleHelp contains a missing authorization vulnerability that could allow low-privileged technicians to create API keys with excessive permissions. These API keys can be used to escalate privileges to the server admin role.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-08", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://simple-help.com/kb---security-vulnerabilities-01-2025#security-vulnerabilities-in-simplehelp-5-5-7-and-earlier ; https://nvd.nist.gov/vuln/detail/CVE-2024-57726", + "cwes": [ + "CWE-862" + ] + }, + { + "cveID": "CVE-2026-39987", + "vendorProject": "Marimo", + "product": "Marimo", + "vulnerabilityName": "Marimo Remote Code Execution Vulnerability", + "dateAdded": "2026-04-23", + "shortDescription": "Marimo contains an pre-authorization remote code execution vulnerability, allowing an unauthenticated attacked to shell access and execute arbitrary system commands.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-07", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://github.com/marimo-team/marimo/security/advisories/GHSA-2679-6mx9-h9xc ; https://nvd.nist.gov/vuln/detail/CVE-2026-39987", + "cwes": [ + "CWE-306" + ] + }, + { + "cveID": "CVE-2026-33825", + "vendorProject": "Microsoft", + "product": "Defender", + "vulnerabilityName": "Microsoft Defender Insufficient Granularity of Access Control Vulnerability", + "dateAdded": "2026-04-22", + "shortDescription": "Microsoft Defender contains an insufficient granularity of access control vulnerability that could allow an authorized attacker to escalate privileges locally.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-06", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-33825 ; https://nvd.nist.gov/vuln/detail/CVE-2026-33825", + "cwes": [ + "CWE-1220" + ] + }, + { + "cveID": "CVE-2026-20122", + "vendorProject": "Cisco", + "product": "Catalyst SD-WAN Manger", + "vulnerabilityName": "Cisco Catalyst SD-WAN Manager Incorrect Use of Privileged APIs Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Cisco Catalyst SD-WAN Manager contains an incorrect use of privileged APIs vulnerability due to improper file handling on the API interface of an affected system. An attacker could exploit this vulnerability by uploading a malicious file on the local file system. A successful exploit could allow the attacker to overwrite arbitrary files on the affected system and gain vmanage user privileges.", + "requiredAction": "Please adhere to CISA’s guidelines to assess exposure and mitigate risks associated with Cisco SD-WAN devices as outlines in CISA’s Emergency Directive 26-03 (URL listed below in Notes) and CISA’s “Hunt & Hardening Guidance for Cisco SD-WAN Devices (URL listed below in Notes). Adhere to the applicable BOD 22-01 guidance for cloud services or discontinue use of the product if mitigations are not available.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "CISA Mitigation Instructions: https://www.cisa.gov/news-events/directives/ed-26-03-mitigate-vulnerabilities-cisco-sd-wan-systems ; https://www.cisa.gov/news-events/directives/supplemental-direction-ed-26-03-hunt-and-hardening-guidance-cisco-sd-wan-systems ; https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sdwan-authbp-qwCX8D4v ; https://nvd.nist.gov/vuln/detail/CVE-2026-20122", + "cwes": [ + "CWE-648" + ] + }, + { + "cveID": "CVE-2026-20133", + "vendorProject": "Cisco", + "product": "Catalyst SD-WAN Manager", + "vulnerabilityName": "Cisco Catalyst SD-WAN Manager Exposure of Sensitive Information to an Unauthorized Actor Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Cisco Catalyst SD-WAN Manager contains an exposure of sensitive information to an unauthorized actor vulnerability that could allow remote attackers to view sensitive information on affected systems.", + "requiredAction": "Please adhere to CISA’s guidelines to assess exposure and mitigate risks associated with Cisco SD-WAN devices as outlines in CISA’s Emergency Directive 26-03 (URL listed below in Notes) and CISA’s “Hunt & Hardening Guidance for Cisco SD-WAN Devices (URL listed below in Notes). Adhere to the applicable BOD 22-01 guidance for cloud services or discontinue use of the product if mitigations are not available.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "CISA Mitigation Instructions: https://www.cisa.gov/news-events/directives/ed-26-03-mitigate-vulnerabilities-cisco-sd-wan-systems ; https://www.cisa.gov/news-events/directives/supplemental-direction-ed-26-03-hunt-and-hardening-guidance-cisco-sd-wan-systems ; https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sdwan-authbp-qwCX8D4v ; https://nvd.nist.gov/vuln/detail/CVE-2026-20133", + "cwes": [ + "CWE-200" + ] + }, + { + "cveID": "CVE-2025-2749", + "vendorProject": "Kentico", + "product": "Kentico Xperience", + "vulnerabilityName": "Kentico Xperience Path Traversal Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Kentico Xperience contains a path traversal vulnerability that could allow an authenticated user's Staging Sync Server to upload arbitrary data to path relative locations.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://devnet.kentico.com/download/hotfixes ; https://nvd.nist.gov/vuln/detail/CVE-2025-2749", + "cwes": [ + "CWE-22", + "CWE-434" + ] + }, + { + "cveID": "CVE-2023-27351", + "vendorProject": "PaperCut", + "product": "NG/MF", + "vulnerabilityName": "PaperCut NG/MF Improper Authentication Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "PaperCut NG/MF contains an improper authentication vulnerability that could allow remote attackers to bypass authentication on affected installations via the SecurityRequestFilter class.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Known", + "notes": "https://www.papercut.com/kb/Main/PO-1216-and-PO-1219 ; https://nvd.nist.gov/vuln/detail/CVE-2023-27351", + "cwes": [ + "CWE-287" + ] + }, + { + "cveID": "CVE-2025-48700", + "vendorProject": "Synacor", + "product": "Zimbra Collaboration Suite (ZCS)", + "vulnerabilityName": "Synacor Zimbra Collaboration Suite (ZCS) Cross-site Scripting Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Synacor Zimbra Collaboration Suite (ZCS) contains a cross-site scripting vulnerability that could allow attackers to execute arbitrary JavaScript within the user's session, potentially leading to unauthorized access to sensitive information.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://wiki.zimbra.com/wiki/Zimbra_Security_Advisories ; https://nvd.nist.gov/vuln/detail/CVE-2025-48700", + "cwes": [ + "CWE-79" + ] + }, + { + "cveID": "CVE-2026-20128", + "vendorProject": "Cisco", + "product": "Catalyst SD-WAN Manager", + "vulnerabilityName": "Cisco Catalyst SD-WAN Manager Storing Passwords in a Recoverable Format Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Cisco Catalyst SD-WAN Manager contains a storing passwords in a recoverable format vulnerability that allows an authenticated, local attacker to gain DCA user privileges by accessing a credential file for the DCA user on the filesystem as a low-privileged user.", + "requiredAction": "Please adhere to CISA’s guidelines to assess exposure and mitigate risks associated with Cisco SD-WAN devices as outlines in CISA’s Emergency Directive 26-03 (URL listed below in Notes) and CISA’s “Hunt & Hardening Guidance for Cisco SD-WAN Devices (URL listed below in Notes). Adhere to the applicable BOD 22-01 guidance for cloud services or discontinue use of the product if mitigations are not available.", + "dueDate": "2026-04-23", + "knownRansomwareCampaignUse": "Unknown", + "notes": "CISA Mitigation Instructions: https://www.cisa.gov/news-events/directives/ed-26-03-mitigate-vulnerabilities-cisco-sd-wan-systems ; https://www.cisa.gov/news-events/directives/supplemental-direction-ed-26-03-hunt-and-hardening-guidance-cisco-sd-wan-systems ; https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sdwan-authbp-qwCX8D4v ; https://nvd.nist.gov/vuln/detail/CVE-2026-20128", + "cwes": [ + "CWE-257" + ] + }, + { + "cveID": "CVE-2025-32975", + "vendorProject": "Quest", + "product": "KACE Systems Management Appliance (SMA)", + "vulnerabilityName": "Quest KACE Systems Management Appliance (SMA) Improper Authentication Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "Quest KACE Systems Management Appliance (SMA) contains an improper authentication vulnerability that could allow attackers to impersonate legitimate users without valid credentials.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://support.quest.com/kb/4379499/quest-response-to-kace-sma-vulnerabilities-cve-2025-32975-cve-2025-32976-cve-2025-32977-cve-2025-32978 ; https://nvd.nist.gov/vuln/detail/CVE-2025-32975", + "cwes": [ + "CWE-287" + ] + }, + { + "cveID": "CVE-2024-27199", + "vendorProject": "JetBrains", + "product": "TeamCity", + "vulnerabilityName": "JetBrains TeamCity Relative Path Traversal Vulnerability", + "dateAdded": "2026-04-20", + "shortDescription": "JetBrains TeamCity contains a relative path traversal vulnerability that could allow limited admin actions to be performed.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-05-04", + "knownRansomwareCampaignUse": "Known", + "notes": "https://www.jetbrains.com/privacy-security/issues-fixed/ ; https://blog.jetbrains.com/teamcity/2024/03/additional-critical-security-issues-affecting-teamcity-on-premises-cve-2024-27198-and-cve-2024-27199-update-to-2023-11-4-now/ ; https://nvd.nist.gov/vuln/detail/CVE-2024-27199", + "cwes": [ + "CWE-23" + ] + }, + { + "cveID": "CVE-2026-34197", + "vendorProject": "Apache", + "product": "ActiveMQ", + "vulnerabilityName": "Apache ActiveMQ Improper Input Validation Vulnerability", + "dateAdded": "2026-04-16", + "shortDescription": "Apache ActiveMQ contains an improper input validation vulnerability that allows for code injection.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-04-30", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://activemq.apache.org/security-advisories.data/CVE-2026-34197-announcement.txt ; https://nvd.nist.gov/vuln/detail/CVE-2026-34197", + "cwes": [ + "CWE-20", + "CWE-94" + ] + }, + { + "cveID": "CVE-2009-0238", + "vendorProject": "Microsoft", + "product": "Office", + "vulnerabilityName": "Microsoft Office Remote Code Execution", + "dateAdded": "2026-04-14", + "shortDescription": "Microsoft Office Excel contains a remote code execution vulnerability that could allow an attacker to take complete control of an affected system if a user opens a specially crafted Excel file that includes a malformed object.", + "requiredAction": "Apply mitigations per vendor instructions, follow applicable BOD 22-01 guidance for cloud services, or discontinue use of the product if mitigations are unavailable.", + "dueDate": "2026-04-28", + "knownRansomwareCampaignUse": "Unknown", + "notes": "https://learn.microsoft.com/en-us/security-updates/securitybulletins/2009/ms09-009 ; https://nvd.nist.gov/vuln/detail/CVE-2009-0238", + "cwes": [ + "CWE-94" + ] + } + ] +} From bdca22ee50cd77620854abe78310cb5f4139d551 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:44:37 -0400 Subject: [PATCH 028/109] feat(monitor/collectors/kev): 1h KEV collector with set-diff and chime topic --- .../internal/collectors/kev/collector.go | 140 +++++++++++++++++ .../internal/collectors/kev/collector_test.go | 146 ++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go new file mode 100644 index 00000000..167e3363 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go @@ -0,0 +1,140 @@ +// ©AngelaMos | 2026 +// collector.go + +package kev + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "kev" + defaultKEVCadence = time.Hour + dateLayout = "2006-01-02" +) + +type Fetcher interface { + FetchCatalog(ctx context.Context) (Catalog, error) +} + +type Repository interface { + Insert(ctx context.Context, row Row) error + KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultKEVCadence + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + cat, err := c.cfg.Fetcher.FetchCatalog(ctx) + if err != nil { + c.logger.Warn("kev fetch", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(cat.Vulnerabilities)) + for _, v := range cat.Vulnerabilities { + ids = append(ids, v.CveID) + } + + known, err := c.cfg.Repo.KnownIDs(ctx, ids) + if err != nil { + c.logger.Warn("kev known ids", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + now := time.Now().UTC() + emitted := int64(0) + for _, v := range cat.Vulnerabilities { + if known[v.CveID] { + continue + } + + dateAdded, _ := time.Parse(dateLayout, v.DateAdded) + var dueDate *time.Time + if t, perr := time.Parse(dateLayout, v.DueDate); perr == nil { + dueDate = &t + } + + raw, _ := json.Marshal(v) + row := Row{ + CveID: v.CveID, + Vendor: v.VendorProject, + Product: v.Product, + VulnerabilityName: v.VulnerabilityName, + DateAdded: dateAdded, + DueDate: dueDate, + RansomwareUse: v.KnownRansomwareCampaignUse, + Payload: raw, + } + + if ierr := c.cfg.Repo.Insert(ctx, row); ierr != nil { + c.logger.Warn("kev insert", "id", v.CveID, "err", ierr) + continue + } + + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicKEVAdded, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(raw), + }) + emitted++ + } + _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go new file mode 100644 index 00000000..faafc732 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go @@ -0,0 +1,146 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package kev_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type stubFetcher struct { + cat kev.Catalog + err error +} + +func (s *stubFetcher) FetchCatalog(context.Context) (kev.Catalog, error) { + return s.cat, s.err +} + +type stubKEVRepo struct { + mu sync.Mutex + inserted []string + known map[string]bool +} + +func (r *stubKEVRepo) Insert(_ context.Context, row kev.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserted = append(r.inserted, row.CveID) + if r.known == nil { + r.known = map[string]bool{} + } + r.known[row.CveID] = true + return nil +} + +func (r *stubKEVRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]bool) + for _, id := range ids { + if r.known[id] { + out[id] = true + } + } + return out, nil +} + +func (r *stubKEVRepo) Inserted() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.inserted)) + copy(out, r.inserted) + return out +} + +type stubKEVEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *stubKEVEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *stubKEVEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type stubKEVState struct{} + +func (stubKEVState) RecordSuccess(context.Context, string, int64) error { return nil } +func (stubKEVState) RecordError(context.Context, string, string) error { return nil } + +func TestCollector_OnlyEmitsNewKEVs(t *testing.T) { + ftch := &stubFetcher{cat: kev.Catalog{Vulnerabilities: []kev.Vulnerability{ + {CveID: "CVE-2024-OLD", VendorProject: "X", DateAdded: "2026-04-01"}, + {CveID: "CVE-2024-NEW", VendorProject: "Y", DateAdded: "2026-05-01"}, + }}} + repo := &stubKEVRepo{known: map[string]bool{"CVE-2024-OLD": true}} + emt := &stubKEVEmitter{} + + c := kev.NewCollector(kev.CollectorConfig{ + Interval: 30 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: stubKEVState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + inserted := repo.Inserted() + require.NotEmpty(t, inserted) + for _, id := range inserted { + require.NotEqual(t, "CVE-2024-OLD", id) + require.Equal(t, "CVE-2024-NEW", id) + } + + evs := emt.Events() + require.NotEmpty(t, evs) + for _, ev := range evs { + require.Equal(t, events.TopicKEVAdded, ev.Topic) + require.Equal(t, kev.Name, ev.Source) + } +} + +func TestCollector_EmptyKnownInsertsAll(t *testing.T) { + ftch := &stubFetcher{cat: kev.Catalog{Vulnerabilities: []kev.Vulnerability{ + {CveID: "CVE-A", DateAdded: "2026-04-01"}, + {CveID: "CVE-B", DateAdded: "2026-04-02"}, + {CveID: "CVE-C", DateAdded: "2026-04-03"}, + }}} + repo := &stubKEVRepo{known: map[string]bool{}} + emt := &stubKEVEmitter{} + + c := kev.NewCollector(kev.CollectorConfig{ + Interval: 25 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: stubKEVState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Len(t, repo.Inserted(), 3) + require.Len(t, emt.Events(), 3) +} From 01a22dc7e4c00ddcd4f01c15878a7d5963052332 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:45:23 -0400 Subject: [PATCH 029/109] feat(monitor/collectors/ransomware): victims repository with idempotent insert --- .../internal/collectors/ransomware/repo.go | 60 +++++++++++++ .../collectors/ransomware/repo_test.go | 87 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go new file mode 100644 index 00000000..3c03d231 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go @@ -0,0 +1,60 @@ +// ©AngelaMos | 2026 +// repo.go + +package ransomware + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type Row struct { + ID string `db:"id"` + PostTitle string `db:"post_title"` + GroupName string `db:"group_name"` + DiscoveredAt time.Time `db:"discovered_at"` + Country string `db:"country"` + Sector string `db:"sector"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Insert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO ransomware_victims + (id, post_title, group_name, discovered_at, country, sector, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING`, + row.ID, row.PostTitle, row.GroupName, row.DiscoveredAt, row.Country, row.Sector, []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("insert ransom %s: %w", row.ID, err) + } + return nil +} + +func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) { + if len(ids) == 0 { + return map[string]bool{}, nil + } + var found []string + if err := r.db.SelectContext(ctx, &found, + `SELECT id FROM ransomware_victims WHERE id = ANY($1::text[])`, pq.Array(ids)); err != nil { + return nil, fmt.Errorf("ransom known ids: %w", err) + } + out := make(map[string]bool, len(found)) + for _, id := range found { + out[id] = true + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo_test.go new file mode 100644 index 00000000..70c1da24 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo_test.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package ransomware_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE ransomware_victims ( + id text PRIMARY KEY, + post_title text NOT NULL, + group_name text NOT NULL, + discovered_at timestamptz NOT NULL, + country text NOT NULL DEFAULT '', + sector text NOT NULL DEFAULT '', + payload jsonb NOT NULL + )`) + require.NoError(t, err) + return db +} + +func TestRepo_InsertIsIdempotent(t *testing.T) { + db := setupDB(t) + repo := ransomware.NewRepo(db) + ctx := context.Background() + + row := ransomware.Row{ + ID: "id-1", + PostTitle: "Acme Corp", + GroupName: "lockbit", + DiscoveredAt: time.Now().UTC(), + Country: "US", + Sector: "Healthcare", + Payload: json.RawMessage(`{}`), + } + require.NoError(t, repo.Insert(ctx, row)) + require.NoError(t, repo.Insert(ctx, row)) + + known, err := repo.KnownIDs(ctx, []string{"id-1", "id-2"}) + require.NoError(t, err) + require.True(t, known["id-1"]) + require.False(t, known["id-2"]) +} + +func TestRepo_KnownIDsHandlesEmptySlice(t *testing.T) { + db := setupDB(t) + repo := ransomware.NewRepo(db) + ctx := context.Background() + + out, err := repo.KnownIDs(ctx, nil) + require.NoError(t, err) + require.Empty(t, out) +} From 3d71fd8c8451765504cdd3a12ab2c5b456b9f03f Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:47:19 -0400 Subject: [PATCH 030/109] feat(monitor/collectors/ransomware): ransomware.live client with stable triple-hash ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-crafted fixture matches spec §8.5 schema. Live API endpoint may move; client takes BaseURL config for swap-in once api.ransomware.live publishes a stable JSON endpoint (current free tier returns HTML landing page on /recentvictims). --- .../internal/collectors/ransomware/client.go | 72 +++++++++++++++++++ .../collectors/ransomware/client_test.go | 44 ++++++++++++ .../ransomware/testdata/recentvictims.json | 26 +++++++ 3 files changed, 142 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/testdata/recentvictims.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go new file mode 100644 index 00000000..eec9d944 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// client.go + +package ransomware + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultRansomBaseURL = "https://api.ransomware.live" + pathRecentVictims = "/recentvictims" + defaultRansomRate = time.Second + defaultRansomBudget = 5 + defaultRansomBreakerWin = 60 * time.Second + idHashBytes = 16 +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultRansomBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "ransomware", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultRansomRate), + Burst: 1, + ConsecutiveFailureBudget: defaultRansomBudget, + BreakerTimeout: defaultRansomBreakerWin, + }), + } +} + +type Victim struct { + PostTitle string `json:"post_title"` + GroupName string `json:"group_name"` + Discovered time.Time `json:"discovered"` + Country string `json:"country"` + Activity string `json:"activity"` + Website string `json:"website"` + Description string `json:"description"` +} + +func (v Victim) ID() string { + h := sha256.Sum256([]byte(v.PostTitle + "|" + v.GroupName + "|" + v.Discovered.UTC().Format(time.RFC3339))) + return hex.EncodeToString(h[:idHashBytes]) +} + +func (c *Client) FetchRecent(ctx context.Context) ([]Victim, error) { + var out []Victim + if err := c.hx.GetJSON(ctx, pathRecentVictims, nil, &out); err != nil { + return nil, fmt.Errorf("fetch recentvictims: %w", err) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go new file mode 100644 index 00000000..063ce9bb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go @@ -0,0 +1,44 @@ +// ©AngelaMos | 2026 +// client_test.go + +package ransomware_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" +) + +func TestClient_FetchRecentVictimsDecodes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/recentvictims.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := ransomware.NewClient(ransomware.ClientConfig{BaseURL: srv.URL}) + vs, err := c.FetchRecent(context.Background()) + require.NoError(t, err) + require.Len(t, vs, 3) + require.Equal(t, "Acme Healthcare Corp", vs[0].PostTitle) + require.Equal(t, "lockbit", vs[0].GroupName) +} + +func TestVictim_IDIsStableAndCollisionFree(t *testing.T) { + d, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z") + v1 := ransomware.Victim{PostTitle: "X", GroupName: "lockbit", Discovered: d} + v2 := ransomware.Victim{PostTitle: "X", GroupName: "lockbit", Discovered: d} + v3 := ransomware.Victim{PostTitle: "Y", GroupName: "lockbit", Discovered: d} + + require.Equal(t, v1.ID(), v2.ID()) + require.NotEqual(t, v1.ID(), v3.ID()) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/testdata/recentvictims.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/testdata/recentvictims.json new file mode 100644 index 00000000..1f290f3d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/testdata/recentvictims.json @@ -0,0 +1,26 @@ +[ + { + "post_title": "Acme Healthcare Corp", + "group_name": "lockbit", + "discovered": "2026-05-01T14:32:00Z", + "country": "US", + "activity": "Healthcare", + "website": "acme-health.example.com", + "description": "Patient records claimed leaked" + }, + { + "post_title": "Northern Steel Manufacturing", + "group_name": "blackcat", + "discovered": "2026-05-01T11:08:00Z", + "country": "DE", + "activity": "Manufacturing", + "website": "northern-steel.example.de" + }, + { + "post_title": "Banco Nacional", + "group_name": "play", + "discovered": "2026-04-30T22:15:00Z", + "country": "AR", + "activity": "Finance" + } +] From 894ecc8fe545ef7b25f37fe900b4c53468aabc46 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:48:09 -0400 Subject: [PATCH 031/109] feat(monitor/collectors/ransomware): 15m collector with hash-diff and victim emit --- .../collectors/ransomware/collector.go | 133 ++++++++++++++++++ .../collectors/ransomware/collector_test.go | 118 ++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go new file mode 100644 index 00000000..5343096f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go @@ -0,0 +1,133 @@ +// ©AngelaMos | 2026 +// collector.go + +package ransomware + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "ransomware" + defaultRansomCadence = 15 * time.Minute +) + +type Fetcher interface { + FetchRecent(ctx context.Context) ([]Victim, error) +} + +type Repository interface { + Insert(ctx context.Context, row Row) error + KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultRansomCadence + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + + c.tick(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + vs, err := c.cfg.Fetcher.FetchRecent(ctx) + if err != nil { + c.logger.Warn("ransomware fetch", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(vs)) + for _, v := range vs { + ids = append(ids, v.ID()) + } + + known, err := c.cfg.Repo.KnownIDs(ctx, ids) + if err != nil { + c.logger.Warn("ransomware known ids", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + now := time.Now().UTC() + emitted := int64(0) + for _, v := range vs { + id := v.ID() + if known[id] { + continue + } + + raw, _ := json.Marshal(v) + row := Row{ + ID: id, + PostTitle: v.PostTitle, + GroupName: v.GroupName, + DiscoveredAt: v.Discovered, + Country: v.Country, + Sector: v.Activity, + Payload: raw, + } + + if err := c.cfg.Repo.Insert(ctx, row); err != nil { + c.logger.Warn("ransomware insert", "id", id, "err", err) + continue + } + + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicRansomwareVictim, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(raw), + }) + emitted++ + } + _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go new file mode 100644 index 00000000..1651161f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go @@ -0,0 +1,118 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package ransomware_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type stubFetcher struct { + victims []ransomware.Victim +} + +func (s *stubFetcher) FetchRecent(context.Context) ([]ransomware.Victim, error) { + return s.victims, nil +} + +type stubRansomRepo struct { + mu sync.Mutex + inserted []string + known map[string]bool +} + +func (r *stubRansomRepo) Insert(_ context.Context, row ransomware.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserted = append(r.inserted, row.ID) + if r.known == nil { + r.known = map[string]bool{} + } + r.known[row.ID] = true + return nil +} + +func (r *stubRansomRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]bool) + for _, id := range ids { + if r.known[id] { + out[id] = true + } + } + return out, nil +} + +func (r *stubRansomRepo) Inserted() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.inserted)) + copy(out, r.inserted) + return out +} + +type stubRansomEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *stubRansomEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *stubRansomEmitter) Events() []events.Event { + e.mu.Lock() + defer e.mu.Unlock() + out := make([]events.Event, len(e.events)) + copy(out, e.events) + return out +} + +type stubRansomState struct{} + +func (stubRansomState) RecordSuccess(context.Context, string, int64) error { return nil } +func (stubRansomState) RecordError(context.Context, string, string) error { return nil } + +func TestCollector_OnlyEmitsNewVictims(t *testing.T) { + now := time.Now().UTC() + known := ransomware.Victim{PostTitle: "Old", GroupName: "lockbit", Discovered: now.Add(-time.Hour)} + new1 := ransomware.Victim{PostTitle: "Acme", GroupName: "blackcat", Discovered: now} + new2 := ransomware.Victim{PostTitle: "Banco", GroupName: "play", Discovered: now} + + ftch := &stubFetcher{victims: []ransomware.Victim{known, new1, new2}} + repo := &stubRansomRepo{known: map[string]bool{known.ID(): true}} + emt := &stubRansomEmitter{} + + c := ransomware.NewCollector(ransomware.CollectorConfig{ + Interval: 30 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: stubRansomState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + inserted := repo.Inserted() + require.Len(t, inserted, 2) + + evs := emt.Events() + require.Len(t, evs, 2) + for _, ev := range evs { + require.Equal(t, events.TopicRansomwareVictim, ev.Topic) + require.Equal(t, ransomware.Name, ev.Source) + } +} From 6cc3e681dea2951987505dabc53cd94eea39706e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:49:48 -0400 Subject: [PATCH 032/109] =?UTF-8?q?feat(monitor/enrich/greynoise):=20on-de?= =?UTF-8?q?mand=20IP=20lookup=20with=20404=E2=86=92ErrUnknownIP=20via=20ht?= =?UTF-8?q?tpx.StatusError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/enrich/greynoise/client.go | 88 +++++++++++++++++++ .../internal/enrich/greynoise/client_test.go | 46 ++++++++++ .../enrich/greynoise/testdata/ip_lookup.json | 10 +++ .../backend/internal/httpx/client.go | 11 ++- 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go new file mode 100644 index 00000000..a8d30cc2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go @@ -0,0 +1,88 @@ +// ©AngelaMos | 2026 +// client.go + +package greynoise + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + + +const ( + defaultGNBaseURL = "https://api.greynoise.io" + pathCommunityLookup = "/v3/community/" + greynoiseAPIHeader = "key" + defaultGNRate = 2 * time.Second + defaultGNBudget = 3 + defaultGNBreakerWin = 5 * time.Minute + defaultGNRequestTO = 10 * time.Second +) + +var ErrUnknownIP = errors.New("greynoise: unknown ip") + +type ClientConfig struct { + BaseURL string + APIKey string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultGNBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "greynoise", + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + APIKeyHeader: greynoiseAPIHeader, + Rate: rate.Every(defaultGNRate), + Burst: 1, + ConsecutiveFailureBudget: defaultGNBudget, + BreakerTimeout: defaultGNBreakerWin, + RequestTimeout: defaultGNRequestTO, + }), + } +} + +type Verdict struct { + IP string `json:"ip"` + Noise bool `json:"noise"` + Riot bool `json:"riot"` + Classification string `json:"classification"` + Name string `json:"name"` + Link string `json:"link"` + LastSeen string `json:"last_seen"` + Message string `json:"message"` +} + +func (c *Client) Lookup(ctx context.Context, ip string) (Verdict, error) { + resp, err := c.hx.Get(ctx, pathCommunityLookup+url.PathEscape(ip), nil) + if err != nil { + var se *httpx.StatusError + if errors.As(err, &se) && se.Code == http.StatusNotFound { + return Verdict{}, ErrUnknownIP + } + return Verdict{}, fmt.Errorf("greynoise lookup %s: %w", ip, err) + } + defer resp.Body.Close() + + var v Verdict + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return Verdict{}, fmt.Errorf("greynoise decode: %w", err) + } + return v, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go new file mode 100644 index 00000000..289fb446 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// client_test.go + +package greynoise_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/greynoise" +) + +func TestClient_LookupDecodesVerdict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v3/community/8.8.8.8", r.URL.Path) + require.Equal(t, "test-key", r.Header.Get("key")) + body, err := os.ReadFile("testdata/ip_lookup.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}) + v, err := c.Lookup(context.Background(), "8.8.8.8") + require.NoError(t, err) + require.Equal(t, "8.8.8.8", v.IP) + require.True(t, v.Riot) + require.Equal(t, "benign", v.Classification) +} + +func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "k"}) + _, err := c.Lookup(context.Background(), "10.0.0.1") + require.ErrorIs(t, err, greynoise.ErrUnknownIP) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json new file mode 100644 index 00000000..ac414915 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/testdata/ip_lookup.json @@ -0,0 +1,10 @@ +{ + "ip": "8.8.8.8", + "noise": false, + "riot": true, + "classification": "benign", + "name": "Google Public DNS", + "link": "https://viz.greynoise.io/ip/8.8.8.8", + "last_seen": "2026-05-02", + "message": "Success" +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go index e214aba6..ba1bf59a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go @@ -52,6 +52,15 @@ type Client struct { hc *http.Client } +type StatusError struct { + Code int + Body string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("client error %d: %s", e.Code, e.Body) +} + func New(cfg Config) *Client { if cfg.RequestTimeout <= 0 { cfg.RequestTimeout = defaultRequestTimeout @@ -122,7 +131,7 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http. case r.StatusCode >= 400: body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit)) drainAndClose(r) - return backoff.Permanent(fmt.Errorf("client error %d: %s", r.StatusCode, body)) + return backoff.Permanent(&StatusError{Code: r.StatusCode, Body: string(body)}) } resp = r return nil From cb8cd902f6178256735a36d42c7fcc4159d39a9c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:50:55 -0400 Subject: [PATCH 033/109] feat(monitor/config): collectors config (toggles, intervals, API key bindings) --- .../backend/internal/config/config.go | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go index ff266e50..9b169704 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -15,15 +15,49 @@ import ( ) type Config struct { - App AppConfig `koanf:"app"` - Server ServerConfig `koanf:"server"` - Database DatabaseConfig `koanf:"database"` - Redis RedisConfig `koanf:"redis"` - JWT JWTConfig `koanf:"jwt"` - RateLimit RateLimitConfig `koanf:"rate_limit"` - CORS CORSConfig `koanf:"cors"` - Log LogConfig `koanf:"log"` - Otel OtelConfig `koanf:"otel"` + App AppConfig `koanf:"app"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + Redis RedisConfig `koanf:"redis"` + JWT JWTConfig `koanf:"jwt"` + RateLimit RateLimitConfig `koanf:"rate_limit"` + CORS CORSConfig `koanf:"cors"` + Log LogConfig `koanf:"log"` + Otel OtelConfig `koanf:"otel"` + Collectors CollectorsConfig `koanf:"collectors"` +} + +type CollectorsConfig struct { + DShield SourceConfig `koanf:"dshield"` + CFRadar CFRadarConfig `koanf:"cfradar"` + CVE CVEConfig `koanf:"cve"` + KEV SourceConfig `koanf:"kev"` + Ransomware SourceConfig `koanf:"ransomware"` + GreyNoise GreyNoiseConfig `koanf:"greynoise"` +} + +type SourceConfig struct { + Enabled bool `koanf:"enabled"` + Interval time.Duration `koanf:"interval"` +} + +type CFRadarConfig struct { + Enabled bool `koanf:"enabled"` + Interval time.Duration `koanf:"interval"` + BearerToken string `koanf:"bearer_token"` + MinConfidence int `koanf:"min_confidence"` +} + +type CVEConfig struct { + Enabled bool `koanf:"enabled"` + Interval time.Duration `koanf:"interval"` + Window time.Duration `koanf:"window"` + NVDAPIKey string `koanf:"nvd_api_key"` +} + +type GreyNoiseConfig struct { + Enabled bool `koanf:"enabled"` + APIKey string `koanf:"api_key"` } type AppConfig struct { @@ -202,6 +236,20 @@ func loadDefaults(k *koanf.Koanf) error { "otel.insecure": true, "otel.sample_rate": 0.1, "otel.service_name": "go-backend", + + "collectors.dshield.enabled": true, + "collectors.dshield.interval": "1h", + "collectors.cfradar.enabled": true, + "collectors.cfradar.interval": "5m", + "collectors.cfradar.min_confidence": 7, + "collectors.cve.enabled": true, + "collectors.cve.interval": "2h", + "collectors.cve.window": "2h", + "collectors.kev.enabled": true, + "collectors.kev.interval": "1h", + "collectors.ransomware.enabled": true, + "collectors.ransomware.interval": "15m", + "collectors.greynoise.enabled": true, } for key, value := range defaults { @@ -236,6 +284,9 @@ var envKeyMap = map[string]string{ "OTEL_ENABLED": "otel.enabled", "OTEL_INSECURE": "otel.insecure", "OTEL_SAMPLE_RATE": "otel.sample_rate", + "NVD_API_KEY": "collectors.cve.nvd_api_key", + "CF_RADAR_TOKEN": "collectors.cfradar.bearer_token", + "GREYNOISE_API_KEY": "collectors.greynoise.api_key", } func envKeyReplacer(s string) string { From c64141bc0b50a8c003a83ec49d71b898d72e1a7e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:51:50 -0400 Subject: [PATCH 034/109] feat(monitor/main): wire dshield/cfradar/cve/kev/ransomware collectors under errgroup --- .../backend/cmd/api/main.go | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index 9fbf02db..f7fa861f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -20,7 +20,13 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/admin" "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" "github.com/carterperez-dev/monitor-the-situation/backend/internal/bus" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/state" "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" @@ -146,10 +152,83 @@ func run(configPath string) error { Emitter: eventBus, }) + collectorState := state.NewRepo(db.DB) + collectorGroup, collectorCtx := errgroup.WithContext(ctx) collectorGroup.Go(func() error { return eventBus.Run(collectorCtx) }) collectorGroup.Go(func() error { return beat.Run(collectorCtx) }) - logger.Info("collectors started", "count", 1) + + if cfg.Collectors.DShield.Enabled { + coll := dshield.NewCollector(dshield.CollectorConfig{ + Interval: cfg.Collectors.DShield.Interval, + Fetcher: dshield.NewClient(dshield.ClientConfig{}), + Persister: dshield.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "dshield"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.CFRadar.Enabled { + coll := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: cfg.Collectors.CFRadar.Interval, + MinConfidence: cfg.Collectors.CFRadar.MinConfidence, + Fetcher: cfradar.NewClient(cfradar.ClientConfig{BearerToken: cfg.Collectors.CFRadar.BearerToken}), + Repo: cfradar.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "cfradar"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.CVE.Enabled { + coll := cve.NewCollector(cve.CollectorConfig{ + Interval: cfg.Collectors.CVE.Interval, + Window: cfg.Collectors.CVE.Window, + NVD: cve.NewNVDClient(cve.NVDClientConfig{APIKey: cfg.Collectors.CVE.NVDAPIKey}), + EPSS: cve.NewEPSSClient(cve.EPSSClientConfig{}), + Repo: cve.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "cve"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.KEV.Enabled { + coll := kev.NewCollector(kev.CollectorConfig{ + Interval: cfg.Collectors.KEV.Interval, + Fetcher: kev.NewClient(kev.ClientConfig{}), + Repo: kev.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "kev"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.Ransomware.Enabled { + coll := ransomware.NewCollector(ransomware.CollectorConfig{ + Interval: cfg.Collectors.Ransomware.Interval, + Fetcher: ransomware.NewClient(ransomware.ClientConfig{}), + Repo: ransomware.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "ransomware"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + logger.Info("collectors started", + "heartbeat", true, + "dshield", cfg.Collectors.DShield.Enabled, + "cfradar", cfg.Collectors.CFRadar.Enabled, + "cve", cfg.Collectors.CVE.Enabled, + "kev", cfg.Collectors.KEV.Enabled, + "ransomware", cfg.Collectors.Ransomware.Enabled, + ) srv := server.New(server.Config{ ServerConfig: cfg.Server, From b9c5fa1e13c5cccafb70b0cfce27b969c3bbd7b5 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Fri, 1 May 2026 22:53:58 -0400 Subject: [PATCH 035/109] docs(monitor): add cyber data source matrix to README --- .../monitor-the-situation-dashboard/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md index a2fb49c9..1eff6b2d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md @@ -19,6 +19,17 @@ Operator-grade real-time situational awareness dashboard. Single-binary Go backe | Ingress | nginx (dev + prod), Cloudflare Tunnel (prod) | | Build / run | `just` recipes, multi-stage Docker, air for live reload | +## Cyber data sources + +| Panel | Source | Cadence | Auth | +| ------------------------------ | ------------------------- | ---------- | --------------------------------------------- | +| Mass-scan firehose | DShield (SANS ISC) | 1h | none | +| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` (Radar:Read scope) | +| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional, raises rate limit) | +| CISA KEV | CISA KEV catalog | 1h | none | +| Ransomware victims | ransomware.live | 15m | none | +| IP enrichment | GreyNoise Community | on-demand | `GREYNOISE_API_KEY` (optional, free tier) | + ## Quickstart (development) ```bash From 687e13389eb78de5f3869e2c060b3ed5a6aab639 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:01:48 -0400 Subject: [PATCH 036/109] chore(monitor): tidy headers, drop unused StateDown, gofmt config defaults --- .../backend/cmd/api/main.go | 2 +- .../backend/internal/collectors/state/repo.go | 1 - .../backend/internal/config/config.go | 26 +++++++++---------- .../migrations/0001_template_baseline.sql | 2 +- .../backend/migrations/0002_alerts.sql | 2 +- .../backend/migrations/0003_panel_data.sql | 2 +- .../migrations/0004_collector_state.sql | 2 +- 7 files changed, 18 insertions(+), 19 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index f7fa861f..4b16eab6 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -1,4 +1,4 @@ -// AngelaMos | 2026 +// ©AngelaMos | 2026 // main.go package main diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go index 4e8c633d..f6852132 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go @@ -16,7 +16,6 @@ type CollectorState string const ( StateHealthy CollectorState = "healthy" StateDegraded CollectorState = "degraded" - StateDown CollectorState = "down" ) type Row struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go index 9b169704..19f7a43b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -1,4 +1,4 @@ -// AngelaMos | 2026 +// ©AngelaMos | 2026 // config.go package config @@ -237,19 +237,19 @@ func loadDefaults(k *koanf.Koanf) error { "otel.sample_rate": 0.1, "otel.service_name": "go-backend", - "collectors.dshield.enabled": true, - "collectors.dshield.interval": "1h", - "collectors.cfradar.enabled": true, - "collectors.cfradar.interval": "5m", + "collectors.dshield.enabled": true, + "collectors.dshield.interval": "1h", + "collectors.cfradar.enabled": true, + "collectors.cfradar.interval": "5m", "collectors.cfradar.min_confidence": 7, - "collectors.cve.enabled": true, - "collectors.cve.interval": "2h", - "collectors.cve.window": "2h", - "collectors.kev.enabled": true, - "collectors.kev.interval": "1h", - "collectors.ransomware.enabled": true, - "collectors.ransomware.interval": "15m", - "collectors.greynoise.enabled": true, + "collectors.cve.enabled": true, + "collectors.cve.interval": "2h", + "collectors.cve.window": "2h", + "collectors.kev.enabled": true, + "collectors.kev.interval": "1h", + "collectors.ransomware.enabled": true, + "collectors.ransomware.interval": "15m", + "collectors.greynoise.enabled": true, } for key, value := range defaults { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql index 1f06134e..1ddf345e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0001_template_baseline.sql @@ -1,5 +1,5 @@ -- ©AngelaMos | 2026 --- 00000_template_baseline.sql +-- 0001_template_baseline.sql -- +goose Up -- +goose StatementBegin diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql index a6c906af..40f1598b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0002_alerts.sql @@ -1,5 +1,5 @@ -- ©AngelaMos | 2026 --- 0001_alerts.sql +-- 0002_alerts.sql -- +goose Up -- +goose StatementBegin diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql index 3bcd3169..89565858 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0003_panel_data.sql @@ -1,5 +1,5 @@ -- ©AngelaMos | 2026 --- 0002_panel_data.sql +-- 0003_panel_data.sql -- +goose Up -- +goose StatementBegin diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql index 29acdd36..a3d4e89e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0004_collector_state.sql @@ -1,5 +1,5 @@ -- ©AngelaMos | 2026 --- 0003_collector_state.sql +-- 0004_collector_state.sql -- +goose Up -- +goose StatementBegin From ae5a83c2f8621d225ef90c0ff92bdfd9f5c75aeb Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:24:55 -0400 Subject: [PATCH 037/109] feat(monitor/collectors/coinbase): ticks + minute OHLC repo with 1h history reads Adds shopspring/decimal as a require (folded with the first consuming task per Plan 2 convention). cenkalti/backoff/v4 is also now consumed transitively; remains indirect until Task 5 (reconnect) imports it. --- .../backend/go.mod | 1 + .../backend/go.sum | 2 + .../internal/collectors/coinbase/repo.go | 101 ++++++++++++ .../internal/collectors/coinbase/repo_test.go | 148 ++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index 01de56e5..fbb03443 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -91,6 +91,7 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 3a4aa362..cafc7d97 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -179,6 +179,8 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go new file mode 100644 index 00000000..6c91fcf3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go @@ -0,0 +1,101 @@ +// ©AngelaMos | 2026 +// repo.go + +package coinbase + +import ( + "context" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" +) + +const ( + history1hLimit = 60 +) + +type Tick struct { + Symbol string `db:"symbol"` + TS time.Time `db:"ts"` + Price decimal.Decimal `db:"price"` + Volume24h decimal.Decimal `db:"volume_24h"` +} + +type MinuteBar struct { + Symbol string `db:"symbol"` + Minute time.Time `db:"minute"` + Open decimal.Decimal `db:"open"` + High decimal.Decimal `db:"high"` + Low decimal.Decimal `db:"low"` + Close decimal.Decimal `db:"close"` + Volume decimal.Decimal `db:"volume"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) InsertTick(ctx context.Context, t Tick) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO btc_eth_ticks (symbol, ts, price, volume_24h) + VALUES ($1, $2, $3, $4) + ON CONFLICT (symbol, ts) DO NOTHING`, + t.Symbol, t.TS, t.Price, t.Volume24h, + ) + if err != nil { + return fmt.Errorf("insert tick %s @ %s: %w", t.Symbol, t.TS, err) + } + return nil +} + +func (r *Repo) UpsertMinute(ctx context.Context, b MinuteBar) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO btc_eth_minute (symbol, minute, open, high, low, close, volume) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (symbol, minute) DO UPDATE SET + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume = EXCLUDED.volume`, + b.Symbol, b.Minute, b.Open, b.High, b.Low, b.Close, b.Volume, + ) + if err != nil { + return fmt.Errorf("upsert minute %s @ %s: %w", b.Symbol, b.Minute, err) + } + return nil +} + +func (r *Repo) LatestTick(ctx context.Context, symbol string) (Tick, error) { + var t Tick + err := r.db.GetContext(ctx, &t, ` + SELECT symbol, ts, price, volume_24h + FROM btc_eth_ticks + WHERE symbol = $1 + ORDER BY ts DESC LIMIT 1`, symbol) + if err != nil { + return Tick{}, err + } + return t, nil +} + +func (r *Repo) History1h(ctx context.Context, symbol string) ([]MinuteBar, error) { + var rows []MinuteBar + err := r.db.SelectContext(ctx, &rows, ` + SELECT symbol, minute, open, high, low, close, volume + FROM btc_eth_minute + WHERE symbol = $1 + ORDER BY minute DESC LIMIT $2`, symbol, history1hLimit, + ) + if err != nil { + return nil, fmt.Errorf("history1h %s: %w", symbol, err) + } + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go new file mode 100644 index 00000000..ed26fe3b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go @@ -0,0 +1,148 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package coinbase_test + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE btc_eth_ticks ( + symbol text NOT NULL, + ts timestamptz NOT NULL, + price numeric(18,8) NOT NULL, + volume_24h numeric(20,8), + PRIMARY KEY (symbol, ts) + ); + CREATE TABLE btc_eth_minute ( + symbol text NOT NULL, + minute timestamptz NOT NULL, + open numeric(18,8) NOT NULL, + high numeric(18,8) NOT NULL, + low numeric(18,8) NOT NULL, + close numeric(18,8) NOT NULL, + volume numeric(20,8), + PRIMARY KEY (symbol, minute) + );`) + require.NoError(t, err) + return db +} + +func TestRepo_InsertTickIdempotent(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + ts := time.Now().UTC().Truncate(time.Microsecond) + tk := coinbase.Tick{ + Symbol: "BTC-USD", + TS: ts, + Price: decimal.RequireFromString("42163.45"), + Volume24h: decimal.RequireFromString("19834.91230000"), + } + require.NoError(t, repo.InsertTick(ctx, tk)) + require.NoError(t, repo.InsertTick(ctx, tk)) + + got, err := repo.LatestTick(ctx, "BTC-USD") + require.NoError(t, err) + require.True(t, got.TS.Equal(ts)) + require.True(t, got.Price.Equal(tk.Price)) + require.True(t, got.Volume24h.Equal(tk.Volume24h)) +} + +func TestRepo_UpsertMinuteUpdatesOHLC(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + minute := time.Now().UTC().Truncate(time.Minute) + bar := coinbase.MinuteBar{ + Symbol: "ETH-USD", + Minute: minute, + Open: decimal.RequireFromString("2310.00"), + High: decimal.RequireFromString("2315.50"), + Low: decimal.RequireFromString("2308.10"), + Close: decimal.RequireFromString("2312.75"), + Volume: decimal.RequireFromString("88.12300000"), + } + require.NoError(t, repo.UpsertMinute(ctx, bar)) + + bar.Close = decimal.RequireFromString("2316.00") + bar.High = decimal.RequireFromString("2317.00") + require.NoError(t, repo.UpsertMinute(ctx, bar)) + + hist, err := repo.History1h(ctx, "ETH-USD") + require.NoError(t, err) + require.Len(t, hist, 1) + require.True(t, hist[0].Close.Equal(decimal.RequireFromString("2316.00"))) + require.True(t, hist[0].High.Equal(decimal.RequireFromString("2317.00"))) +} + +func TestRepo_History1hReturnsLast60MinutesOldestFirst(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + now := time.Now().UTC().Truncate(time.Minute) + for i := 0; i < 75; i++ { + bar := coinbase.MinuteBar{ + Symbol: "BTC-USD", + Minute: now.Add(-time.Duration(i) * time.Minute), + Open: decimal.NewFromInt(int64(40000 + i)), + High: decimal.NewFromInt(int64(40050 + i)), + Low: decimal.NewFromInt(int64(39950 + i)), + Close: decimal.NewFromInt(int64(40010 + i)), + Volume: decimal.NewFromInt(int64(i)), + } + require.NoError(t, repo.UpsertMinute(ctx, bar)) + } + + hist, err := repo.History1h(ctx, "BTC-USD") + require.NoError(t, err) + require.Len(t, hist, 60) + require.True(t, hist[0].Minute.Before(hist[len(hist)-1].Minute), "history must be oldest → newest") +} + +func TestRepo_LatestTickMissingReturnsErrNoRows(t *testing.T) { + db := setupDB(t) + repo := coinbase.NewRepo(db) + ctx := context.Background() + + _, err := repo.LatestTick(ctx, "DOES-NOT-EXIST") + require.True(t, errors.Is(err, sql.ErrNoRows)) +} From 10862314f02cc62b3f1381d244febc73b7b1de93 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:26:58 -0400 Subject: [PATCH 038/109] feat(monitor/collectors/coinbase): WS dialer + frame decoder for ticker/heartbeats/snapshot --- .../internal/collectors/coinbase/client.go | 191 ++++++++++++++++++ .../collectors/coinbase/client_test.go | 190 +++++++++++++++++ .../coinbase/testdata/heartbeats.json | 9 + .../coinbase/testdata/snapshot.json | 15 ++ .../coinbase/testdata/subscriptions.json | 9 + .../collectors/coinbase/testdata/ticker.json | 20 ++ 6 files changed, 434 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/heartbeats.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/snapshot.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/subscriptions.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/ticker.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go new file mode 100644 index 00000000..0489b1e7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go @@ -0,0 +1,191 @@ +// ©AngelaMos | 2026 +// client.go + +package coinbase + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/coder/websocket" + "github.com/shopspring/decimal" +) + +const ( + defaultProductBTC = "BTC-USD" + defaultProductETH = "ETH-USD" + channelTicker = "ticker" + channelHeartbeats = "heartbeats" + channelSubscriptions = "subscriptions" + subscribeWriteTimeout = 5 * time.Second + defaultReadFrameSize = 1 << 20 +) + +type FrameType int + +const ( + FrameTypeUnknown FrameType = iota + FrameTypeSubscriptions + FrameTypeTicker + FrameTypeSnapshot + FrameTypeHeartbeats +) + +type TickerEntry struct { + ProductID string `json:"product_id"` + Price decimal.Decimal `json:"price"` + Volume24h decimal.Decimal `json:"volume_24_h"` + Time time.Time `json:"time"` +} + +type Frame struct { + Kind FrameType + SequenceNum int64 + Timestamp time.Time + Tickers []TickerEntry + HeartbeatTime time.Time +} + +type DialerConfig struct { + URL string + ProductIDs []string + WriteTimeout time.Duration + ReadFrameMax int64 +} + +type Dialer interface { + Dial(ctx context.Context) (*Conn, error) +} + +type WSDialer struct { + cfg DialerConfig +} + +func NewWSDialer(cfg DialerConfig) *WSDialer { + if len(cfg.ProductIDs) == 0 { + cfg.ProductIDs = []string{defaultProductBTC, defaultProductETH} + } + if cfg.WriteTimeout <= 0 { + cfg.WriteTimeout = subscribeWriteTimeout + } + if cfg.ReadFrameMax <= 0 { + cfg.ReadFrameMax = defaultReadFrameSize + } + return &WSDialer{cfg: cfg} +} + +func (d *WSDialer) Dial(ctx context.Context) (*Conn, error) { + c, _, err := websocket.Dial(ctx, d.cfg.URL, nil) + if err != nil { + return nil, fmt.Errorf("dial coinbase ws: %w", err) + } + c.SetReadLimit(d.cfg.ReadFrameMax) + + conn := &Conn{c: c} + subCtx, cancel := context.WithTimeout(ctx, d.cfg.WriteTimeout) + defer cancel() + if err := conn.subscribe(subCtx, channelTicker, d.cfg.ProductIDs); err != nil { + _ = c.Close(websocket.StatusInternalError, "subscribe ticker") + return nil, err + } + if err := conn.subscribe(subCtx, channelHeartbeats, nil); err != nil { + _ = c.Close(websocket.StatusInternalError, "subscribe heartbeats") + return nil, err + } + return conn, nil +} + +type Conn struct { + c *websocket.Conn +} + +func (c *Conn) Close() error { + if c == nil || c.c == nil { + return nil + } + return c.c.Close(websocket.StatusNormalClosure, "") +} + +type subscribeMsg struct { + Type string `json:"type"` + Channel string `json:"channel"` + ProductIDs []string `json:"product_ids,omitempty"` +} + +func (c *Conn) subscribe(ctx context.Context, channel string, productIDs []string) error { + msg := subscribeMsg{Type: "subscribe", Channel: channel, ProductIDs: productIDs} + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal subscribe %s: %w", channel, err) + } + if err := c.c.Write(ctx, websocket.MessageText, body); err != nil { + return fmt.Errorf("write subscribe %s: %w", channel, err) + } + return nil +} + +type rawEnvelope struct { + Channel string `json:"channel"` + SequenceNum int64 `json:"sequence_num"` + Timestamp time.Time `json:"timestamp"` + Events []rawEnvelopeEv `json:"events"` +} + +type rawEnvelopeEv struct { + Type string `json:"type"` + Tickers []TickerEntry `json:"tickers,omitempty"` + CurrentTime time.Time `json:"current_time,omitempty"` +} + +var ErrFrameMalformed = errors.New("coinbase: malformed frame") + +func (c *Conn) ReadFrame(ctx context.Context) (Frame, error) { + _, msg, err := c.c.Read(ctx) + if err != nil { + return Frame{}, fmt.Errorf("read coinbase frame: %w", err) + } + return decodeFrame(msg) +} + +func decodeFrame(msg []byte) (Frame, error) { + var env rawEnvelope + if err := json.Unmarshal(msg, &env); err != nil { + return Frame{}, fmt.Errorf("%w: %v", ErrFrameMalformed, err) + } + + frame := Frame{ + SequenceNum: env.SequenceNum, + Timestamp: env.Timestamp, + } + + switch env.Channel { + case channelSubscriptions: + frame.Kind = FrameTypeSubscriptions + return frame, nil + + case channelHeartbeats: + frame.Kind = FrameTypeHeartbeats + if len(env.Events) > 0 { + frame.HeartbeatTime = env.Events[0].CurrentTime + } + return frame, nil + + case channelTicker: + for _, ev := range env.Events { + frame.Tickers = append(frame.Tickers, ev.Tickers...) + if ev.Type == "snapshot" { + frame.Kind = FrameTypeSnapshot + } + } + if frame.Kind != FrameTypeSnapshot { + frame.Kind = FrameTypeTicker + } + return frame, nil + } + + frame.Kind = FrameTypeUnknown + return frame, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go new file mode 100644 index 00000000..617df843 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go @@ -0,0 +1,190 @@ +// ©AngelaMos | 2026 +// client_test.go + +package coinbase_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +type fakeServer struct { + t *testing.T + frames [][]byte + received []string + mu sync.Mutex + srv *httptest.Server +} + +func newFakeServer(t *testing.T, frames ...[]byte) *fakeServer { + t.Helper() + fs := &fakeServer{t: t, frames: frames} + fs.srv = httptest.NewServer(http.HandlerFunc(fs.handle)) + t.Cleanup(fs.srv.Close) + return fs +} + +func (fs *fakeServer) URL() string { + return "ws" + strings.TrimPrefix(fs.srv.URL, "http") +} + +func (fs *fakeServer) Received() []string { + fs.mu.Lock() + defer fs.mu.Unlock() + out := make([]string, len(fs.received)) + copy(out, fs.received) + return out +} + +func (fs *fakeServer) handle(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + for i := 0; i < 2; i++ { + _, msg, err := conn.Read(ctx) + if err != nil { + return + } + fs.mu.Lock() + fs.received = append(fs.received, string(msg)) + fs.mu.Unlock() + } + + for _, frame := range fs.frames { + if err := conn.Write(ctx, websocket.MessageText, frame); err != nil { + return + } + } + + <-ctx.Done() +} + +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile("testdata/" + name) + require.NoError(t, err) + return b +} + +func TestClient_DialSubscribesTickerAndHeartbeats(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "ticker.json"), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD", "ETH-USD"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + frame, err := conn.ReadFrame(ctx) + require.NoError(t, err) + require.Equal(t, coinbase.FrameTypeSubscriptions, frame.Kind) + + frame, err = conn.ReadFrame(ctx) + require.NoError(t, err) + require.Equal(t, coinbase.FrameTypeTicker, frame.Kind) + require.Len(t, frame.Tickers, 1) + require.Equal(t, "BTC-USD", frame.Tickers[0].ProductID) + require.Equal(t, "42163.45", frame.Tickers[0].Price.String()) + + require.Eventually(t, func() bool { + recv := fs.Received() + if len(recv) < 2 { + return false + } + var subTicker, subHB struct { + Type string `json:"type"` + ProductIDs []string `json:"product_ids"` + Channel string `json:"channel"` + } + if err := json.Unmarshal([]byte(recv[0]), &subTicker); err != nil { + return false + } + if err := json.Unmarshal([]byte(recv[1]), &subHB); err != nil { + return false + } + return subTicker.Type == "subscribe" && + subTicker.Channel == "ticker" && + len(subTicker.ProductIDs) == 2 && + subHB.Type == "subscribe" && + subHB.Channel == "heartbeats" + }, time.Second, 20*time.Millisecond) +} + +func TestClient_DecodeSnapshotFrameYieldsAllTickers(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "snapshot.json"), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD", "ETH-USD"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + for { + frame, err := conn.ReadFrame(ctx) + require.NoError(t, err) + if frame.Kind == coinbase.FrameTypeSnapshot { + require.Len(t, frame.Tickers, 2) + return + } + } +} + +func TestClient_DecodeHeartbeatExposesCurrentTime(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "heartbeats.json"), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + for { + frame, err := conn.ReadFrame(ctx) + require.NoError(t, err) + if frame.Kind == coinbase.FrameTypeHeartbeats { + require.False(t, frame.HeartbeatTime.IsZero()) + return + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/heartbeats.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/heartbeats.json new file mode 100644 index 00000000..e99d33e7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/heartbeats.json @@ -0,0 +1,9 @@ +{ + "channel": "heartbeats", + "client_id": "", + "timestamp": "2026-05-01T22:30:00.000Z", + "sequence_num": 12346, + "events": [ + {"current_time": "2026-05-01T22:30:00.000Z", "heartbeat_counter": 42} + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/snapshot.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/snapshot.json new file mode 100644 index 00000000..73019b59 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/snapshot.json @@ -0,0 +1,15 @@ +{ + "channel": "ticker", + "client_id": "", + "timestamp": "2026-05-01T22:30:01.000Z", + "sequence_num": 1, + "events": [ + { + "type": "snapshot", + "tickers": [ + {"type": "ticker", "product_id": "BTC-USD", "price": "42163.45", "volume_24_h": "19834.9123", "time": "2026-05-01T22:30:01.000Z"}, + {"type": "ticker", "product_id": "ETH-USD", "price": "2312.75", "volume_24_h": "88123.4567", "time": "2026-05-01T22:30:01.000Z"} + ] + } + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/subscriptions.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/subscriptions.json new file mode 100644 index 00000000..d7383fc4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/subscriptions.json @@ -0,0 +1,9 @@ +{ + "channel": "subscriptions", + "client_id": "", + "timestamp": "2026-05-01T22:29:59.000Z", + "sequence_num": 0, + "events": [ + {"subscriptions": {"ticker": ["BTC-USD","ETH-USD"], "heartbeats": [""]}} + ] +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/ticker.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/ticker.json new file mode 100644 index 00000000..1c02de25 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/testdata/ticker.json @@ -0,0 +1,20 @@ +{ + "channel": "ticker", + "client_id": "", + "timestamp": "2026-05-01T22:30:00.000Z", + "sequence_num": 12345, + "events": [ + { + "type": "update", + "tickers": [ + { + "type": "ticker", + "product_id": "BTC-USD", + "price": "42163.45", + "volume_24_h": "19834.9123", + "time": "2026-05-01T22:30:00.000Z" + } + ] + } + ] +} From 3e398da3fbd27aee3c06eb3acc994911fba8bdb5 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:27:32 -0400 Subject: [PATCH 039/109] feat(monitor/collectors/coinbase): per-product sequence-gap detector --- .../internal/collectors/coinbase/sequencer.go | 32 ++++++++++ .../collectors/coinbase/sequencer_test.go | 63 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go new file mode 100644 index 00000000..340a19b9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go @@ -0,0 +1,32 @@ +// ©AngelaMos | 2026 +// sequencer.go + +package coinbase + +import "sync" + +type Sequencer struct { + mu sync.Mutex + last map[string]int64 +} + +func NewSequencer() *Sequencer { + return &Sequencer{last: make(map[string]int64)} +} + +func (s *Sequencer) Observe(productID string, seq int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + prev, ok := s.last[productID] + s.last[productID] = seq + if !ok { + return false + } + return seq != prev+1 +} + +func (s *Sequencer) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.last = make(map[string]int64) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go new file mode 100644 index 00000000..a67ba6fc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go @@ -0,0 +1,63 @@ +// ©AngelaMos | 2026 +// sequencer_test.go + +package coinbase_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func TestSequencer_FirstTickIsAlwaysInOrder(t *testing.T) { + s := coinbase.NewSequencer() + gap := s.Observe("BTC-USD", 100) + require.False(t, gap, "first sequence number must not be a gap") +} + +func TestSequencer_ConsecutiveSequencesAreInOrder(t *testing.T) { + s := coinbase.NewSequencer() + for i := int64(50); i < 60; i++ { + gap := s.Observe("BTC-USD", i) + require.False(t, gap, "i=%d", i) + } +} + +func TestSequencer_GapTriggersReportSignal(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe("BTC-USD", 50)) + require.False(t, s.Observe("BTC-USD", 51)) + require.True(t, s.Observe("BTC-USD", 60), "skipping 52-59 must report gap") +} + +func TestSequencer_GapsArePerProduct(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe("BTC-USD", 100)) + require.False(t, s.Observe("ETH-USD", 200)) + require.False(t, s.Observe("BTC-USD", 101)) + require.True(t, s.Observe("ETH-USD", 250)) + require.False(t, s.Observe("BTC-USD", 102)) +} + +func TestSequencer_ResetClearsAllProducts(t *testing.T) { + s := coinbase.NewSequencer() + s.Observe("BTC-USD", 100) + s.Observe("ETH-USD", 200) + s.Reset() + require.False(t, s.Observe("BTC-USD", 9999)) + require.False(t, s.Observe("ETH-USD", 8888)) +} + +func TestSequencer_DuplicateSequenceTreatedAsGap(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe("BTC-USD", 100)) + require.True(t, s.Observe("BTC-USD", 100), "replaying the same seq is a gap") +} + +func TestSequencer_BackwardSequenceTreatedAsGap(t *testing.T) { + s := coinbase.NewSequencer() + require.False(t, s.Observe("BTC-USD", 100)) + require.True(t, s.Observe("BTC-USD", 90)) +} From 3be4c9d815993b2b313700871b59b6f38b579a06 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:28:24 -0400 Subject: [PATCH 040/109] feat(monitor/collectors/coinbase): reconnect-with-backoff helper (1s init, 60s max, ctx-aware) --- .../internal/collectors/coinbase/reconnect.go | 70 ++++++++++++++++ .../collectors/coinbase/reconnect_test.go | 81 +++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go new file mode 100644 index 00000000..30772b0f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go @@ -0,0 +1,70 @@ +// ©AngelaMos | 2026 +// reconnect.go + +package coinbase + +import ( + "context" + "errors" + "time" + + "github.com/cenkalti/backoff/v4" +) + +const ( + defaultReconnectInitial = time.Second + defaultReconnectMax = 60 * time.Second +) + +type ReconnectConfig struct { + InitialInterval time.Duration + MaxInterval time.Duration +} + +type ConnHandler func(ctx context.Context, conn *Conn) error + +func PermanentReconnectError(err error) error { + return backoff.Permanent(err) +} + +func Reconnect(ctx context.Context, d Dialer, cfg ReconnectConfig, handler ConnHandler) error { + if cfg.InitialInterval <= 0 { + cfg.InitialInterval = defaultReconnectInitial + } + if cfg.MaxInterval <= 0 { + cfg.MaxInterval = defaultReconnectMax + } + + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = cfg.InitialInterval + bo.MaxInterval = cfg.MaxInterval + bo.MaxElapsedTime = 0 + + op := func() error { + if ctx.Err() != nil { + return backoff.Permanent(ctx.Err()) + } + conn, err := d.Dial(ctx) + if err != nil { + if ctx.Err() != nil { + return backoff.Permanent(ctx.Err()) + } + return err + } + hErr := handler(ctx, conn) + _ = conn.Close() + if hErr == nil { + return nil + } + var perm *backoff.PermanentError + if errors.As(hErr, &perm) { + return hErr + } + if ctx.Err() != nil { + return backoff.Permanent(ctx.Err()) + } + return hErr + } + + return backoff.Retry(op, backoff.WithContext(bo, ctx)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go new file mode 100644 index 00000000..dfa9738d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go @@ -0,0 +1,81 @@ +// ©AngelaMos | 2026 +// reconnect_test.go + +package coinbase_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +type stubDialer struct { + failsBeforeOK int32 + called atomic.Int32 +} + +func (d *stubDialer) Dial(_ context.Context) (*coinbase.Conn, error) { + n := d.called.Add(1) + if n <= d.failsBeforeOK { + return nil, errors.New("transient dial failure") + } + return &coinbase.Conn{}, nil +} + +func TestReconnect_StopsOnContextCancel(t *testing.T) { + d := &stubDialer{failsBeforeOK: 0} + + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + + calls := atomic.Int32{} + err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }, func(_ context.Context, _ *coinbase.Conn) error { + calls.Add(1) + return errors.New("upstream closed") + }) + require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) + require.Greater(t, calls.Load(), int32(1), "callback should fire multiple times before ctx expires") +} + +func TestReconnect_TransientDialFailureBacksOffThenSucceeds(t *testing.T) { + d := &stubDialer{failsBeforeOK: 3} + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + cbInvocations := atomic.Int32{} + err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{ + InitialInterval: 10 * time.Millisecond, + MaxInterval: 50 * time.Millisecond, + }, func(_ context.Context, _ *coinbase.Conn) error { + cbInvocations.Add(1) + return errors.New("force loop") + }) + require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) + require.GreaterOrEqual(t, d.called.Load(), int32(4), "must dial at least 4 times (3 failures + 1 success)") + require.GreaterOrEqual(t, cbInvocations.Load(), int32(1)) +} + +func TestReconnect_PermanentErrorFromCallbackPropagates(t *testing.T) { + d := &stubDialer{failsBeforeOK: 0} + sentinel := errors.New("hard stop") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 10 * time.Millisecond, + }, func(_ context.Context, _ *coinbase.Conn) error { + return coinbase.PermanentReconnectError(sentinel) + }) + require.ErrorIs(t, err, sentinel) +} From 23a4e2ded4ed8e474f1f9688809bb44432bd5bc2 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:29:43 -0400 Subject: [PATCH 041/109] =?UTF-8?q?feat(monitor/collectors/coinbase):=20ga?= =?UTF-8?q?p-aware=20ReadLoop=20(snapshot=20resets=20sequencer;=20gap=20?= =?UTF-8?q?=E2=86=92=20ErrSequenceGap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/collectors/coinbase/readloop.go | 46 ++++++++ .../collectors/coinbase/readloop_test.go | 106 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go new file mode 100644 index 00000000..a3b404cf --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go @@ -0,0 +1,46 @@ +// ©AngelaMos | 2026 +// readloop.go + +package coinbase + +import ( + "context" + "errors" +) + +var ErrSequenceGap = errors.New("coinbase: sequence gap detected") + +type FrameHandler func(ctx context.Context, f Frame) error + +func ReadLoop(ctx context.Context, conn *Conn, seq *Sequencer, handler FrameHandler) error { + for { + if err := ctx.Err(); err != nil { + return err + } + frame, err := conn.ReadFrame(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return err + } + + switch frame.Kind { + case FrameTypeSnapshot: + seq.Reset() + for _, t := range frame.Tickers { + _ = seq.Observe(t.ProductID, frame.SequenceNum) + } + case FrameTypeTicker: + for _, t := range frame.Tickers { + if seq.Observe(t.ProductID, frame.SequenceNum) { + return ErrSequenceGap + } + } + } + + if err := handler(ctx, frame); err != nil { + return err + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go new file mode 100644 index 00000000..b1af6e69 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go @@ -0,0 +1,106 @@ +// ©AngelaMos | 2026 +// readloop_test.go + +package coinbase_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func TestReadLoop_DeliversTickerFrames(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + []byte(`{"channel":"ticker","sequence_num":1000,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`), + []byte(`{"channel":"ticker","sequence_num":1001,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + seq := coinbase.NewSequencer() + mu := sync.Mutex{} + tickerFrames := 0 + + loopCtx, loopCancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer loopCancel() + + err = coinbase.ReadLoop(loopCtx, conn, seq, func(_ context.Context, f coinbase.Frame) error { + mu.Lock() + defer mu.Unlock() + if f.Kind == coinbase.FrameTypeTicker { + tickerFrames++ + } + return nil + }) + require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || err == nil) + mu.Lock() + defer mu.Unlock() + require.GreaterOrEqual(t, tickerFrames, 2) +} + +func TestReadLoop_GapTriggersErrSequenceGap(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + []byte(`{"channel":"ticker","sequence_num":100,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`), + []byte(`{"channel":"ticker","sequence_num":250,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + seq := coinbase.NewSequencer() + loopErr := coinbase.ReadLoop(ctx, conn, seq, func(context.Context, coinbase.Frame) error { + return nil + }) + require.ErrorIs(t, loopErr, coinbase.ErrSequenceGap) +} + +func TestReadLoop_SnapshotResetsSequencer(t *testing.T) { + fs := newFakeServer(t, + loadFixture(t, "subscriptions.json"), + loadFixture(t, "snapshot.json"), + []byte(`{"channel":"ticker","sequence_num":2,"timestamp":"2026-05-01T22:30:02Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42164.00","volume_24_h":"1.0","time":"2026-05-01T22:30:02Z"}]}]}`), + ) + + d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, err := d.Dial(ctx) + require.NoError(t, err) + defer conn.Close() + + seq := coinbase.NewSequencer() + mu := sync.Mutex{} + kinds := []coinbase.FrameType{} + + loopCtx, loopCancel := context.WithTimeout(ctx, 800*time.Millisecond) + defer loopCancel() + err = coinbase.ReadLoop(loopCtx, conn, seq, func(_ context.Context, f coinbase.Frame) error { + mu.Lock() + defer mu.Unlock() + kinds = append(kinds, f.Kind) + return nil + }) + require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || err == nil) + + mu.Lock() + defer mu.Unlock() + require.Contains(t, kinds, coinbase.FrameTypeSnapshot) + require.Contains(t, kinds, coinbase.FrameTypeTicker) +} From f6192e9c044adf58c6f8cb22082ce0883be0584e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:30:24 -0400 Subject: [PATCH 042/109] feat(monitor/collectors/coinbase): per-product minute aggregator with rollover-emit --- .../collectors/coinbase/aggregator.go | 70 +++++++++++++++ .../collectors/coinbase/aggregator_test.go | 88 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go new file mode 100644 index 00000000..2da928b2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go @@ -0,0 +1,70 @@ +// ©AngelaMos | 2026 +// aggregator.go + +package coinbase + +import "time" + +type Aggregator struct { + open map[string]MinuteBar +} + +func NewAggregator() *Aggregator { + return &Aggregator{open: make(map[string]MinuteBar)} +} + +func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) { + minute := t.TS.UTC().Truncate(time.Minute) + cur, exists := a.open[t.Symbol] + + if !exists { + cur = MinuteBar{ + Symbol: t.Symbol, + Minute: minute, + Open: t.Price, + High: t.Price, + Low: t.Price, + Close: t.Price, + Volume: t.Volume24h, + } + a.open[t.Symbol] = cur + return nil, cur + } + + if minute.After(cur.Minute) { + closed := cur + cur = MinuteBar{ + Symbol: t.Symbol, + Minute: minute, + Open: t.Price, + High: t.Price, + Low: t.Price, + Close: t.Price, + Volume: t.Volume24h, + } + a.open[t.Symbol] = cur + return &closed, cur + } + + if t.Price.GreaterThan(cur.High) { + cur.High = t.Price + } + if t.Price.LessThan(cur.Low) { + cur.Low = t.Price + } + cur.Close = t.Price + if t.Volume24h.GreaterThan(cur.Volume) { + cur.Volume = t.Volume24h + } + a.open[t.Symbol] = cur + return nil, cur +} + +func (a *Aggregator) Flush() []MinuteBar { + out := make([]MinuteBar, 0, len(a.open)) + for _, bar := range a.open { + out = append(out, bar) + } + a.open = make(map[string]MinuteBar) + return out +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go new file mode 100644 index 00000000..60d46741 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go @@ -0,0 +1,88 @@ +// ©AngelaMos | 2026 +// aggregator_test.go + +package coinbase_test + +import ( + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" +) + +func TestAggregator_FirstTickOpensBar(t *testing.T) { + a := coinbase.NewAggregator() + ts := time.Date(2026, 5, 1, 12, 0, 30, 0, time.UTC) + closed, cur := a.Push(coinbase.Tick{ + Symbol: "BTC-USD", TS: ts, + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }) + require.Nil(t, closed) + require.Equal(t, ts.Truncate(time.Minute), cur.Minute) + require.True(t, cur.Open.Equal(decimal.RequireFromString("42000"))) + require.True(t, cur.Close.Equal(decimal.RequireFromString("42000"))) +} + +func TestAggregator_FoldUpdatesHighLowClose(t *testing.T) { + a := coinbase.NewAggregator() + min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) + a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(20 * time.Second), Price: decimal.RequireFromString("42050"), Volume24h: decimal.RequireFromString("110")}) + closed, cur := a.Push(coinbase.Tick{ + Symbol: "BTC-USD", TS: min.Add(30 * time.Second), + Price: decimal.RequireFromString("41950"), Volume24h: decimal.RequireFromString("120"), + }) + require.Nil(t, closed) + require.True(t, cur.Open.Equal(decimal.RequireFromString("42000"))) + require.True(t, cur.High.Equal(decimal.RequireFromString("42050"))) + require.True(t, cur.Low.Equal(decimal.RequireFromString("41950"))) + require.True(t, cur.Close.Equal(decimal.RequireFromString("41950"))) + require.True(t, cur.Volume.Equal(decimal.RequireFromString("120"))) +} + +func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) { + a := coinbase.NewAggregator() + min1 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + min2 := min1.Add(time.Minute) + + a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min1.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) + a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min1.Add(50 * time.Second), Price: decimal.RequireFromString("42100"), Volume24h: decimal.RequireFromString("105")}) + + closed, cur := a.Push(coinbase.Tick{ + Symbol: "BTC-USD", TS: min2.Add(5 * time.Second), + Price: decimal.RequireFromString("42150"), Volume24h: decimal.RequireFromString("110"), + }) + + require.NotNil(t, closed) + require.Equal(t, min1, closed.Minute) + require.True(t, closed.Close.Equal(decimal.RequireFromString("42100"))) + + require.Equal(t, min2, cur.Minute) + require.True(t, cur.Open.Equal(decimal.RequireFromString("42150"))) +} + +func TestAggregator_BarsArePerProduct(t *testing.T) { + a := coinbase.NewAggregator() + min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + + _, btc := a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) + _, eth := a.Push(coinbase.Tick{Symbol: "ETH-USD", TS: min.Add(11 * time.Second), Price: decimal.RequireFromString("2300"), Volume24h: decimal.RequireFromString("80")}) + + require.Equal(t, "BTC-USD", btc.Symbol) + require.Equal(t, "ETH-USD", eth.Symbol) + require.True(t, btc.Open.Equal(decimal.RequireFromString("42000"))) + require.True(t, eth.Open.Equal(decimal.RequireFromString("2300"))) +} + +func TestAggregator_FlushReturnsAllOpenBars(t *testing.T) { + a := coinbase.NewAggregator() + min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) + a.Push(coinbase.Tick{Symbol: "ETH-USD", TS: min.Add(15 * time.Second), Price: decimal.RequireFromString("2300"), Volume24h: decimal.RequireFromString("80")}) + bars := a.Flush() + require.Len(t, bars, 2) +} From 89f1c102492b013425f9e783e6a2fe6b7b68cad1 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:32:16 -0400 Subject: [PATCH 043/109] feat(monitor/collectors/coinbase): collector Run loop ties dial+readloop+aggregator+repo+throttled-emit --- .../internal/collectors/coinbase/collector.go | 169 +++++++++++++++++ .../collectors/coinbase/collector_test.go | 177 ++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go new file mode 100644 index 00000000..457a51aa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go @@ -0,0 +1,169 @@ +// ©AngelaMos | 2026 +// collector.go + +package coinbase + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "sync" + "time" + + "github.com/shopspring/decimal" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "coinbase" + defaultURL = "wss://advanced-trade-ws.coinbase.com" + defaultThrottle = 250 * time.Millisecond +) + +type Repository interface { + InsertTick(ctx context.Context, t Tick) error + UpsertMinute(ctx context.Context, b MinuteBar) error + LatestTick(ctx context.Context, symbol string) (Tick, error) + History1h(ctx context.Context, symbol string) ([]MinuteBar, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + URL string + ProductIDs []string + Repo Repository + Emitter Emitter + State StateRecorder + Dialer Dialer + Throttle time.Duration + Reconnect ReconnectConfig + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + dialer Dialer + logger *slog.Logger + mu sync.Mutex + lastEmit map[string]time.Time +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.URL == "" { + cfg.URL = defaultURL + } + if len(cfg.ProductIDs) == 0 { + cfg.ProductIDs = []string{defaultProductBTC, defaultProductETH} + } + if cfg.Throttle <= 0 { + cfg.Throttle = defaultThrottle + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + + d := cfg.Dialer + if d == nil { + d = NewWSDialer(DialerConfig{URL: cfg.URL, ProductIDs: cfg.ProductIDs}) + } + + return &Collector{ + cfg: cfg, + dialer: d, + logger: cfg.Logger, + lastEmit: make(map[string]time.Time), + } +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + err := Reconnect(ctx, c.dialer, c.cfg.Reconnect, c.handleConn) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + } + return err +} + +func (c *Collector) handleConn(ctx context.Context, conn *Conn) error { + seq := NewSequencer() + agg := NewAggregator() + count := int64(0) + + loopErr := ReadLoop(ctx, conn, seq, func(hctx context.Context, f Frame) error { + switch f.Kind { + case FrameTypeTicker, FrameTypeSnapshot: + for _, tk := range f.Tickers { + tick := Tick{ + Symbol: tk.ProductID, + TS: tk.Time.UTC(), + Price: tk.Price, + Volume24h: tk.Volume24h, + } + if err := c.cfg.Repo.InsertTick(hctx, tick); err != nil { + c.logger.Warn("insert tick", "symbol", tick.Symbol, "err", err) + continue + } + if closed, _ := agg.Push(tick); closed != nil { + if err := c.cfg.Repo.UpsertMinute(hctx, *closed); err != nil { + c.logger.Warn("upsert minute", "symbol", closed.Symbol, "minute", closed.Minute, "err", err) + } + } + if c.shouldEmit(tick.Symbol) { + c.emitTick(tick) + count++ + } + } + } + return nil + }) + + if loopErr == nil || errors.Is(loopErr, ErrSequenceGap) { + _ = c.cfg.State.RecordSuccess(ctx, Name, count) + } + return loopErr +} + +func (c *Collector) shouldEmit(symbol string) bool { + c.mu.Lock() + defer c.mu.Unlock() + last, ok := c.lastEmit[symbol] + now := time.Now() + if !ok || now.Sub(last) >= c.cfg.Throttle { + c.lastEmit[symbol] = now + return true + } + return false +} + +type tickPayload struct { + Symbol string `json:"symbol"` + TS time.Time `json:"ts"` + Price decimal.Decimal `json:"price"` + Volume24h decimal.Decimal `json:"volume_24h"` +} + +func (c *Collector) emitTick(t Tick) { + body, _ := json.Marshal(tickPayload{ + Symbol: t.Symbol, + TS: t.TS, + Price: t.Price, + Volume24h: t.Volume24h, + }) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicCoinbasePrice, + Timestamp: t.TS, + Source: Name, + Payload: json.RawMessage(body), + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go new file mode 100644 index 00000000..f36258a6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go @@ -0,0 +1,177 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package coinbase_test + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeRepo struct { + mu sync.Mutex + ticks []coinbase.Tick + minutes []coinbase.MinuteBar +} + +func (r *fakeRepo) InsertTick(_ context.Context, t coinbase.Tick) error { + r.mu.Lock() + defer r.mu.Unlock() + r.ticks = append(r.ticks, t) + return nil +} + +func (r *fakeRepo) UpsertMinute(_ context.Context, b coinbase.MinuteBar) error { + r.mu.Lock() + defer r.mu.Unlock() + r.minutes = append(r.minutes, b) + return nil +} + +func (r *fakeRepo) LatestTick(_ context.Context, _ string) (coinbase.Tick, error) { + return coinbase.Tick{}, nil +} + +func (r *fakeRepo) History1h(_ context.Context, _ string) ([]coinbase.MinuteBar, error) { + return nil, nil +} + +func (r *fakeRepo) Tickers() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.ticks) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type fakeState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *fakeState) RecordSuccess(_ context.Context, _ string, _ int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + return nil +} + +func (s *fakeState) RecordError(_ context.Context, _, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + return nil +} + +func tickerFrame(seq int64, productID, price string, ts time.Time) []byte { + body, _ := json.Marshal(map[string]any{ + "channel": "ticker", + "sequence_num": seq, + "timestamp": ts.Format(time.RFC3339Nano), + "events": []any{ + map[string]any{ + "type": "update", + "tickers": []any{ + map[string]any{ + "type": "ticker", + "product_id": productID, + "price": price, + "volume_24_h": "1.0", + "time": ts.Format(time.RFC3339Nano), + }, + }, + }, + }, + }) + return body +} + +func TestCollector_RunPersistsAndEmits(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + frames := [][]byte{loadFixture(t, "subscriptions.json")} + for i := 0; i < 6; i++ { + ts := now.Add(time.Duration(i) * time.Second) + frames = append(frames, tickerFrame(int64(i+1), "BTC-USD", fmt.Sprintf("4200%d.00", i), ts)) + } + fs := newFakeServer(t, frames...) + + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &fakeState{} + + c := coinbase.NewCollector(coinbase.CollectorConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD"}, + Repo: repo, + Emitter: emt, + State: st, + Throttle: 10 * time.Millisecond, + Reconnect: coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Tickers(), 3, "should persist most ticks") + require.GreaterOrEqual(t, emt.Count(), 1, "should emit at least one event after throttle") +} + +func TestCollector_GapTriggersReconnect(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + frames := [][]byte{ + loadFixture(t, "subscriptions.json"), + tickerFrame(100, "BTC-USD", "42000.00", now), + tickerFrame(500, "BTC-USD", "42100.00", now), + } + fs := newFakeServer(t, frames...) + + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &fakeState{} + + c := coinbase.NewCollector(coinbase.CollectorConfig{ + URL: fs.URL(), + ProductIDs: []string{"BTC-USD"}, + Repo: repo, + Emitter: emt, + State: st, + Throttle: 10 * time.Millisecond, + Reconnect: coinbase.ReconnectConfig{ + InitialInterval: 5 * time.Millisecond, + MaxInterval: 20 * time.Millisecond, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + _ = c.Run(ctx) +} From 0a6f4f1e09e659332ec1ce6d9e0693aa05bf031c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:32:53 -0400 Subject: [PATCH 044/109] feat(monitor/config): coinbase config block (enabled, url, product_ids, throttle) --- .../backend/internal/config/config.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go index 19f7a43b..7b5f2bd8 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -34,6 +34,7 @@ type CollectorsConfig struct { KEV SourceConfig `koanf:"kev"` Ransomware SourceConfig `koanf:"ransomware"` GreyNoise GreyNoiseConfig `koanf:"greynoise"` + Coinbase CoinbaseConfig `koanf:"coinbase"` } type SourceConfig struct { @@ -60,6 +61,13 @@ type GreyNoiseConfig struct { APIKey string `koanf:"api_key"` } +type CoinbaseConfig struct { + Enabled bool `koanf:"enabled"` + URL string `koanf:"url"` + ProductIDs []string `koanf:"product_ids"` + Throttle time.Duration `koanf:"throttle"` +} + type AppConfig struct { Name string `koanf:"name"` Version string `koanf:"version"` @@ -250,6 +258,10 @@ func loadDefaults(k *koanf.Koanf) error { "collectors.ransomware.enabled": true, "collectors.ransomware.interval": "15m", "collectors.greynoise.enabled": true, + "collectors.coinbase.enabled": true, + "collectors.coinbase.url": "wss://advanced-trade-ws.coinbase.com", + "collectors.coinbase.product_ids": []string{"BTC-USD", "ETH-USD"}, + "collectors.coinbase.throttle": "250ms", } for key, value := range defaults { From 9c35e14ae87223e08196c8e811911c86ecaadb51 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:36:26 -0400 Subject: [PATCH 045/109] fix(monitor/collectors/coinbase): use envelope timestamp (per-ticker time field is not in real Coinbase frames) --- .../backend/internal/collectors/coinbase/collector.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go index 457a51aa..5682b576 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go @@ -103,10 +103,14 @@ func (c *Collector) handleConn(ctx context.Context, conn *Conn) error { loopErr := ReadLoop(ctx, conn, seq, func(hctx context.Context, f Frame) error { switch f.Kind { case FrameTypeTicker, FrameTypeSnapshot: + ts := f.Timestamp.UTC() + if ts.IsZero() { + ts = time.Now().UTC() + } for _, tk := range f.Tickers { tick := Tick{ Symbol: tk.ProductID, - TS: tk.Time.UTC(), + TS: ts, Price: tk.Price, Volume24h: tk.Volume24h, } From 99f6e4184534b33d75142c428fc0db080ff56b3b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:36:26 -0400 Subject: [PATCH 046/109] feat(monitor/main): wire coinbase BTC/ETH collector under errgroup --- .../backend/cmd/api/main.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index 4b16eab6..dc760b9d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -21,6 +21,7 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" "github.com/carterperez-dev/monitor-the-situation/backend/internal/bus" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat" @@ -221,6 +222,19 @@ func run(configPath string) error { collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) } + if cfg.Collectors.Coinbase.Enabled { + coll := coinbase.NewCollector(coinbase.CollectorConfig{ + URL: cfg.Collectors.Coinbase.URL, + ProductIDs: cfg.Collectors.Coinbase.ProductIDs, + Repo: coinbase.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Throttle: cfg.Collectors.Coinbase.Throttle, + Logger: logger.With("collector", "coinbase"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + logger.Info("collectors started", "heartbeat", true, "dshield", cfg.Collectors.DShield.Enabled, @@ -228,6 +242,7 @@ func run(configPath string) error { "cve", cfg.Collectors.CVE.Enabled, "kev", cfg.Collectors.KEV.Enabled, "ransomware", cfg.Collectors.Ransomware.Enabled, + "coinbase", cfg.Collectors.Coinbase.Enabled, ) srv := server.New(server.Config{ From 0027de6c726b5efbbc927bedc53b358cfbd9bd48 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:37:56 -0400 Subject: [PATCH 047/109] docs(monitor): add coinbase row to cyber data source matrix --- PROJECTS/advanced/monitor-the-situation-dashboard/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md index 1eff6b2d..33d561b4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md @@ -28,6 +28,7 @@ Operator-grade real-time situational awareness dashboard. Single-binary Go backe | CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional, raises rate limit) | | CISA KEV | CISA KEV catalog | 1h | none | | Ransomware victims | ransomware.live | 15m | none | +| Live BTC + ETH ticks | Coinbase Advanced Trade WS| persistent | none | | IP enrichment | GreyNoise Community | on-demand | `GREYNOISE_API_KEY` (optional, free tier) | ## Quickstart (development) From d352a6bce6f50afd3d00090602e9a0e970dc7d19 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:39:51 -0400 Subject: [PATCH 048/109] chore(monitor): gofmt const-block alignment in phase-2 collectors --- .../internal/collectors/cfradar/client.go | 16 ++++++++-------- .../backend/internal/collectors/cve/collector.go | 6 +++--- .../internal/collectors/ransomware/collector.go | 4 ++-- .../backend/internal/enrich/greynoise/client.go | 1 - 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go index deb73273..7a13e32f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go @@ -16,14 +16,14 @@ import ( ) const ( - defaultCFBaseURL = "https://api.cloudflare.com" - defaultCFRate = 250 * time.Millisecond - defaultCFBurst = 5 - defaultCFBudget = 5 - defaultCFBreaker = 60 * time.Second - pathRadarOutages = "/client/v4/radar/annotations/outages" - pathRadarHijacks = "/client/v4/radar/bgp/hijacks/events" - defaultDateRange = "1d" + defaultCFBaseURL = "https://api.cloudflare.com" + defaultCFRate = 250 * time.Millisecond + defaultCFBurst = 5 + defaultCFBudget = 5 + defaultCFBreaker = 60 * time.Second + pathRadarOutages = "/client/v4/radar/annotations/outages" + pathRadarHijacks = "/client/v4/radar/bgp/hijacks/events" + defaultDateRange = "1d" ) type ClientConfig struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go index b90b2eb0..8baceb7f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go @@ -13,9 +13,9 @@ import ( ) const ( - Name = "cve" - defaultCVEInterval = 2 * time.Hour - defaultCVEWindow = 2 * time.Hour + Name = "cve" + defaultCVEInterval = 2 * time.Hour + defaultCVEWindow = 2 * time.Hour ) type NVDFetcher interface { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go index 5343096f..684c0483 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go @@ -13,8 +13,8 @@ import ( ) const ( - Name = "ransomware" - defaultRansomCadence = 15 * time.Minute + Name = "ransomware" + defaultRansomCadence = 15 * time.Minute ) type Fetcher interface { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go index a8d30cc2..a4da1e5f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client.go @@ -17,7 +17,6 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" ) - const ( defaultGNBaseURL = "https://api.greynoise.io" pathCommunityLookup = "/v3/community/" From 2ccc3792556a1626e05caeefb684e0d8d7d5a10b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 03:42:59 -0400 Subject: [PATCH 049/109] fix(monitor/collectors/coinbase): exhaustive switches, %w double-wrap, require.ErrorIs (lint) --- .../backend/internal/collectors/coinbase/client.go | 2 +- .../backend/internal/collectors/coinbase/collector.go | 2 ++ .../backend/internal/collectors/coinbase/readloop.go | 1 + .../backend/internal/collectors/coinbase/repo_test.go | 3 +-- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go index 0489b1e7..20350da0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go @@ -153,7 +153,7 @@ func (c *Conn) ReadFrame(ctx context.Context) (Frame, error) { func decodeFrame(msg []byte) (Frame, error) { var env rawEnvelope if err := json.Unmarshal(msg, &env); err != nil { - return Frame{}, fmt.Errorf("%w: %v", ErrFrameMalformed, err) + return Frame{}, fmt.Errorf("%w: %w", ErrFrameMalformed, err) } frame := Frame{ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go index 5682b576..396bde82 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go @@ -102,6 +102,8 @@ func (c *Collector) handleConn(ctx context.Context, conn *Conn) error { loopErr := ReadLoop(ctx, conn, seq, func(hctx context.Context, f Frame) error { switch f.Kind { + case FrameTypeUnknown, FrameTypeSubscriptions, FrameTypeHeartbeats: + return nil case FrameTypeTicker, FrameTypeSnapshot: ts := f.Timestamp.UTC() if ts.IsZero() { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go index a3b404cf..d69f7625 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go @@ -26,6 +26,7 @@ func ReadLoop(ctx context.Context, conn *Conn, seq *Sequencer, handler FrameHand } switch frame.Kind { + case FrameTypeUnknown, FrameTypeSubscriptions, FrameTypeHeartbeats: case FrameTypeSnapshot: seq.Reset() for _, t := range frame.Tickers { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go index ed26fe3b..b5fa427a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go @@ -6,7 +6,6 @@ package coinbase_test import ( "context" "database/sql" - "errors" "testing" "time" @@ -144,5 +143,5 @@ func TestRepo_LatestTickMissingReturnsErrNoRows(t *testing.T) { ctx := context.Background() _, err := repo.LatestTick(ctx, "DOES-NOT-EXIST") - require.True(t, errors.Is(err, sql.ErrNoRows)) + require.ErrorIs(t, err, sql.ErrNoRows) } From 989e53c9584aeb530eca3e790d452ef2350ef409 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:02:49 -0400 Subject: [PATCH 050/109] fix(monitor/collectors/coinbase): parse Coinbase's Go-style time format in heartbeat current_time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coinbase Advanced Trade WS leaks Go's time.Time.String() format in heartbeat events (e.g. "2026-05-02 07:55:50.784... +0000 UTC m=+102632...") which is not RFC 3339 — UnmarshalJSON into time.Time fails and the entire envelope errors. Decode current_time as a string and parse it with a fallback that handles both RFC 3339 and the Go-string variant (with the m=+monotonic suffix stripped). --- .../internal/collectors/coinbase/client.go | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go index 20350da0..5e5e2009 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/coder/websocket" @@ -137,7 +138,23 @@ type rawEnvelope struct { type rawEnvelopeEv struct { Type string `json:"type"` Tickers []TickerEntry `json:"tickers,omitempty"` - CurrentTime time.Time `json:"current_time,omitempty"` + CurrentTime string `json:"current_time,omitempty"` +} + +func parseCoinbaseTime(s string) time.Time { + if s == "" { + return time.Time{} + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t + } + if idx := strings.Index(s, " m=+"); idx > 0 { + s = s[:idx] + } + if t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", s); err == nil { + return t + } + return time.Time{} } var ErrFrameMalformed = errors.New("coinbase: malformed frame") @@ -169,7 +186,7 @@ func decodeFrame(msg []byte) (Frame, error) { case channelHeartbeats: frame.Kind = FrameTypeHeartbeats if len(env.Events) > 0 { - frame.HeartbeatTime = env.Events[0].CurrentTime + frame.HeartbeatTime = parseCoinbaseTime(env.Events[0].CurrentTime) } return frame, nil From d4995c125822d05ccdc469b20e3ac56de08e4a1a Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:03:09 -0400 Subject: [PATCH 051/109] fix(monitor/collectors/coinbase): connection-global sequencer; gap is non-fatal log Two related issues caught in live verification: 1. Coinbase's sequence_num is connection-global (not per-product). Previous per-product tracking flagged every cross-product frame as a gap. 2. Even after switching to global tracking, gaps still occur regularly under normal operation (Coinbase's exact sequencing pattern across channels is not strictly seq+1 frame-to-frame). Treating gaps as fatal kills the ReadLoop in seconds, never giving the minute aggregator a chance to cross a boundary, so btc_eth_minute stays empty. Resolution: rewrite Sequencer for a single global counter and downgrade gap-detection to a non-fatal Warn. ReadLoop continues; (symbol, ts) PK on btc_eth_ticks is the safety net for any duplicate replay. Includes a diagnostic log on loop exit (handleConn) for ops visibility. --- .../internal/collectors/coinbase/collector.go | 19 +++++++++ .../internal/collectors/coinbase/readloop.go | 18 ++++----- .../collectors/coinbase/readloop_test.go | 13 +++++-- .../internal/collectors/coinbase/sequencer.go | 20 ++++++---- .../collectors/coinbase/sequencer_test.go | 39 +++++++------------ 5 files changed, 62 insertions(+), 47 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go index 396bde82..c47ad3f4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go @@ -134,12 +134,31 @@ func (c *Collector) handleConn(ctx context.Context, conn *Conn) error { return nil }) + c.logger.Info("coinbase loop exit", + "err", loopErrString(loopErr), + "emit_count", count, + "agg_open", aggLen(agg), + ) if loopErr == nil || errors.Is(loopErr, ErrSequenceGap) { _ = c.cfg.State.RecordSuccess(ctx, Name, count) } return loopErr } +func loopErrString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func aggLen(a *Aggregator) int { + if a == nil { + return 0 + } + return len(a.open) +} + func (c *Collector) shouldEmit(symbol string) bool { c.mu.Lock() defer c.mu.Unlock() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go index d69f7625..e8fa7ea2 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go @@ -6,6 +6,7 @@ package coinbase import ( "context" "errors" + "log/slog" ) var ErrSequenceGap = errors.New("coinbase: sequence gap detected") @@ -26,17 +27,16 @@ func ReadLoop(ctx context.Context, conn *Conn, seq *Sequencer, handler FrameHand } switch frame.Kind { - case FrameTypeUnknown, FrameTypeSubscriptions, FrameTypeHeartbeats: + case FrameTypeUnknown, FrameTypeSubscriptions: case FrameTypeSnapshot: seq.Reset() - for _, t := range frame.Tickers { - _ = seq.Observe(t.ProductID, frame.SequenceNum) - } - case FrameTypeTicker: - for _, t := range frame.Tickers { - if seq.Observe(t.ProductID, frame.SequenceNum) { - return ErrSequenceGap - } + _ = seq.Observe(frame.SequenceNum) + case FrameTypeTicker, FrameTypeHeartbeats: + if seq.Observe(frame.SequenceNum) { + slog.Default().Warn("coinbase seq gap (non-fatal)", + "channel_kind", frame.Kind, + "seq", frame.SequenceNum, + ) } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go index b1af6e69..a294ec01 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go @@ -50,7 +50,7 @@ func TestReadLoop_DeliversTickerFrames(t *testing.T) { require.GreaterOrEqual(t, tickerFrames, 2) } -func TestReadLoop_GapTriggersErrSequenceGap(t *testing.T) { +func TestReadLoop_GapIsLoggedButLoopContinues(t *testing.T) { fs := newFakeServer(t, loadFixture(t, "subscriptions.json"), []byte(`{"channel":"ticker","sequence_num":100,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`), @@ -58,17 +58,22 @@ func TestReadLoop_GapTriggersErrSequenceGap(t *testing.T) { ) d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) defer cancel() conn, err := d.Dial(ctx) require.NoError(t, err) defer conn.Close() seq := coinbase.NewSequencer() - loopErr := coinbase.ReadLoop(ctx, conn, seq, func(context.Context, coinbase.Frame) error { + delivered := 0 + loopErr := coinbase.ReadLoop(ctx, conn, seq, func(_ context.Context, f coinbase.Frame) error { + if f.Kind == coinbase.FrameTypeTicker { + delivered++ + } return nil }) - require.ErrorIs(t, loopErr, coinbase.ErrSequenceGap) + require.True(t, errors.Is(loopErr, context.DeadlineExceeded) || errors.Is(loopErr, context.Canceled) || loopErr == nil) + require.Equal(t, 2, delivered, "both ticker frames must be delivered despite the gap") } func TestReadLoop_SnapshotResetsSequencer(t *testing.T) { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go index 340a19b9..bcd33570 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer.go @@ -7,26 +7,30 @@ import "sync" type Sequencer struct { mu sync.Mutex - last map[string]int64 + last int64 + set bool } func NewSequencer() *Sequencer { - return &Sequencer{last: make(map[string]int64)} + return &Sequencer{} } -func (s *Sequencer) Observe(productID string, seq int64) bool { +func (s *Sequencer) Observe(seq int64) bool { s.mu.Lock() defer s.mu.Unlock() - prev, ok := s.last[productID] - s.last[productID] = seq - if !ok { + if !s.set { + s.last = seq + s.set = true return false } - return seq != prev+1 + expected := s.last + 1 + s.last = seq + return seq != expected } func (s *Sequencer) Reset() { s.mu.Lock() defer s.mu.Unlock() - s.last = make(map[string]int64) + s.set = false + s.last = 0 } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go index a67ba6fc..92562081 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/sequencer_test.go @@ -11,53 +11,40 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" ) -func TestSequencer_FirstTickIsAlwaysInOrder(t *testing.T) { +func TestSequencer_FirstObservationIsBaseline(t *testing.T) { s := coinbase.NewSequencer() - gap := s.Observe("BTC-USD", 100) - require.False(t, gap, "first sequence number must not be a gap") + require.False(t, s.Observe(100), "first sequence number must not be a gap") } func TestSequencer_ConsecutiveSequencesAreInOrder(t *testing.T) { s := coinbase.NewSequencer() for i := int64(50); i < 60; i++ { - gap := s.Observe("BTC-USD", i) - require.False(t, gap, "i=%d", i) + require.False(t, s.Observe(i), "i=%d", i) } } func TestSequencer_GapTriggersReportSignal(t *testing.T) { s := coinbase.NewSequencer() - require.False(t, s.Observe("BTC-USD", 50)) - require.False(t, s.Observe("BTC-USD", 51)) - require.True(t, s.Observe("BTC-USD", 60), "skipping 52-59 must report gap") + require.False(t, s.Observe(50)) + require.False(t, s.Observe(51)) + require.True(t, s.Observe(60), "skipping 52-59 must report gap") } -func TestSequencer_GapsArePerProduct(t *testing.T) { +func TestSequencer_ResetClearsBaseline(t *testing.T) { s := coinbase.NewSequencer() - require.False(t, s.Observe("BTC-USD", 100)) - require.False(t, s.Observe("ETH-USD", 200)) - require.False(t, s.Observe("BTC-USD", 101)) - require.True(t, s.Observe("ETH-USD", 250)) - require.False(t, s.Observe("BTC-USD", 102)) -} - -func TestSequencer_ResetClearsAllProducts(t *testing.T) { - s := coinbase.NewSequencer() - s.Observe("BTC-USD", 100) - s.Observe("ETH-USD", 200) + s.Observe(100) s.Reset() - require.False(t, s.Observe("BTC-USD", 9999)) - require.False(t, s.Observe("ETH-USD", 8888)) + require.False(t, s.Observe(9999)) } func TestSequencer_DuplicateSequenceTreatedAsGap(t *testing.T) { s := coinbase.NewSequencer() - require.False(t, s.Observe("BTC-USD", 100)) - require.True(t, s.Observe("BTC-USD", 100), "replaying the same seq is a gap") + require.False(t, s.Observe(100)) + require.True(t, s.Observe(100), "replaying the same seq is a gap") } func TestSequencer_BackwardSequenceTreatedAsGap(t *testing.T) { s := coinbase.NewSequencer() - require.False(t, s.Observe("BTC-USD", 100)) - require.True(t, s.Observe("BTC-USD", 90)) + require.False(t, s.Observe(100)) + require.True(t, s.Observe(90)) } From 11a68de2b79d1c418bd28659c950798f83e0dec8 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:20:49 -0400 Subject: [PATCH 052/109] feat(monitor/collectors/usgs): earthquakes repository (upsert, known-ids, recent-by-mag) --- .../backend/internal/collectors/usgs/repo.go | 84 ++++++++++++ .../internal/collectors/usgs/repo_test.go | 124 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go new file mode 100644 index 00000000..a1d585e7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go @@ -0,0 +1,84 @@ +// ©AngelaMos | 2026 +// repo.go + +package usgs + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +type Row struct { + ID string `db:"id"` + OccurredAt time.Time `db:"occurred_at"` + Mag float64 `db:"mag"` + Place string `db:"place"` + GeomLon float64 `db:"geom_lon"` + GeomLat float64 `db:"geom_lat"` + DepthKm float64 `db:"depth_km"` + Payload json.RawMessage `db:"payload"` +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Upsert(ctx context.Context, row Row) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO earthquakes (id, occurred_at, mag, place, geom_lon, geom_lat, depth_km, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + occurred_at = EXCLUDED.occurred_at, + mag = EXCLUDED.mag, + place = EXCLUDED.place, + geom_lon = EXCLUDED.geom_lon, + geom_lat = EXCLUDED.geom_lat, + depth_km = EXCLUDED.depth_km, + payload = EXCLUDED.payload`, + row.ID, row.OccurredAt, row.Mag, row.Place, + row.GeomLon, row.GeomLat, row.DepthKm, []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("upsert earthquake %s: %w", row.ID, err) + } + return nil +} + +func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) { + out := make(map[string]bool, len(ids)) + if len(ids) == 0 { + return out, nil + } + var found []string + err := r.db.SelectContext(ctx, &found, + `SELECT id FROM earthquakes WHERE id = ANY($1)`, + pq.Array(ids), + ) + if err != nil { + return nil, fmt.Errorf("known earthquake ids: %w", err) + } + for _, id := range found { + out[id] = true + } + return out, nil +} + +func (r *Repo) RecentByMag(ctx context.Context, limit int) ([]Row, error) { + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, occurred_at, mag, place, geom_lon, geom_lat, depth_km, payload + FROM earthquakes + ORDER BY mag DESC, occurred_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent earthquakes: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go new file mode 100644 index 00000000..84c115e4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go @@ -0,0 +1,124 @@ +// ©AngelaMos | 2026 +// repo_test.go + +package usgs_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" +) + +func setupDB(t *testing.T) *sqlx.DB { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + pg, err := postgres.Run(ctx, "postgres:17-alpine", + postgres.WithDatabase("monitor"), + postgres.WithUsername("monitor"), + postgres.WithPassword("monitor"), + postgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Terminate(context.Background()) }) + + dsn, err := pg.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := sqlx.ConnectContext(ctx, "pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE earthquakes ( + id text PRIMARY KEY, + occurred_at timestamptz NOT NULL, + mag numeric(3,1) NOT NULL, + place text, + geom_lon double precision NOT NULL, + geom_lat double precision NOT NULL, + depth_km double precision, + payload jsonb NOT NULL + );`) + require.NoError(t, err) + return db +} + +func TestRepo_UpsertOverwritesRevisions(t *testing.T) { + db := setupDB(t) + repo := usgs.NewRepo(db) + ctx := context.Background() + + row := usgs.Row{ + ID: "us6000abcd", + OccurredAt: time.Now().UTC().Add(-time.Hour), + Mag: 5.5, + Place: "100 km SW of test", + GeomLon: -130.5, + GeomLat: 49.2, + DepthKm: 10.0, + Payload: json.RawMessage(`{"v":1}`), + } + require.NoError(t, repo.Upsert(ctx, row)) + + row.Mag = 5.8 + row.Payload = json.RawMessage(`{"v":2}`) + require.NoError(t, repo.Upsert(ctx, row)) + + got, err := repo.RecentByMag(ctx, 1) + require.NoError(t, err) + require.Len(t, got, 1) + require.InDelta(t, 5.8, got[0].Mag, 0.0001) +} + +func TestRepo_KnownIDsBulkLookup(t *testing.T) { + db := setupDB(t) + repo := usgs.NewRepo(db) + ctx := context.Background() + + for _, id := range []string{"a", "b", "c"} { + require.NoError(t, repo.Upsert(ctx, usgs.Row{ + ID: id, OccurredAt: time.Now().UTC(), Mag: 4.5, + GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`), + })) + } + + known, err := repo.KnownIDs(ctx, []string{"a", "b", "x", "y"}) + require.NoError(t, err) + require.True(t, known["a"]) + require.True(t, known["b"]) + require.False(t, known["x"]) + require.False(t, known["y"]) +} + +func TestRepo_RecentByMagOrdered(t *testing.T) { + db := setupDB(t) + repo := usgs.NewRepo(db) + ctx := context.Background() + + now := time.Now().UTC() + rows := []usgs.Row{ + {ID: "small", OccurredAt: now, Mag: 4.5, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)}, + {ID: "huge", OccurredAt: now, Mag: 7.8, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)}, + {ID: "mid", OccurredAt: now, Mag: 6.0, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)}, + } + for _, r := range rows { + require.NoError(t, repo.Upsert(ctx, r)) + } + + got, err := repo.RecentByMag(ctx, 3) + require.NoError(t, err) + require.Len(t, got, 3) + require.Equal(t, "huge", got[0].ID) + require.Equal(t, "mid", got[1].ID) + require.Equal(t, "small", got[2].ID) +} From 1d414ff341e809463015d3193280f7a7c1c1e14b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:21:38 -0400 Subject: [PATCH 053/109] feat(monitor/collectors/usgs): GeoJSON feed client (2.5_day, ms-since-epoch decoder) --- .../internal/collectors/usgs/client.go | 87 +++++++++++ .../internal/collectors/usgs/client_test.go | 43 ++++++ .../collectors/usgs/testdata/2_5_day.geojson | 141 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/testdata/2_5_day.geojson diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client.go new file mode 100644 index 00000000..43f4e062 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// client.go + +package usgs + +import ( + "context" + "fmt" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultUSGSBaseURL = "https://earthquake.usgs.gov" + pathQuakes2_5_day = "/earthquakes/feed/v1.0/summary/2.5_day.geojson" + defaultUSGSRate = 100 * time.Millisecond + defaultUSGSBurst = 5 + defaultUSGSBudget = 5 + defaultUSGSBreaker = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultUSGSBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "usgs", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultUSGSRate), + Burst: defaultUSGSBurst, + ConsecutiveFailureBudget: defaultUSGSBudget, + BreakerTimeout: defaultUSGSBreaker, + }), + } +} + +type Feed struct { + Type string `json:"type"` + Features []Feature `json:"features"` +} + +type Feature struct { + Type string `json:"type"` + ID string `json:"id"` + Properties Properties `json:"properties"` + Geometry Geometry `json:"geometry"` +} + +type Properties struct { + Mag float64 `json:"mag"` + Place string `json:"place"` + Time int64 `json:"time"` + Updated int64 `json:"updated"` + Alert string `json:"alert"` + Tsunami int `json:"tsunami"` + URL string `json:"url"` + Type string `json:"type"` +} + +func (p Properties) OccurredAt() time.Time { + return time.UnixMilli(p.Time).UTC() +} + +type Geometry struct { + Type string `json:"type"` + Coordinates []float64 `json:"coordinates"` +} + +func (c *Client) Fetch(ctx context.Context) (Feed, error) { + var feed Feed + if err := c.hx.GetJSON(ctx, pathQuakes2_5_day, nil, &feed); err != nil { + return Feed{}, fmt.Errorf("fetch usgs feed: %w", err) + } + return feed, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go new file mode 100644 index 00000000..40d9d621 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// client_test.go + +package usgs_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" +) + +func TestClient_FetchDecodesFeatures(t *testing.T) { + body, err := os.ReadFile("testdata/2_5_day.geojson") + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/geo+json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := usgs.NewClient(usgs.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + feed, err := c.Fetch(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(feed.Features), 1) + + f := feed.Features[0] + require.NotEmpty(t, f.ID) + require.NotEmpty(t, f.Properties.Place) + require.GreaterOrEqual(t, f.Properties.Mag, 2.5) + require.Len(t, f.Geometry.Coordinates, 3) + require.False(t, f.Properties.OccurredAt().IsZero()) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/testdata/2_5_day.geojson b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/testdata/2_5_day.geojson new file mode 100644 index 00000000..60354c9e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/testdata/2_5_day.geojson @@ -0,0 +1,141 @@ +{ + "type": "FeatureCollection", + "metadata": { + "generated": 1777710093000, + "url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson", + "title": "USGS Magnitude 2.5+ Earthquakes, Past Day", + "status": 200, + "api": "2.4.0", + "count": 39 + }, + "features": [ + { + "type": "Feature", + "properties": { + "mag": 2.8, + "place": "18 km NNW of Jal, New Mexico", + "time": 1777707261749, + "updated": 1777707667040, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000shr9", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us7000shr9.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 121, + "net": "us", + "code": "7000shr9", + "ids": ",us7000shr9,", + "sources": ",us,", + "types": ",origin,phase-data,", + "nst": 35, + "dmin": 0.255, + "rms": 0.56, + "gap": 57, + "magType": "mb_lg", + "type": "earthquake", + "title": "M 2.8 - 18 km NNW of Jal, New Mexico" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -103.2923, + 32.2604, + 5.109 + ] + }, + "id": "us7000shr9" + }, + { + "type": "Feature", + "properties": { + "mag": 2.97, + "place": "27 km SW of Esperanza, Puerto Rico", + "time": 1777705903970, + "updated": 1777707214900, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/pr71515393", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/pr71515393.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 136, + "net": "pr", + "code": "71515393", + "ids": ",pr71515393,", + "sources": ",pr,", + "types": ",origin,phase-data,", + "nst": 15, + "dmin": 0.278, + "rms": 0.29, + "gap": 171, + "magType": "md", + "type": "earthquake", + "title": "M 3.0 - 27 km SW of Esperanza, Puerto Rico" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -65.6316666666667, + 17.898, + 7.84 + ] + }, + "id": "pr71515393" + }, + { + "type": "Feature", + "properties": { + "mag": 4.6, + "place": "79 km N of Calama, Chile", + "time": 1777702962852, + "updated": 1777704100040, + "tz": null, + "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us7000shqp", + "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us7000shqp.geojson", + "felt": null, + "cdi": null, + "mmi": null, + "alert": null, + "status": "reviewed", + "tsunami": 0, + "sig": 326, + "net": "us", + "code": "7000shqp", + "ids": ",us7000shqp,", + "sources": ",us,", + "types": ",origin,phase-data,", + "nst": 58, + "dmin": 0.437, + "rms": 1.41, + "gap": 37, + "magType": "mb", + "type": "earthquake", + "title": "M 4.6 - 79 km N of Calama, Chile" + }, + "geometry": { + "type": "Point", + "coordinates": [ + -68.7738, + -21.7539, + 115.882 + ] + }, + "id": "us7000shqp" + } + ], + "bbox": [ + -178.5514, + -57.7679, + 5.109, + 178.8233, + 61.621, + 532.819 + ] +} From 2368ed8f18b346c961d80e95559ccb3bc056b17e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:22:37 -0400 Subject: [PATCH 054/109] feat(monitor/collectors/usgs): 1m collector with id-diff and earthquake emit --- .../internal/collectors/usgs/collector.go | 135 ++++++++++++ .../collectors/usgs/collector_test.go | 194 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go new file mode 100644 index 00000000..68cdb5a5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go @@ -0,0 +1,135 @@ +// ©AngelaMos | 2026 +// collector.go + +package usgs + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "usgs" + defaultUSGSInterval = time.Minute + defaultLogPlaceLimit = 64 +) + +type Fetcher interface { + Fetch(ctx context.Context) (Feed, error) +} + +type Repository interface { + Upsert(ctx context.Context, row Row) error + KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultUSGSInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + c.tick(ctx) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + feed, err := c.cfg.Fetcher.Fetch(ctx) + if err != nil { + c.logger.Warn("usgs fetch", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + ids := make([]string, 0, len(feed.Features)) + for _, f := range feed.Features { + ids = append(ids, f.ID) + } + known, err := c.cfg.Repo.KnownIDs(ctx, ids) + if err != nil { + c.logger.Warn("usgs known ids", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + emitted := int64(0) + now := time.Now().UTC() + for _, f := range feed.Features { + raw, _ := json.Marshal(f) + row := Row{ + ID: f.ID, + OccurredAt: f.Properties.OccurredAt(), + Mag: f.Properties.Mag, + Place: f.Properties.Place, + GeomLat: coord(f.Geometry.Coordinates, 1), + GeomLon: coord(f.Geometry.Coordinates, 0), + DepthKm: coord(f.Geometry.Coordinates, 2), + Payload: raw, + } + if err := c.cfg.Repo.Upsert(ctx, row); err != nil { + c.logger.Warn("usgs upsert", "id", row.ID, "err", err) + continue + } + if known[f.ID] { + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicEarthquake, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(raw), + }) + emitted++ + } + _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) +} + +func coord(c []float64, i int) float64 { + if i < len(c) { + return c[i] + } + return 0 +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go new file mode 100644 index 00000000..d1711de0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go @@ -0,0 +1,194 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package usgs_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + feed usgs.Feed + err error + mu sync.Mutex + n int +} + +func (f *fakeFetcher) Fetch(_ context.Context) (usgs.Feed, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.n++ + if f.err != nil { + return usgs.Feed{}, f.err + } + return f.feed, nil +} + +type fakeRepo struct { + mu sync.Mutex + known map[string]bool + upserts []usgs.Row +} + +func (r *fakeRepo) Upsert(_ context.Context, row usgs.Row) error { + r.mu.Lock() + defer r.mu.Unlock() + r.upserts = append(r.upserts, row) + if r.known == nil { + r.known = make(map[string]bool) + } + r.known[row.ID] = true + return nil +} + +func (r *fakeRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]bool, len(ids)) + for _, id := range ids { + if r.known[id] { + out[id] = true + } + } + return out, nil +} + +func (r *fakeRepo) Upserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.upserts) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int + lastErr string +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + return nil +} + +func (s *recordingState) RecordError(_ context.Context, _, msg string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + s.lastErr = msg + return nil +} + +func TestCollector_TickPersistsAndEmitsNewQuakes(t *testing.T) { + feed := usgs.Feed{ + Type: "FeatureCollection", + Features: []usgs.Feature{ + {ID: "q1", Properties: usgs.Properties{Mag: 4.5, Place: "test 1", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{-120, 49, 5}}}, + {ID: "q2", Properties: usgs.Properties{Mag: 6.5, Place: "test 2", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{140, -30, 10}}}, + {ID: "q3", Properties: usgs.Properties{Mag: 3.0, Place: "test 3", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}}, + }, + } + ftch := &fakeFetcher{feed: feed} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := usgs.NewCollector(usgs.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Upserts(), 3, "should upsert all 3 features at least once") + require.GreaterOrEqual(t, emt.Count(), 3, "should emit 3 new-event events") + for _, ev := range emt.events { + require.Equal(t, events.TopicEarthquake, ev.Topic) + require.Equal(t, usgs.Name, ev.Source) + } + require.Greater(t, st.successes, 0) + require.Equal(t, 0, st.failures) +} + +func TestCollector_FetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := usgs.NewCollector(usgs.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Upserts()) + require.Equal(t, 0, emt.Count()) + require.Greater(t, st.failures, 0) + require.Contains(t, st.lastErr, "upstream 503") +} + +func TestCollector_KnownQuakesNotReEmitted(t *testing.T) { + feed := usgs.Feed{ + Features: []usgs.Feature{ + {ID: "qx", Properties: usgs.Properties{Mag: 4.5, Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}}, + }, + } + ftch := &fakeFetcher{feed: feed} + repo := &fakeRepo{known: map[string]bool{"qx": true}} + emt := &fakeEmitter{} + st := &recordingState{} + + c := usgs.NewCollector(usgs.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, emt.Count(), "known quake should not re-emit") +} From fc3cfb523b54b62753d0b5c7d857db04b16780d2 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:23:37 -0400 Subject: [PATCH 055/109] feat(monitor/redisring): sorted-set ring buffer with score-based retention --- .../backend/internal/redisring/ring.go | 79 +++++++++++++++++ .../backend/internal/redisring/ring_test.go | 87 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go new file mode 100644 index 00000000..cf6be854 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go @@ -0,0 +1,79 @@ +// ©AngelaMos | 2026 +// ring.go + +package redisring + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + defaultRetention = 24 * time.Hour +) + +type Config struct { + Retention time.Duration +} + +type Client struct { + rc *redis.Client + retention time.Duration +} + +func New(rc *redis.Client, cfg Config) *Client { + if cfg.Retention <= 0 { + cfg.Retention = defaultRetention + } + return &Client{rc: rc, retention: cfg.Retention} +} + +func (c *Client) Push(ctx context.Context, key string, score int64, payload []byte) error { + cutoff := time.UnixMilli(score).Add(-c.retention).UnixMilli() + pipe := c.rc.Pipeline() + pipe.ZAdd(ctx, key, redis.Z{Score: float64(score), Member: payload}) + pipe.ZRemRangeByScore(ctx, key, "-inf", "("+strconv.FormatInt(cutoff, 10)) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("ring push %s @ %d: %w", key, score, err) + } + return nil +} + +func (c *Client) Recent(ctx context.Context, key string, n int) ([][]byte, error) { + if n <= 0 { + return nil, nil + } + res, err := c.rc.ZRevRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: "-inf", + Max: "+inf", + Offset: 0, + Count: int64(n), + }).Result() + if err != nil { + return nil, fmt.Errorf("ring recent %s: %w", key, err) + } + out := make([][]byte, 0, len(res)) + for _, s := range res { + out = append(out, []byte(s)) + } + return out, nil +} + +func (c *Client) Range(ctx context.Context, key string, fromScore, toScore int64) ([][]byte, error) { + res, err := c.rc.ZRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: strconv.FormatInt(fromScore, 10), + Max: strconv.FormatInt(toScore, 10), + }).Result() + if err != nil { + return nil, fmt.Errorf("ring range %s: %w", key, err) + } + out := make([][]byte, 0, len(res)) + for _, s := range res { + out = append(out, []byte(s)) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go new file mode 100644 index 00000000..6ec06567 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go @@ -0,0 +1,87 @@ +// ©AngelaMos | 2026 +// ring_test.go + +package redisring_test + +import ( + "context" + "fmt" + "testing" + "time" + + goredis "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + tcredis "github.com/testcontainers/testcontainers-go/modules/redis" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring" +) + +func setupRedis(t *testing.T) *goredis.Client { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + rc, err := tcredis.Run(ctx, "redis:7-alpine") + require.NoError(t, err) + t.Cleanup(func() { _ = rc.Terminate(context.Background()) }) + + uri, err := rc.ConnectionString(ctx) + require.NoError(t, err) + opt, err := goredis.ParseURL(uri) + require.NoError(t, err) + c := goredis.NewClient(opt) + t.Cleanup(func() { _ = c.Close() }) + return c +} + +func TestRing_PushAndRecentRoundtrips(t *testing.T) { + c := setupRedis(t) + r := redisring.New(c, redisring.Config{Retention: 24 * time.Hour}) + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 5; i++ { + ts := now.Add(-time.Duration(i) * time.Minute) + require.NoError(t, r.Push(ctx, "test:key", ts.UnixMilli(), []byte(fmt.Sprintf(`{"i":%d}`, i)))) + } + + got, err := r.Recent(ctx, "test:key", 3) + require.NoError(t, err) + require.Len(t, got, 3) +} + +func TestRing_RetentionPrunesOldEntries(t *testing.T) { + c := setupRedis(t) + r := redisring.New(c, redisring.Config{Retention: 1 * time.Hour}) + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 5; i++ { + old := now.Add(-2 * time.Hour).Add(-time.Duration(i) * time.Minute) + require.NoError(t, r.Push(ctx, "test:key", old.UnixMilli(), []byte(fmt.Sprintf(`old-%d`, i)))) + } + require.NoError(t, r.Push(ctx, "test:key", now.UnixMilli(), []byte("now"))) + + got, err := r.Recent(ctx, "test:key", 100) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "now", string(got[0])) +} + +func TestRing_RangeReturnsItemsWithinScoreWindow(t *testing.T) { + c := setupRedis(t) + r := redisring.New(c, redisring.Config{Retention: 24 * time.Hour}) + ctx := context.Background() + + now := time.Now().UTC() + for i := 0; i < 10; i++ { + ts := now.Add(-time.Duration(i) * time.Minute) + require.NoError(t, r.Push(ctx, "test:key", ts.UnixMilli(), []byte(fmt.Sprintf("i=%d", i)))) + } + + from := now.Add(-3 * time.Minute).UnixMilli() + to := now.UnixMilli() + got, err := r.Range(ctx, "test:key", from, to) + require.NoError(t, err) + require.Len(t, got, 4, "expected items in [now-3m, now] inclusive") +} From 608a7937e729782876d4af4f964498a1b5c1aa04 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:25:17 -0400 Subject: [PATCH 056/109] feat(monitor/collectors/swpc): five-endpoint NOAA SWPC client (plasma/mag/kp/xray/alerts) --- .../internal/collectors/swpc/client.go | 268 ++++++++++++++++++ .../internal/collectors/swpc/client_test.go | 113 ++++++++ .../collectors/swpc/testdata/alerts.json | 17 ++ .../internal/collectors/swpc/testdata/kp.json | 32 +++ .../collectors/swpc/testdata/mag.json | 38 +++ .../collectors/swpc/testdata/plasma.json | 26 ++ .../collectors/swpc/testdata/xray.json | 47 +++ 7 files changed, 541 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go new file mode 100644 index 00000000..5a9104b1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go @@ -0,0 +1,268 @@ +// ©AngelaMos | 2026 +// client.go + +package swpc + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultSWPCBaseURL = "https://services.swpc.noaa.gov" + pathPlasma = "/products/solar-wind/plasma-5-minute.json" + pathMag = "/products/solar-wind/mag-5-minute.json" + pathKp = "/products/noaa-planetary-k-index.json" + pathXray = "/json/goes/primary/xrays-1-day.json" + pathAlerts = "/products/alerts.json" + defaultSWPCRate = 200 * time.Millisecond + defaultSWPCBurst = 5 + defaultSWPCBudget = 5 + defaultSWPCBreaker = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultSWPCBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "swpc", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultSWPCRate), + Burst: defaultSWPCBurst, + ConsecutiveFailureBudget: defaultSWPCBudget, + BreakerTimeout: defaultSWPCBreaker, + }), + } +} + +type PlasmaTick struct { + TimeTag time.Time + Density string + Speed string + Temperature string +} + +type MagTick struct { + TimeTag time.Time + BxGSM string + ByGSM string + BzGSM string + LonGSM string + LatGSM string + Bt string +} + +type KpTick struct { + TimeTag time.Time + Kp float64 + ARunning int + StationCount int +} + +type XrayTick struct { + TimeTag time.Time + Satellite int + Flux float64 + ObservedFlux float64 + Energy string +} + +type AlertItem struct { + ProductID string + IssueDatetime time.Time + Message string +} + +func (c *Client) FetchPlasma(ctx context.Context) ([]PlasmaTick, error) { + rows, err := c.fetchRowArray(ctx, pathPlasma) + if err != nil { + return nil, err + } + out := make([]PlasmaTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r["time_tag"]) + out = append(out, PlasmaTick{ + TimeTag: ts, + Density: r["density"], + Speed: r["speed"], + Temperature: r["temperature"], + }) + } + return out, nil +} + +func (c *Client) FetchMag(ctx context.Context) ([]MagTick, error) { + rows, err := c.fetchRowArray(ctx, pathMag) + if err != nil { + return nil, err + } + out := make([]MagTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r["time_tag"]) + out = append(out, MagTick{ + TimeTag: ts, + BxGSM: r["bx_gsm"], + ByGSM: r["by_gsm"], + BzGSM: r["bz_gsm"], + LonGSM: r["lon_gsm"], + LatGSM: r["lat_gsm"], + Bt: r["bt"], + }) + } + return out, nil +} + +type rawKp struct { + TimeTag string `json:"time_tag"` + Kp float64 `json:"Kp"` + ARunning int `json:"a_running"` + StationCount int `json:"station_count"` +} + +func (c *Client) FetchKp(ctx context.Context) ([]KpTick, error) { + var rows []rawKp + if err := c.hx.GetJSON(ctx, pathKp, nil, &rows); err != nil { + return nil, fmt.Errorf("fetch kp: %w", err) + } + out := make([]KpTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r.TimeTag) + out = append(out, KpTick{ + TimeTag: ts, + Kp: r.Kp, + ARunning: r.ARunning, + StationCount: r.StationCount, + }) + } + return out, nil +} + +type rawXray struct { + TimeTag string `json:"time_tag"` + Satellite int `json:"satellite"` + Flux float64 `json:"flux"` + ObservedFlux float64 `json:"observed_flux"` + Energy string `json:"energy"` +} + +func (c *Client) FetchXray(ctx context.Context) ([]XrayTick, error) { + var rows []rawXray + if err := c.hx.GetJSON(ctx, pathXray, nil, &rows); err != nil { + return nil, fmt.Errorf("fetch xray: %w", err) + } + out := make([]XrayTick, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r.TimeTag) + out = append(out, XrayTick{ + TimeTag: ts, + Satellite: r.Satellite, + Flux: r.Flux, + ObservedFlux: r.ObservedFlux, + Energy: r.Energy, + }) + } + return out, nil +} + +type rawAlert struct { + ProductID string `json:"product_id"` + IssueDatetime string `json:"issue_datetime"` + Message string `json:"message"` +} + +func (c *Client) FetchAlerts(ctx context.Context) ([]AlertItem, error) { + var rows []rawAlert + if err := c.hx.GetJSON(ctx, pathAlerts, nil, &rows); err != nil { + return nil, fmt.Errorf("fetch alerts: %w", err) + } + out := make([]AlertItem, 0, len(rows)) + for _, r := range rows { + ts, _ := ParseTime(r.IssueDatetime) + out = append(out, AlertItem{ + ProductID: r.ProductID, + IssueDatetime: ts, + Message: r.Message, + }) + } + return out, nil +} + +func (c *Client) fetchRowArray(ctx context.Context, path string) ([]map[string]string, error) { + var raw [][]any + if err := c.hx.GetJSON(ctx, path, nil, &raw); err != nil { + return nil, fmt.Errorf("fetch row-array %s: %w", path, err) + } + if len(raw) < 2 { + return nil, nil + } + headers := make([]string, 0, len(raw[0])) + for _, h := range raw[0] { + if s, ok := h.(string); ok { + headers = append(headers, s) + } + } + out := make([]map[string]string, 0, len(raw)-1) + for _, row := range raw[1:] { + m := make(map[string]string, len(headers)) + for i, v := range row { + if i >= len(headers) { + break + } + m[headers[i]] = anyToString(v) + } + out = append(out, m) + } + return out, nil +} + +func anyToString(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + case json.Number: + return x.String() + case bool: + return strconv.FormatBool(x) + default: + return "" + } +} + +var swpcTimeFormats = []string{ + time.RFC3339Nano, + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02 15:04:05.000", + "2006-01-02 15:04:05", +} + +func ParseTime(s string) (time.Time, error) { + if s == "" { + return time.Time{}, fmt.Errorf("empty swpc time") + } + for _, f := range swpcTimeFormats { + if t, err := time.Parse(f, s); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("unrecognized swpc time: %q", s) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go new file mode 100644 index 00000000..ccf699e8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go @@ -0,0 +1,113 @@ +// ©AngelaMos | 2026 +// client_test.go + +package swpc_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc" +) + +func newFakeServer(t *testing.T, route string, fixture string) *httptest.Server { + t.Helper() + body, err := os.ReadFile("testdata/" + fixture) + require.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, route) { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestClient_FetchPlasmaDecodesRowArray(t *testing.T) { + srv := newFakeServer(t, "plasma-5-minute", "plasma.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchPlasma(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].TimeTag.IsZero()) + require.NotEmpty(t, rows[0].Density) + require.NotEmpty(t, rows[0].Speed) +} + +func TestClient_FetchMagDecodesRowArray(t *testing.T) { + srv := newFakeServer(t, "mag-5-minute", "mag.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchMag(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) +} + +func TestClient_FetchKpDecodesObjectArray(t *testing.T) { + srv := newFakeServer(t, "noaa-planetary-k-index", "kp.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchKp(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].TimeTag.IsZero()) + require.GreaterOrEqual(t, rows[0].Kp, 0.0) +} + +func TestClient_FetchXrayDecodesObjectArray(t *testing.T) { + srv := newFakeServer(t, "xrays-1-day", "xray.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchXray(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].TimeTag.IsZero()) + require.Greater(t, rows[0].Flux, 0.0) +} + +func TestClient_FetchAlertsDecodes(t *testing.T) { + srv := newFakeServer(t, "alerts.json", "alerts.json") + c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + rows, err := c.FetchAlerts(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rows), 1) + require.False(t, rows[0].IssueDatetime.IsZero()) + require.NotEmpty(t, rows[0].ProductID) + require.NotEmpty(t, rows[0].Message) +} + +func TestParseSWPCTime_AcceptsAllKnownFormats(t *testing.T) { + cases := []string{ + "2026-05-02 08:20:00.000", + "2026-04-25T00:00:00", + "2026-05-01T08:24:00Z", + "2026-05-01 15:50:32.247", + } + for _, s := range cases { + got, err := swpc.ParseTime(s) + require.NoError(t, err, "input %q", s) + require.False(t, got.IsZero()) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json new file mode 100644 index 00000000..f8bc3a23 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json @@ -0,0 +1,17 @@ +[ + { + "product_id": "TIIA", + "issue_datetime": "2026-05-01 15:50:32.247", + "message": "Space Weather Message Code: ALTTP2\r\nSerial Number: 1481\r\nIssue Time: 2026 May 01 1550 UTC\r\n\r\nALERT: Type II Radio Emission\r\nBegin Time: 2026 May 01 1535 UTC\r\nEstimated Velocity: 593 km/s\r\n\r\n\r\n\r\nDescription: Type II emissions occur in association with eruptions on the sun and typically indicate a coronal mass ejection is associated with a flare event." + }, + { + "product_id": "K05A", + "issue_datetime": "2026-04-30 23:59:26.927", + "message": "Space Weather Message Code: ALTK05\r\nSerial Number: 2013\r\nIssue Time: 2026 Apr 30 2359 UTC\r\n\r\nALERT: Geomagnetic K-index of 5\r\n Threshold Reached: 2026 Apr 30 2359 UTC\r\nSynoptic Period: 2100-2400 UTC\r\n \r\nActive Warning: Yes\r\nNOAA Scale: G1 - Minor\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 60 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nSpacecraft - Minor impact on satellite operations possible.\r\nAurora - Aurora may be visible at high latitudes, i.e., northern tier of the U.S. such as northern Michigan and Maine." + }, + { + "product_id": "K04W", + "issue_datetime": "2026-04-30 23:11:27.400", + "message": "Space Weather Message Code: WARK04\r\nSerial Number: 5332\r\nIssue Time: 2026 Apr 30 2311 UTC\r\n\r\nEXTENDED WARNING: Geomagnetic K-index of 4 expected\r\nExtension to Serial Number: 5331\r\nValid From: 2026 Apr 30 2005 UTC\r\nNow Valid Until: 2026 May 01 1500 UTC\r\nWarning Condition: Persistence\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 65 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nAurora - Aurora may be visible at high latitudes such as Canada and Alaska." + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json new file mode 100644 index 00000000..f5e0e768 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json @@ -0,0 +1,32 @@ +[ + { + "time_tag": "2026-04-25T00:00:00", + "Kp": 3.33, + "a_running": 18, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T03:00:00", + "Kp": 1.33, + "a_running": 5, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T06:00:00", + "Kp": 1.33, + "a_running": 5, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T09:00:00", + "Kp": 0.67, + "a_running": 3, + "station_count": 8 + }, + { + "time_tag": "2026-04-25T12:00:00", + "Kp": 1.33, + "a_running": 5, + "station_count": 8 + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json new file mode 100644 index 00000000..b594b512 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json @@ -0,0 +1,38 @@ +[ + [ + "time_tag", + "bx_gsm", + "by_gsm", + "bz_gsm", + "lon_gsm", + "lat_gsm", + "bt" + ], + [ + "2026-05-02 08:20:00.000", + "0.36", + "0.12", + "4.41", + "18.07", + "85.10", + "4.43" + ], + [ + "2026-05-02 08:21:00.000", + "-0.32", + "-0.19", + "4.50", + "210.52", + "85.21", + "4.52" + ], + [ + "2026-05-02 08:22:00.000", + "-0.63", + "0.12", + "4.53", + "168.89", + "81.97", + "4.57" + ] +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json new file mode 100644 index 00000000..4dac31f0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json @@ -0,0 +1,26 @@ +[ + [ + "time_tag", + "density", + "speed", + "temperature" + ], + [ + "2026-05-02 08:20:00.000", + "2.94", + "450.6", + "93030" + ], + [ + "2026-05-02 08:21:00.000", + "2.90", + "448.0", + "96988" + ], + [ + "2026-05-02 08:22:00.000", + "2.65", + "452.1", + "84580" + ] +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json new file mode 100644 index 00000000..dbf1971c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json @@ -0,0 +1,47 @@ +[ + { + "time_tag": "2026-05-01T08:24:00Z", + "satellite": 18, + "flux": 7.719492600699596E-9, + "observed_flux": 1.051609821445254E-8, + "electron_correction": 2.79660583579755E-9, + "electron_contaminaton": false, + "energy": "0.05-0.4nm" + }, + { + "time_tag": "2026-05-01T08:24:00Z", + "satellite": 18, + "flux": 9.1065174956384E-7, + "observed_flux": 9.206618756252283E-7, + "electron_correction": 1.0010085205180985E-8, + "electron_contaminaton": false, + "energy": "0.1-0.8nm" + }, + { + "time_tag": "2026-05-01T08:25:00Z", + "satellite": 18, + "flux": 8.121048722387059E-9, + "observed_flux": 1.0973010944326234E-8, + "electron_correction": 2.851962221939175E-9, + "electron_contaminaton": false, + "energy": "0.05-0.4nm" + }, + { + "time_tag": "2026-05-01T08:25:00Z", + "satellite": 18, + "flux": 9.191372782879625E-7, + "observed_flux": 9.28974202452082E-7, + "electron_correction": 9.836912617799953E-9, + "electron_contaminaton": false, + "energy": "0.1-0.8nm" + }, + { + "time_tag": "2026-05-01T08:26:00Z", + "satellite": 18, + "flux": 6.153450637924607E-9, + "observed_flux": 9.192835825899692E-9, + "electron_correction": 3.0393856320642954E-9, + "electron_contaminaton": false, + "energy": "0.05-0.4nm" + } +] From 05ca2f529ffb27817b702aecd3c67b948f1c9d0a Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:27:13 -0400 Subject: [PATCH 057/109] feat(monitor/collectors/swpc): collector with 1m + 3h cadences and ring-buffered persistence --- .../internal/collectors/swpc/collector.go | 211 ++++++++++++++++++ .../collectors/swpc/collector_test.go | 159 +++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go new file mode 100644 index 00000000..3c9dab9d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go @@ -0,0 +1,211 @@ +// ©AngelaMos | 2026 +// collector.go + +package swpc + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "swpc" + defaultFastInterval = time.Minute + defaultSlowInterval = 3 * time.Hour + + keyPlasma = "swpc:plasma" + keyMag = "swpc:mag" + keyKp = "swpc:kp" + keyXray = "swpc:xray" + keyAlerts = "swpc:alerts" +) + +type Fetcher interface { + FetchPlasma(ctx context.Context) ([]PlasmaTick, error) + FetchMag(ctx context.Context) ([]MagTick, error) + FetchKp(ctx context.Context) ([]KpTick, error) + FetchXray(ctx context.Context) ([]XrayTick, error) + FetchAlerts(ctx context.Context) ([]AlertItem, error) +} + +type Ring interface { + Push(ctx context.Context, key string, score int64, payload []byte) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + FastInterval time.Duration + SlowInterval time.Duration + Fetcher Fetcher + Ring Ring + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.FastInterval <= 0 { + cfg.FastInterval = defaultFastInterval + } + if cfg.SlowInterval <= 0 { + cfg.SlowInterval = defaultSlowInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + fast := time.NewTicker(c.cfg.FastInterval) + defer fast.Stop() + slow := time.NewTicker(c.cfg.SlowInterval) + defer slow.Stop() + + c.tickFast(ctx) + c.tickSlow(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-fast.C: + c.tickFast(ctx) + case <-slow.C: + c.tickSlow(ctx) + } + } +} + +func (c *Collector) tickFast(ctx context.Context) { + pushed := int64(0) + hadError := false + + if n, err := c.pushPlasma(ctx); err != nil { + c.logger.Warn("swpc plasma", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + if n, err := c.pushMag(ctx); err != nil { + c.logger.Warn("swpc mag", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + if n, err := c.pushXray(ctx); err != nil { + c.logger.Warn("swpc xray", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + if n, err := c.pushAlerts(ctx); err != nil { + c.logger.Warn("swpc alerts", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + } else { + pushed += n + } + + if pushed > 0 { + body, _ := json.Marshal(map[string]any{ + "ts": time.Now().UTC(), + "pushed": pushed, + }) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicSpaceWeather, + Timestamp: time.Now().UTC(), + Source: Name, + Payload: json.RawMessage(body), + }) + } + + if !hadError { + _ = c.cfg.State.RecordSuccess(ctx, Name, pushed) + } +} + +func (c *Collector) tickSlow(ctx context.Context) { + if _, err := c.pushKp(ctx); err != nil { + c.logger.Warn("swpc kp", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + } +} + +func (c *Collector) pushPlasma(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchPlasma(ctx) + if err != nil { + return 0, err + } + return pushAll(ctx, c.cfg.Ring, keyPlasma, rows, func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() }) +} + +func (c *Collector) pushMag(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchMag(ctx) + if err != nil { + return 0, err + } + return pushAll(ctx, c.cfg.Ring, keyMag, rows, func(r MagTick) int64 { return r.TimeTag.UnixMilli() }) +} + +func (c *Collector) pushKp(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchKp(ctx) + if err != nil { + return 0, err + } + return pushAll(ctx, c.cfg.Ring, keyKp, rows, func(r KpTick) int64 { return r.TimeTag.UnixMilli() }) +} + +func (c *Collector) pushXray(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchXray(ctx) + if err != nil { + return 0, err + } + return pushAll(ctx, c.cfg.Ring, keyXray, rows, func(r XrayTick) int64 { return r.TimeTag.UnixMilli() }) +} + +func (c *Collector) pushAlerts(ctx context.Context) (int64, error) { + rows, err := c.cfg.Fetcher.FetchAlerts(ctx) + if err != nil { + return 0, err + } + return pushAll(ctx, c.cfg.Ring, keyAlerts, rows, func(r AlertItem) int64 { return r.IssueDatetime.UnixMilli() }) +} + +func pushAll[T any](ctx context.Context, ring Ring, key string, rows []T, score func(T) int64) (int64, error) { + pushed := int64(0) + for _, r := range rows { + s := score(r) + if s == 0 { + continue + } + body, _ := json.Marshal(r) + if err := ring.Push(ctx, key, s, body); err != nil { + return pushed, err + } + pushed++ + } + return pushed, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go new file mode 100644 index 00000000..11bedc5f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go @@ -0,0 +1,159 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package swpc_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + plasma []swpc.PlasmaTick + mag []swpc.MagTick + kp []swpc.KpTick + xray []swpc.XrayTick + alerts []swpc.AlertItem + err error +} + +func (f *fakeFetcher) FetchPlasma(_ context.Context) ([]swpc.PlasmaTick, error) { + return f.plasma, f.err +} +func (f *fakeFetcher) FetchMag(_ context.Context) ([]swpc.MagTick, error) { return f.mag, f.err } +func (f *fakeFetcher) FetchKp(_ context.Context) ([]swpc.KpTick, error) { return f.kp, f.err } +func (f *fakeFetcher) FetchXray(_ context.Context) ([]swpc.XrayTick, error) { + return f.xray, f.err +} +func (f *fakeFetcher) FetchAlerts(_ context.Context) ([]swpc.AlertItem, error) { + return f.alerts, f.err +} + +type fakeRing struct { + mu sync.Mutex + pushes map[string]int +} + +func (r *fakeRing) Push(_ context.Context, key string, _ int64, _ []byte) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.pushes == nil { + r.pushes = make(map[string]int) + } + r.pushes[key]++ + return nil +} + +func (r *fakeRing) PushCount(key string) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.pushes[key] +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + return nil +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + return nil +} + +func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + plasma: []swpc.PlasmaTick{{TimeTag: now, Density: "2.94", Speed: "450", Temperature: "93030"}}, + mag: []swpc.MagTick{{TimeTag: now, Bt: "5.6"}}, + xray: []swpc.XrayTick{{TimeTag: now, Flux: 1e-7, Energy: "0.1-0.8nm"}}, + alerts: []swpc.AlertItem{{ProductID: "TIIA", IssueDatetime: now, Message: "test alert"}}, + kp: []swpc.KpTick{{TimeTag: now, Kp: 3.0}}, + } + ring := &fakeRing{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := swpc.NewCollector(swpc.CollectorConfig{ + FastInterval: 20 * time.Millisecond, + SlowInterval: 50 * time.Millisecond, + Fetcher: ftch, + Ring: ring, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, ring.PushCount("swpc:plasma"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:mag"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:xray"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:alerts"), 1) + require.GreaterOrEqual(t, ring.PushCount("swpc:kp"), 1) + + require.GreaterOrEqual(t, emt.Count(), 1) + for _, ev := range emt.events { + require.Equal(t, events.TopicSpaceWeather, ev.Topic) + } + require.Greater(t, st.successes, 0) + require.Equal(t, 0, st.failures) +} + +func TestCollector_FetchErrorsRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + ring := &fakeRing{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := swpc.NewCollector(swpc.CollectorConfig{ + FastInterval: 20 * time.Millisecond, + SlowInterval: 50 * time.Millisecond, + Fetcher: ftch, + Ring: ring, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, ring.PushCount("swpc:plasma")) + require.Greater(t, st.failures, 0) +} From 85626a0e11a6463e23d5616ccd652481b7b9974e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:28:28 -0400 Subject: [PATCH 058/109] feat(monitor/collectors/wikipedia): goquery-backed ITN parser (revid + entries with article slugs) --- .../backend/go.mod | 8 +- .../backend/go.sum | 76 ++++++++++++++++++ .../internal/collectors/wikipedia/parser.go | 77 +++++++++++++++++++ .../collectors/wikipedia/parser_test.go | 58 ++++++++++++++ .../wikipedia/testdata/itn_response.json | 1 + 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/testdata/itn_response.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index fbb03443..ecf3a902 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -23,7 +23,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 go.opentelemetry.io/otel/sdk v1.35.0 go.opentelemetry.io/otel/trace v1.41.0 - golang.org/x/crypto v0.48.0 + golang.org/x/crypto v0.49.0 golang.org/x/time v0.14.0 google.golang.org/grpc v1.68.1 ) @@ -32,6 +32,8 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PuerkitoBio/goquery v1.12.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -105,10 +107,10 @@ require ( go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index cafc7d97..66b233ad 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -8,6 +8,10 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -79,6 +83,7 @@ github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -206,6 +211,7 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -232,23 +238,93 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go new file mode 100644 index 00000000..8c65ffce --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go @@ -0,0 +1,77 @@ +// ©AngelaMos | 2026 +// parser.go + +package wikipedia + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +type Response struct { + RevID int64 + HTML string +} + +type ITNEntry struct { + Text string + ArticleSlug string +} + +type rawAPI struct { + Parse struct { + Title string `json:"title"` + RevID int64 `json:"revid"` + Text struct { + Star string `json:"*"` + } `json:"text"` + } `json:"parse"` +} + +func DecodeResponse(body []byte) (Response, error) { + var r rawAPI + if err := json.Unmarshal(body, &r); err != nil { + return Response{}, fmt.Errorf("decode wikipedia response: %w", err) + } + return Response{RevID: r.Parse.RevID, HTML: r.Parse.Text.Star}, nil +} + +func ParseEntries(html string) []ITNEntry { + if strings.TrimSpace(html) == "" { + return nil + } + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil + } + var out []ITNEntry + doc.Find("ul li").Each(func(_ int, s *goquery.Selection) { + text := strings.TrimSpace(s.Text()) + if text == "" { + return + } + entry := ITNEntry{Text: text} + if href, ok := s.Find("a").First().Attr("href"); ok { + if slug := slugFromHref(href); slug != "" { + entry.ArticleSlug = slug + } + } + out = append(out, entry) + }) + return out +} + +func slugFromHref(href string) string { + const prefix = "/wiki/" + if !strings.HasPrefix(href, prefix) { + return "" + } + slug := strings.TrimPrefix(href, prefix) + if hash := strings.Index(slug, "#"); hash >= 0 { + slug = slug[:hash] + } + return slug +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go new file mode 100644 index 00000000..ea0d46d7 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go @@ -0,0 +1,58 @@ +// ©AngelaMos | 2026 +// parser_test.go + +package wikipedia_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" +) + +func TestParser_ExtractsITNEntriesFromFixture(t *testing.T) { + body, err := os.ReadFile("testdata/itn_response.json") + require.NoError(t, err) + + resp, err := wikipedia.DecodeResponse(body) + require.NoError(t, err) + require.NotZero(t, resp.RevID) + + entries := wikipedia.ParseEntries(resp.HTML) + require.GreaterOrEqual(t, len(entries), 1) + for _, e := range entries { + require.NotEmpty(t, e.Text) + } + hasLink := false + for _, e := range entries { + if e.ArticleSlug != "" { + hasLink = true + break + } + } + require.True(t, hasLink, "at least one entry should carry an article slug") +} + +func TestParser_HandlesEmptyHTML(t *testing.T) { + entries := wikipedia.ParseEntries("") + require.Empty(t, entries) +} + +func TestParser_StripsHTMLTagsFromText(t *testing.T) { + entries := wikipedia.ParseEntries(`
  • Plain bold headline with link.
`) + require.Len(t, entries, 1) + require.Contains(t, entries[0].Text, "Plain") + require.Contains(t, entries[0].Text, "bold") + require.Contains(t, entries[0].Text, "link") + require.NotContains(t, entries[0].Text, "") + require.Equal(t, "Topic", entries[0].ArticleSlug) +} + +func TestParser_SkipsListItemsWithoutLinks(t *testing.T) { + entries := wikipedia.ParseEntries(`
  • Has link
  • No link here
`) + require.Len(t, entries, 2) + require.Equal(t, "Foo", entries[0].ArticleSlug) + require.Empty(t, entries[1].ArticleSlug) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/testdata/itn_response.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/testdata/itn_response.json new file mode 100644 index 00000000..365f36b6 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/testdata/itn_response.json @@ -0,0 +1 @@ +{"parse":{"title":"Template:In the news","pageid":482256,"revid":1352110642,"text":{"*":"
\n
\n\"Sabastian
Sabastian Sawe
\n
\n\n
\n
Ongoing:
\n
\n\n
\n
\n
\n\"\" Template documentation[view] [edit] [history] [purge]
\n\n\n
\"Click\nIn the news toolbox
\n
\n\n
For a talk-page template to indicate that a Wikipedia page has been mentioned by a media organisation, see Template:Press.
\n

\"In the news\" mentions and links to entries of timely interestencyclopedia articles that have been updated to reflect an important current event – rather than conventional news items. To prevent vandalism on the Main Page, it is a permanently protected template. See Wikipedia:In the news for more details. \n

See an error? Please note it on Wikipedia:Main Page/Errors for administrator assistance.\n

The template accepts one parameter: adding |nocurrenteventslink=1 removes the link to Portal:Current events. This is used on the current events page to hide the link there.\n

Admins: After updating please purge the cache of the Main Page so that the updated version appears.\n

In terms of Main Page balance, right now we're configured for widescreen monitors, which it appears most editors use. Non-widescreen users, please do not change the balance even if it looks off on your monitor.\n

Click here to purge the template's cache manually, if it does not appear to be updating on the Main Page after you edit it.\n

\n

Main page template. \"In the news\" mentions and links to entries of timely interest \u2013 encyclopedia articles that have been updated to reflect an important current event \u2013 rather than conventional news items. To prevent vandalism on the Main Page, it is a permanently protected template. See [[Wikipedia:In the news]] for more details.

Template parameters[Edit template data]

ParameterDescriptionTypeStatus
No 'Current events' linknocurrenteventslink

Removes the link to [[Portal:Current events]]. This is used on the current events page to hide the link there

Example
1
Auto value
1
Booleanoptional
\n
\n
\n
\n\n\n\n\n\n\n\n
"}}} From e1d021a99aba9faf7b5e070da569b1bcd7cbbc8a Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:30:14 -0400 Subject: [PATCH 059/109] feat(monitor/collectors/wikipedia): 5m ITN collector with revid-based dedup and per-entry id hash --- .../internal/collectors/wikipedia/client.go | 75 ++++++++ .../collectors/wikipedia/client_test.go | 40 ++++ .../collectors/wikipedia/collector.go | 138 ++++++++++++++ .../collectors/wikipedia/collector_test.go | 178 ++++++++++++++++++ .../internal/collectors/wikipedia/repo.go | 72 +++++++ 5 files changed, 503 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go new file mode 100644 index 00000000..5ba0c445 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go @@ -0,0 +1,75 @@ +// ©AngelaMos | 2026 +// client.go + +package wikipedia + +import ( + "context" + "fmt" + "net/url" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultWikiBaseURL = "https://en.wikipedia.org" + pathAPI = "/w/api.php" + defaultWikiRate = 200 * time.Millisecond + defaultWikiBurst = 5 + defaultWikiBudget = 5 + defaultWikiBreaker = 60 * time.Second +) + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultWikiBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "wikipedia", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultWikiRate), + Burst: defaultWikiBurst, + ConsecutiveFailureBudget: defaultWikiBudget, + BreakerTimeout: defaultWikiBreaker, + }), + } +} + +func (c *Client) Fetch(ctx context.Context) (Response, error) { + q := url.Values{} + q.Set("action", "parse") + q.Set("page", "Template:In_the_news") + q.Set("prop", "text|revid") + q.Set("format", "json") + + resp, err := c.hx.Get(ctx, pathAPI, q) + if err != nil { + return Response{}, fmt.Errorf("fetch wikipedia ITN: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body := make([]byte, 0, 32*1024) + buf := make([]byte, 4*1024) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if rerr != nil { + break + } + } + return DecodeResponse(body) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go new file mode 100644 index 00000000..6732c28f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go @@ -0,0 +1,40 @@ +// ©AngelaMos | 2026 +// client_test.go + +package wikipedia_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" +) + +func TestClient_FetchDecodesITNResponse(t *testing.T) { + body, err := os.ReadFile("testdata/itn_response.json") + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/w/api.php", r.URL.Path) + require.Equal(t, "parse", r.URL.Query().Get("action")) + require.Equal(t, "Template:In_the_news", r.URL.Query().Get("page")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := wikipedia.NewClient(wikipedia.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + resp, err := c.Fetch(ctx) + require.NoError(t, err) + require.NotZero(t, resp.RevID) + require.NotEmpty(t, resp.HTML) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go new file mode 100644 index 00000000..2262ed88 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go @@ -0,0 +1,138 @@ +// ©AngelaMos | 2026 +// collector.go + +package wikipedia + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "wikipedia" + defaultWikiInterval = 5 * time.Minute + idHashBytes = 16 +) + +type Fetcher interface { + Fetch(ctx context.Context) (Response, error) +} + +type Repository interface { + RememberRevID(ctx context.Context, revID int64) error + LastRevID(ctx context.Context) (int64, bool, error) + Insert(ctx context.Context, e Entry) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultWikiInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + c.tick(ctx) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + resp, err := c.cfg.Fetcher.Fetch(ctx) + if err != nil { + c.logger.Warn("wikipedia fetch", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + + last, found, err := c.cfg.Repo.LastRevID(ctx) + if err != nil { + c.logger.Warn("wikipedia revid lookup", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + if found && last == resp.RevID { + _ = c.cfg.State.RecordSuccess(ctx, Name, 0) + return + } + + entries := ParseEntries(resp.HTML) + now := time.Now().UTC() + emitted := int64(0) + for _, e := range entries { + id := entryID(e) + body, _ := json.Marshal(map[string]any{ + "text": e.Text, + "slug": e.ArticleSlug, + }) + entry := Entry{ + ID: id, + Headline: e.Text, + OccurredAt: now, + Payload: body, + } + if err := c.cfg.Repo.Insert(ctx, entry); err != nil { + c.logger.Warn("wikipedia insert", "id", id, "err", err) + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicWikipediaITN, + Timestamp: now, + Source: Name, + Payload: json.RawMessage(body), + }) + emitted++ + } + + if err := c.cfg.Repo.RememberRevID(ctx, resp.RevID); err != nil { + c.logger.Warn("wikipedia remember revid", "err", err) + } + _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) +} + +func entryID(e ITNEntry) string { + h := sha256.Sum256([]byte(e.Text + "|" + e.ArticleSlug)) + return hex.EncodeToString(h[:idHashBytes]) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go new file mode 100644 index 00000000..628fd9a8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go @@ -0,0 +1,178 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package wikipedia_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + resp wikipedia.Response + err error +} + +func (f *fakeFetcher) Fetch(_ context.Context) (wikipedia.Response, error) { + return f.resp, f.err +} + +type fakeRepo struct { + mu sync.Mutex + revID int64 + revKnown bool + inserts []wikipedia.Entry + saveCalls int +} + +func (r *fakeRepo) RememberRevID(_ context.Context, revID int64) error { + r.mu.Lock() + defer r.mu.Unlock() + r.revID = revID + r.revKnown = true + r.saveCalls++ + return nil +} + +func (r *fakeRepo) LastRevID(_ context.Context) (int64, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.revID, r.revKnown, nil +} + +func (r *fakeRepo) Insert(_ context.Context, e wikipedia.Entry) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserts = append(r.inserts, e) + return nil +} + +func (r *fakeRepo) Inserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.inserts) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + return nil +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + return nil +} + +func TestCollector_NewRevidInsertsAndEmits(t *testing.T) { + resp := wikipedia.Response{ + RevID: 999, + HTML: ``, + } + ftch := &fakeFetcher{resp: resp} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Inserts(), 2) + require.GreaterOrEqual(t, emt.Count(), 2) + for _, ev := range emt.events { + require.Equal(t, events.TopicWikipediaITN, ev.Topic) + } + require.Greater(t, st.successes, 0) +} + +func TestCollector_RevIDUnchangedSkipsInsert(t *testing.T) { + resp := wikipedia.Response{ + RevID: 555, + HTML: ``, + } + ftch := &fakeFetcher{resp: resp} + repo := &fakeRepo{revID: 555, revKnown: true} + emt := &fakeEmitter{} + st := &recordingState{} + + c := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Inserts()) + require.Equal(t, 0, emt.Count()) + require.Greater(t, st.successes, 0) +} + +func TestCollector_FetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: 20 * time.Millisecond, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Inserts()) + require.Greater(t, st.failures, 0) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go new file mode 100644 index 00000000..6f54aba4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// repo.go + +package wikipedia + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/jmoiron/sqlx" + "github.com/redis/go-redis/v9" +) + +const ( + keyRevID = "state:wiki_itn:revid" + sourceWikiITN = "wiki_itn" +) + +type Entry struct { + ID string + Headline string + OccurredAt time.Time + Payload json.RawMessage +} + +type Repo struct { + db *sqlx.DB + rdb *redis.Client +} + +func NewRepo(db *sqlx.DB, rdb *redis.Client) *Repo { + return &Repo{db: db, rdb: rdb} +} + +func (r *Repo) RememberRevID(ctx context.Context, revID int64) error { + if err := r.rdb.Set(ctx, keyRevID, strconv.FormatInt(revID, 10), 0).Err(); err != nil { + return fmt.Errorf("save wiki revid: %w", err) + } + return nil +} + +func (r *Repo) LastRevID(ctx context.Context) (int64, bool, error) { + s, err := r.rdb.Get(ctx, keyRevID).Result() + if errors.Is(err, redis.Nil) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("load wiki revid: %w", err) + } + rev, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, false, fmt.Errorf("parse wiki revid %q: %w", s, err) + } + return rev, true, nil +} + +func (r *Repo) Insert(ctx context.Context, e Entry) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO world_events (id, source, occurred_at, headline, payload) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING`, + e.ID, sourceWikiITN, e.OccurredAt, e.Headline, []byte(e.Payload), + ) + if err != nil { + return fmt.Errorf("insert wiki event %s: %w", e.ID, err) + } + return nil +} From 066e2debc2daaf471c3e09ba59fc7f668a6a7510 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:31:10 -0400 Subject: [PATCH 060/109] feat(monitor/collectors/gdelt): per-theme rolling z-score baseline detector --- .../internal/collectors/gdelt/baseline.go | 66 +++++++++++++++++++ .../collectors/gdelt/baseline_test.go | 58 ++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go new file mode 100644 index 00000000..cbaab34b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline.go @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// baseline.go + +package gdelt + +import "math" + +const ( + minStdDev = 1.0 +) + +type Bucket struct { + Score int64 + Count int +} + +type ThemeState struct { + buckets []Bucket + capN int +} + +func NewThemeState(capN int) *ThemeState { + if capN <= 0 { + capN = 96 + } + return &ThemeState{capN: capN} +} + +func (s *ThemeState) Push(b Bucket) { + s.buckets = append(s.buckets, b) + if len(s.buckets) > s.capN { + s.buckets = s.buckets[len(s.buckets)-s.capN:] + } +} + +func (s *ThemeState) Len() int { + return len(s.buckets) +} + +func (s *ThemeState) Stats() (mean, stddev float64) { + if len(s.buckets) == 0 { + return 0, 0 + } + sum := 0.0 + for _, b := range s.buckets { + sum += float64(b.Count) + } + mean = sum / float64(len(s.buckets)) + + variance := 0.0 + for _, b := range s.buckets { + d := float64(b.Count) - mean + variance += d * d + } + variance /= float64(len(s.buckets)) + stddev = math.Sqrt(variance) + return mean, stddev +} + +func (s *ThemeState) ZScore(count int) float64 { + mean, stddev := s.Stats() + if stddev < minStdDev { + return 0 + } + return (float64(count) - mean) / stddev +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go new file mode 100644 index 00000000..cdd383b9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go @@ -0,0 +1,58 @@ +// ©AngelaMos | 2026 +// baseline_test.go + +package gdelt_test + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" +) + +func TestBaseline_EmptyReturnsZeroZ(t *testing.T) { + b := gdelt.NewThemeState(96) + require.InDelta(t, 0.0, b.ZScore(100), 0.0001) +} + +func TestBaseline_StableThenSpikeReturnsHighZ(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)}) + } + z := b.ZScore(500) + require.Greater(t, z, 3.0) +} + +func TestBaseline_StableThenNormalReturnsLowZ(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)}) + } + z := b.ZScore(108) + require.Less(t, z, 3.0) +} + +func TestBaseline_CapacityCapsBuckets(t *testing.T) { + b := gdelt.NewThemeState(10) + for i := 0; i < 200; i++ { + b.Push(gdelt.Bucket{Score: int64(i), Count: i}) + } + require.Equal(t, 10, b.Len()) +} + +func TestBaseline_TinyStdDevDoesNotDivByZero(t *testing.T) { + b := gdelt.NewThemeState(96) + now := time.Now().UTC() + for i := 0; i < 96; i++ { + b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100}) + } + z := b.ZScore(101) + require.False(t, math.IsInf(z, 0)) + require.False(t, math.IsNaN(z)) +} From 317fadeeef8444a261fbe62e621cfaac39812460 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:35:15 -0400 Subject: [PATCH 061/109] feat(monitor/collectors/gdelt): 15m collector with per-theme rolling z-score spike emit --- .../internal/collectors/gdelt/client.go | 123 ++++++++++++ .../internal/collectors/gdelt/client_test.go | 43 +++++ .../internal/collectors/gdelt/collector.go | 163 ++++++++++++++++ .../collectors/gdelt/collector_test.go | 181 ++++++++++++++++++ .../backend/internal/collectors/gdelt/repo.go | 44 +++++ .../gdelt/testdata/timelinevolinfo.json | 16 ++ 6 files changed, 570 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevolinfo.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go new file mode 100644 index 00000000..ec2bd0ff --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go @@ -0,0 +1,123 @@ +// ©AngelaMos | 2026 +// client.go + +package gdelt + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultGDELTBaseURL = "https://api.gdeltproject.org" + pathDoc = "/api/v2/doc/doc" + defaultGDELTRate = 500 * time.Millisecond + defaultGDELTBurst = 2 + defaultGDELTBudget = 5 + defaultGDELTBreaker = 60 * time.Second + + gdeltDateFormat = "20060102T150405Z" +) + +var DefaultThemes = []string{ + "NATURAL_DISASTER", + "ARMEDCONFLICT", + "DISEASE_OUTBREAK", + "ECON_BANKRUPTCY", + "TERROR", +} + +type ClientConfig struct { + BaseURL string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultGDELTBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "gdelt", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultGDELTRate), + Burst: defaultGDELTBurst, + ConsecutiveFailureBudget: defaultGDELTBudget, + BreakerTimeout: defaultGDELTBreaker, + }), + } +} + +type ThemeBucket struct { + Theme string + Time time.Time + Count int +} + +type rawTimeline struct { + Timeline []struct { + Data []struct { + Date string `json:"date"` + Value int `json:"value"` + } `json:"data"` + } `json:"timeline"` +} + +func (c *Client) FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, error) { + q := url.Values{} + q.Set("query", "theme:"+theme) + q.Set("mode", "timelinevolinfo") + q.Set("TIMELINESMOOTH", "5") + q.Set("format", "json") + q.Set("timespan", "1d") + + resp, err := c.hx.Get(ctx, pathDoc, q) + if err != nil { + return nil, fmt.Errorf("fetch gdelt theme %s: %w", theme, err) + } + defer func() { _ = resp.Body.Close() }() + + body := strings.Builder{} + buf := make([]byte, 4096) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + body.Write(buf[:n]) + } + if rerr != nil { + break + } + } + + var raw rawTimeline + if err := json.Unmarshal([]byte(body.String()), &raw); err != nil { + return nil, fmt.Errorf("decode gdelt theme %s: %w", theme, err) + } + if len(raw.Timeline) == 0 { + return nil, nil + } + out := make([]ThemeBucket, 0, len(raw.Timeline[0].Data)) + for _, d := range raw.Timeline[0].Data { + ts, err := time.Parse(gdeltDateFormat, d.Date) + if err != nil { + continue + } + out = append(out, ThemeBucket{ + Theme: theme, + Time: ts.UTC(), + Count: d.Value, + }) + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go new file mode 100644 index 00000000..0f7e4c26 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// client_test.go + +package gdelt_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" +) + +func TestClient_FetchThemeDecodesBuckets(t *testing.T) { + body, err := os.ReadFile("testdata/timelinevolinfo.json") + require.NoError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v2/doc/doc", r.URL.Path) + require.Equal(t, "theme:NATURAL_DISASTER", r.URL.Query().Get("query")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := gdelt.NewClient(gdelt.ClientConfig{BaseURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + buckets, err := c.FetchTheme(ctx, "NATURAL_DISASTER") + require.NoError(t, err) + require.GreaterOrEqual(t, len(buckets), 1) + for _, b := range buckets { + require.False(t, b.Time.IsZero()) + require.Greater(t, b.Count, 0) + require.Equal(t, "NATURAL_DISASTER", b.Theme) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go new file mode 100644 index 00000000..cf1d491b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go @@ -0,0 +1,163 @@ +// ©AngelaMos | 2026 +// collector.go + +package gdelt + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "gdelt" + defaultGDELTInterval = 15 * time.Minute + defaultBaselineCap = 96 + zScoreSpikeThreshold = 3.0 + idHashBytes = 16 +) + +type Fetcher interface { + FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, error) +} + +type Repository interface { + Insert(ctx context.Context, row SpikeRow) error +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + Interval time.Duration + Themes []string + BaselineCap int + Fetcher Fetcher + Repo Repository + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger + baselines map[string]*ThemeState + emitted map[string]bool +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.Interval <= 0 { + cfg.Interval = defaultGDELTInterval + } + if cfg.BaselineCap <= 0 { + cfg.BaselineCap = defaultBaselineCap + } + if len(cfg.Themes) == 0 { + cfg.Themes = DefaultThemes + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + c := &Collector{ + cfg: cfg, + logger: cfg.Logger, + baselines: make(map[string]*ThemeState, len(cfg.Themes)), + emitted: make(map[string]bool), + } + for _, t := range cfg.Themes { + c.baselines[t] = NewThemeState(cfg.BaselineCap) + } + return c +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + ticker := time.NewTicker(c.cfg.Interval) + defer ticker.Stop() + c.tick(ctx) + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + c.tick(ctx) + } + } +} + +func (c *Collector) tick(ctx context.Context) { + hadError := false + emitted := int64(0) + + for _, theme := range c.cfg.Themes { + buckets, err := c.cfg.Fetcher.FetchTheme(ctx, theme) + if err != nil { + c.logger.Warn("gdelt fetch", "theme", theme, "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + hadError = true + continue + } + baseline := c.baselines[theme] + for _, b := range buckets { + z := baseline.ZScore(b.Count) + baseline.Push(Bucket{Score: b.Time.UnixMilli(), Count: b.Count}) + + if z <= zScoreSpikeThreshold { + continue + } + id := spikeID(theme, b.Time) + if c.emitted[id] { + continue + } + c.emitted[id] = true + + payload, _ := json.Marshal(map[string]any{ + "theme": theme, + "time": b.Time, + "count": b.Count, + "zscore": z, + }) + row := SpikeRow{ + ID: id, + Theme: theme, + OccurredAt: b.Time, + Headline: fmt.Sprintf("Theme spike: %s (z=%.2f, count=%d)", theme, z, b.Count), + Payload: payload, + } + if err := c.cfg.Repo.Insert(ctx, row); err != nil { + c.logger.Warn("gdelt insert", "id", id, "err", err) + continue + } + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicGDELTSpike, + Timestamp: b.Time, + Source: Name, + Payload: json.RawMessage(payload), + }) + emitted++ + } + } + + if !hadError { + _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + } +} + +func spikeID(theme string, t time.Time) string { + h := sha256.Sum256([]byte(theme + "|" + t.UTC().Format(time.RFC3339))) + return hex.EncodeToString(h[:idHashBytes]) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go new file mode 100644 index 00000000..a599adbf --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go @@ -0,0 +1,181 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package gdelt_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + mu sync.Mutex + calls int + buckets map[string][]gdelt.ThemeBucket + err error +} + +func (f *fakeFetcher) FetchTheme(_ context.Context, theme string) ([]gdelt.ThemeBucket, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.buckets[theme], nil +} + +type fakeRepo struct { + mu sync.Mutex + inserts []gdelt.SpikeRow +} + +func (r *fakeRepo) Insert(_ context.Context, row gdelt.SpikeRow) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserts = append(r.inserts, row) + return nil +} + +func (r *fakeRepo) Inserts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.inserts) +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + return nil +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + return nil +} + +func TestCollector_NoBaselineNoSpike(t *testing.T) { + now := time.Now().UTC() + buckets := []gdelt.ThemeBucket{ + {Theme: "X", Time: now, Count: 5000}, + } + ftch := &fakeFetcher{buckets: map[string][]gdelt.ThemeBucket{"X": buckets}} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: 20 * time.Millisecond, + Themes: []string{"X"}, + BaselineCap: 8, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Inserts(), "first observation has no baseline → cannot detect spike") + require.Equal(t, 0, emt.Count()) + require.Greater(t, st.successes, 0) +} + +func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) { + base := time.Now().UTC().Truncate(15 * time.Minute) + stable := make([]gdelt.ThemeBucket, 0, 10) + for i := 0; i < 10; i++ { + stable = append(stable, gdelt.ThemeBucket{ + Theme: "X", + Time: base.Add(time.Duration(i) * 15 * time.Minute), + Count: 100 + i, + }) + } + spikeBucket := gdelt.ThemeBucket{Theme: "X", Time: base.Add(15 * 15 * time.Minute), Count: 5000} + buckets := append(stable, spikeBucket) + + ftch := &fakeFetcher{buckets: map[string][]gdelt.ThemeBucket{"X": buckets}} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: 20 * time.Millisecond, + Themes: []string{"X"}, + BaselineCap: 8, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, repo.Inserts(), 1, "spike must be inserted") + require.GreaterOrEqual(t, emt.Count(), 1) + for _, ev := range emt.events { + require.Equal(t, events.TopicGDELTSpike, ev.Topic) + } +} + +func TestCollector_FetchErrorsRecordsState(t *testing.T) { + ftch := &fakeFetcher{err: errors.New("upstream 503")} + repo := &fakeRepo{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: 20 * time.Millisecond, + Themes: []string{"X"}, + BaselineCap: 8, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, repo.Inserts()) + require.Greater(t, st.failures, 0) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go new file mode 100644 index 00000000..93a09c51 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go @@ -0,0 +1,44 @@ +// ©AngelaMos | 2026 +// repo.go + +package gdelt + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +const ( + sourceGDELTSpike = "gdelt_spike" +) + +type SpikeRow struct { + ID string + Theme string + OccurredAt time.Time + Headline string + Payload json.RawMessage +} + +type Repo struct { + db *sqlx.DB +} + +func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } + +func (r *Repo) Insert(ctx context.Context, row SpikeRow) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO world_events (id, source, occurred_at, headline, payload) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO NOTHING`, + row.ID, sourceGDELTSpike, row.OccurredAt, row.Headline, []byte(row.Payload), + ) + if err != nil { + return fmt.Errorf("insert gdelt spike %s: %w", row.ID, err) + } + return nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevolinfo.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevolinfo.json new file mode 100644 index 00000000..6aef353b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevolinfo.json @@ -0,0 +1,16 @@ +{ + "timeline": [ + { + "data": [ + {"date": "20260501T120000Z", "value": 100}, + {"date": "20260501T121500Z", "value": 105}, + {"date": "20260501T123000Z", "value": 102}, + {"date": "20260501T124500Z", "value": 108}, + {"date": "20260501T130000Z", "value": 112}, + {"date": "20260501T131500Z", "value": 98}, + {"date": "20260501T133000Z", "value": 103}, + {"date": "20260501T134500Z", "value": 107} + ] + } + ] +} From 928db0befcb6827437af68173750eb5fd3483bdd Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:37:25 -0400 Subject: [PATCH 062/109] feat(monitor/collectors/iss): SGP4 propagator wrapper (Position + LookAngles via go-satellite) --- .../backend/go.mod | 2 + .../backend/go.sum | 7 +++ .../internal/collectors/iss/propagator.go | 49 +++++++++++++++++ .../collectors/iss/propagator_test.go | 55 +++++++++++++++++++ .../collectors/iss/testdata/position.json | 1 + .../internal/collectors/iss/testdata/tle.json | 1 + .../internal/collectors/iss/testdata/tle.txt | 3 + 7 files changed, 118 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/position.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.txt diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index ecf3a902..906c0999 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -62,6 +62,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -89,6 +90,7 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/segmentio/asm v1.2.1 // indirect diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 66b233ad..376d0ed0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -100,6 +100,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b h1:JlltDRgni6FuoFwluvoZCrE6cmpojccO4WsqeYlFJLE= +github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b/go.mod h1:msW2QeN9IsnRyvuK8OBAzBwn6DHwXpiAiqBk8dbLfrU= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -168,10 +170,14 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/onsi/ginkgo v1.2.1-0.20160509182050-5437a97bf824/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20160516222431-c73e51675ad2/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -337,6 +343,7 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go new file mode 100644 index 00000000..73e951a4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go @@ -0,0 +1,49 @@ +// ©AngelaMos | 2026 +// propagator.go + +package iss + +import ( + "fmt" + "math" + "strings" + "time" + + satellite "github.com/joshuaferrara/go-satellite" +) + +type Sat struct { + inner satellite.Satellite +} + +func LoadTLE(line1, line2 string) (Sat, error) { + if !strings.HasPrefix(line1, "1 ") || !strings.HasPrefix(line2, "2 ") { + return Sat{}, fmt.Errorf("invalid TLE: lines must start with '1 ' and '2 '") + } + s := satellite.TLEToSat(line1, line2, satellite.GravityWGS84) + if s.Error != 0 || s.ErrorStr != "" { + return Sat{}, fmt.Errorf("parse TLE: error %d / %s", s.Error, s.ErrorStr) + } + return Sat{inner: s}, nil +} + +func Position(s Sat, t time.Time) (lat, lon, altKm float64) { + t = t.UTC() + pos, _ := satellite.Propagate(s.inner, t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) + gmst := satellite.GSTimeFromDate(t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) + altitude, _, ll := satellite.ECIToLLA(pos, gmst) + deg := satellite.LatLongDeg(ll) + return deg.Latitude, deg.Longitude, altitude +} + +func LookAngles(s Sat, t time.Time, observerLatDeg, observerLonDeg, observerAltKm float64) (azimuthDeg, elevationDeg, rangeKm float64) { + t = t.UTC() + pos, _ := satellite.Propagate(s.inner, t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) + jday := satellite.JDay(t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) + obs := satellite.LatLong{ + Latitude: observerLatDeg * math.Pi / 180, + Longitude: observerLonDeg * math.Pi / 180, + } + la := satellite.ECIToLookAngles(pos, obs, observerAltKm, jday) + return la.Az * 180 / math.Pi, la.El * 180 / math.Pi, la.Rg +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go new file mode 100644 index 00000000..ccb18a6c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go @@ -0,0 +1,55 @@ +// ©AngelaMos | 2026 +// propagator_test.go + +package iss_test + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss" +) + +func loadTLE(t *testing.T) (string, string) { + t.Helper() + body, err := os.ReadFile("testdata/tle.txt") + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + require.GreaterOrEqual(t, len(lines), 3, "TLE file must have name + 2 element lines") + return strings.TrimRight(lines[1], " \r"), strings.TrimRight(lines[2], " \r") +} + +func TestPropagator_PositionWithinReasonableBounds(t *testing.T) { + l1, l2 := loadTLE(t) + sat, err := iss.LoadTLE(l1, l2) + require.NoError(t, err) + + when := time.Date(2026, 5, 2, 3, 0, 0, 0, time.UTC) + lat, lon, alt := iss.Position(sat, when) + + require.True(t, lat >= -90 && lat <= 90, "lat=%f", lat) + require.True(t, lon >= -180 && lon <= 180, "lon=%f", lon) + require.True(t, alt >= 350 && alt <= 500, "ISS altitude is roughly 380-420 km, got %f", alt) +} + +func TestPropagator_LookAnglesInValidRange(t *testing.T) { + l1, l2 := loadTLE(t) + sat, err := iss.LoadTLE(l1, l2) + require.NoError(t, err) + + when := time.Date(2026, 5, 2, 3, 0, 0, 0, time.UTC) + az, el, rng := iss.LookAngles(sat, when, 40.7128, -74.0060, 0) + + require.True(t, az >= 0 && az <= 360, "az=%f", az) + require.True(t, el >= -90 && el <= 90, "el=%f", el) + require.Greater(t, rng, 0.0) +} + +func TestPropagator_BadTLEReturnsError(t *testing.T) { + _, err := iss.LoadTLE("not a tle", "really not a tle") + require.Error(t, err) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/position.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/position.json new file mode 100644 index 00000000..e451a0c3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/position.json @@ -0,0 +1 @@ +{"name":"iss","id":25544,"latitude":10.63651293933,"longitude":-10.956952860344,"altitude":423.74642684314,"velocity":27564.677940445,"visibility":"daylight","footprint":4526.4746978082,"timestamp":1777710988,"daynum":2461162.8586574,"solar_lat":15.425197566858,"solar_lon":50.133029500693,"units":"kilometers"} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.json new file mode 100644 index 00000000..ac67dea9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.json @@ -0,0 +1 @@ +[{"OBJECT_NAME":"ISS (ZARYA)","OBJECT_ID":"1998-067A","EPOCH":"2026-05-02T03:14:52.956096","MEAN_MOTION":15.49057929,"ECCENTRICITY":0.0007234,"INCLINATION":51.6309,"RA_OF_ASC_NODE":168.049,"ARG_OF_PERICENTER":13.9157,"MEAN_ANOMALY":346.2029,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"U","NORAD_CAT_ID":25544,"ELEMENT_SET_NO":999,"REV_AT_EPOCH":56461,"BSTAR":0.00014312,"MEAN_MOTION_DOT":7.461e-5,"MEAN_MOTION_DDOT":0}] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.txt b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.txt new file mode 100644 index 00000000..b3934d64 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/testdata/tle.txt @@ -0,0 +1,3 @@ +ISS (ZARYA) +1 25544U 98067A 26122.13533514 .00007461 00000+0 14312-3 0 9999 +2 25544 51.6309 168.0490 0007234 13.9157 346.2029 15.49057929564615 From ae011130869ddf643bab25d688e646ca6c8a80c4 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:39:07 -0400 Subject: [PATCH 063/109] feat(monitor/collectors/iss): 10s position poll + daily TLE refresh + propagator-ready collector --- .../backend/internal/collectors/iss/client.go | 121 ++++++++++++ .../internal/collectors/iss/collector.go | 119 ++++++++++++ .../internal/collectors/iss/collector_test.go | 183 ++++++++++++++++++ .../internal/collectors/iss/propagator.go | 2 +- .../collectors/iss/propagator_test.go | 2 +- .../internal/collectors/iss/tle_store.go | 53 +++++ 6 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/tle_store.go diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go new file mode 100644 index 00000000..364ca26e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go @@ -0,0 +1,121 @@ +// ©AngelaMos | 2026 +// client.go + +package iss + +import ( + "context" + "fmt" + "strings" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultISSPositionURL = "https://api.wheretheiss.at" + defaultISSTLEURL = "https://celestrak.org" + pathPosition = "/v1/satellites/25544" + pathTLE = "/NORAD/elements/gp.php" + defaultISSRate = 200 * time.Millisecond + defaultISSBurst = 5 + defaultISSBudget = 5 + defaultISSBreaker = 60 * time.Second +) + +type Position struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude float64 `json:"altitude"` + Velocity float64 `json:"velocity"` + Timestamp int64 `json:"timestamp"` + FetchedAt time.Time `json:"fetched_at"` +} + +type TLE struct { + Line1 string `json:"line1"` + Line2 string `json:"line2"` + FetchedAt time.Time `json:"fetched_at"` +} + +type ClientConfig struct { + PositionBaseURL string + TLEBaseURL string +} + +type Client struct { + pos *httpx.Client + tle *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.PositionBaseURL == "" { + cfg.PositionBaseURL = defaultISSPositionURL + } + if cfg.TLEBaseURL == "" { + cfg.TLEBaseURL = defaultISSTLEURL + } + return &Client{ + pos: httpx.New(httpx.Config{ + Name: "iss-position", + BaseURL: cfg.PositionBaseURL, + Rate: rate.Every(defaultISSRate), + Burst: defaultISSBurst, + ConsecutiveFailureBudget: defaultISSBudget, + BreakerTimeout: defaultISSBreaker, + }), + tle: httpx.New(httpx.Config{ + Name: "iss-tle", + BaseURL: cfg.TLEBaseURL, + Rate: rate.Every(time.Second), + Burst: 1, + ConsecutiveFailureBudget: defaultISSBudget, + BreakerTimeout: defaultISSBreaker, + }), + } +} + +func (c *Client) FetchPosition(ctx context.Context) (Position, error) { + var p Position + if err := c.pos.GetJSON(ctx, pathPosition, nil, &p); err != nil { + return Position{}, fmt.Errorf("fetch iss position: %w", err) + } + p.FetchedAt = time.Now().UTC() + return p, nil +} + +func (c *Client) FetchTLE(ctx context.Context) (TLE, error) { + q := map[string][]string{ + "CATNR": {"25544"}, + "FORMAT": {"TLE"}, + } + resp, err := c.tle.Get(ctx, pathTLE, q) + if err != nil { + return TLE{}, fmt.Errorf("fetch iss tle: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body := strings.Builder{} + buf := make([]byte, 1024) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + body.Write(buf[:n]) + } + if rerr != nil { + break + } + } + + lines := strings.Split(strings.TrimSpace(body.String()), "\n") + if len(lines) < 3 { + return TLE{}, fmt.Errorf("iss tle: expected 3 lines, got %d", len(lines)) + } + return TLE{ + Line1: strings.TrimRight(lines[1], " \r"), + Line2: strings.TrimRight(lines[2], " \r"), + FetchedAt: time.Now().UTC(), + }, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go new file mode 100644 index 00000000..36b27b4c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go @@ -0,0 +1,119 @@ +// ©AngelaMos | 2026 +// collector.go + +package iss + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +const ( + Name = "iss" + defaultPositionInterval = 10 * time.Second + defaultTLEInterval = 24 * time.Hour +) + +type Fetcher interface { + FetchPosition(ctx context.Context) (Position, error) + FetchTLE(ctx context.Context) (TLE, error) +} + +type TLEStorer interface { + Save(ctx context.Context, tle TLE) error + Load(ctx context.Context) (TLE, bool, error) +} + +type Emitter interface { + Emit(ev events.Event) +} + +type StateRecorder interface { + RecordSuccess(ctx context.Context, name string, eventCount int64) error + RecordError(ctx context.Context, name, errMsg string) error +} + +type CollectorConfig struct { + PositionInterval time.Duration + TLEInterval time.Duration + Fetcher Fetcher + TLEStore TLEStorer + Emitter Emitter + State StateRecorder + Logger *slog.Logger +} + +type Collector struct { + cfg CollectorConfig + logger *slog.Logger +} + +func NewCollector(cfg CollectorConfig) *Collector { + if cfg.PositionInterval <= 0 { + cfg.PositionInterval = defaultPositionInterval + } + if cfg.TLEInterval <= 0 { + cfg.TLEInterval = defaultTLEInterval + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + return &Collector{cfg: cfg, logger: cfg.Logger} +} + +func (c *Collector) Name() string { return Name } + +func (c *Collector) Run(ctx context.Context) error { + pos := time.NewTicker(c.cfg.PositionInterval) + defer pos.Stop() + tle := time.NewTicker(c.cfg.TLEInterval) + defer tle.Stop() + + c.tickPosition(ctx) + c.tickTLE(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-pos.C: + c.tickPosition(ctx) + case <-tle.C: + c.tickTLE(ctx) + } + } +} + +func (c *Collector) tickPosition(ctx context.Context) { + p, err := c.cfg.Fetcher.FetchPosition(ctx) + if err != nil { + c.logger.Warn("iss position fetch", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + body, _ := json.Marshal(p) + c.cfg.Emitter.Emit(events.Event{ + Topic: events.TopicISSPosition, + Timestamp: p.FetchedAt, + Source: Name, + Payload: json.RawMessage(body), + }) + _ = c.cfg.State.RecordSuccess(ctx, Name, 1) +} + +func (c *Collector) tickTLE(ctx context.Context) { + tle, err := c.cfg.Fetcher.FetchTLE(ctx) + if err != nil { + c.logger.Warn("iss tle fetch", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + return + } + if err := c.cfg.TLEStore.Save(ctx, tle); err != nil { + c.logger.Warn("iss tle save", "err", err) + _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go new file mode 100644 index 00000000..07141ca3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go @@ -0,0 +1,183 @@ +// ©AngelaMos | 2026 +// collector_test.go + +package iss_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +type fakeFetcher struct { + mu sync.Mutex + pos iss.Position + tle iss.TLE + posErr error + tleErr error + posN int + tleN int +} + +func (f *fakeFetcher) FetchPosition(_ context.Context) (iss.Position, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.posN++ + return f.pos, f.posErr +} + +func (f *fakeFetcher) FetchTLE(_ context.Context) (iss.TLE, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.tleN++ + return f.tle, f.tleErr +} + +type fakeStore struct { + mu sync.Mutex + saves int + saved iss.TLE +} + +func (s *fakeStore) Save(_ context.Context, tle iss.TLE) error { + s.mu.Lock() + defer s.mu.Unlock() + s.saves++ + s.saved = tle + return nil +} + +func (s *fakeStore) Load(_ context.Context) (iss.TLE, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.saves == 0 { + return iss.TLE{}, false, nil + } + return s.saved, true, nil +} + +type fakeEmitter struct { + mu sync.Mutex + events []events.Event +} + +func (e *fakeEmitter) Emit(ev events.Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.events = append(e.events, ev) +} + +func (e *fakeEmitter) Count() int { + e.mu.Lock() + defer e.mu.Unlock() + return len(e.events) +} + +type recordingState struct { + mu sync.Mutex + successes int + failures int +} + +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.successes++ + return nil +} + +func (s *recordingState) RecordError(_ context.Context, _, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.failures++ + return nil +} + +func TestCollector_PositionLoopEmitsAndRecords(t *testing.T) { + ftch := &fakeFetcher{ + pos: iss.Position{Latitude: 10, Longitude: 20, Altitude: 420, Velocity: 27500, Timestamp: 1234, FetchedAt: time.Now().UTC()}, + tle: iss.TLE{Line1: "1 25544U ...", Line2: "2 25544 ..."}, + } + store := &fakeStore{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: 20 * time.Millisecond, + TLEInterval: time.Hour, + Fetcher: ftch, + TLEStore: store, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, ftch.posN, 2, "position should fire multiple times") + require.GreaterOrEqual(t, emt.Count(), 2) + for _, ev := range emt.events { + require.Equal(t, events.TopicISSPosition, ev.Topic) + } + require.Greater(t, st.successes, 0) +} + +func TestCollector_TLELoopSavesToStore(t *testing.T) { + ftch := &fakeFetcher{ + pos: iss.Position{Latitude: 10, Longitude: 20, Altitude: 420, FetchedAt: time.Now().UTC()}, + tle: iss.TLE{Line1: "1 25544U test", Line2: "2 25544 test"}, + } + store := &fakeStore{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: 50 * time.Millisecond, + TLEInterval: 30 * time.Millisecond, + Fetcher: ftch, + TLEStore: store, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, store.saves, 2, "TLE should refresh multiple times in 100ms with 30ms interval") + require.Equal(t, "1 25544U test", store.saved.Line1) +} + +func TestCollector_PositionFetchErrorRecordsState(t *testing.T) { + ftch := &fakeFetcher{ + posErr: errors.New("upstream 503"), + tle: iss.TLE{Line1: "1 ...", Line2: "2 ..."}, + } + store := &fakeStore{} + emt := &fakeEmitter{} + st := &recordingState{} + + c := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: 20 * time.Millisecond, + TLEInterval: time.Hour, + Fetcher: ftch, + TLEStore: store, + Emitter: emt, + State: st, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.Equal(t, 0, emt.Count()) + require.Greater(t, st.failures, 0) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go index 73e951a4..c446ab6f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go @@ -27,7 +27,7 @@ func LoadTLE(line1, line2 string) (Sat, error) { return Sat{inner: s}, nil } -func Position(s Sat, t time.Time) (lat, lon, altKm float64) { +func Propagate(s Sat, t time.Time) (lat, lon, altKm float64) { t = t.UTC() pos, _ := satellite.Propagate(s.inner, t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) gmst := satellite.GSTimeFromDate(t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go index ccb18a6c..3317e467 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go @@ -29,7 +29,7 @@ func TestPropagator_PositionWithinReasonableBounds(t *testing.T) { require.NoError(t, err) when := time.Date(2026, 5, 2, 3, 0, 0, 0, time.UTC) - lat, lon, alt := iss.Position(sat, when) + lat, lon, alt := iss.Propagate(sat, when) require.True(t, lat >= -90 && lat <= 90, "lat=%f", lat) require.True(t, lon >= -180 && lon <= 180, "lon=%f", lon) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/tle_store.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/tle_store.go new file mode 100644 index 00000000..c548e90e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/tle_store.go @@ -0,0 +1,53 @@ +// ©AngelaMos | 2026 +// tle_store.go + +package iss + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + tleKey = "iss:tle" + tleTTL = 24 * time.Hour +) + +type TLEStore struct { + rdb *redis.Client +} + +func NewTLEStore(rdb *redis.Client) *TLEStore { + return &TLEStore{rdb: rdb} +} + +func (s *TLEStore) Save(ctx context.Context, tle TLE) error { + body, err := json.Marshal(tle) + if err != nil { + return fmt.Errorf("marshal tle: %w", err) + } + if err := s.rdb.Set(ctx, tleKey, body, tleTTL).Err(); err != nil { + return fmt.Errorf("save iss tle: %w", err) + } + return nil +} + +func (s *TLEStore) Load(ctx context.Context) (TLE, bool, error) { + body, err := s.rdb.Get(ctx, tleKey).Bytes() + if errors.Is(err, redis.Nil) { + return TLE{}, false, nil + } + if err != nil { + return TLE{}, false, fmt.Errorf("load iss tle: %w", err) + } + var tle TLE + if err := json.Unmarshal(body, &tle); err != nil { + return TLE{}, false, fmt.Errorf("unmarshal tle: %w", err) + } + return tle, true, nil +} From a7f0d5b09736687d234ba5f75ed2dfd6498f0883 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:39:51 -0400 Subject: [PATCH 064/109] feat(monitor/config): usgs/swpc/wikipedia/gdelt/iss config blocks --- .../backend/internal/config/config.go | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go index 7b5f2bd8..c26931d3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -35,6 +35,11 @@ type CollectorsConfig struct { Ransomware SourceConfig `koanf:"ransomware"` GreyNoise GreyNoiseConfig `koanf:"greynoise"` Coinbase CoinbaseConfig `koanf:"coinbase"` + USGS SourceConfig `koanf:"usgs"` + SWPC SWPCConfig `koanf:"swpc"` + Wikipedia SourceConfig `koanf:"wikipedia"` + GDELT SourceConfig `koanf:"gdelt"` + ISS ISSConfig `koanf:"iss"` } type SourceConfig struct { @@ -68,6 +73,18 @@ type CoinbaseConfig struct { Throttle time.Duration `koanf:"throttle"` } +type SWPCConfig struct { + Enabled bool `koanf:"enabled"` + FastInterval time.Duration `koanf:"fast_interval"` + SlowInterval time.Duration `koanf:"slow_interval"` +} + +type ISSConfig struct { + Enabled bool `koanf:"enabled"` + PositionInterval time.Duration `koanf:"position_interval"` + TLEInterval time.Duration `koanf:"tle_interval"` +} + type AppConfig struct { Name string `koanf:"name"` Version string `koanf:"version"` @@ -262,6 +279,18 @@ func loadDefaults(k *koanf.Koanf) error { "collectors.coinbase.url": "wss://advanced-trade-ws.coinbase.com", "collectors.coinbase.product_ids": []string{"BTC-USD", "ETH-USD"}, "collectors.coinbase.throttle": "250ms", + "collectors.usgs.enabled": true, + "collectors.usgs.interval": "1m", + "collectors.swpc.enabled": true, + "collectors.swpc.fast_interval": "1m", + "collectors.swpc.slow_interval": "3h", + "collectors.wikipedia.enabled": true, + "collectors.wikipedia.interval": "5m", + "collectors.gdelt.enabled": true, + "collectors.gdelt.interval": "15m", + "collectors.iss.enabled": true, + "collectors.iss.position_interval": "10s", + "collectors.iss.tle_interval": "24h", } for key, value := range defaults { From 994b510fa4a485b1e9e263dbde6c0f62f7da7f55 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:40:46 -0400 Subject: [PATCH 065/109] feat(monitor/main): wire usgs/swpc/wikipedia/gdelt/iss collectors under errgroup --- .../backend/cmd/api/main.go | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index dc760b9d..b872c50a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -24,14 +24,20 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/coinbase" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/dshield" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/gdelt" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/heartbeat" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/iss" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/state" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring" "github.com/carterperez-dev/monitor-the-situation/backend/internal/server" "github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot" "github.com/carterperez-dev/monitor-the-situation/backend/internal/user" @@ -235,6 +241,69 @@ func run(configPath string) error { collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) } + if cfg.Collectors.USGS.Enabled { + coll := usgs.NewCollector(usgs.CollectorConfig{ + Interval: cfg.Collectors.USGS.Interval, + Fetcher: usgs.NewClient(usgs.ClientConfig{}), + Repo: usgs.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "usgs"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.SWPC.Enabled { + ring := redisring.New(redis.Client, redisring.Config{Retention: 24 * time.Hour}) + coll := swpc.NewCollector(swpc.CollectorConfig{ + FastInterval: cfg.Collectors.SWPC.FastInterval, + SlowInterval: cfg.Collectors.SWPC.SlowInterval, + Fetcher: swpc.NewClient(swpc.ClientConfig{}), + Ring: ring, + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "swpc"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.Wikipedia.Enabled { + coll := wikipedia.NewCollector(wikipedia.CollectorConfig{ + Interval: cfg.Collectors.Wikipedia.Interval, + Fetcher: wikipedia.NewClient(wikipedia.ClientConfig{}), + Repo: wikipedia.NewRepo(db.DB, redis.Client), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "wikipedia"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.GDELT.Enabled { + coll := gdelt.NewCollector(gdelt.CollectorConfig{ + Interval: cfg.Collectors.GDELT.Interval, + Fetcher: gdelt.NewClient(gdelt.ClientConfig{}), + Repo: gdelt.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "gdelt"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + + if cfg.Collectors.ISS.Enabled { + coll := iss.NewCollector(iss.CollectorConfig{ + PositionInterval: cfg.Collectors.ISS.PositionInterval, + TLEInterval: cfg.Collectors.ISS.TLEInterval, + Fetcher: iss.NewClient(iss.ClientConfig{}), + TLEStore: iss.NewTLEStore(redis.Client), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "iss"), + }) + collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) + } + logger.Info("collectors started", "heartbeat", true, "dshield", cfg.Collectors.DShield.Enabled, @@ -243,6 +312,11 @@ func run(configPath string) error { "kev", cfg.Collectors.KEV.Enabled, "ransomware", cfg.Collectors.Ransomware.Enabled, "coinbase", cfg.Collectors.Coinbase.Enabled, + "usgs", cfg.Collectors.USGS.Enabled, + "swpc", cfg.Collectors.SWPC.Enabled, + "wikipedia", cfg.Collectors.Wikipedia.Enabled, + "gdelt", cfg.Collectors.GDELT.Enabled, + "iss", cfg.Collectors.ISS.Enabled, ) srv := server.New(server.Config{ From d662a8c81aec0a752221c1950cf48f5d8b38f398 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:41:34 -0400 Subject: [PATCH 066/109] fix(monitor/collectors/wikipedia): move revid out of state: namespace (was leaking into snapshot) --- .../backend/internal/collectors/wikipedia/repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go index 6f54aba4..7a710728 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go @@ -16,7 +16,7 @@ import ( ) const ( - keyRevID = "state:wiki_itn:revid" + keyRevID = "wiki:itn:revid" sourceWikiITN = "wiki_itn" ) From 2f0c019f201d3c4553a1aa54e6ff177731f2757c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:42:39 -0400 Subject: [PATCH 067/109] fix(monitor/collectors/gdelt): GDELT 'value' field is float64 not int (caught in live verify) --- .../backend/internal/collectors/gdelt/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go index ec2bd0ff..745a3742 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go @@ -68,8 +68,8 @@ type ThemeBucket struct { type rawTimeline struct { Timeline []struct { Data []struct { - Date string `json:"date"` - Value int `json:"value"` + Date string `json:"date"` + Value float64 `json:"value"` } `json:"data"` } `json:"timeline"` } @@ -116,7 +116,7 @@ func (c *Client) FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, e out = append(out, ThemeBucket{ Theme: theme, Time: ts.UTC(), - Count: d.Value, + Count: int(d.Value), }) } return out, nil From 2ac91da809673f387a3f63a8e1f55ef605d6e088 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:43:03 -0400 Subject: [PATCH 068/109] docs(monitor): add four world-collector rows to data source matrix --- .../monitor-the-situation-dashboard/README.md | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md index 33d561b4..e04ffd14 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md @@ -19,17 +19,21 @@ Operator-grade real-time situational awareness dashboard. Single-binary Go backe | Ingress | nginx (dev + prod), Cloudflare Tunnel (prod) | | Build / run | `just` recipes, multi-stage Docker, air for live reload | -## Cyber data sources +## Data sources -| Panel | Source | Cadence | Auth | -| ------------------------------ | ------------------------- | ---------- | --------------------------------------------- | -| Mass-scan firehose | DShield (SANS ISC) | 1h | none | -| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` (Radar:Read scope) | -| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional, raises rate limit) | -| CISA KEV | CISA KEV catalog | 1h | none | -| Ransomware victims | ransomware.live | 15m | none | -| Live BTC + ETH ticks | Coinbase Advanced Trade WS| persistent | none | -| IP enrichment | GreyNoise Community | on-demand | `GREYNOISE_API_KEY` (optional, free tier) | +| Panel | Source | Cadence | Auth | +| ------------------------------ | ---------------------------- | ---------- | --------------------------------------------- | +| Mass-scan firehose | DShield (SANS ISC) | 1h | none | +| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` (Radar:Read scope) | +| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional, raises rate limit) | +| CISA KEV | CISA KEV catalog | 1h | none | +| Ransomware victims | ransomware.live | 15m | none | +| Live BTC + ETH ticks | Coinbase Advanced Trade WS | persistent | none | +| Earthquakes (M2.5+) | USGS GeoJSON feed | 1m | none | +| Space weather | NOAA SWPC (5 endpoints) | 1m / 3h | none | +| World events | Wikipedia ITN + GDELT v2 API | 5m / 15m | none | +| ISS position | wheretheiss.at + CelesTrak | 10s / 24h | none | +| IP enrichment | GreyNoise Community | on-demand | `GREYNOISE_API_KEY` (optional, free tier) | ## Quickstart (development) From 94d7e6035e528f4f173b7e9edc2135858317a040 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 05:31:32 -0400 Subject: [PATCH 069/109] docs(monitor/frontend): mirror ethos hard rules into SCSS comment block --- .../frontend/src/styles/_ethos.scss | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_ethos.scss diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_ethos.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_ethos.scss new file mode 100644 index 00000000..73c50277 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_ethos.scss @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// _ethos.scss +// +// HARD DESIGN RULES — referenced from docs/design/ethos-reference.md. +// Mirrored here so anyone editing styles trips over them. If you find +// yourself wanting to break one of these, the answer is no. Fix the +// data, not the design. +// +// 1. Tables beat charts. Charts only when shape IS the insight. +// 2. One alarm color: --amber. Never for decoration. +// 3. Monochrome base. 3-4 gray tiers. No branded color per metric. +// 4. No rounded corners, no shadows, no gradients. Straight edges. Flat. +// 5. Motion = meaning. Only motion allowed is "data changed." +// 6. Density > breathing room. Line-height 1.25. Row padding 4px 8px. +// 7. Raw source one click away. +// 8. No onboarding, no empty-state illustrations, no tooltips for +// common terms. +// 9. Zoomed-out feel. AI defaults to 120%-zoom cramped. Push back. +// +// Reference: k9s / lazygit / htop / btop / bottom / tig / NOC-SOC. +// NOT: Bloomberg amber-on-black, kali matrix rain, Vercel rounded-cards. From a34a175f14531fe28fcde156e0367ca907aded1f Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 05:32:26 -0400 Subject: [PATCH 070/109] feat(monitor/frontend): ethos design tokens (colors, type, density) --- .../frontend/src/lib/ethos-tokens.ts | 28 ++++++++++++ .../frontend/src/styles/_tokens.scss | 43 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts new file mode 100644 index 00000000..789a2df9 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts @@ -0,0 +1,28 @@ +// ©AngelaMos | 2026 +// ethos-tokens.ts + +export const colors = { + bg: '#0a0a0a', + bgPanel: '#0f0f0f', + bgRowHover: '#141414', + fg1: '#e5e5e5', + fg2: '#a3a3a3', + fg3: '#6b7280', + fg4: '#404040', + ok: '#4ade80', + amber: '#f59e0b', +} as const; + +export const density = { + rowPy: 4, + rowPx: 8, + lineHeightTight: 1.25, + letterSpacingLabel: '0.08em', +} as const; + +export const typeSizes = { + body: 13, + label: 11, + numL: 20, + numXL: 28, +} as const; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss index be1f6dd6..2bfdcd91 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/styles/_tokens.scss @@ -166,3 +166,46 @@ $container-lg: 32rem; $container-xl: 36rem; $container-2xl: 42rem; $container-full: 100%; + +// ============================================================================ +// ETHOS TOKENS — operator-grade dashboard surface +// +// Per docs/design/ethos-reference.md (also mirrored in _ethos.scss). +// Used by /pages/dashboard/, /pages/globe/, /pages/panels/. NOT used by +// auth pages — those keep the template's SCSS variables above. +// ============================================================================ + +@import 'ethos'; + +:root { + // Background tiers + --bg: #0a0a0a; // very dark neutral, NOT pure black + --bg-panel: #0f0f0f; // panel surface, one tier lighter + --bg-row-hover: #141414; // table-row hover, subtle (no interactive theatre) + + // Text tiers (3 + 1 muted/border) + --fg-1: #e5e5e5; // primary + --fg-2: #a3a3a3; // secondary (labels, axes, headers) + --fg-3: #6b7280; // muted (timestamps, footnotes) + --fg-4: #404040; // borders / lines + + // Accents — ONE green, ONE amber. That is all. + --ok: #4ade80; // live / healthy / busy + --amber: #f59e0b; // stale / breached / anomaly. NEVER for decoration. + + // Type + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-mono: "Geist Mono", ui-monospace, "SF Mono", "Cascadia Code", monospace; + + // Density (matches the spec — small padding, tight line-height) + --row-py: 4px; + --row-px: 8px; + --line-height-tight: 1.25; + --letter-spacing-label: 0.08em; + + // Sizes — intentionally smaller than SaaS defaults for operator density + --type-body: 13px; + --type-label: 11px; // UPPERCASE labels + --type-num-l: 20px; // KPI numbers + --type-num-xl: 28px; // hero KPI numbers (rare; only top-of-panel) +} From 4216de3435e416adf4b622d3682595df8b3c8fff Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 05:33:12 -0400 Subject: [PATCH 071/109] feat(monitor/frontend): ethos-aligned base styles (zero rounding, density, tabular nums) --- .../frontend/index.html | 3 +- .../frontend/src/styles.scss | 35 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html index ce1f11bb..1c9c7a4d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html @@ -22,8 +22,9 @@ /> + Monitor The Situation Dashboard Date: Sat, 2 May 2026 05:33:53 -0400 Subject: [PATCH 072/109] feat(monitor/frontend): useSnapshot hook (single bootstrap, no refetch) --- .../frontend/src/api/snapshot.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/snapshot.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/snapshot.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/snapshot.ts new file mode 100644 index 00000000..3c4365da --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/snapshot.ts @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// snapshot.ts + +import { useQuery } from '@tanstack/react-query' +import { apiClient } from '@/core/api' + +export type Snapshot = Record + +export const SNAPSHOT_KEY = ['snapshot'] as const + +export function useSnapshot() { + return useQuery({ + queryKey: SNAPSHOT_KEY, + queryFn: async (): Promise => { + const res = await apiClient.get('/v1/snapshot') + return res.data + }, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) +} From f7f276df39cc80dd14e2131852cbd8f46fea0a36 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 05:35:18 -0400 Subject: [PATCH 073/109] feat(monitor/frontend): dashboard WS client with reconnect + init handshake (TDD, 6 tests) --- .../frontend/package.json | 9 +- .../frontend/pnpm-lock.yaml | 386 ++++++++++++++++++ .../frontend/src/api/ws.test.ts | 141 +++++++ .../frontend/src/api/ws.ts | 134 ++++++ 4 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json index fc6e78c7..aa631e5f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json @@ -12,7 +12,9 @@ "format": "biome format --write .", "typecheck": "tsc -b", "lint:scss": "stylelint '**/*.scss'", - "lint:scss:fix": "stylelint '**/*.scss' --fix" + "lint:scss:fix": "stylelint '**/*.scss' --fix", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.90.12", @@ -34,13 +36,16 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.1.5", + "happy-dom": "^20.9.0", "sass": "^1.95.0", "stylelint": "^16.26.1", "stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-standard-scss": "^16.0.0", "typescript": "~5.9.3", "vite": "npm:rolldown-vite@7.2.5", - "vite-tsconfig-paths": "^5.1.0" + "vite-tsconfig-paths": "^5.1.0", + "vitest": "^4.1.5" }, "pnpm": { "overrides": { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml index d04a7163..51c9446a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml @@ -63,6 +63,12 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.1 version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + '@vitest/ui': + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.5) + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 sass: specifier: ^1.95.0 version: 1.95.0 @@ -84,6 +90,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.0 version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))(typescript@5.9.3) + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@24.10.2)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) packages: @@ -196,24 +205,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.8': resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.8': resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.8': resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.8': resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} @@ -348,36 +361,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -401,6 +420,9 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/binding-android-arm64@1.0.0-beta.50': resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} engines: {node: ^20.19.0 || >=22.12.0} @@ -436,24 +458,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} @@ -490,6 +516,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tanstack/query-core@5.90.12': resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} @@ -522,6 +551,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@24.10.2': resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} @@ -533,12 +571,52 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitejs/plugin-react@5.1.2': resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/ui@4.1.5': + resolution: {integrity: sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==} + peerDependencies: + vitest: 4.1.5 + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -557,6 +635,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -600,6 +682,10 @@ packages: caniuse-lite@1.0.30001759: resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -690,6 +776,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -705,6 +795,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -717,6 +810,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -743,6 +843,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@11.1.1: resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} @@ -756,6 +859,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -815,6 +921,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -958,24 +1068,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -1002,6 +1116,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1035,6 +1152,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1053,6 +1174,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1065,6 +1189,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1244,10 +1371,17 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1266,6 +1400,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1339,14 +1479,29 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -1385,14 +1540,76 @@ packages: vite: optional: true + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + write-file-atomic@5.0.1: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1726,6 +1943,8 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.1 optional: true + '@polka/url@1.0.0-next.29': {} + '@rolldown/binding-android-arm64@1.0.0-beta.50': optional: true @@ -1774,6 +1993,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.53': {} + '@standard-schema/spec@1.1.0': {} + '@tanstack/query-core@5.90.12': {} '@tanstack/query-devtools@5.91.1': {} @@ -1815,6 +2036,15 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/node@24.10.2': dependencies: undici-types: 7.16.0 @@ -1827,6 +2057,12 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.2 + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': dependencies: '@babel/core': 7.28.5 @@ -1839,6 +2075,58 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/ui@4.1.5(vitest@4.1.5)': + dependencies: + '@vitest/utils': 4.1.5 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@24.10.2)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -1856,6 +2144,8 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} asynckit@0.4.0: {} @@ -1905,6 +2195,8 @@ snapshots: caniuse-lite@1.0.30001759: {} + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1972,6 +2264,8 @@ snapshots: emoji-regex@8.0.0: {} + entities@7.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -1982,6 +2276,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -1995,6 +2291,12 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2017,6 +2319,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@11.1.1: dependencies: flat-cache: 6.1.19 @@ -2033,6 +2337,8 @@ snapshots: flatted@3.3.3: {} + flatted@3.4.2: {} + follow-redirects@1.15.11: {} form-data@4.0.5: @@ -2097,6 +2403,18 @@ snapshots: gopd@1.2.0: {} + happy-dom@20.9.0: + dependencies: + '@types/node': 24.10.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -2227,6 +2545,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} mathml-tag-names@2.1.3: {} @@ -2250,6 +2572,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -2261,6 +2585,8 @@ snapshots: normalize-path@3.0.0: {} + obug@2.1.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2274,6 +2600,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -2412,8 +2740,16 @@ snapshots: set-cookie-parser@2.7.2: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} slice-ansi@4.0.0: @@ -2429,6 +2765,10 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2545,15 +2885,23 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -2584,15 +2932,53 @@ snapshots: - supports-color - typescript + vitest@4.1.5(@types/node@24.10.2)(@vitest/ui@4.1.5)(happy-dom@20.9.0)(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.2)(sass@1.95.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.2 + '@vitest/ui': 4.1.5(vitest@4.1.5) + happy-dom: 20.9.0 + transitivePeerDependencies: + - msw + + whatwg-mimetype@3.0.0: {} + which@1.3.1: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + write-file-atomic@5.0.1: dependencies: imurmurhash: 0.1.4 signal-exit: 4.1.0 + ws@8.20.0: {} + yallist@3.1.1: {} zod@4.1.13: {} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts new file mode 100644 index 00000000..bd43e1d8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts @@ -0,0 +1,141 @@ +// ©AngelaMos | 2026 +// ws.test.ts + +import { describe, it, expect, vi } from 'vitest' +import { createDashboardWS, type WSDriver } from './ws' + +class FakeDriver implements WSDriver { + onOpen: (() => void) | null = null + onMessage: ((data: string) => void) | null = null + onClose: (() => void) | null = null + sent: string[] = [] + closed = false + + send(s: string) { + this.sent.push(s) + } + + close() { + if (this.closed) return + this.closed = true + this.onClose?.() + } + + fire(data: string) { + this.onMessage?.(data) + } + + open() { + this.onOpen?.() + } +} + +describe('dashboard WS', () => { + it('does not send init until setReady() is called', () => { + const drv = new FakeDriver() + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + }) + ws.connect() + drv.open() + expect(drv.sent).toEqual([]) + ws.setReady() + expect(drv.sent).toContain('{"op":"init"}') + ws.disconnect() + }) + + it('buffers events received before ready, replays on setReady', () => { + const drv = new FakeDriver() + const events: unknown[] = [] + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + onEvent: (e) => events.push(e), + }) + ws.connect() + drv.open() + drv.fire(JSON.stringify({ topic: 'heartbeat', payload: {} })) + expect(events).toHaveLength(0) + ws.setReady() + expect(events).toHaveLength(1) + ws.disconnect() + }) + + it('forwards events received after ready immediately', () => { + const drv = new FakeDriver() + const events: unknown[] = [] + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + onEvent: (e) => events.push(e), + }) + ws.connect() + drv.open() + ws.setReady() + drv.fire(JSON.stringify({ topic: 'heartbeat', payload: { v: 1 } })) + expect(events).toHaveLength(1) + ws.disconnect() + }) + + it('reconnects with backoff after drop and resends init when ready', async () => { + const drivers: FakeDriver[] = [] + const ws = createDashboardWS({ + driver: () => { + const d = new FakeDriver() + drivers.push(d) + return d + }, + topics: ['heartbeat'], + backoff: { initialMs: 5, maxMs: 20 }, + }) + ws.connect() + drivers[0].open() + ws.setReady() + expect(drivers[0].sent).toContain('{"op":"init"}') + + drivers[0].close() + await new Promise((r) => setTimeout(r, 50)) + + expect(drivers.length).toBeGreaterThanOrEqual(2) + drivers[1].open() + expect(drivers[1].sent).toContain('{"op":"init"}') + ws.disconnect() + }) + + it('does not reconnect after explicit disconnect', async () => { + const drivers: FakeDriver[] = [] + const ws = createDashboardWS({ + driver: () => { + const d = new FakeDriver() + drivers.push(d) + return d + }, + topics: ['heartbeat'], + backoff: { initialMs: 5, maxMs: 20 }, + }) + ws.connect() + drivers[0].open() + ws.disconnect() + await new Promise((r) => setTimeout(r, 30)) + expect(drivers).toHaveLength(1) + }) + + it('ignores malformed JSON without crashing', () => { + const drv = new FakeDriver() + const events: unknown[] = [] + const errors = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const ws = createDashboardWS({ + driver: () => drv, + topics: ['heartbeat'], + onEvent: (e) => events.push(e), + }) + ws.connect() + drv.open() + ws.setReady() + drv.fire('{not json') + expect(events).toHaveLength(0) + errors.mockRestore() + ws.disconnect() + }) +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts new file mode 100644 index 00000000..017968cc --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts @@ -0,0 +1,134 @@ +// ©AngelaMos | 2026 +// ws.ts + +export interface WSDriver { + onOpen: (() => void) | null + onMessage: ((data: string) => void) | null + onClose: (() => void) | null + send(s: string): void + close(): void +} + +export interface WSEvent { + topic: string + source?: string + timestamp?: string + payload?: unknown +} + +export interface BackoffConfig { + initialMs: number + maxMs: number +} + +export interface CreateDashboardWSOpts { + driver: () => WSDriver + topics: string[] + onEvent?: (ev: WSEvent) => void + backoff?: BackoffConfig +} + +const DEFAULT_BACKOFF: BackoffConfig = { + initialMs: 1_000, + maxMs: 30_000, +} + +interface DashboardWS { + connect: () => void + setReady: () => void + disconnect: () => void +} + +export function createDashboardWS(opts: CreateDashboardWSOpts): DashboardWS { + const backoff = opts.backoff ?? DEFAULT_BACKOFF + const onEvent = opts.onEvent ?? (() => undefined) + + let driver: WSDriver | null = null + let ready = false + let closed = false + let buffer: WSEvent[] = [] + let nextDelay = backoff.initialMs + let reconnectTimer: ReturnType | null = null + + function attach() { + if (closed) return + const d = opts.driver() + driver = d + d.onOpen = () => { + // reset backoff on a successful open; init is sent only after setReady() + nextDelay = backoff.initialMs + if (ready) sendInit() + } + d.onMessage = (data) => { + let parsed: WSEvent + try { + parsed = JSON.parse(data) as WSEvent + } catch { + console.warn('coinbase ws: malformed frame', data) + return + } + if (ready) { + onEvent(parsed) + } else { + buffer.push(parsed) + } + } + d.onClose = () => { + driver = null + if (closed) return + const delay = nextDelay + nextDelay = Math.min(nextDelay * 2, backoff.maxMs) + reconnectTimer = setTimeout(attach, delay) + } + } + + function sendInit() { + driver?.send(JSON.stringify({ op: 'init' })) + } + + return { + connect() { + attach() + }, + setReady() { + ready = true + const flush = buffer + buffer = [] + for (const ev of flush) onEvent(ev) + sendInit() + }, + disconnect() { + closed = true + ready = false + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + driver?.close() + driver = null + buffer = [] + }, + } +} + +export function browserDriver(url: string, topics: string[]): WSDriver { + const u = new URL(url, window.location.origin.replace('http', 'ws')) + if (topics.length > 0) u.searchParams.set('topics', topics.join(',')) + const sock = new WebSocket(u.toString()) + + const driver: WSDriver = { + onOpen: null, + onMessage: null, + onClose: null, + send(s) { + sock.send(s) + }, + close() { + sock.close() + }, + } + sock.addEventListener('open', () => driver.onOpen?.()) + sock.addEventListener('message', (e) => driver.onMessage?.(String(e.data))) + sock.addEventListener('close', () => driver.onClose?.()) + return driver +} From 7a97aac091ee282b94ca221d78288244e1316364 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 05:36:18 -0400 Subject: [PATCH 074/109] feat(monitor/frontend): zustand stores (globe / ticker / prices / ui / audio + ticker tests) --- .../frontend/src/stores/audio.ts | 22 +++++++ .../frontend/src/stores/globeEvents.ts | 62 +++++++++++++++++++ .../frontend/src/stores/prices.ts | 51 +++++++++++++++ .../frontend/src/stores/ticker.test.ts | 33 ++++++++++ .../frontend/src/stores/ticker.ts | 30 +++++++++ .../frontend/src/stores/ui.ts | 38 ++++++++++++ 6 files changed, 236 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/audio.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/prices.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.test.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.ts create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ui.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/audio.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/audio.ts new file mode 100644 index 00000000..eb302b7d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/audio.ts @@ -0,0 +1,22 @@ +// ©AngelaMos | 2026 +// audio.ts + +import { create } from 'zustand' + +interface AudioStore { + ctx: AudioContext | null + buffer: AudioBuffer | null + unlocked: boolean + setCtx: (ctx: AudioContext | null) => void + setBuffer: (b: AudioBuffer | null) => void + setUnlocked: (u: boolean) => void +} + +export const useAudioStore = create((set) => ({ + ctx: null, + buffer: null, + unlocked: false, + setCtx: (ctx) => set({ ctx }), + setBuffer: (buffer) => set({ buffer }), + setUnlocked: (unlocked) => set({ unlocked }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts new file mode 100644 index 00000000..807376a2 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts @@ -0,0 +1,62 @@ +// ©AngelaMos | 2026 +// globeEvents.ts + +import { create } from 'zustand' + +export type GlobePointType = + | 'earthquake' + | 'ransomware' + | 'scan' + | 'iss' + | 'outage' + | 'hijack' + +export interface GlobePoint { + id: string + type: GlobePointType + lat: number + lng: number + emittedAt: number + meta?: Record +} + +export interface GlobeRing { + id: string + lat: number + lng: number + emittedAt: number + ttlMs: number +} + +interface GlobeStore { + points: GlobePoint[] + rings: GlobeRing[] + focusEvent: GlobePoint | null + pushPoint: (p: GlobePoint) => void + pushRing: (r: GlobeRing) => void + evict: (now: number) => void + focus: (p: GlobePoint | null) => void +} + +const POINT_TTL_MS = 15 * 60 * 1000 +const POINT_CAP = 500 + +export const useGlobeEvents = create((set) => ({ + points: [], + rings: [], + focusEvent: null, + pushPoint: (p) => + set((s) => ({ + points: [...s.points.slice(-POINT_CAP + 1), p], + })), + pushRing: (r) => + set((s) => ({ + rings: [...s.rings, r], + })), + evict: (now) => + set((s) => ({ + points: s.points.filter((p) => now - p.emittedAt < POINT_TTL_MS), + rings: s.rings.filter((r) => now - r.emittedAt < r.ttlMs), + })), + focus: (p) => set({ focusEvent: p }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/prices.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/prices.ts new file mode 100644 index 00000000..bfd95848 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/prices.ts @@ -0,0 +1,51 @@ +// ©AngelaMos | 2026 +// prices.ts + +import { create } from 'zustand' + +export interface PriceTick { + symbol: string + price: string + ts: number + volume24h?: string +} + +export interface MinuteBar { + symbol: string + minute: number + open: string + high: string + low: string + close: string + volume?: string +} + +interface PricesStore { + latest: Record + history: Record + pushTick: (t: PriceTick) => void + pushMinute: (b: MinuteBar) => void +} + +const HISTORY_CAP = 60 + +export const usePrices = create((set) => ({ + latest: {}, + history: {}, + pushTick: (t) => + set((s) => ({ + latest: { ...s.latest, [t.symbol]: t }, + })), + pushMinute: (b) => + set((s) => { + const cur = s.history[b.symbol] ?? [] + const idx = cur.findIndex((c) => c.minute === b.minute) + let next: MinuteBar[] + if (idx >= 0) { + next = [...cur.slice(0, idx), b, ...cur.slice(idx + 1)] + } else { + next = [...cur, b].slice(-HISTORY_CAP) + } + return { history: { ...s.history, [b.symbol]: next } } + }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.test.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.test.ts new file mode 100644 index 00000000..45dce76a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.test.ts @@ -0,0 +1,33 @@ +// ©AngelaMos | 2026 +// ticker.test.ts + +import { describe, it, expect, beforeEach } from 'vitest' +import { useTicker } from './ticker' + +describe('useTicker', () => { + beforeEach(() => { + useTicker.getState().clear() + }) + + it('caps at 50 entries oldest-out', () => { + for (let i = 0; i < 100; i++) { + useTicker.getState().push({ + id: `e${i}`, + source: 'wiki', + headline: `headline ${i}`, + ts: Date.now() + i, + }) + } + expect(useTicker.getState().items).toHaveLength(50) + // oldest survivors are e50..e99 + expect(useTicker.getState().items[0].id).toBe('e50') + expect(useTicker.getState().items.at(-1)?.id).toBe('e99') + }) + + it('deduplicates by id', () => { + const item = { id: 'dup', source: 'gdelt', headline: 'h', ts: 0 } + useTicker.getState().push(item) + useTicker.getState().push(item) + expect(useTicker.getState().items).toHaveLength(1) + }) +}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.ts new file mode 100644 index 00000000..f4bbbf3d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ticker.ts @@ -0,0 +1,30 @@ +// ©AngelaMos | 2026 +// ticker.ts + +import { create } from 'zustand' + +export interface TickerItem { + id: string + source: string + headline: string + ts: number +} + +interface TickerStore { + items: TickerItem[] + push: (item: TickerItem) => void + clear: () => void +} + +const TICKER_CAP = 50 + +export const useTicker = create((set) => ({ + items: [], + push: (item) => + set((s) => { + if (s.items.some((i) => i.id === item.id)) return s + const next = [...s.items, item] + return { items: next.slice(-TICKER_CAP) } + }), + clear: () => set({ items: [] }), +})) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ui.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ui.ts new file mode 100644 index 00000000..4c72084c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/ui.ts @@ -0,0 +1,38 @@ +// ©AngelaMos | 2026 +// ui.ts + +import { create } from 'zustand' + +export interface AlertState { + message: string + severity: 'info' | 'warn' +} + +interface UIStore { + presentationMode: boolean + setPresentationMode: (on: boolean) => void + togglePresentationMode: () => void + + currentAlert: AlertState | null + showAlert: (a: AlertState) => void + dismissAlert: () => void + + aboutOpen: boolean + openAbout: () => void + closeAbout: () => void +} + +export const useUIStore = create((set) => ({ + presentationMode: false, + setPresentationMode: (on) => set({ presentationMode: on }), + togglePresentationMode: () => + set((s) => ({ presentationMode: !s.presentationMode })), + + currentAlert: null, + showAlert: (a) => set({ currentAlert: a }), + dismissAlert: () => set({ currentAlert: null }), + + aboutOpen: false, + openAbout: () => set({ aboutOpen: true }), + closeAbout: () => set({ aboutOpen: false }), +})) From 50b0e96735f3037bf4480e28e0ded5c468ede853 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 02:04:26 -0400 Subject: [PATCH 075/109] chore(monitor/frontend): demolish template shell + landing for clean dashboard canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleared: - core/app/shell.tsx + shell.module.scss (sidebar+header layout) - core/app/toast.module.scss (replaced by inline ethos toaster styling) - core/lib/shell.ui.store.ts (sidebar collapsed state, no longer needed) - pages/landing/ (template marketing copy) - pages/dashboard/ template placeholder (replaced with minimal scaffold) - components/index.tsx (empty stub) Kept: - All auth infrastructure (apiClient, auth.store, ProtectedRoute) - Auth pages (login/register/settings/admin) — they keep the template's visual style for now; not on the operator's hot path - All my Phase 5 work (ethos tokens, snapshot/ws/stores) Routes now: / -> dashboard scaffold (no Shell), /login + /register standalone, /settings + /admin auth-gated standalone (no Shell wrapper). --- .../frontend/src/App.tsx | 36 +- .../frontend/src/components/index.tsx | 4 - .../frontend/src/core/app/routers.tsx | 35 +- .../frontend/src/core/app/shell.module.scss | 331 ------------------ .../frontend/src/core/app/shell.tsx | 160 --------- .../frontend/src/core/app/toast.module.scss | 67 ---- .../frontend/src/core/lib/index.ts | 5 +- .../frontend/src/core/lib/shell.ui.store.ts | 63 ---- .../src/pages/dashboard/dashboard.module.scss | 155 +------- .../frontend/src/pages/dashboard/index.tsx | 84 +---- .../frontend/src/pages/landing/index.tsx | 97 ----- .../src/pages/landing/landing.module.scss | 161 --------- 12 files changed, 40 insertions(+), 1158 deletions(-) delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx delete mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx index 77f8c360..9006702e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/App.tsx @@ -1,7 +1,5 @@ -// =========================== -// ©AngelaMos | 2025 +// ©AngelaMos | 2026 // App.tsx -// =========================== import { QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' @@ -10,26 +8,26 @@ import { Toaster } from 'sonner' import { queryClient } from '@/core/api' import { router } from '@/core/app/routers' -import '@/core/app/toast.module.scss' export default function App(): React.ReactElement { return ( -
- - -
+ +
) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx deleted file mode 100644 index 6b0a7329..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/components/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -/** - * ©AngelaMos | 2025 - * index.tsx - */ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx index cb2473c9..117c81ed 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/routers.tsx @@ -1,18 +1,15 @@ -// =================== -// © AngelaMos | 2025 +// ©AngelaMos | 2026 // routers.tsx -// =================== import { createBrowserRouter, type RouteObject } from 'react-router-dom' import { UserRole } from '@/api/types' import { ROUTES } from '@/config' import { ProtectedRoute } from './protected-route' -import { Shell } from './shell' const routes: RouteObject[] = [ { path: ROUTES.HOME, - lazy: () => import('@/pages/landing'), + lazy: () => import('@/pages/dashboard'), }, { path: ROUTES.LOGIN, @@ -26,17 +23,8 @@ const routes: RouteObject[] = [ element: , children: [ { - element: , - children: [ - { - path: ROUTES.DASHBOARD, - lazy: () => import('@/pages/dashboard'), - }, - { - path: ROUTES.SETTINGS, - lazy: () => import('@/pages/settings'), - }, - ], + path: ROUTES.SETTINGS, + lazy: () => import('@/pages/settings'), }, ], }, @@ -44,23 +32,14 @@ const routes: RouteObject[] = [ element: , children: [ { - element: , - children: [ - { - path: ROUTES.ADMIN.USERS, - lazy: () => import('@/pages/admin'), - }, - ], + path: ROUTES.ADMIN.USERS, + lazy: () => import('@/pages/admin'), }, ], }, - { - path: ROUTES.UNAUTHORIZED, - lazy: () => import('@/pages/landing'), - }, { path: '*', - lazy: () => import('@/pages/landing'), + lazy: () => import('@/pages/dashboard'), }, ] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss deleted file mode 100644 index b57d7a96..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.module.scss +++ /dev/null @@ -1,331 +0,0 @@ -// =================== -// © AngelaMos | 2025 -// shell.module.scss -// =================== - -@use '@/styles' as *; - -$sidebar-width: 240px; -$sidebar-collapsed-width: 64px; -$header-height: 56px; - -.shell { - display: flex; - min-height: 100vh; - min-height: 100dvh; -} - -.sidebar { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: $sidebar-width; - background: $bg-surface-100; - border-right: 1px solid $border-default; - display: flex; - flex-direction: column; - z-index: $z-fixed; - @include transition-fast; - - &.collapsed { - width: $sidebar-collapsed-width; - } - - @include breakpoint-down('sm') { - transform: translateX(-100%); - - &.open { - transform: translateX(0); - } - - &.collapsed { - width: $sidebar-width; - } - } -} - -.sidebarHeader { - height: $header-height; - padding: 0 $space-3; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid $border-default; - - .sidebar.collapsed & { - justify-content: center; - padding: 0; - } -} - -.logo { - font-size: $font-size-base; - font-weight: $font-weight-semibold; - color: $text-default; - @include transition-fast; - - .sidebar.collapsed & { - display: none; - } -} - -.nav { - flex: 1; - padding: $space-3; - display: flex; - flex-direction: column; - gap: $space-1; -} - -.navItem { - display: flex; - align-items: center; - gap: $space-3; - padding: $space-2 $space-3; - border-radius: $radius-md; - font-size: $font-size-sm; - color: $text-light; - @include transition-fast; - - @include hover { - background: $bg-surface-200; - color: $text-default; - } - - &.active { - background: $bg-selection; - color: $text-default; - } - - .sidebar.collapsed & { - justify-content: center; - } -} - -.navIcon { - width: 17px; - height: 17px; - flex-shrink: 0; -} - -.navLabel { - @include transition-fast; - - .sidebar.collapsed & { - display: none; - } -} - -.adminItem { - margin-top: auto; - border-top: 1px solid $border-default; - padding-top: $space-3; -} - -.collapseBtn { - width: 45px; - height: 45px; - border-radius: $radius-md; - color: $text-light; - @include flex-center; - @include transition-fast; - - svg { - width: 23.5px; - height: 23.5px; - } - - @include hover { - background: $bg-surface-200; - color: $text-default; - } - - @include breakpoint-down('sm') { - display: none; - } -} - -.sidebarFooter { - padding: $space-3; - border-top: 1px solid $border-default; -} - -.logoutBtn { - width: 100%; - display: flex; - align-items: center; - gap: $space-3; - padding: $space-3; - border-radius: $radius-md; - font-size: $font-size-sm; - color: $text-default; - @include transition-fast; - - @include hover { - background: $bg-surface-200; - } - - .sidebar.collapsed & { - justify-content: center; - - .logoutText { - display: none; - } - } -} - -.logoutIcon { - width: 18px; - height: 18px; - flex-shrink: 0; -} - -.logoutText { - font-weight: $font-weight-medium; - @include transition-fast; -} - -.overlay { - position: fixed; - inset: 0; - background: rgb(0, 0, 0, 50%); - z-index: calc($z-fixed - 1); - display: none; - border: none; - padding: 0; - cursor: pointer; - - @include breakpoint-down('sm') { - display: block; - } -} - -.main { - flex: 1; - display: flex; - flex-direction: column; - margin-left: $sidebar-width; - min-width: 0; - @include transition-fast; - - &.collapsed { - margin-left: $sidebar-collapsed-width; - } - - @include breakpoint-down('sm') { - margin-left: 0; - - &.collapsed { - margin-left: 0; - } - } -} - -.header { - position: sticky; - top: 0; - height: $header-height; - background: $bg-surface-100; - border-bottom: 1px solid $border-default; - z-index: $z-sticky; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 $space-4; -} - -.headerLeft { - display: flex; - align-items: center; - gap: $space-3; -} - -.menuBtn { - display: none; - width: 36px; - height: 36px; - border-radius: $radius-md; - color: $text-light; - align-items: center; - justify-content: center; - @include transition-fast; - - svg { - width: 20px; - height: 20px; - } - - @include hover { - background: $bg-surface-200; - color: $text-default; - } - - @media (width <= 479px) { - display: flex; - } -} - -.pageTitle { - font-size: $font-size-base; - font-weight: $font-weight-medium; - color: $text-default; - margin-left: 7px; -} - -.headerRight { - display: flex; - align-items: center; - gap: $space-3; -} - -.avatar { - width: 32px; - height: 32px; - border-radius: $radius-full; - background: $bg-surface-300; - color: $text-light; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - @include flex-center; - cursor: pointer; - @include transition-fast; - - @include hover { - filter: brightness(1.2); - } -} - -.content { - flex: 1; - overflow-y: auto; -} - -.loading { - @include flex-center; - height: 100%; - color: $text-muted; -} - -.error { - @include flex-column-center; - height: 100%; - gap: $space-4; - padding: $space-6; - color: $error-default; - - h2 { - font-size: $font-size-xl; - font-weight: $font-weight-semibold; - } - - pre { - font-family: $font-mono; - font-size: $font-size-sm; - padding: $space-4; - background: $bg-surface-200; - border-radius: $radius-lg; - overflow-x: auto; - max-width: 100%; - } -} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx deleted file mode 100644 index 65fe7f37..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/shell.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/** - * ©AngelaMos | 2025 - * shell.tsx - */ - -import { Suspense } from 'react' -import { ErrorBoundary } from 'react-error-boundary' -import { GiCardAceClubs, GiCardJoker, GiExitDoor } from 'react-icons/gi' -import { LuChevronLeft, LuChevronRight, LuMenu, LuShield } from 'react-icons/lu' -import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' -import { useLogout } from '@/api/hooks' -import { ROUTES } from '@/config' -import { useIsAdmin, useUIStore, useUser } from '@/core/lib' -import styles from './shell.module.scss' - -const NAV_ITEMS = [ - { path: ROUTES.DASHBOARD, label: 'Dashboard', icon: GiCardJoker }, - { path: ROUTES.SETTINGS, label: 'Settings', icon: GiCardAceClubs }, -] - -const ADMIN_NAV_ITEM = { - path: ROUTES.ADMIN.USERS, - label: 'Admin', - icon: LuShield, -} - -function ShellErrorFallback({ error }: { error: Error }): React.ReactElement { - return ( -
-

Something went wrong

-
{error.message}
-
- ) -} - -function ShellLoading(): React.ReactElement { - return
Loading...
-} - -function getPageTitle(pathname: string, isAdmin: boolean): string { - if (isAdmin && pathname === ADMIN_NAV_ITEM.path) { - return ADMIN_NAV_ITEM.label - } - const item = NAV_ITEMS.find((i) => i.path === pathname) - return item?.label ?? 'Dashboard' -} - -export function Shell(): React.ReactElement { - const location = useLocation() - const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } = - useUIStore() - const { mutate: logout } = useLogout() - const isAdmin = useIsAdmin() - const user = useUser() - - const pageTitle = getPageTitle(location.pathname, isAdmin) - const avatarLetter = - user?.full_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U' - - return ( -
- - - {sidebarOpen && ( - -

{pageTitle}

-
- -
- - {avatarLetter} - -
- - -
- - }> - - - -
- - - ) -} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss deleted file mode 100644 index d50ab7fb..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/toast.module.scss +++ /dev/null @@ -1,67 +0,0 @@ -// =================== -// © AngelaMos | 2025 -// toast.module.scss -// =================== - -@use '@/styles' as *; - -:global { - [data-sonner-toaster] { - --normal-bg: #{$bg-surface-100}; - --normal-border: #{$border-default}; - --normal-text: #{$text-default}; - - --success-bg: #{$bg-surface-100}; - --success-border: #{$border-default}; - --success-text: #{$text-default}; - - --error-bg: #{$bg-surface-100}; - --error-border: #{$error-default}; - --error-text: #{$text-default}; - - --warning-bg: #{$bg-surface-100}; - --warning-border: #{$border-default}; - --warning-text: #{$text-default}; - - --info-bg: #{$bg-surface-100}; - --info-border: #{$border-default}; - --info-text: #{$text-default}; - - font-family: $font-sans; - } - - [data-sonner-toast] { - border-radius: $radius-md; - padding: $space-3 $space-4; - font-size: $font-size-sm; - border: 1px solid $border-default; - background: $bg-surface-100; - color: $text-default; - - [data-title] { - font-weight: $font-weight-medium; - } - - [data-description] { - color: $text-light; - font-size: $font-size-xs; - } - - [data-close-button] { - background: none; - border: none; - padding: 0; - cursor: pointer; - color: $text-muted; - @include transition-fast; - - @include hover { - color: $text-default; - } - } - } - - [data-sonner-toast][data-type='error'] { - border-color: $error-default; - } -} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts index 191757f4..a42c054e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/index.ts @@ -1,8 +1,5 @@ -// =================== -// © AngelaMos | 2025 +// ©AngelaMos | 2026 // index.ts -// =================== export * from './auth.form.store' export * from './auth.store' -export * from './shell.ui.store' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts deleted file mode 100644 index d601a53b..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/lib/shell.ui.store.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * ©AngelaMos | 2025 - * ui.store.ts - */ - -import { create } from 'zustand' -import { devtools, persist } from 'zustand/middleware' - -type Theme = 'light' | 'dark' | 'system' - -interface UIState { - theme: Theme - sidebarOpen: boolean - sidebarCollapsed: boolean - setTheme: (theme: Theme) => void - toggleSidebar: () => void - setSidebarOpen: (open: boolean) => void - toggleSidebarCollapsed: () => void -} - -export const useUIStore = create()( - devtools( - persist( - (set) => ({ - theme: 'dark', - sidebarOpen: false, - sidebarCollapsed: false, - - setTheme: (theme) => set({ theme }, false, 'ui/setTheme'), - - toggleSidebar: () => - set( - (state) => ({ sidebarOpen: !state.sidebarOpen }), - false, - 'ui/toggleSidebar' - ), - - setSidebarOpen: (open) => - set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'), - - toggleSidebarCollapsed: () => - set( - (state) => ({ sidebarCollapsed: !state.sidebarCollapsed }), - false, - 'ui/toggleSidebarCollapsed' - ), - }), - { - name: 'ui-storage', - partialize: (state) => ({ - theme: state.theme, - sidebarCollapsed: state.sidebarCollapsed, - }), - } - ), - { name: 'UIStore' } - ) -) - -export const useTheme = (): Theme => useUIStore((s) => s.theme) -export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen) -export const useSidebarCollapsed = (): boolean => - useUIStore((s) => s.sidebarCollapsed) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss index ecac295d..0fa5d6d3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/dashboard.module.scss @@ -1,152 +1,19 @@ -// =================== -// © AngelaMos | 2025 +// ©AngelaMos | 2026 // dashboard.module.scss -// =================== -@use '@/styles' as *; - -.page { - min-height: calc(100vh - 56px); - padding: $space-6; - background-color: $bg-default; -} - -.container { - max-width: 800px; - margin: 0 auto; -} - -.header { - margin-bottom: $space-6; -} - -.title { - font-size: $font-size-2xl; - font-weight: $font-weight-semibold; - color: $text-default; - margin-bottom: $space-2; -} - -.subtitle { - font-size: $font-size-sm; - color: $text-lighter; -} - -.userCard { +.scaffold { + height: 100dvh; + width: 100%; + background: var(--bg); display: flex; align-items: center; - gap: $space-4; - padding: $space-5; - background: $bg-surface-100; - border: 1px solid $border-default; - border-radius: $radius-lg; - margin-bottom: $space-8; + justify-content: center; } -.avatar { - width: 56px; - height: 56px; - border-radius: $radius-full; - background: $bg-surface-300; - color: $text-default; - font-size: $font-size-xl; - font-weight: $font-weight-semibold; - @include flex-center; - flex-shrink: 0; -} - -.userInfo { - @include flex-column; - gap: $space-1; - min-width: 0; -} - -.userName { - font-size: $font-size-base; - font-weight: $font-weight-medium; - color: $text-default; - @include truncate; -} - -.userEmail { - font-size: $font-size-sm; - color: $text-light; - @include truncate; -} - -.userRole { - display: inline-flex; - align-self: flex-start; - padding: $space-0-5 $space-2; - background: $bg-surface-200; - border-radius: $radius-full; - font-size: $font-size-xs; - color: $text-lighter; +.label { + font-family: var(--font-sans); + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); text-transform: uppercase; - letter-spacing: $tracking-wide; -} - -.section { - margin-bottom: $space-8; -} - -.sectionTitle { - font-size: $font-size-sm; - font-weight: $font-weight-medium; - color: $text-light; - text-transform: uppercase; - letter-spacing: $tracking-wide; - margin-bottom: $space-4; -} - -.grid { - display: grid; - gap: $space-4; - - @include breakpoint-up('md') { - grid-template-columns: repeat(3, 1fr); - } -} - -.card { - padding: $space-4; - background: $bg-surface-100; - border: 1px solid $border-default; - border-radius: $radius-lg; -} - -.hookName { - display: inline-block; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - color: $text-default; - background: $bg-surface-200; - padding: $space-1 $space-2; - border-radius: $radius-sm; - margin-bottom: $space-2; -} - -.description { - font-size: $font-size-sm; - color: $text-light; - margin-bottom: $space-2; - line-height: $line-height-relaxed; -} - -.file { - font-size: $font-size-xs; - color: $text-muted; - font-family: $font-mono; -} - -.list { - @include flex-column; - gap: $space-2; - padding-left: $space-5; - list-style: disc; - - li { - font-size: $font-size-sm; - color: $text-light; - } + color: var(--fg-3); } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx index 5d3f97ec..a418e3db 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/index.tsx @@ -1,88 +1,12 @@ -/** - * ©AngelaMos | 2025 - * index.tsx - */ +// ©AngelaMos | 2026 +// index.tsx -import { useUser } from '@/core/lib' import styles from './dashboard.module.scss' -const AVAILABLE_STORES = [ - { - name: 'useUser()', - file: 'core/lib/auth.store.ts', - description: 'Get current authenticated user', - }, - { - name: 'useIsAuthenticated()', - file: 'core/lib/auth.store.ts', - description: 'Check if user is logged in', - }, - { - name: 'useIsAdmin()', - file: 'core/lib/auth.store.ts', - description: 'Check if user has admin role', - }, -] - -const SUGGESTED_FEATURES = [ - 'User stats and metrics', - 'Recent activity feed', - 'Quick actions', - 'Charts and analytics', - 'Notifications overview', - 'Task/project summary', -] - export function Component(): React.ReactElement { - const user = useUser() - return ( -
-
-
-

- Welcome{user?.full_name ? `, ${user.full_name}` : ''} -

-

- Template page — build your dashboard here -

-
- -
-
- {user?.full_name?.[0]?.toUpperCase() ?? - user?.email?.[0]?.toUpperCase() ?? - 'U'} -
-
- {user?.full_name ?? 'User'} - {user?.email} - {user?.role} -
-
- -
-

Available Stores

-
- {AVAILABLE_STORES.map((store) => ( -
- {store.name} -

{store.description}

- {store.file} -
- ))} -
-
- -
-

Suggested Features

-
    - {SUGGESTED_FEATURES.map((feature) => ( -
  • {feature}
  • - ))} -
-
-
+
+ MONITOR THE SITUATION · SCAFFOLD
) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx deleted file mode 100644 index 48729f0b..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/index.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * ©AngelaMos | 2026 - * index.tsx - */ - -import { FiGithub } from 'react-icons/fi' -import { Link } from 'react-router-dom' -import { ROUTES } from '@/config' -import styles from './landing.module.scss' - -export function Component(): React.ReactElement { - return ( -
-
-

Full Stack Template

-

by Carter Perez

- - - -
- -
-

- Boilerplate for medium-large scale applications. Built with modern - patterns, strict typing, and security best practices. -

- -
-
-

Frontend

-
    -
  • React 19 + TypeScript with strict mode
  • -
  • TanStack Query for server state caching
  • -
  • Zustand stores with persistence
  • -
  • Axios interceptors with auto token refresh
  • -
  • Zod runtime validation on API responses
  • -
  • SCSS modules with design tokens
  • -
-
- -
-

Backend

-
    -
  • DDD + DI Architecture
  • -
  • FastAPI with async/await throughout
  • -
  • SQLAlchemy 2.0+ async with connection pooling
  • -
  • JWT auth with token rotation and replay detection
  • -
  • Argon2id hashing with timing safe verification
  • -
  • Pydantic v2 strict validation
  • -
-
- -
-

Infrastructure

-
    -
  • Docker Compose with dev/prod configs
  • -
  • Nginx reverse proxy with rate limiting
  • -
  • PostgreSQL 18 + Redis 7
  • -
  • Health checks and graceful shutdown
  • -
-
- -
-

DevOps

-
    -
  • GitHub Actions CI (Ruff, Pylint, Mypy, Biome)
  • -
  • Strict type checking on both ends
  • -
  • Alembic async migrations
  • -
-
-
- -
- - Try Auth Flow - - - API Docs - -
-
-
- ) -} - -Component.displayName = 'Landing' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss deleted file mode 100644 index 3013e87a..00000000 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/landing/landing.module.scss +++ /dev/null @@ -1,161 +0,0 @@ -// =================== -// © AngelaMos | 2025 -// landing.module.scss -// =================== - -@use '@/styles' as *; - -.page { - min-height: 100vh; - min-height: 100dvh; - @include flex-column-center; - background-color: $bg-default; - background-image: radial-gradient( - circle, - $bg-landing 1px, - transparent 1px - ); - background-size: 20px 20px; - padding: $space-8; -} - -.header { - text-align: center; - margin-bottom: $space-5; -} - -.title { - font-size: $font-size-4xl; - font-weight: $font-weight-semibold; - color: $text-default; - letter-spacing: $tracking-tight; - margin-bottom: $space-2; -} - -.subtitle { - font-size: $font-size-lg; - color: $text-light; - margin-bottom: $space-3; -} - -.github { - display: inline-flex; - color: $text-muted; - font-size: $font-size-2xl; - @include transition-fast; - - @include hover { - &:hover { - color: $text-default; - } - } -} - -.content { - max-width: 800px; - text-align: center; -} - -.description { - font-size: $font-size-base; - color: $text-light; - line-height: $line-height-relaxed; - margin-bottom: $space-8; -} - -.sections { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: $space-6; - margin-bottom: $space-10; - text-align: left; - - @include breakpoint-down(md) { - grid-template-columns: 1fr; - } -} - -.section { - padding: $space-5; - background-color: $bg-surface-75; - border: 1px solid $border-muted; - border-radius: $radius-lg; -} - -.sectionTitle { - font-size: $font-size-sm; - font-weight: $font-weight-semibold; - color: $text-default; - margin-bottom: $space-3; - letter-spacing: $tracking-wide; - text-transform: uppercase; -} - -.features { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: $space-2; - - li { - font-size: $font-size-sm; - color: $text-muted; - - &::before { - content: '→'; - margin-right: $space-2; - color: $text-lighter; - } - } -} - -.actions { - display: flex; - gap: $space-3; - justify-content: center; - flex-wrap: wrap; -} - -.button { - display: inline-flex; - align-items: center; - justify-content: center; - padding: $space-3 $space-5; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - color: $bg-default; - background-color: $text-default; - border-radius: $radius-md; - text-decoration: none; - @include transition-fast; - - @include hover { - &:hover { - filter: brightness(0.9); - } - } -} - -.buttonOutline { - display: inline-flex; - align-items: center; - justify-content: center; - padding: $space-3 $space-5; - font-size: $font-size-sm; - font-weight: $font-weight-medium; - color: $text-default; - background-color: transparent; - border: 1px solid $border-default; - border-radius: $radius-md; - text-decoration: none; - @include transition-fast; - - @include hover { - &:hover { - border-color: $border-strong; - background-color: $bg-surface-75; - } - } -} From c7a0dd33a2bee425a075cfc506bbb29a4905f91e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 06:49:16 -0400 Subject: [PATCH 076/109] feat(monitor/frontend): dashboard 3-col grid layout (320 / 1fr / 320, no body scroll) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the demolition scaffold with the structural grid that Tasks 8-21 fill in. Root is a 4-row grid (topstrip / alertbanner / main / bottomticker) with named grid-areas; the main row contains a 3-col sub-grid (left aside / center section / right aside) at fixed 320px / 1fr / 320px. 100dvh, no body scroll, ethos custom properties only — no template SCSS variables touched. Adds --col-panel-width: 320px to the ethos token block (no magic number in the layout module). index.tsx becomes a one-line re-export of Dashboard so the lazy-route contract in routers.tsx stays unchanged. No placeholder components. The empty +
) } From aa9a24bdd0a3b839387aa357719e155bb8b4271b Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 09:47:40 -0400 Subject: [PATCH 095/109] =?UTF-8?q?feat(monitor/frontend):=20dashboard=20l?= =?UTF-8?q?ifecycle=20hook=20(snapshot=20bootstrap=20=E2=86=92=20WS=20?= =?UTF-8?q?=E2=86=92=20typed=20routing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDashboardLifecycle() orchestrates the three pieces that make the dashboard alive: 1. Audio gesture unlock — unlockOnFirstGesture() on mount so playChime() is ready when Phase 6 wires alert triggers. 2. Snapshot-then-WS — useSnapshot()'s isSuccess gates createDashboardWS so we never open the WS before initial state is in TanStack cache (per spec §10.2 race resolution). After connect+setReady the server stops buffering and starts streaming deltas. 3. Globe ring eviction — every 5min calls useGlobeEvents.evict(now) so expired pulse rings drop off. Topic routing (typed switch, no `as any` — every payload narrows to a local interface that matches the verified backend shape): cve_new → useCveStore.push kev_added → useKevStore.push ransomware_victim → useRansomwareStore.push coinbase_price → usePrices.pushTick (ISO ts → number, snake_case volume_24h → camelCase) earthquake → globe point + ring pulse (4s TTL) iss_position → globe point (id='iss-current' so each tick replaces the previous) + setQueryData(iss_position) so the ISS panel sees the latest wiki_itn → ticker push (source 'Wikipedia') gdelt_spike → ticker push (source 'GDELT', headline includes z-score and count) space_weather/internet_outage/bgp_hijack/scan_firehose → setQueryData to merge into snapshot (panels read from there) heartbeat → no-op (connection liveness only) Small bug fix in browserDriver: WebSocket.send() throws when readyState is CONNECTING. createDashboardWS.setReady() can be called before the socket opens (we call it immediately after snapshot resolves), so the inner sock.send is now guarded by a readyState===OPEN check. The onOpen handler in createDashboardWS resends init when the socket actually opens — belt-and-suspenders. This is the load-bearing wiring per the plan. Panels stop rendering single snapshot rows and start growing as WS events arrive. --- .../frontend/src/api/ws.ts | 2 +- .../src/pages/dashboard/Dashboard.tsx | 2 + .../frontend/src/pages/dashboard/lifecycle.ts | 238 ++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts index 017968cc..e24135c5 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts @@ -121,7 +121,7 @@ export function browserDriver(url: string, topics: string[]): WSDriver { onMessage: null, onClose: null, send(s) { - sock.send(s) + if (sock.readyState === WebSocket.OPEN) sock.send(s) }, close() { sock.close() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx index 1074ddd7..e93295f0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx @@ -15,10 +15,12 @@ import { About } from './About' import { AlertBanner } from './AlertBanner' import { BottomTicker } from './BottomTicker' import styles from './Dashboard.module.scss' +import { useDashboardLifecycle } from './lifecycle' import { presentationMode } from './presentationMode' import { TopStrip } from './TopStrip' export function Dashboard(): React.ReactElement { + useDashboardLifecycle() presentationMode.useGlobalShortcut() const isPresentation = useUIStore((s) => s.presentationMode) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts new file mode 100644 index 00000000..df0b215e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts @@ -0,0 +1,238 @@ +// ©AngelaMos | 2026 +// lifecycle.ts + +import { type QueryClient, useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { SNAPSHOT_KEY, useSnapshot } from '@/api/snapshot' +import { browserDriver, createDashboardWS, type WSEvent } from '@/api/ws' +import { unlockOnFirstGesture } from '@/lib/audio' +import { type CveEvent, useCveStore } from '@/stores/cve' +import { useGlobeEvents } from '@/stores/globeEvents' +import { type KevEntry, useKevStore } from '@/stores/kev' +import { usePrices } from '@/stores/prices' +import { type RansomwareVictim, useRansomwareStore } from '@/stores/ransomware' +import { useTicker } from '@/stores/ticker' + +const ALL_TOPICS: readonly string[] = [ + 'heartbeat', + 'scan_firehose', + 'internet_outage', + 'bgp_hijack', + 'cve_new', + 'kev_added', + 'ransomware_victim', + 'coinbase_price', + 'earthquake', + 'space_weather', + 'wiki_itn', + 'gdelt_spike', + 'iss_position', +] + +const WS_URL = '/api/v1/ws' +const GLOBE_RING_TTL_MS = 4_000 +const GLOBE_EVICT_INTERVAL_MS = 5 * 60_000 + +interface CoinbaseTickPayload { + symbol: string + ts: string + price: string + volume_24h?: string +} + +interface IssPositionPayload { + latitude: number + longitude: number + altitude: number + velocity: number + timestamp: number + fetched_at?: string +} + +interface EarthquakePayload { + id: string + geometry?: { coordinates?: number[] } + properties?: Record +} + +interface WikiItnPayload { + text: string + slug: string +} + +interface GdeltSpikePayload { + theme: string + time: string + count: number + zscore: number +} + +export function useDashboardLifecycle(): void { + const { data: snapshot, isSuccess } = useSnapshot() + const queryClient = useQueryClient() + const wsRef = useRef | null>(null) + + useEffect(() => { + unlockOnFirstGesture() + }, []) + + useEffect(() => { + if (!isSuccess) return + + const ws = createDashboardWS({ + driver: () => browserDriver(WS_URL, [...ALL_TOPICS]), + topics: [...ALL_TOPICS], + onEvent: (ev) => routeEvent(ev, queryClient), + }) + wsRef.current = ws + ws.connect() + ws.setReady() + + return () => { + ws.disconnect() + wsRef.current = null + } + }, [isSuccess, queryClient]) + + useEffect(() => { + const id = setInterval(() => { + useGlobeEvents.getState().evict(Date.now()) + }, GLOBE_EVICT_INTERVAL_MS) + return () => clearInterval(id) + }, []) + + void snapshot +} + +function routeEvent(ev: WSEvent, queryClient: QueryClient): void { + const data = ev.payload + if (data === undefined || data === null) return + + switch (ev.topic) { + case 'cve_new': + handleCve(data as CveEvent) + break + case 'kev_added': + handleKev(data as KevEntry) + break + case 'ransomware_victim': + handleRansomware(data as RansomwareVictim) + break + case 'coinbase_price': + handleCoinbase(data as CoinbaseTickPayload) + break + case 'earthquake': + handleEarthquake(data as EarthquakePayload) + break + case 'iss_position': + handleIss(data as IssPositionPayload, queryClient) + break + case 'wiki_itn': + handleWiki(data as WikiItnPayload) + break + case 'gdelt_spike': + handleGdelt(data as GdeltSpikePayload) + break + case 'space_weather': + case 'internet_outage': + case 'bgp_hijack': + case 'scan_firehose': + mergeIntoSnapshot(queryClient, ev.topic, data) + break + default: + break + } +} + +function handleCve(p: CveEvent): void { + if (!p.CveID) return + useCveStore.getState().push(p) +} + +function handleKev(p: KevEntry): void { + if (!p.cveID) return + useKevStore.getState().push(p) +} + +function handleRansomware(p: RansomwareVictim): void { + if (!p.post_title) return + useRansomwareStore.getState().push(p) +} + +function handleCoinbase(p: CoinbaseTickPayload): void { + if (!p.symbol || !p.ts) return + usePrices.getState().pushTick({ + symbol: p.symbol, + ts: new Date(p.ts).getTime(), + price: p.price, + volume24h: p.volume_24h, + }) +} + +function handleEarthquake(p: EarthquakePayload): void { + const coords = p.geometry?.coordinates + if (!Array.isArray(coords)) return + const lng = coords[0] + const lat = coords[1] + if (typeof lat !== 'number' || typeof lng !== 'number') return + const now = Date.now() + useGlobeEvents.getState().pushPoint({ + id: `eq-${p.id}`, + type: 'earthquake', + lat, + lng, + emittedAt: now, + meta: p.properties, + }) + useGlobeEvents.getState().pushRing({ + id: `eq-ring-${p.id}-${now}`, + lat, + lng, + emittedAt: now, + ttlMs: GLOBE_RING_TTL_MS, + }) +} + +function handleIss(p: IssPositionPayload, queryClient: QueryClient): void { + if (typeof p.latitude !== 'number' || typeof p.longitude !== 'number') return + useGlobeEvents.getState().pushPoint({ + id: 'iss-current', + type: 'iss', + lat: p.latitude, + lng: p.longitude, + emittedAt: Date.now(), + }) + mergeIntoSnapshot(queryClient, 'iss_position', p) +} + +function handleWiki(p: WikiItnPayload): void { + if (!p.text) return + const id = `wiki-${p.slug || p.text}` + useTicker.getState().push({ + id, + source: 'Wikipedia', + headline: p.text, + ts: Date.now(), + }) +} + +function handleGdelt(p: GdeltSpikePayload): void { + if (!p.theme) return + useTicker.getState().push({ + id: `gdelt-${p.theme}-${p.time}`, + source: 'GDELT', + headline: `${p.theme} spike (z=${p.zscore.toFixed(2)}, n=${p.count})`, + ts: Date.now(), + }) +} + +function mergeIntoSnapshot( + queryClient: QueryClient, + topic: string, + data: unknown +): void { + queryClient.setQueryData>(SNAPSHOT_KEY, (prev) => ({ + ...(prev ?? {}), + [topic]: data, + })) +} From a6dcaed7dc725d6ddbbfe47fe9cb3698b2df887c Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 17:49:55 -0400 Subject: [PATCH 096/109] feat(monitor/frontend): globe country outlines (thin --fg-4 stroke, local 110m geojson) --- .../public/world-countries-110m.geo.json | 1 + .../frontend/src/pages/globe/Globe.tsx | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/world-countries-110m.geo.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/world-countries-110m.geo.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/world-countries-110m.geo.json new file mode 100644 index 00000000..9e46ec38 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/world-countries-110m.geo.json @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Afghanistan","SOV_A3":"AFG","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Afghanistan","ADM0_A3":"AFG","GEOU_DIF":0,"GEOUNIT":"Afghanistan","GU_A3":"AFG","SU_DIF":0,"SUBUNIT":"Afghanistan","SU_A3":"AFG","BRK_DIFF":0,"NAME":"Afghanistan","NAME_LONG":"Afghanistan","BRK_A3":"AFG","BRK_NAME":"Afghanistan","BRK_GROUP":null,"ABBREV":"Afg.","POSTAL":"AF","FORMAL_EN":"Islamic State of Afghanistan","FORMAL_FR":null,"NAME_CIAWF":"Afghanistan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Afghanistan","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":6,"MAPCOLOR9":8,"MAPCOLOR13":7,"POP_EST":34124811,"POP_RANK":15,"GDP_MD_EST":64080,"POP_YEAR":2017,"LASTCENSUS":1979,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"AF","ISO_A2":"AF","ISO_A3":"AFG","ISO_A3_EH":"AFG","ISO_N3":"004","UN_A3":"004","WB_A2":"AF","WB_A3":"AFG","WOE_ID":23424739,"WOE_ID_EH":23424739,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"AFG","ADM0_A3_US":"AFG","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"South Asia","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[60.52843,29.318572,75.158028,38.486282],"geometry":{"type":"Polygon","coordinates":[[[61.210817,35.650072],[62.230651,35.270664],[62.984662,35.404041],[63.193538,35.857166],[63.982896,36.007957],[64.546479,36.312073],[64.746105,37.111818],[65.588948,37.305217],[65.745631,37.661164],[66.217385,37.39379],[66.518607,37.362784],[67.075782,37.356144],[67.83,37.144994],[68.135562,37.023115],[68.859446,37.344336],[69.196273,37.151144],[69.518785,37.608997],[70.116578,37.588223],[70.270574,37.735165],[70.376304,38.138396],[70.806821,38.486282],[71.348131,38.258905],[71.239404,37.953265],[71.541918,37.905774],[71.448693,37.065645],[71.844638,36.738171],[72.193041,36.948288],[72.63689,37.047558],[73.260056,37.495257],[73.948696,37.421566],[74.980002,37.41999],[75.158028,37.133031],[74.575893,37.020841],[74.067552,36.836176],[72.920025,36.720007],[71.846292,36.509942],[71.262348,36.074388],[71.498768,35.650563],[71.613076,35.153203],[71.115019,34.733126],[71.156773,34.348911],[70.881803,33.988856],[69.930543,34.02012],[70.323594,33.358533],[69.687147,33.105499],[69.262522,32.501944],[69.317764,31.901412],[68.926677,31.620189],[68.556932,31.71331],[67.792689,31.58293],[67.683394,31.303154],[66.938891,31.304911],[66.381458,30.738899],[66.346473,29.887943],[65.046862,29.472181],[64.350419,29.560031],[64.148002,29.340819],[63.550261,29.468331],[62.549857,29.318572],[60.874248,29.829239],[61.781222,30.73585],[61.699314,31.379506],[60.941945,31.548075],[60.863655,32.18292],[60.536078,32.981269],[60.9637,33.528832],[60.52843,33.676446],[60.803193,34.404102],[61.210817,35.650072]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Angola","SOV_A3":"AGO","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Angola","ADM0_A3":"AGO","GEOU_DIF":0,"GEOUNIT":"Angola","GU_A3":"AGO","SU_DIF":0,"SUBUNIT":"Angola","SU_A3":"AGO","BRK_DIFF":0,"NAME":"Angola","NAME_LONG":"Angola","BRK_A3":"AGO","BRK_NAME":"Angola","BRK_GROUP":null,"ABBREV":"Ang.","POSTAL":"AO","FORMAL_EN":"People's Republic of Angola","FORMAL_FR":null,"NAME_CIAWF":"Angola","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Angola","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":6,"MAPCOLOR13":1,"POP_EST":29310273,"POP_RANK":15,"GDP_MD_EST":189000,"POP_YEAR":2017,"LASTCENSUS":1970,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"AO","ISO_A2":"AO","ISO_A3":"AGO","ISO_A3_EH":"AGO","ISO_N3":"024","UN_A3":"024","WB_A2":"AO","WB_A3":"AGO","WOE_ID":23424745,"WOE_ID_EH":23424745,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"AGO","ADM0_A3_US":"AGO","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[11.640096,-17.930636,24.079905,-4.438023],"geometry":{"type":"MultiPolygon","coordinates":[[[[23.904154,-11.722282],[24.079905,-12.191297],[23.930922,-12.565848],[24.016137,-12.911046],[21.933886,-12.898437],[21.887843,-16.08031],[22.562478,-16.898451],[23.215048,-17.523116],[21.377176,-17.930636],[18.956187,-17.789095],[18.263309,-17.309951],[14.209707,-17.353101],[14.058501,-17.423381],[13.462362,-16.971212],[12.814081,-16.941343],[12.215461,-17.111668],[11.734199,-17.301889],[11.640096,-16.673142],[11.778537,-15.793816],[12.123581,-14.878316],[12.175619,-14.449144],[12.500095,-13.5477],[12.738479,-13.137906],[13.312914,-12.48363],[13.633721,-12.038645],[13.738728,-11.297863],[13.686379,-10.731076],[13.387328,-10.373578],[13.120988,-9.766897],[12.87537,-9.166934],[12.929061,-8.959091],[13.236433,-8.562629],[12.93304,-7.596539],[12.728298,-6.927122],[12.227347,-6.294448],[12.322432,-6.100092],[12.735171,-5.965682],[13.024869,-5.984389],[13.375597,-5.864241],[16.326528,-5.87747],[16.57318,-6.622645],[16.860191,-7.222298],[17.089996,-7.545689],[17.47297,-8.068551],[18.134222,-7.987678],[18.464176,-7.847014],[19.016752,-7.988246],[19.166613,-7.738184],[19.417502,-7.155429],[20.037723,-7.116361],[20.091622,-6.94309],[20.601823,-6.939318],[20.514748,-7.299606],[21.728111,-7.290872],[21.746456,-7.920085],[21.949131,-8.305901],[21.801801,-8.908707],[21.875182,-9.523708],[22.208753,-9.894796],[22.155268,-11.084801],[22.402798,-10.993075],[22.837345,-11.017622],[23.456791,-10.867863],[23.912215,-10.926826],[24.017894,-11.237298],[23.904154,-11.722282]]],[[[12.182337,-5.789931],[11.914963,-5.037987],[12.318608,-4.60623],[12.62076,-4.438023],[12.995517,-4.781103],[12.631612,-4.991271],[12.468004,-5.248362],[12.436688,-5.684304],[12.182337,-5.789931]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Albania","SOV_A3":"ALB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Albania","ADM0_A3":"ALB","GEOU_DIF":0,"GEOUNIT":"Albania","GU_A3":"ALB","SU_DIF":0,"SUBUNIT":"Albania","SU_A3":"ALB","BRK_DIFF":0,"NAME":"Albania","NAME_LONG":"Albania","BRK_A3":"ALB","BRK_NAME":"Albania","BRK_GROUP":null,"ABBREV":"Alb.","POSTAL":"AL","FORMAL_EN":"Republic of Albania","FORMAL_FR":null,"NAME_CIAWF":"Albania","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Albania","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":6,"POP_EST":3047987,"POP_RANK":12,"GDP_MD_EST":33900,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"AL","ISO_A2":"AL","ISO_A3":"ALB","ISO_A3_EH":"ALB","ISO_N3":"008","UN_A3":"008","WB_A2":"AL","WB_A3":"ALB","WOE_ID":23424742,"WOE_ID_EH":23424742,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ALB","ADM0_A3_US":"ALB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[19.304486,39.624998,21.02004,42.688247],"geometry":{"type":"Polygon","coordinates":[[[21.02004,40.842727],[20.99999,40.580004],[20.674997,40.435],[20.615,40.110007],[20.150016,39.624998],[19.98,39.694993],[19.960002,39.915006],[19.406082,40.250773],[19.319059,40.72723],[19.40355,41.409566],[19.540027,41.719986],[19.371769,41.877548],[19.371768,41.877551],[19.304486,42.195745],[19.738051,42.688247],[19.801613,42.500093],[20.0707,42.58863],[20.283755,42.32026],[20.52295,42.21787],[20.590247,41.855409],[20.590247,41.855404],[20.463175,41.515089],[20.605182,41.086226],[21.02004,40.842727]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"United Arab Emirates","SOV_A3":"ARE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"United Arab Emirates","ADM0_A3":"ARE","GEOU_DIF":0,"GEOUNIT":"United Arab Emirates","GU_A3":"ARE","SU_DIF":0,"SUBUNIT":"United Arab Emirates","SU_A3":"ARE","BRK_DIFF":0,"NAME":"United Arab Emirates","NAME_LONG":"United Arab Emirates","BRK_A3":"ARE","BRK_NAME":"United Arab Emirates","BRK_GROUP":null,"ABBREV":"U.A.E.","POSTAL":"AE","FORMAL_EN":"United Arab Emirates","FORMAL_FR":null,"NAME_CIAWF":"United Arab Emirates","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"United Arab Emirates","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":3,"POP_EST":6072475,"POP_RANK":13,"GDP_MD_EST":667200,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"AE","ISO_A2":"AE","ISO_A3":"ARE","ISO_A3_EH":"ARE","ISO_N3":"784","UN_A3":"784","WB_A2":"AE","WB_A3":"ARE","WOE_ID":23424738,"WOE_ID_EH":23424738,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ARE","ADM0_A3_US":"ARE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":20,"LONG_LEN":20,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[51.579519,22.496948,56.396847,26.055464],"geometry":{"type":"Polygon","coordinates":[[[51.579519,24.245497],[51.757441,24.294073],[51.794389,24.019826],[52.577081,24.177439],[53.404007,24.151317],[54.008001,24.121758],[54.693024,24.797892],[55.439025,25.439145],[56.070821,26.055464],[56.261042,25.714606],[56.396847,24.924732],[55.886233,24.920831],[55.804119,24.269604],[55.981214,24.130543],[55.528632,23.933604],[55.525841,23.524869],[55.234489,23.110993],[55.208341,22.70833],[55.006803,22.496948],[52.000733,23.001154],[51.617708,24.014219],[51.579519,24.245497]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Argentina","SOV_A3":"ARG","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Argentina","ADM0_A3":"ARG","GEOU_DIF":0,"GEOUNIT":"Argentina","GU_A3":"ARG","SU_DIF":0,"SUBUNIT":"Argentina","SU_A3":"ARG","BRK_DIFF":0,"NAME":"Argentina","NAME_LONG":"Argentina","BRK_A3":"ARG","BRK_NAME":"Argentina","BRK_GROUP":null,"ABBREV":"Arg.","POSTAL":"AR","FORMAL_EN":"Argentine Republic","FORMAL_FR":null,"NAME_CIAWF":"Argentina","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Argentina","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":13,"POP_EST":44293293,"POP_RANK":15,"GDP_MD_EST":879400,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"AR","ISO_A2":"AR","ISO_A3":"ARG","ISO_A3_EH":"ARG","ISO_N3":"032","UN_A3":"032","WB_A2":"AR","WB_A3":"ARG","WOE_ID":23424747,"WOE_ID_EH":23424747,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ARG","ADM0_A3_US":"ARG","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[-73.415436,-55.25,-53.628349,-21.83231],"geometry":{"type":"MultiPolygon","coordinates":[[[[-66.95992,-54.89681],[-67.56244,-54.87001],[-68.63335,-54.8695],[-68.63401,-52.63637],[-68.25,-53.1],[-67.75,-53.85],[-66.45,-54.45],[-65.05,-54.7],[-65.5,-55.2],[-66.45,-55.25],[-66.95992,-54.89681]]],[[[-68.571545,-52.299444],[-69.498362,-52.142761],[-71.914804,-52.009022],[-72.329404,-51.425956],[-72.309974,-50.67701],[-72.975747,-50.74145],[-73.328051,-50.378785],[-73.415436,-49.318436],[-72.648247,-48.878618],[-72.331161,-48.244238],[-72.447355,-47.738533],[-71.917258,-46.884838],[-71.552009,-45.560733],[-71.659316,-44.973689],[-71.222779,-44.784243],[-71.329801,-44.407522],[-71.793623,-44.207172],[-71.464056,-43.787611],[-71.915424,-43.408565],[-72.148898,-42.254888],[-71.746804,-42.051386],[-71.915734,-40.832339],[-71.680761,-39.808164],[-71.413517,-38.916022],[-70.814664,-38.552995],[-71.118625,-37.576827],[-71.121881,-36.658124],[-70.364769,-36.005089],[-70.388049,-35.169688],[-69.817309,-34.193571],[-69.814777,-33.273886],[-70.074399,-33.09121],[-70.535069,-31.36501],[-69.919008,-30.336339],[-70.01355,-29.367923],[-69.65613,-28.459141],[-69.001235,-27.521214],[-68.295542,-26.89934],[-68.5948,-26.506909],[-68.386001,-26.185016],[-68.417653,-24.518555],[-67.328443,-24.025303],[-66.985234,-22.986349],[-67.106674,-22.735925],[-66.273339,-21.83231],[-64.964892,-22.075862],[-64.377021,-22.798091],[-63.986838,-21.993644],[-62.846468,-22.034985],[-62.685057,-22.249029],[-60.846565,-23.880713],[-60.028966,-24.032796],[-58.807128,-24.771459],[-57.777217,-25.16234],[-57.63366,-25.603657],[-58.618174,-27.123719],[-57.60976,-27.395899],[-56.486702,-27.548499],[-55.695846,-27.387837],[-54.788795,-26.621786],[-54.625291,-25.739255],[-54.13005,-25.547639],[-53.628349,-26.124865],[-53.648735,-26.923473],[-54.490725,-27.474757],[-55.162286,-27.881915],[-56.2909,-28.852761],[-57.625133,-30.216295],[-57.874937,-31.016556],[-58.14244,-32.044504],[-58.132648,-33.040567],[-58.349611,-33.263189],[-58.427074,-33.909454],[-58.495442,-34.43149],[-57.22583,-35.288027],[-57.362359,-35.97739],[-56.737487,-36.413126],[-56.788285,-36.901572],[-57.749157,-38.183871],[-59.231857,-38.72022],[-61.237445,-38.928425],[-62.335957,-38.827707],[-62.125763,-39.424105],[-62.330531,-40.172586],[-62.145994,-40.676897],[-62.745803,-41.028761],[-63.770495,-41.166789],[-64.73209,-40.802677],[-65.118035,-41.064315],[-64.978561,-42.058001],[-64.303408,-42.359016],[-63.755948,-42.043687],[-63.458059,-42.563138],[-64.378804,-42.873558],[-65.181804,-43.495381],[-65.328823,-44.501366],[-65.565269,-45.036786],[-66.509966,-45.039628],[-67.293794,-45.551896],[-67.580546,-46.301773],[-66.597066,-47.033925],[-65.641027,-47.236135],[-65.985088,-48.133289],[-67.166179,-48.697337],[-67.816088,-49.869669],[-68.728745,-50.264218],[-69.138539,-50.73251],[-68.815561,-51.771104],[-68.149995,-52.349983],[-68.571545,-52.299444]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Armenia","SOV_A3":"ARM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Armenia","ADM0_A3":"ARM","GEOU_DIF":0,"GEOUNIT":"Armenia","GU_A3":"ARM","SU_DIF":0,"SUBUNIT":"Armenia","SU_A3":"ARM","BRK_DIFF":0,"NAME":"Armenia","NAME_LONG":"Armenia","BRK_A3":"ARM","BRK_NAME":"Armenia","BRK_GROUP":null,"ABBREV":"Arm.","POSTAL":"ARM","FORMAL_EN":"Republic of Armenia","FORMAL_FR":null,"NAME_CIAWF":"Armenia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Armenia","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":1,"MAPCOLOR9":2,"MAPCOLOR13":10,"POP_EST":3045191,"POP_RANK":12,"GDP_MD_EST":26300,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"AM","ISO_A2":"AM","ISO_A3":"ARM","ISO_A3_EH":"ARM","ISO_N3":"051","UN_A3":"051","WB_A2":"AM","WB_A3":"ARM","WOE_ID":23424743,"WOE_ID_EH":23424743,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ARM","ADM0_A3_US":"ARM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[43.582746,38.741201,46.50572,41.248129],"geometry":{"type":"Polygon","coordinates":[[[43.582746,41.092143],[44.97248,41.248129],[45.179496,40.985354],[45.560351,40.81229],[45.359175,40.561504],[45.891907,40.218476],[45.610012,39.899994],[46.034534,39.628021],[46.483499,39.464155],[46.50572,38.770605],[46.143623,38.741201],[45.735379,39.319719],[45.739978,39.473999],[45.298145,39.471751],[45.001987,39.740004],[44.79399,39.713003],[44.400009,40.005],[43.656436,40.253564],[43.752658,40.740201],[43.582746,41.092143]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Antarctica","SOV_A3":"ATA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Indeterminate","ADMIN":"Antarctica","ADM0_A3":"ATA","GEOU_DIF":0,"GEOUNIT":"Antarctica","GU_A3":"ATA","SU_DIF":0,"SUBUNIT":"Antarctica","SU_A3":"ATA","BRK_DIFF":0,"NAME":"Antarctica","NAME_LONG":"Antarctica","BRK_A3":"ATA","BRK_NAME":"Antarctica","BRK_GROUP":null,"ABBREV":"Ant.","POSTAL":"AQ","FORMAL_EN":null,"FORMAL_FR":null,"NAME_CIAWF":null,"NOTE_ADM0":null,"NOTE_BRK":"Multiple claims held in abeyance","NAME_SORT":"Antarctica","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":5,"MAPCOLOR9":1,"MAPCOLOR13":-99,"POP_EST":4050,"POP_RANK":4,"GDP_MD_EST":810,"POP_YEAR":2013,"LASTCENSUS":-99,"GDP_YEAR":2013,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"AY","ISO_A2":"AQ","ISO_A3":"ATA","ISO_A3_EH":"ATA","ISO_N3":"010","UN_A3":"-099","WB_A2":"-99","WB_A3":"-99","WOE_ID":28289409,"WOE_ID_EH":28289409,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ATA","ADM0_A3_US":"ATA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Antarctica","REGION_UN":"Antarctica","SUBREGION":"Antarctica","REGION_WB":"Antarctica","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-180,-90,180,-63.27066],"geometry":{"type":"MultiPolygon","coordinates":[[[[-59.572095,-80.040179],[-59.865849,-80.549657],[-60.159656,-81.000327],[-62.255393,-80.863178],[-64.488125,-80.921934],[-65.741666,-80.588827],[-65.741666,-80.549657],[-66.290031,-80.255773],[-64.037688,-80.294944],[-61.883246,-80.39287],[-61.138976,-79.981371],[-60.610119,-79.628679],[-59.572095,-80.040179]]],[[[-159.208184,-79.497059],[-161.127601,-79.634209],[-162.439847,-79.281465],[-163.027408,-78.928774],[-163.066604,-78.869966],[-163.712896,-78.595667],[-163.712896,-78.595667],[-163.105801,-78.223338],[-161.245113,-78.380176],[-160.246208,-78.693645],[-159.482405,-79.046338],[-159.208184,-79.497059]]],[[[-45.154758,-78.04707],[-43.920828,-78.478103],[-43.48995,-79.08556],[-43.372438,-79.516645],[-43.333267,-80.026123],[-44.880537,-80.339644],[-46.506174,-80.594357],[-48.386421,-80.829485],[-50.482107,-81.025442],[-52.851988,-80.966685],[-54.164259,-80.633528],[-53.987991,-80.222028],[-51.853134,-79.94773],[-50.991326,-79.614623],[-50.364595,-79.183487],[-49.914131,-78.811209],[-49.306959,-78.458569],[-48.660616,-78.047018],[-48.660616,-78.047019],[-48.151396,-78.04707],[-46.662857,-77.831476],[-45.154758,-78.04707]]],[[[-121.211511,-73.50099],[-119.918851,-73.657725],[-118.724143,-73.481353],[-119.292119,-73.834097],[-120.232217,-74.08881],[-121.62283,-74.010468],[-122.621735,-73.657778],[-122.621735,-73.657777],[-122.406245,-73.324619],[-121.211511,-73.50099]]],[[[-125.559566,-73.481353],[-124.031882,-73.873268],[-124.619469,-73.834097],[-125.912181,-73.736118],[-127.28313,-73.461769],[-127.28313,-73.461768],[-126.558472,-73.246226],[-125.559566,-73.481353]]],[[[-98.98155,-71.933334],[-97.884743,-72.070535],[-96.787937,-71.952971],[-96.20035,-72.521205],[-96.983765,-72.442864],[-98.198083,-72.482035],[-99.432013,-72.442864],[-100.783455,-72.50162],[-101.801868,-72.305663],[-102.330725,-71.894164],[-102.330725,-71.894164],[-101.703967,-71.717792],[-100.430919,-71.854993],[-98.98155,-71.933334]]],[[[-68.451346,-70.955823],[-68.333834,-71.406493],[-68.510128,-71.798407],[-68.784297,-72.170736],[-69.959471,-72.307885],[-71.075889,-72.503842],[-72.388134,-72.484257],[-71.8985,-72.092343],[-73.073622,-72.229492],[-74.19004,-72.366693],[-74.953895,-72.072757],[-75.012625,-71.661258],[-73.915819,-71.269345],[-73.915819,-71.269344],[-73.230331,-71.15178],[-72.074717,-71.190951],[-71.780962,-70.681473],[-71.72218,-70.309196],[-71.741791,-69.505782],[-71.173815,-69.035475],[-70.253252,-68.87874],[-69.724447,-69.251017],[-69.489422,-69.623346],[-69.058518,-70.074016],[-68.725541,-70.505153],[-68.451346,-70.955823]]],[[[-58.614143,-64.152467],[-59.045073,-64.36801],[-59.789342,-64.211223],[-60.611928,-64.309202],[-61.297416,-64.54433],[-62.0221,-64.799094],[-62.51176,-65.09303],[-62.648858,-65.484942],[-62.590128,-65.857219],[-62.120079,-66.190326],[-62.805567,-66.425505],[-63.74569,-66.503847],[-64.294106,-66.837004],[-64.881693,-67.150474],[-65.508425,-67.58161],[-65.665082,-67.953887],[-65.312545,-68.365335],[-64.783715,-68.678908],[-63.961103,-68.913984],[-63.1973,-69.227556],[-62.785955,-69.619419],[-62.570516,-69.991747],[-62.276736,-70.383661],[-61.806661,-70.716768],[-61.512906,-71.089045],[-61.375809,-72.010074],[-61.081977,-72.382351],[-61.003661,-72.774265],[-60.690269,-73.166179],[-60.827367,-73.695242],[-61.375809,-74.106742],[-61.96337,-74.439848],[-63.295201,-74.576997],[-63.74569,-74.92974],[-64.352836,-75.262847],[-65.860987,-75.635124],[-67.192818,-75.79191],[-68.446282,-76.007452],[-69.797724,-76.222995],[-70.600724,-76.634494],[-72.206776,-76.673665],[-73.969536,-76.634494],[-75.555977,-76.712887],[-77.24037,-76.712887],[-76.926979,-77.104802],[-75.399294,-77.28107],[-74.282876,-77.55542],[-73.656119,-77.908112],[-74.772536,-78.221633],[-76.4961,-78.123654],[-77.925858,-78.378419],[-77.984666,-78.789918],[-78.023785,-79.181833],[-76.848637,-79.514939],[-76.633224,-79.887216],[-75.360097,-80.259545],[-73.244852,-80.416331],[-71.442946,-80.69063],[-70.013163,-81.004151],[-68.191646,-81.317672],[-65.704279,-81.474458],[-63.25603,-81.748757],[-61.552026,-82.042692],[-59.691416,-82.37585],[-58.712121,-82.846106],[-58.222487,-83.218434],[-57.008117,-82.865691],[-55.362894,-82.571755],[-53.619771,-82.258235],[-51.543644,-82.003521],[-49.76135,-81.729171],[-47.273931,-81.709586],[-44.825708,-81.846735],[-42.808363,-82.081915],[-42.16202,-81.65083],[-40.771433,-81.356894],[-38.244818,-81.337309],[-36.26667,-81.121715],[-34.386397,-80.906172],[-32.310296,-80.769023],[-30.097098,-80.592651],[-28.549802,-80.337938],[-29.254901,-79.985195],[-29.685805,-79.632503],[-29.685805,-79.260226],[-31.624808,-79.299397],[-33.681324,-79.456132],[-35.639912,-79.456132],[-35.914107,-79.083855],[-35.77701,-78.339248],[-35.326546,-78.123654],[-33.896763,-77.888526],[-32.212369,-77.65345],[-30.998051,-77.359515],[-29.783732,-77.065579],[-28.882779,-76.673665],[-27.511752,-76.497345],[-26.160336,-76.360144],[-25.474822,-76.281803],[-23.927552,-76.24258],[-22.458598,-76.105431],[-21.224694,-75.909474],[-20.010375,-75.674346],[-18.913543,-75.439218],[-17.522982,-75.125698],[-16.641589,-74.79254],[-15.701491,-74.498604],[-15.40771,-74.106742],[-16.46532,-73.871614],[-16.112784,-73.460114],[-15.446855,-73.146542],[-14.408805,-72.950585],[-13.311973,-72.715457],[-12.293508,-72.401936],[-11.510067,-72.010074],[-11.020433,-71.539767],[-10.295774,-71.265416],[-9.101015,-71.324224],[-8.611381,-71.65733],[-7.416622,-71.696501],[-7.377451,-71.324224],[-6.868232,-70.93231],[-5.790985,-71.030289],[-5.536375,-71.402617],[-4.341667,-71.461373],[-3.048981,-71.285053],[-1.795492,-71.167438],[-0.659489,-71.226246],[-0.228637,-71.637745],[0.868195,-71.304639],[1.886686,-71.128267],[3.022638,-70.991118],[4.139055,-70.853917],[5.157546,-70.618789],[6.273912,-70.462055],[7.13572,-70.246512],[7.742866,-69.893769],[8.48711,-70.148534],[9.525135,-70.011333],[10.249845,-70.48164],[10.817821,-70.834332],[11.953824,-70.638375],[12.404287,-70.246512],[13.422778,-69.972162],[14.734998,-70.030918],[15.126757,-70.403247],[15.949342,-70.030918],[17.026589,-69.913354],[18.201711,-69.874183],[19.259373,-69.893769],[20.375739,-70.011333],[21.452985,-70.07014],[21.923034,-70.403247],[22.569403,-70.697182],[23.666184,-70.520811],[24.841357,-70.48164],[25.977309,-70.48164],[27.093726,-70.462055],[28.09258,-70.324854],[29.150242,-70.20729],[30.031583,-69.93294],[30.971733,-69.75662],[31.990172,-69.658641],[32.754053,-69.384291],[33.302443,-68.835642],[33.870419,-68.502588],[34.908495,-68.659271],[35.300202,-69.012014],[36.16201,-69.247142],[37.200035,-69.168748],[37.905108,-69.52144],[38.649404,-69.776205],[39.667894,-69.541077],[40.020431,-69.109941],[40.921358,-68.933621],[41.959434,-68.600514],[42.938702,-68.463313],[44.113876,-68.267408],[44.897291,-68.051866],[45.719928,-67.816738],[46.503343,-67.601196],[47.44344,-67.718759],[48.344419,-67.366068],[48.990736,-67.091718],[49.930885,-67.111303],[50.753471,-66.876175],[50.949325,-66.523484],[51.791547,-66.249133],[52.614133,-66.053176],[53.613038,-65.89639],[54.53355,-65.818049],[55.414943,-65.876805],[56.355041,-65.974783],[57.158093,-66.249133],[57.255968,-66.680218],[58.137361,-67.013324],[58.744508,-67.287675],[59.939318,-67.405239],[60.605221,-67.679589],[61.427806,-67.953887],[62.387489,-68.012695],[63.19049,-67.816738],[64.052349,-67.405239],[64.992447,-67.620729],[65.971715,-67.738345],[66.911864,-67.855909],[67.891133,-67.934302],[68.890038,-67.934302],[69.712624,-68.972791],[69.673453,-69.227556],[69.555941,-69.678226],[68.596258,-69.93294],[67.81274,-70.305268],[67.949889,-70.697182],[69.066307,-70.677545],[68.929157,-71.069459],[68.419989,-71.441788],[67.949889,-71.853287],[68.71377,-72.166808],[69.869307,-72.264787],[71.024895,-72.088415],[71.573285,-71.696501],[71.906288,-71.324224],[72.454627,-71.010703],[73.08141,-70.716768],[73.33602,-70.364024],[73.864877,-69.874183],[74.491557,-69.776205],[75.62756,-69.737034],[76.626465,-69.619419],[77.644904,-69.462684],[78.134539,-69.07077],[78.428371,-68.698441],[79.113859,-68.326216],[80.093127,-68.071503],[80.93535,-67.875546],[81.483792,-67.542388],[82.051767,-67.366068],[82.776426,-67.209282],[83.775331,-67.30726],[84.676206,-67.209282],[85.655527,-67.091718],[86.752359,-67.150474],[87.477017,-66.876175],[87.986289,-66.209911],[88.358411,-66.484261],[88.828408,-66.954568],[89.67063,-67.150474],[90.630365,-67.228867],[91.5901,-67.111303],[92.608539,-67.189696],[93.548637,-67.209282],[94.17542,-67.111303],[95.017591,-67.170111],[95.781472,-67.385653],[96.682399,-67.248504],[97.759646,-67.248504],[98.68021,-67.111303],[99.718182,-67.248504],[100.384188,-66.915346],[100.893356,-66.58224],[101.578896,-66.30789],[102.832411,-65.563284],[103.478676,-65.700485],[104.242557,-65.974783],[104.90846,-66.327527],[106.181561,-66.934931],[107.160881,-66.954568],[108.081393,-66.954568],[109.15864,-66.837004],[110.235835,-66.699804],[111.058472,-66.425505],[111.74396,-66.13157],[112.860378,-66.092347],[113.604673,-65.876805],[114.388088,-66.072762],[114.897308,-66.386283],[115.602381,-66.699804],[116.699161,-66.660633],[117.384701,-66.915346],[118.57946,-67.170111],[119.832924,-67.268089],[120.871,-67.189696],[121.654415,-66.876175],[122.320369,-66.562654],[123.221296,-66.484261],[124.122274,-66.621462],[125.160247,-66.719389],[126.100396,-66.562654],[127.001427,-66.562654],[127.882768,-66.660633],[128.80328,-66.758611],[129.704259,-66.58224],[130.781454,-66.425505],[131.799945,-66.386283],[132.935896,-66.386283],[133.85646,-66.288304],[134.757387,-66.209963],[135.031582,-65.72007],[135.070753,-65.308571],[135.697485,-65.582869],[135.873805,-66.033591],[136.206705,-66.44509],[136.618049,-66.778197],[137.460271,-66.954568],[138.596223,-66.895761],[139.908442,-66.876175],[140.809421,-66.817367],[142.121692,-66.817367],[143.061842,-66.797782],[144.374061,-66.837004],[145.490427,-66.915346],[146.195552,-67.228867],[145.999699,-67.601196],[146.646067,-67.895131],[147.723263,-68.130259],[148.839629,-68.385024],[150.132314,-68.561292],[151.483705,-68.71813],[152.502247,-68.874813],[153.638199,-68.894502],[154.284567,-68.561292],[155.165857,-68.835642],[155.92979,-69.149215],[156.811132,-69.384291],[158.025528,-69.482269],[159.181013,-69.599833],[159.670699,-69.991747],[160.80665,-70.226875],[161.570479,-70.579618],[162.686897,-70.736353],[163.842434,-70.716768],[164.919681,-70.775524],[166.11444,-70.755938],[167.309095,-70.834332],[168.425616,-70.971481],[169.463589,-71.20666],[170.501665,-71.402617],[171.20679,-71.696501],[171.089227,-72.088415],[170.560422,-72.441159],[170.109958,-72.891829],[169.75737,-73.24452],[169.287321,-73.65602],[167.975101,-73.812806],[167.387489,-74.165498],[166.094803,-74.38104],[165.644391,-74.772954],[164.958851,-75.145283],[164.234193,-75.458804],[163.822797,-75.870303],[163.568239,-76.24258],[163.47026,-76.693302],[163.489897,-77.065579],[164.057873,-77.457442],[164.273363,-77.82977],[164.743464,-78.182514],[166.604126,-78.319611],[166.995781,-78.750748],[165.193876,-78.907483],[163.666217,-79.123025],[161.766385,-79.162248],[160.924162,-79.730482],[160.747894,-80.200737],[160.316964,-80.573066],[159.788211,-80.945395],[161.120016,-81.278501],[161.629287,-81.690001],[162.490992,-82.062278],[163.705336,-82.395435],[165.095949,-82.708956],[166.604126,-83.022477],[168.895665,-83.335998],[169.404782,-83.825891],[172.283934,-84.041433],[172.477049,-84.117914],[173.224083,-84.41371],[175.985672,-84.158997],[178.277212,-84.472518],[180,-84.71338],[180,-90],[-180,-90],[-180,-84.71338],[-179.942499,-84.721443],[-179.058677,-84.139412],[-177.256772,-84.452933],[-177.140807,-84.417941],[-176.084673,-84.099259],[-175.947235,-84.110449],[-175.829882,-84.117914],[-174.382503,-84.534323],[-173.116559,-84.117914],[-172.889106,-84.061019],[-169.951223,-83.884647],[-168.999989,-84.117914],[-168.530199,-84.23739],[-167.022099,-84.570497],[-164.182144,-84.82521],[-161.929775,-85.138731],[-158.07138,-85.37391],[-155.192253,-85.09956],[-150.942099,-85.295517],[-148.533073,-85.609038],[-145.888918,-85.315102],[-143.107718,-85.040752],[-142.892279,-84.570497],[-146.829068,-84.531274],[-150.060732,-84.296146],[-150.902928,-83.904232],[-153.586201,-83.68869],[-153.409907,-83.23802],[-153.037759,-82.82652],[-152.665637,-82.454192],[-152.861517,-82.042692],[-154.526299,-81.768394],[-155.29018,-81.41565],[-156.83745,-81.102129],[-154.408787,-81.160937],[-152.097662,-81.004151],[-150.648293,-81.337309],[-148.865998,-81.043373],[-147.22075,-80.671045],[-146.417749,-80.337938],[-146.770286,-79.926439],[-148.062947,-79.652089],[-149.531901,-79.358205],[-151.588416,-79.299397],[-153.390322,-79.162248],[-155.329376,-79.064269],[-155.975668,-78.69194],[-157.268302,-78.378419],[-158.051768,-78.025676],[-158.365134,-76.889207],[-157.875474,-76.987238],[-156.974573,-77.300759],[-155.329376,-77.202728],[-153.742832,-77.065579],[-152.920247,-77.496664],[-151.33378,-77.398737],[-150.00195,-77.183143],[-148.748486,-76.908845],[-147.612483,-76.575738],[-146.104409,-76.47776],[-146.143528,-76.105431],[-146.496091,-75.733154],[-146.20231,-75.380411],[-144.909624,-75.204039],[-144.322037,-75.537197],[-142.794353,-75.34124],[-141.638764,-75.086475],[-140.209007,-75.06689],[-138.85759,-74.968911],[-137.5062,-74.733783],[-136.428901,-74.518241],[-135.214583,-74.302699],[-134.431194,-74.361455],[-133.745654,-74.439848],[-132.257168,-74.302699],[-130.925311,-74.479019],[-129.554284,-74.459433],[-128.242038,-74.322284],[-126.890622,-74.420263],[-125.402082,-74.518241],[-124.011496,-74.479019],[-122.562152,-74.498604],[-121.073613,-74.518241],[-119.70256,-74.479019],[-118.684145,-74.185083],[-117.469801,-74.028348],[-116.216312,-74.243891],[-115.021552,-74.067519],[-113.944331,-73.714828],[-113.297988,-74.028348],[-112.945452,-74.38104],[-112.299083,-74.714198],[-111.261059,-74.420263],[-110.066325,-74.79254],[-108.714909,-74.910103],[-107.559346,-75.184454],[-106.149148,-75.125698],[-104.876074,-74.949326],[-103.367949,-74.988497],[-102.016507,-75.125698],[-100.645531,-75.302018],[-100.1167,-74.870933],[-100.763043,-74.537826],[-101.252703,-74.185083],[-102.545337,-74.106742],[-103.113313,-73.734413],[-103.328752,-73.362084],[-103.681289,-72.61753],[-102.917485,-72.754679],[-101.60524,-72.813436],[-100.312528,-72.754679],[-99.13738,-72.911414],[-98.118889,-73.20535],[-97.688037,-73.558041],[-96.336595,-73.616849],[-95.043961,-73.4797],[-93.672907,-73.283743],[-92.439003,-73.166179],[-91.420564,-73.401307],[-90.088733,-73.322914],[-89.226951,-72.558722],[-88.423951,-73.009393],[-87.268337,-73.185764],[-86.014822,-73.087786],[-85.192236,-73.4797],[-83.879991,-73.518871],[-82.665646,-73.636434],[-81.470913,-73.851977],[-80.687447,-73.4797],[-80.295791,-73.126956],[-79.296886,-73.518871],[-77.925858,-73.420892],[-76.907367,-73.636434],[-76.221879,-73.969541],[-74.890049,-73.871614],[-73.852024,-73.65602],[-72.833533,-73.401307],[-71.619215,-73.264157],[-70.209042,-73.146542],[-68.935916,-73.009393],[-67.956622,-72.79385],[-67.369061,-72.480329],[-67.134036,-72.049244],[-67.251548,-71.637745],[-67.56494,-71.245831],[-67.917477,-70.853917],[-68.230843,-70.462055],[-68.485452,-70.109311],[-68.544209,-69.717397],[-68.446282,-69.325535],[-67.976233,-68.953206],[-67.5845,-68.541707],[-67.427843,-68.149844],[-67.62367,-67.718759],[-67.741183,-67.326845],[-67.251548,-66.876175],[-66.703184,-66.58224],[-66.056815,-66.209963],[-65.371327,-65.89639],[-64.568276,-65.602506],[-64.176542,-65.171423],[-63.628152,-64.897073],[-63.001394,-64.642308],[-62.041686,-64.583552],[-61.414928,-64.270031],[-60.709855,-64.074074],[-59.887269,-63.95651],[-59.162585,-63.701745],[-58.594557,-63.388224],[-57.811143,-63.27066],[-57.223582,-63.525425],[-57.59573,-63.858532],[-58.614143,-64.152467]]]]}},{"type":"Feature","properties":{"scalerank":3,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"France","SOV_A3":"FR1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Dependency","ADMIN":"French Southern and Antarctic Lands","ADM0_A3":"ATF","GEOU_DIF":0,"GEOUNIT":"French Southern and Antarctic Lands","GU_A3":"ATF","SU_DIF":0,"SUBUNIT":"French Southern and Antarctic Lands","SU_A3":"ATF","BRK_DIFF":0,"NAME":"Fr. S. Antarctic Lands","NAME_LONG":"French Southern and Antarctic Lands","BRK_A3":"ATF","BRK_NAME":"Fr. S. and Antarctic Lands","BRK_GROUP":null,"ABBREV":"Fr. S.A.L.","POSTAL":"TF","FORMAL_EN":"Territory of the French Southern and Antarctic Lands","FORMAL_FR":null,"NAME_CIAWF":null,"NOTE_ADM0":"Fr.","NOTE_BRK":null,"NAME_SORT":"French Southern and Antarctic Lands","NAME_ALT":null,"MAPCOLOR7":7,"MAPCOLOR8":5,"MAPCOLOR9":9,"MAPCOLOR13":11,"POP_EST":140,"POP_RANK":1,"GDP_MD_EST":16,"POP_YEAR":2017,"LASTCENSUS":-99,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"FS","ISO_A2":"TF","ISO_A3":"ATF","ISO_A3_EH":"ATF","ISO_N3":"260","UN_A3":"-099","WB_A2":"-99","WB_A3":"-99","WOE_ID":28289406,"WOE_ID_EH":28289406,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ATF","ADM0_A3_US":"ATF","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Seven seas (open ocean)","REGION_UN":"Seven seas (open ocean)","SUBREGION":"Seven seas (open ocean)","REGION_WB":"Sub-Saharan Africa","NAME_LEN":22,"LONG_LEN":35,"ABBREV_LEN":10,"TINY":2,"HOMEPART":-99,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[68.72,-49.775,70.56,-48.625],"geometry":{"type":"Polygon","coordinates":[[[68.935,-48.625],[69.58,-48.94],[70.525,-49.065],[70.56,-49.255],[70.28,-49.71],[68.745,-49.775],[68.72,-49.2425],[68.8675,-48.83],[68.935,-48.625]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Australia","SOV_A3":"AU1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"Australia","ADM0_A3":"AUS","GEOU_DIF":0,"GEOUNIT":"Australia","GU_A3":"AUS","SU_DIF":0,"SUBUNIT":"Australia","SU_A3":"AUS","BRK_DIFF":0,"NAME":"Australia","NAME_LONG":"Australia","BRK_A3":"AUS","BRK_NAME":"Australia","BRK_GROUP":null,"ABBREV":"Auz.","POSTAL":"AU","FORMAL_EN":"Commonwealth of Australia","FORMAL_FR":null,"NAME_CIAWF":"Australia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Australia","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":7,"POP_EST":23232413,"POP_RANK":15,"GDP_MD_EST":1189000,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"AS","ISO_A2":"AU","ISO_A3":"AUS","ISO_A3_EH":"AUS","ISO_N3":"036","UN_A3":"036","WB_A2":"AU","WB_A3":"AUS","WOE_ID":-90,"WOE_ID_EH":23424748,"WOE_NOTE":"Includes Ashmore and Cartier Islands (23424749) and Coral Sea Islands (23424790).","ADM0_A3_IS":"AUS","ADM0_A3_US":"AUS","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Oceania","REGION_UN":"Oceania","SUBREGION":"Australia and New Zealand","REGION_WB":"East Asia & Pacific","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":5.7},"bbox":[113.338953,-43.634597,153.569469,-10.668186],"geometry":{"type":"MultiPolygon","coordinates":[[[[145.397978,-40.792549],[146.364121,-41.137695],[146.908584,-41.000546],[147.689259,-40.808258],[148.289068,-40.875438],[148.359865,-42.062445],[148.017301,-42.407024],[147.914052,-43.211522],[147.564564,-42.937689],[146.870343,-43.634597],[146.663327,-43.580854],[146.048378,-43.549745],[145.43193,-42.693776],[145.29509,-42.03361],[144.718071,-41.162552],[144.743755,-40.703975],[145.397978,-40.792549]]],[[[143.561811,-13.763656],[143.922099,-14.548311],[144.563714,-14.171176],[144.894908,-14.594458],[145.374724,-14.984976],[145.271991,-15.428205],[145.48526,-16.285672],[145.637033,-16.784918],[145.888904,-16.906926],[146.160309,-17.761655],[146.063674,-18.280073],[146.387478,-18.958274],[147.471082,-19.480723],[148.177602,-19.955939],[148.848414,-20.39121],[148.717465,-20.633469],[149.28942,-21.260511],[149.678337,-22.342512],[150.077382,-22.122784],[150.482939,-22.556142],[150.727265,-22.402405],[150.899554,-23.462237],[151.609175,-24.076256],[152.07354,-24.457887],[152.855197,-25.267501],[153.136162,-26.071173],[153.161949,-26.641319],[153.092909,-27.2603],[153.569469,-28.110067],[153.512108,-28.995077],[153.339095,-29.458202],[153.069241,-30.35024],[153.089602,-30.923642],[152.891578,-31.640446],[152.450002,-32.550003],[151.709117,-33.041342],[151.343972,-33.816023],[151.010555,-34.31036],[150.714139,-35.17346],[150.32822,-35.671879],[150.075212,-36.420206],[149.946124,-37.109052],[149.997284,-37.425261],[149.423882,-37.772681],[148.304622,-37.809061],[147.381733,-38.219217],[146.922123,-38.606532],[146.317922,-39.035757],[145.489652,-38.593768],[144.876976,-38.417448],[145.032212,-37.896188],[144.485682,-38.085324],[143.609974,-38.809465],[142.745427,-38.538268],[142.17833,-38.380034],[141.606582,-38.308514],[140.638579,-38.019333],[139.992158,-37.402936],[139.806588,-36.643603],[139.574148,-36.138362],[139.082808,-35.732754],[138.120748,-35.612296],[138.449462,-35.127261],[138.207564,-34.384723],[137.71917,-35.076825],[136.829406,-35.260535],[137.352371,-34.707339],[137.503886,-34.130268],[137.890116,-33.640479],[137.810328,-32.900007],[136.996837,-33.752771],[136.372069,-34.094766],[135.989043,-34.890118],[135.208213,-34.47867],[135.239218,-33.947953],[134.613417,-33.222778],[134.085904,-32.848072],[134.273903,-32.617234],[132.990777,-32.011224],[132.288081,-31.982647],[131.326331,-31.495803],[129.535794,-31.590423],[128.240938,-31.948489],[127.102867,-32.282267],[126.148714,-32.215966],[125.088623,-32.728751],[124.221648,-32.959487],[124.028947,-33.483847],[123.659667,-33.890179],[122.811036,-33.914467],[122.183064,-34.003402],[121.299191,-33.821036],[120.580268,-33.930177],[119.893695,-33.976065],[119.298899,-34.509366],[119.007341,-34.464149],[118.505718,-34.746819],[118.024972,-35.064733],[117.295507,-35.025459],[116.625109,-35.025097],[115.564347,-34.386428],[115.026809,-34.196517],[115.048616,-33.623425],[115.545123,-33.487258],[115.714674,-33.259572],[115.679379,-32.900369],[115.801645,-32.205062],[115.689611,-31.612437],[115.160909,-30.601594],[114.997043,-30.030725],[115.040038,-29.461095],[114.641974,-28.810231],[114.616498,-28.516399],[114.173579,-28.118077],[114.048884,-27.334765],[113.477498,-26.543134],[113.338953,-26.116545],[113.778358,-26.549025],[113.440962,-25.621278],[113.936901,-25.911235],[114.232852,-26.298446],[114.216161,-25.786281],[113.721255,-24.998939],[113.625344,-24.683971],[113.393523,-24.384764],[113.502044,-23.80635],[113.706993,-23.560215],[113.843418,-23.059987],[113.736552,-22.475475],[114.149756,-21.755881],[114.225307,-22.517488],[114.647762,-21.82952],[115.460167,-21.495173],[115.947373,-21.068688],[116.711615,-20.701682],[117.166316,-20.623599],[117.441545,-20.746899],[118.229559,-20.374208],[118.836085,-20.263311],[118.987807,-20.044203],[119.252494,-19.952942],[119.805225,-19.976506],[120.85622,-19.683708],[121.399856,-19.239756],[121.655138,-18.705318],[122.241665,-18.197649],[122.286624,-17.798603],[122.312772,-17.254967],[123.012574,-16.4052],[123.433789,-17.268558],[123.859345,-17.069035],[123.503242,-16.596506],[123.817073,-16.111316],[124.258287,-16.327944],[124.379726,-15.56706],[124.926153,-15.0751],[125.167275,-14.680396],[125.670087,-14.51007],[125.685796,-14.230656],[126.125149,-14.347341],[126.142823,-14.095987],[126.582589,-13.952791],[127.065867,-13.817968],[127.804633,-14.276906],[128.35969,-14.86917],[128.985543,-14.875991],[129.621473,-14.969784],[129.4096,-14.42067],[129.888641,-13.618703],[130.339466,-13.357376],[130.183506,-13.10752],[130.617795,-12.536392],[131.223495,-12.183649],[131.735091,-12.302453],[132.575298,-12.114041],[132.557212,-11.603012],[131.824698,-11.273782],[132.357224,-11.128519],[133.019561,-11.376411],[133.550846,-11.786515],[134.393068,-12.042365],[134.678632,-11.941183],[135.298491,-12.248606],[135.882693,-11.962267],[136.258381,-12.049342],[136.492475,-11.857209],[136.95162,-12.351959],[136.685125,-12.887223],[136.305407,-13.29123],[135.961758,-13.324509],[136.077617,-13.724278],[135.783836,-14.223989],[135.428664,-14.715432],[135.500184,-14.997741],[136.295175,-15.550265],[137.06536,-15.870762],[137.580471,-16.215082],[138.303217,-16.807604],[138.585164,-16.806622],[139.108543,-17.062679],[139.260575,-17.371601],[140.215245,-17.710805],[140.875463,-17.369069],[141.07111,-16.832047],[141.274095,-16.38887],[141.398222,-15.840532],[141.702183,-15.044921],[141.56338,-14.561333],[141.63552,-14.270395],[141.519869,-13.698078],[141.65092,-12.944688],[141.842691,-12.741548],[141.68699,-12.407614],[141.928629,-11.877466],[142.118488,-11.328042],[142.143706,-11.042737],[142.51526,-10.668186],[142.79731,-11.157355],[142.866763,-11.784707],[143.115947,-11.90563],[143.158632,-12.325656],[143.522124,-12.834358],[143.597158,-13.400422],[143.561811,-13.763656]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Austria","SOV_A3":"AUT","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Austria","ADM0_A3":"AUT","GEOU_DIF":0,"GEOUNIT":"Austria","GU_A3":"AUT","SU_DIF":0,"SUBUNIT":"Austria","SU_A3":"AUT","BRK_DIFF":0,"NAME":"Austria","NAME_LONG":"Austria","BRK_A3":"AUT","BRK_NAME":"Austria","BRK_GROUP":null,"ABBREV":"Aust.","POSTAL":"A","FORMAL_EN":"Republic of Austria","FORMAL_FR":null,"NAME_CIAWF":"Austria","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Austria","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":4,"POP_EST":8754413,"POP_RANK":13,"GDP_MD_EST":416600,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"AU","ISO_A2":"AT","ISO_A3":"AUT","ISO_A3_EH":"AUT","ISO_N3":"040","UN_A3":"040","WB_A2":"AT","WB_A3":"AUT","WOE_ID":23424750,"WOE_ID_EH":23424750,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"AUT","ADM0_A3_US":"AUT","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Western Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[9.47997,46.431817,16.979667,49.039074],"geometry":{"type":"Polygon","coordinates":[[[16.979667,48.123497],[16.903754,47.714866],[16.340584,47.712902],[16.534268,47.496171],[16.202298,46.852386],[16.011664,46.683611],[15.137092,46.658703],[14.632472,46.431817],[13.806475,46.509306],[12.376485,46.767559],[12.153088,47.115393],[11.164828,46.941579],[11.048556,46.751359],[10.442701,46.893546],[9.932448,46.920728],[9.47997,47.10281],[9.632932,47.347601],[9.594226,47.525058],[9.896068,47.580197],[10.402084,47.302488],[10.544504,47.566399],[11.426414,47.523766],[12.141357,47.703083],[12.62076,47.672388],[12.932627,47.467646],[13.025851,47.637584],[12.884103,48.289146],[13.243357,48.416115],[13.595946,48.877172],[14.338898,48.555305],[14.901447,48.964402],[15.253416,49.039074],[16.029647,48.733899],[16.499283,48.785808],[16.960288,48.596982],[16.879983,48.470013],[16.979667,48.123497]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Azerbaijan","SOV_A3":"AZE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Azerbaijan","ADM0_A3":"AZE","GEOU_DIF":0,"GEOUNIT":"Azerbaijan","GU_A3":"AZE","SU_DIF":0,"SUBUNIT":"Azerbaijan","SU_A3":"AZE","BRK_DIFF":0,"NAME":"Azerbaijan","NAME_LONG":"Azerbaijan","BRK_A3":"AZE","BRK_NAME":"Azerbaijan","BRK_GROUP":null,"ABBREV":"Aze.","POSTAL":"AZ","FORMAL_EN":"Republic of Azerbaijan","FORMAL_FR":null,"NAME_CIAWF":"Azerbaijan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Azerbaijan","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":6,"MAPCOLOR9":5,"MAPCOLOR13":8,"POP_EST":9961396,"POP_RANK":13,"GDP_MD_EST":167900,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"AJ","ISO_A2":"AZ","ISO_A3":"AZE","ISO_A3_EH":"AZE","ISO_N3":"031","UN_A3":"031","WB_A2":"AZ","WB_A3":"AZE","WOE_ID":23424741,"WOE_ID_EH":23424741,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"AZE","ADM0_A3_US":"AZE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[44.79399,38.270378,50.392821,41.860675],"geometry":{"type":"MultiPolygon","coordinates":[[[[46.50572,38.770605],[46.483499,39.464155],[46.034534,39.628021],[45.610012,39.899994],[45.891907,40.218476],[45.359175,40.561504],[45.560351,40.81229],[45.179496,40.985354],[44.97248,41.248129],[45.217426,41.411452],[45.962601,41.123873],[46.501637,41.064445],[46.637908,41.181673],[46.145432,41.722802],[46.404951,41.860675],[46.686071,41.827137],[47.373315,41.219732],[47.815666,41.151416],[47.987283,41.405819],[48.584353,41.808869],[49.110264,41.282287],[49.618915,40.572924],[50.08483,40.526157],[50.392821,40.256561],[49.569202,40.176101],[49.395259,39.399482],[49.223228,39.049219],[48.856532,38.815486],[48.883249,38.320245],[48.634375,38.270378],[48.010744,38.794015],[48.355529,39.288765],[48.060095,39.582235],[47.685079,39.508364],[46.50572,38.770605]]],[[[44.79399,39.713003],[45.001987,39.740004],[45.298145,39.471751],[45.739978,39.473999],[45.735379,39.319719],[46.143623,38.741201],[45.457722,38.874139],[44.952688,39.335765],[44.79399,39.713003]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Burundi","SOV_A3":"BDI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Burundi","ADM0_A3":"BDI","GEOU_DIF":0,"GEOUNIT":"Burundi","GU_A3":"BDI","SU_DIF":0,"SUBUNIT":"Burundi","SU_A3":"BDI","BRK_DIFF":0,"NAME":"Burundi","NAME_LONG":"Burundi","BRK_A3":"BDI","BRK_NAME":"Burundi","BRK_GROUP":null,"ABBREV":"Bur.","POSTAL":"BI","FORMAL_EN":"Republic of Burundi","FORMAL_FR":null,"NAME_CIAWF":"Burundi","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Burundi","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":5,"MAPCOLOR13":8,"POP_EST":11466756,"POP_RANK":14,"GDP_MD_EST":7892,"POP_YEAR":2017,"LASTCENSUS":2008,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"BY","ISO_A2":"BI","ISO_A3":"BDI","ISO_A3_EH":"BDI","ISO_N3":"108","UN_A3":"108","WB_A2":"BI","WB_A3":"BDI","WOE_ID":23424774,"WOE_ID_EH":23424774,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BDI","ADM0_A3_US":"BDI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[29.024926,-4.499983,30.75224,-2.348487],"geometry":{"type":"Polygon","coordinates":[[[29.339998,-4.499983],[29.276384,-3.293907],[29.024926,-2.839258],[29.632176,-2.917858],[29.938359,-2.348487],[30.469674,-2.413855],[30.52766,-2.80762],[30.74301,-3.03431],[30.75224,-3.35931],[30.50554,-3.56858],[30.11632,-4.09012],[29.753512,-4.452389],[29.339998,-4.499983]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Belgium","SOV_A3":"BEL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Belgium","ADM0_A3":"BEL","GEOU_DIF":0,"GEOUNIT":"Belgium","GU_A3":"BEL","SU_DIF":0,"SUBUNIT":"Belgium","SU_A3":"BEL","BRK_DIFF":0,"NAME":"Belgium","NAME_LONG":"Belgium","BRK_A3":"BEL","BRK_NAME":"Belgium","BRK_GROUP":null,"ABBREV":"Belg.","POSTAL":"B","FORMAL_EN":"Kingdom of Belgium","FORMAL_FR":null,"NAME_CIAWF":"Belgium","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Belgium","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":1,"MAPCOLOR13":8,"POP_EST":11491346,"POP_RANK":14,"GDP_MD_EST":508600,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"BE","ISO_A2":"BE","ISO_A3":"BEL","ISO_A3_EH":"BEL","ISO_N3":"056","UN_A3":"056","WB_A2":"BE","WB_A3":"BEL","WOE_ID":23424757,"WOE_ID_EH":23424757,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BEL","ADM0_A3_US":"BEL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Western Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[2.513573,49.529484,6.156658,51.475024],"geometry":{"type":"Polygon","coordinates":[[[4.047071,51.267259],[4.973991,51.475024],[5.606976,51.037298],[6.156658,50.803721],[6.043073,50.128052],[5.782417,50.090328],[5.674052,49.529484],[4.799222,49.985373],[4.286023,49.907497],[3.588184,50.378992],[3.123252,50.780363],[2.658422,50.796848],[2.513573,51.148506],[3.314971,51.345781],[3.315011,51.345777],[3.314971,51.345755],[4.047071,51.267259]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Benin","SOV_A3":"BEN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Benin","ADM0_A3":"BEN","GEOU_DIF":0,"GEOUNIT":"Benin","GU_A3":"BEN","SU_DIF":0,"SUBUNIT":"Benin","SU_A3":"BEN","BRK_DIFF":0,"NAME":"Benin","NAME_LONG":"Benin","BRK_A3":"BEN","BRK_NAME":"Benin","BRK_GROUP":null,"ABBREV":"Benin","POSTAL":"BJ","FORMAL_EN":"Republic of Benin","FORMAL_FR":null,"NAME_CIAWF":"Benin","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Benin","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":12,"POP_EST":11038805,"POP_RANK":14,"GDP_MD_EST":24310,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"BN","ISO_A2":"BJ","ISO_A3":"BEN","ISO_A3_EH":"BEN","ISO_N3":"204","UN_A3":"204","WB_A2":"BJ","WB_A3":"BEN","WOE_ID":23424764,"WOE_ID_EH":23424764,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BEN","ADM0_A3_US":"BEN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[0.772336,6.142158,3.797112,12.235636],"geometry":{"type":"Polygon","coordinates":[[[2.691702,6.258817],[1.865241,6.142158],[1.618951,6.832038],[1.664478,9.12859],[1.463043,9.334624],[1.425061,9.825395],[1.077795,10.175607],[0.772336,10.470808],[0.899563,10.997339],[1.24347,11.110511],[1.447178,11.547719],[1.935986,11.64115],[2.154474,11.94015],[2.490164,12.233052],[2.848643,12.235636],[3.61118,11.660167],[3.572216,11.327939],[3.797112,10.734746],[3.60007,10.332186],[3.705438,10.06321],[3.220352,9.444153],[2.912308,9.137608],[2.723793,8.506845],[2.749063,7.870734],[2.691702,6.258817]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Burkina Faso","SOV_A3":"BFA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Burkina Faso","ADM0_A3":"BFA","GEOU_DIF":0,"GEOUNIT":"Burkina Faso","GU_A3":"BFA","SU_DIF":0,"SUBUNIT":"Burkina Faso","SU_A3":"BFA","BRK_DIFF":0,"NAME":"Burkina Faso","NAME_LONG":"Burkina Faso","BRK_A3":"BFA","BRK_NAME":"Burkina Faso","BRK_GROUP":null,"ABBREV":"B.F.","POSTAL":"BF","FORMAL_EN":"Burkina Faso","FORMAL_FR":null,"NAME_CIAWF":"Burkina Faso","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Burkina Faso","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":1,"MAPCOLOR9":5,"MAPCOLOR13":11,"POP_EST":20107509,"POP_RANK":15,"GDP_MD_EST":32990,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"UV","ISO_A2":"BF","ISO_A3":"BFA","ISO_A3_EH":"BFA","ISO_N3":"854","UN_A3":"854","WB_A2":"BF","WB_A3":"BFA","WOE_ID":23424978,"WOE_ID_EH":23424978,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BFA","ADM0_A3_US":"BFA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":12,"LONG_LEN":12,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-5.470565,9.610835,2.177108,15.116158],"geometry":{"type":"Polygon","coordinates":[[[2.154474,11.94015],[1.935986,11.64115],[1.447178,11.547719],[1.24347,11.110511],[0.899563,10.997339],[0.023803,11.018682],[-0.438702,11.098341],[-0.761576,10.93693],[-1.203358,11.009819],[-2.940409,10.96269],[-2.963896,10.395335],[-2.827496,9.642461],[-3.511899,9.900326],[-3.980449,9.862344],[-4.330247,9.610835],[-4.779884,9.821985],[-4.954653,10.152714],[-5.404342,10.370737],[-5.470565,10.95127],[-5.197843,11.375146],[-5.220942,11.713859],[-4.427166,12.542646],[-4.280405,13.228444],[-4.006391,13.472485],[-3.522803,13.337662],[-3.103707,13.541267],[-2.967694,13.79815],[-2.191825,14.246418],[-2.001035,14.559008],[-1.066363,14.973815],[-0.515854,15.116158],[-0.266257,14.924309],[0.374892,14.928908],[0.295646,14.444235],[0.429928,13.988733],[0.993046,13.33575],[1.024103,12.851826],[2.177108,12.625018],[2.154474,11.94015]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Bangladesh","SOV_A3":"BGD","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Bangladesh","ADM0_A3":"BGD","GEOU_DIF":0,"GEOUNIT":"Bangladesh","GU_A3":"BGD","SU_DIF":0,"SUBUNIT":"Bangladesh","SU_A3":"BGD","BRK_DIFF":0,"NAME":"Bangladesh","NAME_LONG":"Bangladesh","BRK_A3":"BGD","BRK_NAME":"Bangladesh","BRK_GROUP":null,"ABBREV":"Bang.","POSTAL":"BD","FORMAL_EN":"People's Republic of Bangladesh","FORMAL_FR":null,"NAME_CIAWF":"Bangladesh","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Bangladesh","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":4,"MAPCOLOR9":7,"MAPCOLOR13":7,"POP_EST":157826578,"POP_RANK":17,"GDP_MD_EST":628400,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"BG","ISO_A2":"BD","ISO_A3":"BGD","ISO_A3_EH":"BGD","ISO_N3":"050","UN_A3":"050","WB_A2":"BD","WB_A3":"BGD","WOE_ID":23424759,"WOE_ID_EH":23424759,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BGD","ADM0_A3_US":"BGD","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"South Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[88.084422,20.670883,92.672721,26.446526],"geometry":{"type":"Polygon","coordinates":[[[92.672721,22.041239],[92.652257,21.324048],[92.303234,21.475485],[92.368554,20.670883],[92.082886,21.192195],[92.025215,21.70157],[91.834891,22.182936],[91.417087,22.765019],[90.496006,22.805017],[90.586957,22.392794],[90.272971,21.836368],[89.847467,22.039146],[89.70205,21.857116],[89.418863,21.966179],[89.031961,22.055708],[88.876312,22.879146],[88.52977,23.631142],[88.69994,24.233715],[88.084422,24.501657],[88.306373,24.866079],[88.931554,25.238692],[88.209789,25.768066],[88.563049,26.446526],[89.355094,26.014407],[89.832481,25.965082],[89.920693,25.26975],[90.872211,25.132601],[91.799596,25.147432],[92.376202,24.976693],[91.915093,24.130414],[91.46773,24.072639],[91.158963,23.503527],[91.706475,22.985264],[91.869928,23.624346],[92.146035,23.627499],[92.672721,22.041239]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Bulgaria","SOV_A3":"BGR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Bulgaria","ADM0_A3":"BGR","GEOU_DIF":0,"GEOUNIT":"Bulgaria","GU_A3":"BGR","SU_DIF":0,"SUBUNIT":"Bulgaria","SU_A3":"BGR","BRK_DIFF":0,"NAME":"Bulgaria","NAME_LONG":"Bulgaria","BRK_A3":"BGR","BRK_NAME":"Bulgaria","BRK_GROUP":null,"ABBREV":"Bulg.","POSTAL":"BG","FORMAL_EN":"Republic of Bulgaria","FORMAL_FR":null,"NAME_CIAWF":"Bulgaria","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Bulgaria","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":5,"MAPCOLOR9":1,"MAPCOLOR13":8,"POP_EST":7101510,"POP_RANK":13,"GDP_MD_EST":143100,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"BU","ISO_A2":"BG","ISO_A3":"BGR","ISO_A3_EH":"BGR","ISO_N3":"100","UN_A3":"100","WB_A2":"BG","WB_A3":"BGR","WOE_ID":23424771,"WOE_ID_EH":23424771,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BGR","ADM0_A3_US":"BGR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[22.380526,41.234486,28.558081,44.234923],"geometry":{"type":"Polygon","coordinates":[[[22.65715,44.234923],[22.944832,43.823785],[23.332302,43.897011],[24.100679,43.741051],[25.569272,43.688445],[26.065159,43.943494],[27.2424,44.175986],[27.970107,43.812468],[28.558081,43.707462],[28.039095,43.293172],[27.673898,42.577892],[27.99672,42.007359],[27.135739,42.141485],[26.117042,41.826905],[26.106138,41.328899],[25.197201,41.234486],[24.492645,41.583896],[23.692074,41.309081],[22.952377,41.337994],[22.881374,41.999297],[22.380526,42.32026],[22.545012,42.461362],[22.436595,42.580321],[22.604801,42.898519],[22.986019,43.211161],[22.500157,43.642814],[22.410446,44.008063],[22.65715,44.234923]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"The Bahamas","SOV_A3":"BHS","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"The Bahamas","ADM0_A3":"BHS","GEOU_DIF":0,"GEOUNIT":"The Bahamas","GU_A3":"BHS","SU_DIF":0,"SUBUNIT":"The Bahamas","SU_A3":"BHS","BRK_DIFF":0,"NAME":"Bahamas","NAME_LONG":"Bahamas","BRK_A3":"BHS","BRK_NAME":"Bahamas","BRK_GROUP":null,"ABBREV":"Bhs.","POSTAL":"BS","FORMAL_EN":"Commonwealth of the Bahamas","FORMAL_FR":null,"NAME_CIAWF":"Bahamas, The","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Bahamas, The","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":1,"MAPCOLOR9":2,"MAPCOLOR13":5,"POP_EST":329988,"POP_RANK":10,"GDP_MD_EST":9066,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"BF","ISO_A2":"BS","ISO_A3":"BHS","ISO_A3_EH":"BHS","ISO_N3":"044","UN_A3":"044","WB_A2":"BS","WB_A3":"BHS","WOE_ID":23424758,"WOE_ID_EH":23424758,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BHS","ADM0_A3_US":"BHS","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Caribbean","REGION_WB":"Latin America & Caribbean","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-78.98,23.71,-77,27.04],"geometry":{"type":"MultiPolygon","coordinates":[[[[-77.53466,23.75975],[-77.78,23.71],[-78.03405,24.28615],[-78.40848,24.57564],[-78.19087,25.2103],[-77.89,25.17],[-77.54,24.34],[-77.53466,23.75975]]],[[[-77.82,26.58],[-78.91,26.42],[-78.98,26.79],[-78.51,26.87],[-77.85,26.84],[-77.82,26.58]]],[[[-77,26.59],[-77.17255,25.87918],[-77.35641,26.00735],[-77.34,26.53],[-77.78802,26.92516],[-77.79,27.04],[-77,26.59]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Bosnia and Herzegovina","SOV_A3":"BIH","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Bosnia and Herzegovina","ADM0_A3":"BIH","GEOU_DIF":0,"GEOUNIT":"Bosnia and Herzegovina","GU_A3":"BIH","SU_DIF":0,"SUBUNIT":"Bosnia and Herzegovina","SU_A3":"BIH","BRK_DIFF":0,"NAME":"Bosnia and Herz.","NAME_LONG":"Bosnia and Herzegovina","BRK_A3":"BIH","BRK_NAME":"Bosnia and Herz.","BRK_GROUP":null,"ABBREV":"B.H.","POSTAL":"BiH","FORMAL_EN":"Bosnia and Herzegovina","FORMAL_FR":null,"NAME_CIAWF":"Bosnia and Herzegovina","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Bosnia and Herzegovina","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":1,"MAPCOLOR9":1,"MAPCOLOR13":2,"POP_EST":3856181,"POP_RANK":12,"GDP_MD_EST":42530,"POP_YEAR":2017,"LASTCENSUS":1991,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"BK","ISO_A2":"BA","ISO_A3":"BIH","ISO_A3_EH":"BIH","ISO_N3":"070","UN_A3":"070","WB_A2":"BA","WB_A3":"BIH","WOE_ID":23424761,"WOE_ID_EH":23424761,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BIH","ADM0_A3_US":"BIH","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":16,"LONG_LEN":22,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4.5,"MAX_LABEL":9.5},"bbox":[15.750026,42.65,19.59976,45.233777],"geometry":{"type":"Polygon","coordinates":[[[19.36803,44.863],[19.11761,44.42307],[19.59976,44.03847],[19.454,43.5681],[19.21852,43.52384],[19.03165,43.43253],[18.70648,43.20011],[18.56,42.65],[17.674922,43.028563],[17.297373,43.446341],[16.916156,43.667722],[16.456443,44.04124],[16.23966,44.351143],[15.750026,44.818712],[15.959367,45.233777],[16.318157,45.004127],[16.534939,45.211608],[17.002146,45.233777],[17.861783,45.06774],[18.553214,45.08159],[19.005485,44.860234],[19.00548,44.86023],[19.36803,44.863]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Belarus","SOV_A3":"BLR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Belarus","ADM0_A3":"BLR","GEOU_DIF":0,"GEOUNIT":"Belarus","GU_A3":"BLR","SU_DIF":0,"SUBUNIT":"Belarus","SU_A3":"BLR","BRK_DIFF":0,"NAME":"Belarus","NAME_LONG":"Belarus","BRK_A3":"BLR","BRK_NAME":"Belarus","BRK_GROUP":null,"ABBREV":"Bela.","POSTAL":"BY","FORMAL_EN":"Republic of Belarus","FORMAL_FR":null,"NAME_CIAWF":"Belarus","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Belarus","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":1,"MAPCOLOR9":5,"MAPCOLOR13":11,"POP_EST":9549747,"POP_RANK":13,"GDP_MD_EST":165400,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"BO","ISO_A2":"BY","ISO_A3":"BLR","ISO_A3_EH":"BLR","ISO_N3":"112","UN_A3":"112","WB_A2":"BY","WB_A3":"BLR","WOE_ID":23424765,"WOE_ID_EH":23424765,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BLR","ADM0_A3_US":"BLR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[23.199494,51.319503,32.693643,56.16913],"geometry":{"type":"Polygon","coordinates":[[[23.484128,53.912498],[24.450684,53.905702],[25.536354,54.282423],[25.768433,54.846963],[26.588279,55.167176],[26.494331,55.615107],[27.10246,55.783314],[28.176709,56.16913],[29.229513,55.918344],[29.371572,55.670091],[29.896294,55.789463],[30.873909,55.550976],[30.971836,55.081548],[30.757534,54.811771],[31.384472,54.157056],[31.791424,53.974639],[31.731273,53.794029],[32.405599,53.618045],[32.693643,53.351421],[32.304519,53.132726],[31.49764,53.16743],[31.305201,53.073996],[31.540018,52.742052],[31.78597,52.10168],[31.785992,52.101678],[30.927549,52.042353],[30.619454,51.822806],[30.555117,51.319503],[30.157364,51.416138],[29.254938,51.368234],[28.992835,51.602044],[28.617613,51.427714],[28.241615,51.572227],[27.454066,51.592303],[26.337959,51.832289],[25.327788,51.910656],[24.553106,51.888461],[24.005078,51.617444],[23.527071,51.578454],[23.508002,52.023647],[23.199494,52.486977],[23.799199,52.691099],[23.804935,53.089731],[23.527536,53.470122],[23.484128,53.912498]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Belize","SOV_A3":"BLZ","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Belize","ADM0_A3":"BLZ","GEOU_DIF":0,"GEOUNIT":"Belize","GU_A3":"BLZ","SU_DIF":0,"SUBUNIT":"Belize","SU_A3":"BLZ","BRK_DIFF":0,"NAME":"Belize","NAME_LONG":"Belize","BRK_A3":"BLZ","BRK_NAME":"Belize","BRK_GROUP":null,"ABBREV":"Belize","POSTAL":"BZ","FORMAL_EN":"Belize","FORMAL_FR":null,"NAME_CIAWF":"Belize","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Belize","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":5,"MAPCOLOR13":7,"POP_EST":360346,"POP_RANK":10,"GDP_MD_EST":3088,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"BH","ISO_A2":"BZ","ISO_A3":"BLZ","ISO_A3_EH":"BLZ","ISO_N3":"084","UN_A3":"084","WB_A2":"BZ","WB_A3":"BLZ","WOE_ID":23424760,"WOE_ID_EH":23424760,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BLZ","ADM0_A3_US":"BLZ","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[-89.229122,15.886938,-88.106813,18.499982],"geometry":{"type":"Polygon","coordinates":[[[-89.14308,17.808319],[-89.150909,17.955468],[-89.029857,18.001511],[-88.848344,17.883198],[-88.490123,18.486831],[-88.300031,18.499982],[-88.296336,18.353273],[-88.106813,18.348674],[-88.123479,18.076675],[-88.285355,17.644143],[-88.197867,17.489475],[-88.302641,17.131694],[-88.239518,17.036066],[-88.355428,16.530774],[-88.551825,16.265467],[-88.732434,16.233635],[-88.930613,15.887273],[-89.229122,15.886938],[-89.150806,17.015577],[-89.14308,17.808319]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Bolivia","SOV_A3":"BOL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Bolivia","ADM0_A3":"BOL","GEOU_DIF":0,"GEOUNIT":"Bolivia","GU_A3":"BOL","SU_DIF":0,"SUBUNIT":"Bolivia","SU_A3":"BOL","BRK_DIFF":0,"NAME":"Bolivia","NAME_LONG":"Bolivia","BRK_A3":"BOL","BRK_NAME":"Bolivia","BRK_GROUP":null,"ABBREV":"Bolivia","POSTAL":"BO","FORMAL_EN":"Plurinational State of Bolivia","FORMAL_FR":null,"NAME_CIAWF":"Bolivia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Bolivia","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":5,"MAPCOLOR9":2,"MAPCOLOR13":3,"POP_EST":11138234,"POP_RANK":14,"GDP_MD_EST":78350,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"BL","ISO_A2":"BO","ISO_A3":"BOL","ISO_A3_EH":"BOL","ISO_N3":"068","UN_A3":"068","WB_A2":"BO","WB_A3":"BOL","WOE_ID":23424762,"WOE_ID_EH":23424762,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BOL","ADM0_A3_US":"BOL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":7,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7.5},"bbox":[-69.590424,-22.872919,-57.498371,-9.761988],"geometry":{"type":"Polygon","coordinates":[[[-62.685057,-22.249029],[-62.846468,-22.034985],[-63.986838,-21.993644],[-64.377021,-22.798091],[-64.964892,-22.075862],[-66.273339,-21.83231],[-67.106674,-22.735925],[-67.82818,-22.872919],[-68.219913,-21.494347],[-68.757167,-20.372658],[-68.442225,-19.405068],[-68.966818,-18.981683],[-69.100247,-18.260125],[-69.590424,-17.580012],[-68.959635,-16.500698],[-69.389764,-15.660129],[-69.160347,-15.323974],[-69.339535,-14.953195],[-68.948887,-14.453639],[-68.929224,-13.602684],[-68.88008,-12.899729],[-68.66508,-12.5613],[-69.529678,-10.951734],[-68.786158,-11.03638],[-68.271254,-11.014521],[-68.048192,-10.712059],[-67.173801,-10.306812],[-66.646908,-9.931331],[-65.338435,-9.761988],[-65.444837,-10.511451],[-65.321899,-10.895872],[-65.402281,-11.56627],[-64.316353,-12.461978],[-63.196499,-12.627033],[-62.80306,-13.000653],[-62.127081,-13.198781],[-61.713204,-13.489202],[-61.084121,-13.479384],[-60.503304,-13.775955],[-60.459198,-14.354007],[-60.264326,-14.645979],[-60.251149,-15.077219],[-60.542966,-15.09391],[-60.15839,-16.258284],[-58.24122,-16.299573],[-58.388058,-16.877109],[-58.280804,-17.27171],[-57.734558,-17.552468],[-57.498371,-18.174188],[-57.676009,-18.96184],[-57.949997,-19.400004],[-57.853802,-19.969995],[-58.166392,-20.176701],[-58.183471,-19.868399],[-59.115042,-19.356906],[-60.043565,-19.342747],[-61.786326,-19.633737],[-62.265961,-20.513735],[-62.291179,-21.051635],[-62.685057,-22.249029]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Brazil","SOV_A3":"BRA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Brazil","ADM0_A3":"BRA","GEOU_DIF":0,"GEOUNIT":"Brazil","GU_A3":"BRA","SU_DIF":0,"SUBUNIT":"Brazil","SU_A3":"BRA","BRK_DIFF":0,"NAME":"Brazil","NAME_LONG":"Brazil","BRK_A3":"BRA","BRK_NAME":"Brazil","BRK_GROUP":null,"ABBREV":"Brazil","POSTAL":"BR","FORMAL_EN":"Federative Republic of Brazil","FORMAL_FR":null,"NAME_CIAWF":"Brazil","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Brazil","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":6,"MAPCOLOR9":5,"MAPCOLOR13":7,"POP_EST":207353391,"POP_RANK":17,"GDP_MD_EST":3081000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"3. Emerging region: BRIC","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"BR","ISO_A2":"BR","ISO_A3":"BRA","ISO_A3_EH":"BRA","ISO_N3":"076","UN_A3":"076","WB_A2":"BR","WB_A3":"BRA","WOE_ID":23424768,"WOE_ID_EH":23424768,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BRA","ADM0_A3_US":"BRA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":5.7},"bbox":[-73.987235,-33.768378,-34.729993,5.244486],"geometry":{"type":"Polygon","coordinates":[[[-57.625133,-30.216295],[-56.2909,-28.852761],[-55.162286,-27.881915],[-54.490725,-27.474757],[-53.648735,-26.923473],[-53.628349,-26.124865],[-54.13005,-25.547639],[-54.625291,-25.739255],[-54.428946,-25.162185],[-54.293476,-24.5708],[-54.29296,-24.021014],[-54.652834,-23.839578],[-55.027902,-24.001274],[-55.400747,-23.956935],[-55.517639,-23.571998],[-55.610683,-22.655619],[-55.797958,-22.35693],[-56.473317,-22.0863],[-56.88151,-22.282154],[-57.937156,-22.090176],[-57.870674,-20.732688],[-58.166392,-20.176701],[-57.853802,-19.969995],[-57.949997,-19.400004],[-57.676009,-18.96184],[-57.498371,-18.174188],[-57.734558,-17.552468],[-58.280804,-17.27171],[-58.388058,-16.877109],[-58.24122,-16.299573],[-60.15839,-16.258284],[-60.542966,-15.09391],[-60.251149,-15.077219],[-60.264326,-14.645979],[-60.459198,-14.354007],[-60.503304,-13.775955],[-61.084121,-13.479384],[-61.713204,-13.489202],[-62.127081,-13.198781],[-62.80306,-13.000653],[-63.196499,-12.627033],[-64.316353,-12.461978],[-65.402281,-11.56627],[-65.321899,-10.895872],[-65.444837,-10.511451],[-65.338435,-9.761988],[-66.646908,-9.931331],[-67.173801,-10.306812],[-68.048192,-10.712059],[-68.271254,-11.014521],[-68.786158,-11.03638],[-69.529678,-10.951734],[-70.093752,-11.123972],[-70.548686,-11.009147],[-70.481894,-9.490118],[-71.302412,-10.079436],[-72.184891,-10.053598],[-72.563033,-9.520194],[-73.226713,-9.462213],[-73.015383,-9.032833],[-73.571059,-8.424447],[-73.987235,-7.52383],[-73.723401,-7.340999],[-73.724487,-6.918595],[-73.120027,-6.629931],[-73.219711,-6.089189],[-72.964507,-5.741251],[-72.891928,-5.274561],[-71.748406,-4.593983],[-70.928843,-4.401591],[-70.794769,-4.251265],[-69.893635,-4.298187],[-69.444102,-1.556287],[-69.420486,-1.122619],[-69.577065,-0.549992],[-70.020656,-0.185156],[-70.015566,0.541414],[-69.452396,0.706159],[-69.252434,0.602651],[-69.218638,0.985677],[-69.804597,1.089081],[-69.816973,1.714805],[-67.868565,1.692455],[-67.53781,2.037163],[-67.259998,1.719999],[-67.065048,1.130112],[-66.876326,1.253361],[-66.325765,0.724452],[-65.548267,0.789254],[-65.354713,1.095282],[-64.611012,1.328731],[-64.199306,1.492855],[-64.083085,1.916369],[-63.368788,2.2009],[-63.422867,2.411068],[-64.269999,2.497006],[-64.408828,3.126786],[-64.368494,3.79721],[-64.816064,4.056445],[-64.628659,4.148481],[-63.888343,4.02053],[-63.093198,3.770571],[-62.804533,4.006965],[-62.08543,4.162124],[-60.966893,4.536468],[-60.601179,4.918098],[-60.733574,5.200277],[-60.213683,5.244486],[-59.980959,5.014061],[-60.111002,4.574967],[-59.767406,4.423503],[-59.53804,3.958803],[-59.815413,3.606499],[-59.974525,2.755233],[-59.718546,2.24963],[-59.646044,1.786894],[-59.030862,1.317698],[-58.540013,1.268088],[-58.429477,1.463942],[-58.11345,1.507195],[-57.660971,1.682585],[-57.335823,1.948538],[-56.782704,1.863711],[-56.539386,1.899523],[-55.995698,1.817667],[-55.9056,2.021996],[-56.073342,2.220795],[-55.973322,2.510364],[-55.569755,2.421506],[-55.097587,2.523748],[-54.524754,2.311849],[-54.088063,2.105557],[-53.778521,2.376703],[-53.554839,2.334897],[-53.418465,2.053389],[-52.939657,2.124858],[-52.556425,2.504705],[-52.249338,3.241094],[-51.657797,4.156232],[-51.317146,4.203491],[-51.069771,3.650398],[-50.508875,1.901564],[-49.974076,1.736483],[-49.947101,1.04619],[-50.699251,0.222984],[-50.388211,-0.078445],[-48.620567,-0.235489],[-48.584497,-1.237805],[-47.824956,-0.581618],[-46.566584,-0.941028],[-44.905703,-1.55174],[-44.417619,-2.13775],[-44.581589,-2.691308],[-43.418791,-2.38311],[-41.472657,-2.912018],[-39.978665,-2.873054],[-38.500383,-3.700652],[-37.223252,-4.820946],[-36.452937,-5.109404],[-35.597796,-5.149504],[-35.235389,-5.464937],[-34.89603,-6.738193],[-34.729993,-7.343221],[-35.128212,-8.996401],[-35.636967,-9.649282],[-37.046519,-11.040721],[-37.683612,-12.171195],[-38.423877,-13.038119],[-38.673887,-13.057652],[-38.953276,-13.79337],[-38.882298,-15.667054],[-39.161092,-17.208407],[-39.267339,-17.867746],[-39.583521,-18.262296],[-39.760823,-19.599113],[-40.774741,-20.904512],[-40.944756,-21.937317],[-41.754164,-22.370676],[-41.988284,-22.97007],[-43.074704,-22.967693],[-44.647812,-23.351959],[-45.352136,-23.796842],[-46.472093,-24.088969],[-47.648972,-24.885199],[-48.495458,-25.877025],[-48.641005,-26.623698],[-48.474736,-27.175912],[-48.66152,-28.186135],[-48.888457,-28.674115],[-49.587329,-29.224469],[-50.696874,-30.984465],[-51.576226,-31.777698],[-52.256081,-32.24537],[-52.7121,-33.196578],[-53.373662,-33.768378],[-53.650544,-33.202004],[-53.209589,-32.727666],[-53.787952,-32.047243],[-54.572452,-31.494511],[-55.60151,-30.853879],[-55.973245,-30.883076],[-56.976026,-30.109686],[-57.625133,-30.216295]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Brunei","SOV_A3":"BRN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Brunei","ADM0_A3":"BRN","GEOU_DIF":0,"GEOUNIT":"Brunei","GU_A3":"BRN","SU_DIF":0,"SUBUNIT":"Brunei","SU_A3":"BRN","BRK_DIFF":0,"NAME":"Brunei","NAME_LONG":"Brunei Darussalam","BRK_A3":"BRN","BRK_NAME":"Brunei","BRK_GROUP":null,"ABBREV":"Brunei","POSTAL":"BN","FORMAL_EN":"Negara Brunei Darussalam","FORMAL_FR":null,"NAME_CIAWF":"Brunei","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Brunei","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":6,"MAPCOLOR9":6,"MAPCOLOR13":12,"POP_EST":443593,"POP_RANK":10,"GDP_MD_EST":33730,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"BX","ISO_A2":"BN","ISO_A3":"BRN","ISO_A3_EH":"BRN","ISO_N3":"096","UN_A3":"096","WB_A2":"BN","WB_A3":"BRN","WOE_ID":23424773,"WOE_ID_EH":23424773,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BRN","ADM0_A3_US":"BRN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":6,"LONG_LEN":17,"ABBREV_LEN":6,"TINY":2,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[114.204017,4.007637,115.45071,5.44773],"geometry":{"type":"Polygon","coordinates":[[[114.204017,4.525874],[114.599961,4.900011],[115.45071,5.44773],[115.4057,4.955228],[115.347461,4.316636],[114.869557,4.348314],[114.659596,4.007637],[114.204017,4.525874]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Bhutan","SOV_A3":"BTN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Bhutan","ADM0_A3":"BTN","GEOU_DIF":0,"GEOUNIT":"Bhutan","GU_A3":"BTN","SU_DIF":0,"SUBUNIT":"Bhutan","SU_A3":"BTN","BRK_DIFF":0,"NAME":"Bhutan","NAME_LONG":"Bhutan","BRK_A3":"BTN","BRK_NAME":"Bhutan","BRK_GROUP":null,"ABBREV":"Bhutan","POSTAL":"BT","FORMAL_EN":"Kingdom of Bhutan","FORMAL_FR":null,"NAME_CIAWF":"Bhutan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Bhutan","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":6,"MAPCOLOR9":1,"MAPCOLOR13":8,"POP_EST":758288,"POP_RANK":11,"GDP_MD_EST":6432,"POP_YEAR":2017,"LASTCENSUS":2005,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"BT","ISO_A2":"BT","ISO_A3":"BTN","ISO_A3_EH":"BTN","ISO_N3":"064","UN_A3":"064","WB_A2":"BT","WB_A3":"BTN","WOE_ID":23424770,"WOE_ID_EH":23424770,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BTN","ADM0_A3_US":"BTN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"South Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[88.814248,26.719403,92.103712,28.296439],"geometry":{"type":"Polygon","coordinates":[[[91.696657,27.771742],[92.103712,27.452614],[92.033484,26.83831],[91.217513,26.808648],[90.373275,26.875724],[89.744528,26.719403],[88.835643,27.098966],[88.814248,27.299316],[89.47581,28.042759],[90.015829,28.296439],[90.730514,28.064954],[91.258854,28.040614],[91.696657,27.771742]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Botswana","SOV_A3":"BWA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Botswana","ADM0_A3":"BWA","GEOU_DIF":0,"GEOUNIT":"Botswana","GU_A3":"BWA","SU_DIF":0,"SUBUNIT":"Botswana","SU_A3":"BWA","BRK_DIFF":0,"NAME":"Botswana","NAME_LONG":"Botswana","BRK_A3":"BWA","BRK_NAME":"Botswana","BRK_GROUP":null,"ABBREV":"Bwa.","POSTAL":"BW","FORMAL_EN":"Republic of Botswana","FORMAL_FR":null,"NAME_CIAWF":"Botswana","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Botswana","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":5,"MAPCOLOR9":7,"MAPCOLOR13":3,"POP_EST":2214858,"POP_RANK":12,"GDP_MD_EST":35900,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"BC","ISO_A2":"BW","ISO_A3":"BWA","ISO_A3_EH":"BWA","ISO_N3":"072","UN_A3":"072","WB_A2":"BW","WB_A3":"BWA","WOE_ID":23424755,"WOE_ID_EH":23424755,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"BWA","ADM0_A3_US":"BWA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Southern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[19.895458,-26.828543,29.432188,-17.661816],"geometry":{"type":"Polygon","coordinates":[[[29.432188,-22.091313],[28.017236,-22.827754],[27.11941,-23.574323],[26.786407,-24.240691],[26.485753,-24.616327],[25.941652,-24.696373],[25.765849,-25.174845],[25.664666,-25.486816],[25.025171,-25.71967],[24.211267,-25.670216],[23.73357,-25.390129],[23.312097,-25.26869],[22.824271,-25.500459],[22.579532,-25.979448],[22.105969,-26.280256],[21.605896,-26.726534],[20.889609,-26.828543],[20.66647,-26.477453],[20.758609,-25.868136],[20.165726,-24.917962],[19.895768,-24.76779],[19.895458,-21.849157],[20.881134,-21.814327],[20.910641,-18.252219],[21.65504,-18.219146],[23.196858,-17.869038],[23.579006,-18.281261],[24.217365,-17.889347],[24.520705,-17.887125],[25.084443,-17.661816],[25.264226,-17.73654],[25.649163,-18.536026],[25.850391,-18.714413],[26.164791,-19.293086],[27.296505,-20.39152],[27.724747,-20.499059],[27.727228,-20.851802],[28.02137,-21.485975],[28.794656,-21.639454],[29.432188,-22.091313]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Central African Republic","SOV_A3":"CAF","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Central African Republic","ADM0_A3":"CAF","GEOU_DIF":0,"GEOUNIT":"Central African Republic","GU_A3":"CAF","SU_DIF":0,"SUBUNIT":"Central African Republic","SU_A3":"CAF","BRK_DIFF":0,"NAME":"Central African Rep.","NAME_LONG":"Central African Republic","BRK_A3":"CAF","BRK_NAME":"Central African Rep.","BRK_GROUP":null,"ABBREV":"C.A.R.","POSTAL":"CF","FORMAL_EN":"Central African Republic","FORMAL_FR":null,"NAME_CIAWF":"Central African Republic","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Central African Republic","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":6,"MAPCOLOR9":6,"MAPCOLOR13":9,"POP_EST":5625118,"POP_RANK":13,"GDP_MD_EST":3206,"POP_YEAR":2017,"LASTCENSUS":2003,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"CT","ISO_A2":"CF","ISO_A3":"CAF","ISO_A3_EH":"CAF","ISO_N3":"140","UN_A3":"140","WB_A2":"CF","WB_A3":"CAF","WOE_ID":23424792,"WOE_ID_EH":23424792,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CAF","ADM0_A3_US":"CAF","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":20,"LONG_LEN":24,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[14.459407,2.26764,27.374226,11.142395],"geometry":{"type":"Polygon","coordinates":[[[15.27946,7.421925],[16.106232,7.497088],[16.290562,7.754307],[16.456185,7.734774],[16.705988,7.508328],[17.96493,7.890914],[18.389555,8.281304],[18.911022,8.630895],[18.81201,8.982915],[19.094008,9.074847],[20.059685,9.012706],[21.000868,9.475985],[21.723822,10.567056],[22.231129,10.971889],[22.864165,11.142395],[22.977544,10.714463],[23.554304,10.089255],[23.55725,9.681218],[23.394779,9.265068],[23.459013,8.954286],[23.805813,8.666319],[24.567369,8.229188],[25.114932,7.825104],[25.124131,7.500085],[25.796648,6.979316],[26.213418,6.546603],[26.465909,5.946717],[27.213409,5.550953],[27.374226,5.233944],[27.044065,5.127853],[26.402761,5.150875],[25.650455,5.256088],[25.278798,5.170408],[25.128833,4.927245],[24.805029,4.897247],[24.410531,5.108784],[23.297214,4.609693],[22.84148,4.710126],[22.704124,4.633051],[22.405124,4.02916],[21.659123,4.224342],[20.927591,4.322786],[20.290679,4.691678],[19.467784,5.031528],[18.932312,4.709506],[18.542982,4.201785],[18.453065,3.504386],[17.8099,3.560196],[17.133042,3.728197],[16.537058,3.198255],[16.012852,2.26764],[15.907381,2.557389],[15.862732,3.013537],[15.405396,3.335301],[15.03622,3.851367],[14.950953,4.210389],[14.478372,4.732605],[14.558936,5.030598],[14.459407,5.451761],[14.53656,6.226959],[14.776545,6.408498],[15.27946,7.421925]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Canada","SOV_A3":"CAN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Canada","ADM0_A3":"CAN","GEOU_DIF":0,"GEOUNIT":"Canada","GU_A3":"CAN","SU_DIF":0,"SUBUNIT":"Canada","SU_A3":"CAN","BRK_DIFF":0,"NAME":"Canada","NAME_LONG":"Canada","BRK_A3":"CAN","BRK_NAME":"Canada","BRK_GROUP":null,"ABBREV":"Can.","POSTAL":"CA","FORMAL_EN":"Canada","FORMAL_FR":null,"NAME_CIAWF":"Canada","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Canada","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":6,"MAPCOLOR9":2,"MAPCOLOR13":2,"POP_EST":35623680,"POP_RANK":15,"GDP_MD_EST":1674000,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"1. Developed region: G7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"CA","ISO_A2":"CA","ISO_A3":"CAN","ISO_A3_EH":"CAN","ISO_N3":"124","UN_A3":"124","WB_A2":"CA","WB_A3":"CAN","WOE_ID":23424775,"WOE_ID_EH":23424775,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CAN","ADM0_A3_US":"CAN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Northern America","REGION_WB":"North America","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":5.7},"bbox":[-140.99778,41.675105,-52.648099,83.23324],"geometry":{"type":"MultiPolygon","coordinates":[[[[-63.6645,46.55001],[-62.9393,46.41587],[-62.01208,46.44314],[-62.50391,46.03339],[-62.87433,45.96818],[-64.1428,46.39265],[-64.39261,46.72747],[-64.01486,47.03601],[-63.6645,46.55001]]],[[[-61.806305,49.10506],[-62.29318,49.08717],[-63.58926,49.40069],[-64.51912,49.87304],[-64.17322,49.95718],[-62.85829,49.70641],[-61.835585,49.28855],[-61.806305,49.10506]]],[[[-123.510002,48.510011],[-124.012891,48.370846],[-125.655013,48.825005],[-125.954994,49.179996],[-126.850004,49.53],[-127.029993,49.814996],[-128.059336,49.994959],[-128.444584,50.539138],[-128.358414,50.770648],[-127.308581,50.552574],[-126.695001,50.400903],[-125.755007,50.295018],[-125.415002,49.950001],[-124.920768,49.475275],[-123.922509,49.062484],[-123.510002,48.510011]]],[[[-56.134036,50.68701],[-56.795882,49.812309],[-56.143105,50.150117],[-55.471492,49.935815],[-55.822401,49.587129],[-54.935143,49.313011],[-54.473775,49.556691],[-53.476549,49.249139],[-53.786014,48.516781],[-53.086134,48.687804],[-52.958648,48.157164],[-52.648099,47.535548],[-53.069158,46.655499],[-53.521456,46.618292],[-54.178936,46.807066],[-53.961869,47.625207],[-54.240482,47.752279],[-55.400773,46.884994],[-55.997481,46.91972],[-55.291219,47.389562],[-56.250799,47.632545],[-57.325229,47.572807],[-59.266015,47.603348],[-59.419494,47.899454],[-58.796586,48.251525],[-59.231625,48.523188],[-58.391805,49.125581],[-57.35869,50.718274],[-56.73865,51.287438],[-55.870977,51.632094],[-55.406974,51.588273],[-55.600218,51.317075],[-56.134036,50.68701]]],[[[-133.180004,54.169975],[-132.710008,54.040009],[-131.74999,54.120004],[-132.04948,52.984621],[-131.179043,52.180433],[-131.57783,52.182371],[-132.180428,52.639707],[-132.549992,53.100015],[-133.054611,53.411469],[-133.239664,53.85108],[-133.180004,54.169975]]],[[[-79.26582,62.158675],[-79.65752,61.63308],[-80.09956,61.7181],[-80.36215,62.01649],[-80.315395,62.085565],[-79.92939,62.3856],[-79.52002,62.36371],[-79.26582,62.158675]]],[[[-81.89825,62.7108],[-83.06857,62.15922],[-83.77462,62.18231],[-83.99367,62.4528],[-83.25048,62.91409],[-81.87699,62.90458],[-81.89825,62.7108]]],[[[-85.161308,65.657285],[-84.975764,65.217518],[-84.464012,65.371772],[-83.882626,65.109618],[-82.787577,64.766693],[-81.642014,64.455136],[-81.55344,63.979609],[-80.817361,64.057486],[-80.103451,63.725981],[-80.99102,63.411246],[-82.547178,63.651722],[-83.108798,64.101876],[-84.100417,63.569712],[-85.523405,63.052379],[-85.866769,63.637253],[-87.221983,63.541238],[-86.35276,64.035833],[-86.224886,64.822917],[-85.883848,65.738778],[-85.161308,65.657285]]],[[[-75.86588,67.14886],[-76.98687,67.09873],[-77.2364,67.58809],[-76.81166,68.14856],[-75.89521,68.28721],[-75.1145,68.01036],[-75.10333,67.58202],[-75.21597,67.44425],[-75.86588,67.14886]]],[[[-95.647681,69.10769],[-96.269521,68.75704],[-97.617401,69.06003],[-98.431801,68.9507],[-99.797401,69.40003],[-98.917401,69.71003],[-98.218261,70.14354],[-97.157401,69.86003],[-96.557401,69.68003],[-96.257401,69.49003],[-95.647681,69.10769]]],[[[-68.23444,47.35486],[-68.905,47.185],[-69.237216,47.447781],[-69.99997,46.69307],[-70.305,45.915],[-70.66,45.46],[-71.08482,45.30524],[-71.405,45.255],[-71.50506,45.0082],[-73.34783,45.00738],[-74.867,45.00048],[-75.31821,44.81645],[-76.375,44.09631],[-76.5,44.018459],[-76.820034,43.628784],[-77.737885,43.629056],[-78.72028,43.625089],[-79.171674,43.466339],[-79.01,43.27],[-78.92,42.965],[-78.939362,42.863611],[-80.247448,42.3662],[-81.277747,42.209026],[-82.439278,41.675105],[-82.690089,41.675105],[-83.02981,41.832796],[-83.142,41.975681],[-83.12,42.08],[-82.9,42.43],[-82.43,42.98],[-82.137642,43.571088],[-82.337763,44.44],[-82.550925,45.347517],[-83.592851,45.816894],[-83.469551,45.994686],[-83.616131,46.116927],[-83.890765,46.116927],[-84.091851,46.275419],[-84.14212,46.512226],[-84.3367,46.40877],[-84.6049,46.4396],[-84.543749,46.538684],[-84.779238,46.637102],[-84.87608,46.900083],[-85.652363,47.220219],[-86.461991,47.553338],[-87.439793,47.94],[-88.378114,48.302918],[-89.272917,48.019808],[-89.6,48.01],[-90.83,48.27],[-91.64,48.14],[-92.61,48.45],[-93.63087,48.60926],[-94.32914,48.67074],[-94.64,48.84],[-94.81758,49.38905],[-95.15609,49.38425],[-95.15907,49],[-97.22872,49.0007],[-100.65,49],[-104.04826,48.99986],[-107.05,49],[-110.05,49],[-113,49],[-116.04818,49],[-117.03121,49],[-120,49],[-122.84,49],[-122.97421,49.002538],[-124.91024,49.98456],[-125.62461,50.41656],[-127.43561,50.83061],[-127.99276,51.71583],[-127.85032,52.32961],[-129.12979,52.75538],[-129.30523,53.56159],[-130.51497,54.28757],[-130.536109,54.802754],[-130.53611,54.80278],[-129.98,55.285],[-130.00778,55.91583],[-131.70781,56.55212],[-132.73042,57.69289],[-133.35556,58.41028],[-134.27111,58.86111],[-134.945,59.27056],[-135.47583,59.78778],[-136.47972,59.46389],[-137.4525,58.905],[-138.34089,59.56211],[-139.039,60],[-140.013,60.27682],[-140.99778,60.30639],[-140.9925,66.00003],[-140.986,69.712],[-140.985988,69.711998],[-139.12052,69.47102],[-137.54636,68.99002],[-136.50358,68.89804],[-135.62576,69.31512],[-134.41464,69.62743],[-132.92925,69.50534],[-131.43136,69.94451],[-129.79471,70.19369],[-129.10773,69.77927],[-128.36156,70.01286],[-128.13817,70.48384],[-127.44712,70.37721],[-125.75632,69.48058],[-124.42483,70.1584],[-124.28968,69.39969],[-123.06108,69.56372],[-122.6835,69.85553],[-121.47226,69.79778],[-119.94288,69.37786],[-117.60268,69.01128],[-116.22643,68.84151],[-115.2469,68.90591],[-113.89794,68.3989],[-115.30489,67.90261],[-113.49727,67.68815],[-110.798,67.80612],[-109.94619,67.98104],[-108.8802,67.38144],[-107.79239,67.88736],[-108.81299,68.31164],[-108.16721,68.65392],[-106.95,68.7],[-106.15,68.8],[-105.34282,68.56122],[-104.33791,68.018],[-103.22115,68.09775],[-101.45433,67.64689],[-99.90195,67.80566],[-98.4432,67.78165],[-98.5586,68.40394],[-97.66948,68.57864],[-96.11991,68.23939],[-96.12588,67.29338],[-95.48943,68.0907],[-94.685,68.06383],[-94.23282,69.06903],[-95.30408,69.68571],[-96.47131,70.08976],[-96.39115,71.19482],[-95.2088,71.92053],[-93.88997,71.76015],[-92.87818,71.31869],[-91.51964,70.19129],[-92.40692,69.69997],[-90.5471,69.49766],[-90.55151,68.47499],[-89.21515,69.25873],[-88.01966,68.61508],[-88.31749,67.87338],[-87.35017,67.19872],[-86.30607,67.92146],[-85.57664,68.78456],[-85.52197,69.88211],[-84.10081,69.80539],[-82.62258,69.65826],[-81.28043,69.16202],[-81.2202,68.66567],[-81.96436,68.13253],[-81.25928,67.59716],[-81.38653,67.11078],[-83.34456,66.41154],[-84.73542,66.2573],[-85.76943,66.55833],[-86.0676,66.05625],[-87.03143,65.21297],[-87.32324,64.77563],[-88.48296,64.09897],[-89.91444,64.03273],[-90.70398,63.61017],[-90.77004,62.96021],[-91.93342,62.83508],[-93.15698,62.02469],[-94.24153,60.89865],[-94.62931,60.11021],[-94.6846,58.94882],[-93.21502,58.78212],[-92.76462,57.84571],[-92.29703,57.08709],[-90.89769,57.28468],[-89.03953,56.85172],[-88.03978,56.47162],[-87.32421,55.99914],[-86.07121,55.72383],[-85.01181,55.3026],[-83.36055,55.24489],[-82.27285,55.14832],[-82.4362,54.28227],[-82.12502,53.27703],[-81.40075,52.15788],[-79.91289,51.20842],[-79.14301,51.53393],[-78.60191,52.56208],[-79.12421,54.14145],[-79.82958,54.66772],[-78.22874,55.13645],[-77.0956,55.83741],[-76.54137,56.53423],[-76.62319,57.20263],[-77.30226,58.05209],[-78.51688,58.80458],[-77.33676,59.85261],[-77.77272,60.75788],[-78.10687,62.31964],[-77.41067,62.55053],[-75.69621,62.2784],[-74.6682,62.18111],[-73.83988,62.4438],[-72.90853,62.10507],[-71.67708,61.52535],[-71.37369,61.13717],[-69.59042,61.06141],[-69.62033,60.22125],[-69.2879,58.95736],[-68.37455,58.80106],[-67.64976,58.21206],[-66.20178,58.76731],[-65.24517,59.87071],[-64.58352,60.33558],[-63.80475,59.4426],[-62.50236,58.16708],[-61.39655,56.96745],[-61.79866,56.33945],[-60.46853,55.77548],[-59.56962,55.20407],[-57.97508,54.94549],[-57.3332,54.6265],[-56.93689,53.78032],[-56.15811,53.64749],[-55.75632,53.27036],[-55.68338,52.14664],[-56.40916,51.7707],[-57.12691,51.41972],[-58.77482,51.0643],[-60.03309,50.24277],[-61.72366,50.08046],[-63.86251,50.29099],[-65.36331,50.2982],[-66.39905,50.22897],[-67.23631,49.51156],[-68.51114,49.06836],[-69.95362,47.74488],[-71.10458,46.82171],[-70.25522,46.98606],[-68.65,48.3],[-66.55243,49.1331],[-65.05626,49.23278],[-64.17099,48.74248],[-65.11545,48.07085],[-64.79854,46.99297],[-64.47219,46.23849],[-63.17329,45.73902],[-61.52072,45.88377],[-60.51815,47.00793],[-60.4486,46.28264],[-59.80287,45.9204],[-61.03988,45.26525],[-63.25471,44.67014],[-64.24656,44.26553],[-65.36406,43.54523],[-66.1234,43.61867],[-66.16173,44.46512],[-64.42549,45.29204],[-66.02605,45.25931],[-67.13741,45.13753],[-67.79134,45.70281],[-67.79046,47.06636],[-68.23444,47.35486]]],[[[-114.16717,73.12145],[-114.66634,72.65277],[-112.44102,72.9554],[-111.05039,72.4504],[-109.92035,72.96113],[-109.00654,72.63335],[-108.18835,71.65089],[-107.68599,72.06548],[-108.39639,73.08953],[-107.51645,73.23598],[-106.52259,73.07601],[-105.40246,72.67259],[-104.77484,71.6984],[-104.46476,70.99297],[-102.78537,70.49776],[-100.98078,70.02432],[-101.08929,69.58447],[-102.73116,69.50402],[-102.09329,69.11962],[-102.43024,68.75282],[-104.24,68.91],[-105.96,69.18],[-107.12254,69.11922],[-109,68.78],[-111.534149,68.630059],[-113.3132,68.53554],[-113.85496,69.00744],[-115.22,69.28],[-116.10794,69.16821],[-117.34,69.96],[-116.67473,70.06655],[-115.13112,70.2373],[-113.72141,70.19237],[-112.4161,70.36638],[-114.35,70.6],[-116.48684,70.52045],[-117.9048,70.54056],[-118.43238,70.9092],[-116.11311,71.30918],[-117.65568,71.2952],[-119.40199,71.55859],[-118.56267,72.30785],[-117.86642,72.70594],[-115.18909,73.31459],[-114.16717,73.12145]]],[[[-104.5,73.42],[-105.38,72.76],[-106.94,73.46],[-106.6,73.6],[-105.26,73.64],[-104.5,73.42]]],[[[-76.34,73.102685],[-76.251404,72.826385],[-77.314438,72.855545],[-78.39167,72.876656],[-79.486252,72.742203],[-79.775833,72.802902],[-80.876099,73.333183],[-80.833885,73.693184],[-80.353058,73.75972],[-78.064438,73.651932],[-76.34,73.102685]]],[[[-86.562179,73.157447],[-85.774371,72.534126],[-84.850112,73.340278],[-82.31559,73.750951],[-80.600088,72.716544],[-80.748942,72.061907],[-78.770639,72.352173],[-77.824624,72.749617],[-75.605845,72.243678],[-74.228616,71.767144],[-74.099141,71.33084],[-72.242226,71.556925],[-71.200015,70.920013],[-68.786054,70.525024],[-67.91497,70.121948],[-66.969033,69.186087],[-68.805123,68.720198],[-66.449866,68.067163],[-64.862314,67.847539],[-63.424934,66.928473],[-61.851981,66.862121],[-62.163177,66.160251],[-63.918444,64.998669],[-65.14886,65.426033],[-66.721219,66.388041],[-68.015016,66.262726],[-68.141287,65.689789],[-67.089646,65.108455],[-65.73208,64.648406],[-65.320168,64.382737],[-64.669406,63.392927],[-65.013804,62.674185],[-66.275045,62.945099],[-68.783186,63.74567],[-67.369681,62.883966],[-66.328297,62.280075],[-66.165568,61.930897],[-68.877367,62.330149],[-71.023437,62.910708],[-72.235379,63.397836],[-71.886278,63.679989],[-73.378306,64.193963],[-74.834419,64.679076],[-74.818503,64.389093],[-77.70998,64.229542],[-78.555949,64.572906],[-77.897281,65.309192],[-76.018274,65.326969],[-73.959795,65.454765],[-74.293883,65.811771],[-73.944912,66.310578],[-72.651167,67.284576],[-72.92606,67.726926],[-73.311618,68.069437],[-74.843307,68.554627],[-76.869101,68.894736],[-76.228649,69.147769],[-77.28737,69.76954],[-78.168634,69.826488],[-78.957242,70.16688],[-79.492455,69.871808],[-81.305471,69.743185],[-84.944706,69.966634],[-87.060003,70.260001],[-88.681713,70.410741],[-89.51342,70.762038],[-88.467721,71.218186],[-89.888151,71.222552],[-90.20516,72.235074],[-89.436577,73.129464],[-88.408242,73.537889],[-85.826151,73.803816],[-86.562179,73.157447]]],[[[-100.35642,73.84389],[-99.16387,73.63339],[-97.38,73.76],[-97.12,73.47],[-98.05359,72.99052],[-96.54,72.56],[-96.72,71.66],[-98.35966,71.27285],[-99.32286,71.35639],[-100.01482,71.73827],[-102.5,72.51],[-102.48,72.83],[-100.43836,72.70588],[-101.54,73.36],[-100.35642,73.84389]]],[[[-93.196296,72.771992],[-94.269047,72.024596],[-95.409856,72.061881],[-96.033745,72.940277],[-96.018268,73.43743],[-95.495793,73.862417],[-94.503658,74.134907],[-92.420012,74.100025],[-90.509793,73.856732],[-92.003965,72.966244],[-93.196296,72.771992]]],[[[-120.46,71.383602],[-123.09219,70.90164],[-123.62,71.34],[-125.928949,71.868688],[-125.5,72.292261],[-124.80729,73.02256],[-123.94,73.68],[-124.91775,74.29275],[-121.53788,74.44893],[-120.10978,74.24135],[-117.55564,74.18577],[-116.58442,73.89607],[-115.51081,73.47519],[-116.76794,73.22292],[-119.22,72.52],[-120.46,71.82],[-120.46,71.383602]]],[[[-93.612756,74.979997],[-94.156909,74.592347],[-95.608681,74.666864],[-96.820932,74.927623],[-96.288587,75.377828],[-94.85082,75.647218],[-93.977747,75.29649],[-93.612756,74.979997]]],[[[-98.5,76.72],[-97.735585,76.25656],[-97.704415,75.74344],[-98.16,75],[-99.80874,74.89744],[-100.88366,75.05736],[-100.86292,75.64075],[-102.50209,75.5638],[-102.56552,76.3366],[-101.48973,76.30537],[-99.98349,76.64634],[-98.57699,76.58859],[-98.5,76.72]]],[[[-108.21141,76.20168],[-107.81943,75.84552],[-106.92893,76.01282],[-105.881,75.9694],[-105.70498,75.47951],[-106.31347,75.00527],[-109.7,74.85],[-112.22307,74.41696],[-113.74381,74.39427],[-113.87135,74.72029],[-111.79421,75.1625],[-116.31221,75.04343],[-117.7104,75.2222],[-116.34602,76.19903],[-115.40487,76.47887],[-112.59056,76.14134],[-110.81422,75.54919],[-109.0671,75.47321],[-110.49726,76.42982],[-109.5811,76.79417],[-108.54859,76.67832],[-108.21141,76.20168]]],[[[-94.684086,77.097878],[-93.573921,76.776296],[-91.605023,76.778518],[-90.741846,76.449597],[-90.969661,76.074013],[-89.822238,75.847774],[-89.187083,75.610166],[-87.838276,75.566189],[-86.379192,75.482421],[-84.789625,75.699204],[-82.753445,75.784315],[-81.128531,75.713983],[-80.057511,75.336849],[-79.833933,74.923127],[-80.457771,74.657304],[-81.948843,74.442459],[-83.228894,74.564028],[-86.097452,74.410032],[-88.15035,74.392307],[-89.764722,74.515555],[-92.422441,74.837758],[-92.768285,75.38682],[-92.889906,75.882655],[-93.893824,76.319244],[-95.962457,76.441381],[-97.121379,76.751078],[-96.745123,77.161389],[-94.684086,77.097878]]],[[[-116.198587,77.645287],[-116.335813,76.876962],[-117.106051,76.530032],[-118.040412,76.481172],[-119.899318,76.053213],[-121.499995,75.900019],[-122.854924,76.116543],[-122.854925,76.116543],[-121.157535,76.864508],[-119.103939,77.51222],[-117.570131,77.498319],[-116.198587,77.645287]]],[[[-93.840003,77.519997],[-94.295608,77.491343],[-96.169654,77.555111],[-96.436304,77.834629],[-94.422577,77.820005],[-93.720656,77.634331],[-93.840003,77.519997]]],[[[-110.186938,77.697015],[-112.051191,77.409229],[-113.534279,77.732207],[-112.724587,78.05105],[-111.264443,78.152956],[-109.854452,77.996325],[-110.186938,77.697015]]],[[[-109.663146,78.601973],[-110.881314,78.40692],[-112.542091,78.407902],[-112.525891,78.550555],[-111.50001,78.849994],[-110.963661,78.804441],[-109.663146,78.601973]]],[[[-95.830295,78.056941],[-97.309843,77.850597],[-98.124289,78.082857],[-98.552868,78.458105],[-98.631984,78.87193],[-97.337231,78.831984],[-96.754399,78.765813],[-95.559278,78.418315],[-95.830295,78.056941]]],[[[-100.060192,78.324754],[-99.670939,77.907545],[-101.30394,78.018985],[-102.949809,78.343229],[-105.176133,78.380332],[-104.210429,78.67742],[-105.41958,78.918336],[-105.492289,79.301594],[-103.529282,79.165349],[-100.825158,78.800462],[-100.060192,78.324754]]],[[[-87.02,79.66],[-85.81435,79.3369],[-87.18756,79.0393],[-89.03535,78.28723],[-90.80436,78.21533],[-92.87669,78.34333],[-93.95116,78.75099],[-93.93574,79.11373],[-93.14524,79.3801],[-94.974,79.37248],[-96.07614,79.70502],[-96.70972,80.15777],[-96.01644,80.60233],[-95.32345,80.90729],[-94.29843,80.97727],[-94.73542,81.20646],[-92.40984,81.25739],[-91.13289,80.72345],[-89.45,80.509322],[-87.81,80.32],[-87.02,79.66]]],[[[-68.5,83.106322],[-65.82735,83.02801],[-63.68,82.9],[-61.85,82.6286],[-61.89388,82.36165],[-64.334,81.92775],[-66.75342,81.72527],[-67.65755,81.50141],[-65.48031,81.50657],[-67.84,80.9],[-69.4697,80.61683],[-71.18,79.8],[-73.2428,79.63415],[-73.88,79.430162],[-76.90773,79.32309],[-75.52924,79.19766],[-76.22046,79.01907],[-75.39345,78.52581],[-76.34354,78.18296],[-77.88851,77.89991],[-78.36269,77.50859],[-79.75951,77.20968],[-79.61965,76.98336],[-77.91089,77.022045],[-77.88911,76.777955],[-80.56125,76.17812],[-83.17439,76.45403],[-86.11184,76.29901],[-87.6,76.42],[-89.49068,76.47239],[-89.6161,76.95213],[-87.76739,77.17833],[-88.26,77.9],[-87.65,77.970222],[-84.97634,77.53873],[-86.34,78.18],[-87.96192,78.37181],[-87.15198,78.75867],[-85.37868,78.9969],[-85.09495,79.34543],[-86.50734,79.73624],[-86.93179,80.25145],[-84.19844,80.20836],[-83.408696,80.1],[-81.84823,80.46442],[-84.1,80.58],[-87.59895,80.51627],[-89.36663,80.85569],[-90.2,81.26],[-91.36786,81.5531],[-91.58702,81.89429],[-90.1,82.085],[-88.93227,82.11751],[-86.97024,82.27961],[-85.5,82.652273],[-84.260005,82.6],[-83.18,82.32],[-82.42,82.86],[-81.1,83.02],[-79.30664,83.13056],[-76.25,83.172059],[-75.71878,83.06404],[-72.83153,83.23324],[-70.665765,83.169781],[-68.5,83.106322]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Switzerland","SOV_A3":"CHE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Switzerland","ADM0_A3":"CHE","GEOU_DIF":0,"GEOUNIT":"Switzerland","GU_A3":"CHE","SU_DIF":0,"SUBUNIT":"Switzerland","SU_A3":"CHE","BRK_DIFF":0,"NAME":"Switzerland","NAME_LONG":"Switzerland","BRK_A3":"CHE","BRK_NAME":"Switzerland","BRK_GROUP":null,"ABBREV":"Switz.","POSTAL":"CH","FORMAL_EN":"Swiss Confederation","FORMAL_FR":null,"NAME_CIAWF":"Switzerland","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Switzerland","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":2,"MAPCOLOR9":7,"MAPCOLOR13":3,"POP_EST":8236303,"POP_RANK":13,"GDP_MD_EST":496300,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"SZ","ISO_A2":"CH","ISO_A3":"CHE","ISO_A3_EH":"CHE","ISO_N3":"756","UN_A3":"756","WB_A2":"CH","WB_A3":"CHE","WOE_ID":23424957,"WOE_ID_EH":23424957,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CHE","ADM0_A3_US":"CHE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Western Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[6.022609,45.776948,10.442701,47.830828],"geometry":{"type":"Polygon","coordinates":[[[9.594226,47.525058],[9.632932,47.347601],[9.47997,47.10281],[9.932448,46.920728],[10.442701,46.893546],[10.363378,46.483571],[9.922837,46.314899],[9.182882,46.440215],[8.966306,46.036932],[8.489952,46.005151],[8.31663,46.163642],[7.755992,45.82449],[7.273851,45.776948],[6.843593,45.991147],[6.5001,46.429673],[6.022609,46.27299],[6.037389,46.725779],[6.768714,47.287708],[6.736571,47.541801],[7.192202,47.449766],[7.466759,47.620582],[8.317301,47.61358],[8.522612,47.830828],[9.594226,47.525058]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Chile","SOV_A3":"CHL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Chile","ADM0_A3":"CHL","GEOU_DIF":0,"GEOUNIT":"Chile","GU_A3":"CHL","SU_DIF":0,"SUBUNIT":"Chile","SU_A3":"CHL","BRK_DIFF":0,"NAME":"Chile","NAME_LONG":"Chile","BRK_A3":"CHL","BRK_NAME":"Chile","BRK_GROUP":null,"ABBREV":"Chile","POSTAL":"CL","FORMAL_EN":"Republic of Chile","FORMAL_FR":null,"NAME_CIAWF":"Chile","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Chile","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":1,"MAPCOLOR9":5,"MAPCOLOR13":9,"POP_EST":17789267,"POP_RANK":14,"GDP_MD_EST":436100,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"CI","ISO_A2":"CL","ISO_A3":"CHL","ISO_A3_EH":"CHL","ISO_N3":"152","UN_A3":"152","WB_A2":"CL","WB_A3":"CHL","WOE_ID":23424782,"WOE_ID_EH":23424782,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CHL","ADM0_A3_US":"CHL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[-75.644395,-55.61183,-66.95992,-17.580012],"geometry":{"type":"MultiPolygon","coordinates":[[[[-68.63401,-52.63637],[-68.63335,-54.8695],[-67.56244,-54.87001],[-66.95992,-54.89681],[-67.29103,-55.30124],[-68.14863,-55.61183],[-68.639991,-55.580018],[-69.2321,-55.49906],[-69.95809,-55.19843],[-71.00568,-55.05383],[-72.2639,-54.49514],[-73.2852,-53.95752],[-74.66253,-52.83749],[-73.8381,-53.04743],[-72.43418,-53.7154],[-71.10773,-54.07433],[-70.59178,-53.61583],[-70.26748,-52.93123],[-69.34565,-52.5183],[-68.63401,-52.63637]]],[[[-67.106674,-22.735925],[-66.985234,-22.986349],[-67.328443,-24.025303],[-68.417653,-24.518555],[-68.386001,-26.185016],[-68.5948,-26.506909],[-68.295542,-26.89934],[-69.001235,-27.521214],[-69.65613,-28.459141],[-70.01355,-29.367923],[-69.919008,-30.336339],[-70.535069,-31.36501],[-70.074399,-33.09121],[-69.814777,-33.273886],[-69.817309,-34.193571],[-70.388049,-35.169688],[-70.364769,-36.005089],[-71.121881,-36.658124],[-71.118625,-37.576827],[-70.814664,-38.552995],[-71.413517,-38.916022],[-71.680761,-39.808164],[-71.915734,-40.832339],[-71.746804,-42.051386],[-72.148898,-42.254888],[-71.915424,-43.408565],[-71.464056,-43.787611],[-71.793623,-44.207172],[-71.329801,-44.407522],[-71.222779,-44.784243],[-71.659316,-44.973689],[-71.552009,-45.560733],[-71.917258,-46.884838],[-72.447355,-47.738533],[-72.331161,-48.244238],[-72.648247,-48.878618],[-73.415436,-49.318436],[-73.328051,-50.378785],[-72.975747,-50.74145],[-72.309974,-50.67701],[-72.329404,-51.425956],[-71.914804,-52.009022],[-69.498362,-52.142761],[-68.571545,-52.299444],[-69.461284,-52.291951],[-69.94278,-52.537931],[-70.845102,-52.899201],[-71.006332,-53.833252],[-71.429795,-53.856455],[-72.557943,-53.53141],[-73.702757,-52.835069],[-73.702757,-52.83507],[-74.946763,-52.262754],[-75.260026,-51.629355],[-74.976632,-51.043396],[-75.479754,-50.378372],[-75.608015,-48.673773],[-75.18277,-47.711919],[-74.126581,-46.939253],[-75.644395,-46.647643],[-74.692154,-45.763976],[-74.351709,-44.103044],[-73.240356,-44.454961],[-72.717804,-42.383356],[-73.3889,-42.117532],[-73.701336,-43.365776],[-74.331943,-43.224958],[-74.017957,-41.794813],[-73.677099,-39.942213],[-73.217593,-39.258689],[-73.505559,-38.282883],[-73.588061,-37.156285],[-73.166717,-37.12378],[-72.553137,-35.50884],[-71.861732,-33.909093],[-71.43845,-32.418899],[-71.668721,-30.920645],[-71.370083,-30.095682],[-71.489894,-28.861442],[-70.905124,-27.64038],[-70.724954,-25.705924],[-70.403966,-23.628997],[-70.091246,-21.393319],[-70.16442,-19.756468],[-70.372572,-18.347975],[-69.858444,-18.092694],[-69.590424,-17.580012],[-69.100247,-18.260125],[-68.966818,-18.981683],[-68.442225,-19.405068],[-68.757167,-20.372658],[-68.219913,-21.494347],[-67.82818,-22.872919],[-67.106674,-22.735925]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"China","SOV_A3":"CH1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"China","ADM0_A3":"CHN","GEOU_DIF":0,"GEOUNIT":"China","GU_A3":"CHN","SU_DIF":0,"SUBUNIT":"China","SU_A3":"CHN","BRK_DIFF":0,"NAME":"China","NAME_LONG":"China","BRK_A3":"CHN","BRK_NAME":"China","BRK_GROUP":null,"ABBREV":"China","POSTAL":"CN","FORMAL_EN":"People's Republic of China","FORMAL_FR":null,"NAME_CIAWF":"China","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"China","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":4,"MAPCOLOR9":4,"MAPCOLOR13":3,"POP_EST":1379302771,"POP_RANK":18,"GDP_MD_EST":21140000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"3. Emerging region: BRIC","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"CH","ISO_A2":"CN","ISO_A3":"CHN","ISO_A3_EH":"CHN","ISO_N3":"156","UN_A3":"156","WB_A2":"CN","WB_A3":"CHN","WOE_ID":23424781,"WOE_ID_EH":23424781,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CHN","ADM0_A3_US":"CHN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":5.7},"bbox":[73.675379,18.197701,135.026311,53.4588],"geometry":{"type":"MultiPolygon","coordinates":[[[[75.158028,37.133031],[74.980002,37.41999],[74.829986,37.990007],[74.864816,38.378846],[74.257514,38.606507],[73.928852,38.505815],[73.675379,39.431237],[73.960013,39.660008],[73.822244,39.893973],[74.776862,40.366425],[75.467828,40.562072],[76.526368,40.427946],[76.904484,41.066486],[78.187197,41.185316],[78.543661,41.582243],[80.11943,42.123941],[80.25999,42.349999],[80.18015,42.920068],[80.866206,43.180362],[79.966106,44.917517],[81.947071,45.317027],[82.458926,45.53965],[83.180484,47.330031],[85.16429,47.000956],[85.720484,47.452969],[85.768233,48.455751],[86.598776,48.549182],[87.35997,49.214981],[87.751264,49.297198],[88.013832,48.599463],[88.854298,48.069082],[90.280826,47.693549],[90.970809,46.888146],[90.585768,45.719716],[90.94554,45.286073],[92.133891,45.115076],[93.480734,44.975472],[94.688929,44.352332],[95.306875,44.241331],[95.762455,43.319449],[96.349396,42.725635],[97.451757,42.74889],[99.515817,42.524691],[100.845866,42.663804],[101.83304,42.514873],[103.312278,41.907468],[104.522282,41.908347],[104.964994,41.59741],[106.129316,42.134328],[107.744773,42.481516],[109.243596,42.519446],[110.412103,42.871234],[111.129682,43.406834],[111.829588,43.743118],[111.667737,44.073176],[111.348377,44.457442],[111.873306,45.102079],[112.436062,45.011646],[113.463907,44.808893],[114.460332,45.339817],[115.985096,45.727235],[116.717868,46.388202],[117.421701,46.672733],[118.874326,46.805412],[119.66327,46.69268],[119.772824,47.048059],[118.866574,47.74706],[118.064143,48.06673],[117.295507,47.697709],[116.308953,47.85341],[115.742837,47.726545],[115.485282,48.135383],[116.191802,49.134598],[116.678801,49.888531],[117.879244,49.510983],[119.288461,50.142883],[119.27939,50.58292],[120.18208,51.64355],[120.7382,51.96411],[120.725789,52.516226],[120.177089,52.753886],[121.003085,53.251401],[122.245748,53.431726],[123.57147,53.4588],[125.068211,53.161045],[125.946349,52.792799],[126.564399,51.784255],[126.939157,51.353894],[127.287456,50.739797],[127.6574,49.76027],[129.397818,49.4406],[130.582293,48.729687],[130.98726,47.79013],[132.50669,47.78896],[133.373596,48.183442],[135.026311,48.47823],[134.50081,47.57845],[134.11235,47.21248],[133.769644,46.116927],[133.09712,45.14409],[131.883454,45.321162],[131.02519,44.96796],[131.288555,44.11152],[131.144688,42.92999],[130.633866,42.903015],[130.64,42.395024],[129.994267,42.985387],[129.596669,42.424982],[128.052215,41.994285],[128.208433,41.466772],[127.343783,41.503152],[126.869083,41.816569],[126.182045,41.107336],[125.079942,40.569824],[124.265625,39.928493],[122.86757,39.637788],[122.131388,39.170452],[121.054554,38.897471],[121.585995,39.360854],[121.376757,39.750261],[122.168595,40.422443],[121.640359,40.94639],[120.768629,40.593388],[119.639602,39.898056],[119.023464,39.252333],[118.042749,39.204274],[117.532702,38.737636],[118.059699,38.061476],[118.87815,37.897325],[118.911636,37.448464],[119.702802,37.156389],[120.823457,37.870428],[121.711259,37.481123],[122.357937,37.454484],[122.519995,36.930614],[121.104164,36.651329],[120.637009,36.11144],[119.664562,35.609791],[119.151208,34.909859],[120.227525,34.360332],[120.620369,33.376723],[121.229014,32.460319],[121.908146,31.692174],[121.891919,30.949352],[121.264257,30.676267],[121.503519,30.142915],[122.092114,29.83252],[121.938428,29.018022],[121.684439,28.225513],[121.125661,28.135673],[120.395473,27.053207],[119.585497,25.740781],[118.656871,24.547391],[117.281606,23.624501],[115.890735,22.782873],[114.763827,22.668074],[114.152547,22.22376],[113.80678,22.54834],[113.241078,22.051367],[111.843592,21.550494],[110.785466,21.397144],[110.444039,20.341033],[109.889861,20.282457],[109.627655,21.008227],[109.864488,21.395051],[108.522813,21.715212],[108.05018,21.55238],[107.04342,21.811899],[106.567273,22.218205],[106.725403,22.794268],[105.811247,22.976892],[105.329209,23.352063],[104.476858,22.81915],[103.504515,22.703757],[102.706992,22.708795],[102.170436,22.464753],[101.652018,22.318199],[101.80312,21.174367],[101.270026,21.201652],[101.180005,21.436573],[101.150033,21.849984],[100.416538,21.558839],[99.983489,21.742937],[99.240899,22.118314],[99.531992,22.949039],[98.898749,23.142722],[98.660262,24.063286],[97.60472,23.897405],[97.724609,25.083637],[98.671838,25.918703],[98.712094,26.743536],[98.68269,27.508812],[98.246231,27.747221],[97.911988,28.335945],[97.327114,28.261583],[96.248833,28.411031],[96.586591,28.83098],[96.117679,29.452802],[95.404802,29.031717],[94.56599,29.277438],[93.413348,28.640629],[92.503119,27.896876],[91.696657,27.771742],[91.258854,28.040614],[90.730514,28.064954],[90.015829,28.296439],[89.47581,28.042759],[88.814248,27.299316],[88.730326,28.086865],[88.120441,27.876542],[86.954517,27.974262],[85.82332,28.203576],[85.011638,28.642774],[84.23458,28.839894],[83.898993,29.320226],[83.337115,29.463732],[82.327513,30.115268],[81.525804,30.422717],[81.111256,30.183481],[79.721367,30.882715],[78.738894,31.515906],[78.458446,32.618164],[79.176129,32.48378],[79.208892,32.994395],[78.811086,33.506198],[78.912269,34.321936],[77.837451,35.49401],[76.192848,35.898403],[75.896897,36.666806],[75.158028,37.133031]]],[[[110.339188,18.678395],[109.47521,18.197701],[108.655208,18.507682],[108.626217,19.367888],[109.119056,19.821039],[110.211599,20.101254],[110.786551,20.077534],[111.010051,19.69593],[110.570647,19.255879],[110.339188,18.678395]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Ivory Coast","SOV_A3":"CIV","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Ivory Coast","ADM0_A3":"CIV","GEOU_DIF":0,"GEOUNIT":"Ivory Coast","GU_A3":"CIV","SU_DIF":0,"SUBUNIT":"Ivory Coast","SU_A3":"CIV","BRK_DIFF":0,"NAME":"Côte d'Ivoire","NAME_LONG":"Côte d'Ivoire","BRK_A3":"CIV","BRK_NAME":"Côte d'Ivoire","BRK_GROUP":null,"ABBREV":"I.C.","POSTAL":"CI","FORMAL_EN":"Republic of Ivory Coast","FORMAL_FR":"Republic of Cote D'Ivoire","NAME_CIAWF":"Cote D'ivoire","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Côte d'Ivoire","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":6,"MAPCOLOR9":3,"MAPCOLOR13":3,"POP_EST":24184810,"POP_RANK":15,"GDP_MD_EST":87120,"POP_YEAR":2017,"LASTCENSUS":1998,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"IV","ISO_A2":"CI","ISO_A3":"CIV","ISO_A3_EH":"CIV","ISO_N3":"384","UN_A3":"384","WB_A2":"CI","WB_A3":"CIV","WOE_ID":23424854,"WOE_ID_EH":23424854,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CIV","ADM0_A3_US":"CIV","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":13,"LONG_LEN":13,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-8.60288,4.338288,-2.56219,10.524061],"geometry":{"type":"Polygon","coordinates":[[[-5.404342,10.370737],[-4.954653,10.152714],[-4.779884,9.821985],[-4.330247,9.610835],[-3.980449,9.862344],[-3.511899,9.900326],[-2.827496,9.642461],[-2.56219,8.219628],[-2.983585,7.379705],[-3.24437,6.250472],[-2.810701,5.389051],[-2.856125,4.994476],[-3.311084,4.984296],[-4.00882,5.179813],[-4.649917,5.168264],[-5.834496,4.993701],[-6.528769,4.705088],[-7.518941,4.338288],[-7.712159,4.364566],[-7.635368,5.188159],[-7.539715,5.313345],[-7.570153,5.707352],[-7.993693,6.12619],[-8.311348,6.193033],[-8.60288,6.467564],[-8.385452,6.911801],[-8.485446,7.395208],[-8.439298,7.686043],[-8.280703,7.68718],[-8.221792,8.123329],[-8.299049,8.316444],[-8.203499,8.455453],[-7.8321,8.575704],[-8.079114,9.376224],[-8.309616,9.789532],[-8.229337,10.12902],[-8.029944,10.206535],[-7.89959,10.297382],[-7.622759,10.147236],[-6.850507,10.138994],[-6.666461,10.430811],[-6.493965,10.411303],[-6.205223,10.524061],[-6.050452,10.096361],[-5.816926,10.222555],[-5.404342,10.370737]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Cameroon","SOV_A3":"CMR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Cameroon","ADM0_A3":"CMR","GEOU_DIF":0,"GEOUNIT":"Cameroon","GU_A3":"CMR","SU_DIF":0,"SUBUNIT":"Cameroon","SU_A3":"CMR","BRK_DIFF":0,"NAME":"Cameroon","NAME_LONG":"Cameroon","BRK_A3":"CMR","BRK_NAME":"Cameroon","BRK_GROUP":null,"ABBREV":"Cam.","POSTAL":"CM","FORMAL_EN":"Republic of Cameroon","FORMAL_FR":null,"NAME_CIAWF":"Cameroon","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Cameroon","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":3,"POP_EST":24994885,"POP_RANK":15,"GDP_MD_EST":77240,"POP_YEAR":2017,"LASTCENSUS":2005,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"CM","ISO_A2":"CM","ISO_A3":"CMR","ISO_A3_EH":"CMR","ISO_N3":"120","UN_A3":"120","WB_A2":"CM","WB_A3":"CMR","WOE_ID":23424785,"WOE_ID_EH":23424785,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CMR","ADM0_A3_US":"CMR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[8.488816,1.727673,16.012852,12.859396],"geometry":{"type":"Polygon","coordinates":[[[15.27946,7.421925],[14.776545,6.408498],[14.53656,6.226959],[14.459407,5.451761],[14.558936,5.030598],[14.478372,4.732605],[14.950953,4.210389],[15.03622,3.851367],[15.405396,3.335301],[15.862732,3.013537],[15.907381,2.557389],[16.012852,2.26764],[15.940919,1.727673],[15.146342,1.964015],[14.337813,2.227875],[13.075822,2.267097],[12.951334,2.321616],[12.35938,2.192812],[11.751665,2.326758],[11.276449,2.261051],[9.649158,2.283866],[9.795196,3.073404],[9.404367,3.734527],[8.948116,3.904129],[8.744924,4.352215],[8.488816,4.495617],[8.500288,4.771983],[8.757533,5.479666],[9.233163,6.444491],[9.522706,6.453482],[10.118277,7.03877],[10.497375,7.055358],[11.058788,6.644427],[11.745774,6.981383],[11.839309,7.397042],[12.063946,7.799808],[12.218872,8.305824],[12.753672,8.717763],[12.955468,9.417772],[13.1676,9.640626],[13.308676,10.160362],[13.57295,10.798566],[14.415379,11.572369],[14.468192,11.904752],[14.577178,12.085361],[14.181336,12.483657],[14.213531,12.802035],[14.495787,12.859396],[14.89336,12.21905],[14.960152,11.555574],[14.923565,10.891325],[15.467873,9.982337],[14.909354,9.992129],[14.627201,9.920919],[14.171466,10.021378],[13.954218,9.549495],[14.544467,8.965861],[14.979996,8.796104],[15.120866,8.38215],[15.436092,7.692812],[15.27946,7.421925]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Democratic Republic of the Congo","SOV_A3":"COD","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Democratic Republic of the Congo","ADM0_A3":"COD","GEOU_DIF":0,"GEOUNIT":"Democratic Republic of the Congo","GU_A3":"COD","SU_DIF":0,"SUBUNIT":"Democratic Republic of the Congo","SU_A3":"COD","BRK_DIFF":0,"NAME":"Dem. Rep. Congo","NAME_LONG":"Democratic Republic of the Congo","BRK_A3":"COD","BRK_NAME":"Democratic Republic of the Congo","BRK_GROUP":null,"ABBREV":"D.R.C.","POSTAL":"DRC","FORMAL_EN":"Democratic Republic of the Congo","FORMAL_FR":null,"NAME_CIAWF":"Congo, Democratic Republic of the","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Congo, Dem. Rep.","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":4,"MAPCOLOR9":4,"MAPCOLOR13":7,"POP_EST":83301151,"POP_RANK":16,"GDP_MD_EST":66010,"POP_YEAR":2017,"LASTCENSUS":1984,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"CG","ISO_A2":"CD","ISO_A3":"COD","ISO_A3_EH":"COD","ISO_N3":"180","UN_A3":"180","WB_A2":"ZR","WB_A3":"ZAR","WOE_ID":23424780,"WOE_ID_EH":23424780,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"COD","ADM0_A3_US":"COD","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":15,"LONG_LEN":32,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[12.182337,-13.257227,31.174149,5.256088],"geometry":{"type":"Polygon","coordinates":[[[23.912215,-10.926826],[23.456791,-10.867863],[22.837345,-11.017622],[22.402798,-10.993075],[22.155268,-11.084801],[22.208753,-9.894796],[21.875182,-9.523708],[21.801801,-8.908707],[21.949131,-8.305901],[21.746456,-7.920085],[21.728111,-7.290872],[20.514748,-7.299606],[20.601823,-6.939318],[20.091622,-6.94309],[20.037723,-7.116361],[19.417502,-7.155429],[19.166613,-7.738184],[19.016752,-7.988246],[18.464176,-7.847014],[18.134222,-7.987678],[17.47297,-8.068551],[17.089996,-7.545689],[16.860191,-7.222298],[16.57318,-6.622645],[16.326528,-5.87747],[13.375597,-5.864241],[13.024869,-5.984389],[12.735171,-5.965682],[12.322432,-6.100092],[12.182337,-5.789931],[12.436688,-5.684304],[12.468004,-5.248362],[12.631612,-4.991271],[12.995517,-4.781103],[13.25824,-4.882957],[13.600235,-4.500138],[14.144956,-4.510009],[14.209035,-4.793092],[14.582604,-4.970239],[15.170992,-4.343507],[15.75354,-3.855165],[16.00629,-3.535133],[15.972803,-2.712392],[16.407092,-1.740927],[16.865307,-1.225816],[17.523716,-0.74383],[17.638645,-0.424832],[17.663553,-0.058084],[17.82654,0.288923],[17.774192,0.855659],[17.898835,1.741832],[18.094276,2.365722],[18.393792,2.900443],[18.453065,3.504386],[18.542982,4.201785],[18.932312,4.709506],[19.467784,5.031528],[20.290679,4.691678],[20.927591,4.322786],[21.659123,4.224342],[22.405124,4.02916],[22.704124,4.633051],[22.84148,4.710126],[23.297214,4.609693],[24.410531,5.108784],[24.805029,4.897247],[25.128833,4.927245],[25.278798,5.170408],[25.650455,5.256088],[26.402761,5.150875],[27.044065,5.127853],[27.374226,5.233944],[27.979977,4.408413],[28.428994,4.287155],[28.696678,4.455077],[29.159078,4.389267],[29.715995,4.600805],[29.9535,4.173699],[30.833852,3.509172],[30.83386,3.509166],[30.773347,2.339883],[31.174149,2.204465],[30.85267,1.849396],[30.468508,1.583805],[30.086154,1.062313],[29.875779,0.59738],[29.819503,-0.20531],[29.587838,-0.587406],[29.579466,-1.341313],[29.291887,-1.620056],[29.254835,-2.21511],[29.117479,-2.292211],[29.024926,-2.839258],[29.276384,-3.293907],[29.339998,-4.499983],[29.519987,-5.419979],[29.419993,-5.939999],[29.620032,-6.520015],[30.199997,-7.079981],[30.740015,-8.340007],[30.74001,-8.340006],[30.346086,-8.238257],[29.002912,-8.407032],[28.734867,-8.526559],[28.449871,-9.164918],[28.673682,-9.605925],[28.49607,-10.789884],[28.372253,-11.793647],[28.642417,-11.971569],[29.341548,-12.360744],[29.616001,-12.178895],[29.699614,-13.257227],[28.934286,-13.248958],[28.523562,-12.698604],[28.155109,-12.272481],[27.388799,-12.132747],[27.16442,-11.608748],[26.553088,-11.92444],[25.75231,-11.784965],[25.418118,-11.330936],[24.78317,-11.238694],[24.314516,-11.262826],[24.257155,-10.951993],[23.912215,-10.926826]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Republic of the Congo","SOV_A3":"COG","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Republic of the Congo","ADM0_A3":"COG","GEOU_DIF":0,"GEOUNIT":"Republic of the Congo","GU_A3":"COG","SU_DIF":0,"SUBUNIT":"Republic of the Congo","SU_A3":"COG","BRK_DIFF":0,"NAME":"Congo","NAME_LONG":"Republic of the Congo","BRK_A3":"COG","BRK_NAME":"Republic of the Congo","BRK_GROUP":null,"ABBREV":"Rep. Congo","POSTAL":"CG","FORMAL_EN":"Republic of the Congo","FORMAL_FR":null,"NAME_CIAWF":"Congo, Republic of the","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Congo, Rep.","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":10,"POP_EST":4954674,"POP_RANK":12,"GDP_MD_EST":30270,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"CF","ISO_A2":"CG","ISO_A3":"COG","ISO_A3_EH":"COG","ISO_N3":"178","UN_A3":"178","WB_A2":"CG","WB_A3":"COG","WOE_ID":23424779,"WOE_ID_EH":23424779,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"COG","ADM0_A3_US":"COG","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":5,"LONG_LEN":21,"ABBREV_LEN":10,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[11.093773,-5.037987,18.453065,3.728197],"geometry":{"type":"Polygon","coordinates":[[[12.995517,-4.781103],[12.62076,-4.438023],[12.318608,-4.60623],[11.914963,-5.037987],[11.093773,-3.978827],[11.855122,-3.426871],[11.478039,-2.765619],[11.820964,-2.514161],[12.495703,-2.391688],[12.575284,-1.948511],[13.109619,-2.42874],[13.992407,-2.470805],[14.29921,-1.998276],[14.425456,-1.333407],[14.316418,-0.552627],[13.843321,0.038758],[14.276266,1.19693],[14.026669,1.395677],[13.282631,1.314184],[13.003114,1.830896],[13.075822,2.267097],[14.337813,2.227875],[15.146342,1.964015],[15.940919,1.727673],[16.012852,2.26764],[16.537058,3.198255],[17.133042,3.728197],[17.8099,3.560196],[18.453065,3.504386],[18.393792,2.900443],[18.094276,2.365722],[17.898835,1.741832],[17.774192,0.855659],[17.82654,0.288923],[17.663553,-0.058084],[17.638645,-0.424832],[17.523716,-0.74383],[16.865307,-1.225816],[16.407092,-1.740927],[15.972803,-2.712392],[16.00629,-3.535133],[15.75354,-3.855165],[15.170992,-4.343507],[14.582604,-4.970239],[14.209035,-4.793092],[14.144956,-4.510009],[13.600235,-4.500138],[13.25824,-4.882957],[12.995517,-4.781103]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Colombia","SOV_A3":"COL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Colombia","ADM0_A3":"COL","GEOU_DIF":0,"GEOUNIT":"Colombia","GU_A3":"COL","SU_DIF":0,"SUBUNIT":"Colombia","SU_A3":"COL","BRK_DIFF":0,"NAME":"Colombia","NAME_LONG":"Colombia","BRK_A3":"COL","BRK_NAME":"Colombia","BRK_GROUP":null,"ABBREV":"Col.","POSTAL":"CO","FORMAL_EN":"Republic of Colombia","FORMAL_FR":null,"NAME_CIAWF":"Colombia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Colombia","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":1,"POP_EST":47698524,"POP_RANK":15,"GDP_MD_EST":688000,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"CO","ISO_A2":"CO","ISO_A3":"COL","ISO_A3_EH":"COL","ISO_N3":"170","UN_A3":"170","WB_A2":"CO","WB_A3":"COL","WOE_ID":23424787,"WOE_ID_EH":23424787,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"COL","ADM0_A3_US":"COL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[-78.990935,-4.298187,-66.876326,12.437303],"geometry":{"type":"Polygon","coordinates":[[[-66.876326,1.253361],[-67.065048,1.130112],[-67.259998,1.719999],[-67.53781,2.037163],[-67.868565,1.692455],[-69.816973,1.714805],[-69.804597,1.089081],[-69.218638,0.985677],[-69.252434,0.602651],[-69.452396,0.706159],[-70.015566,0.541414],[-70.020656,-0.185156],[-69.577065,-0.549992],[-69.420486,-1.122619],[-69.444102,-1.556287],[-69.893635,-4.298187],[-70.394044,-3.766591],[-70.692682,-3.742872],[-70.047709,-2.725156],[-70.813476,-2.256865],[-71.413646,-2.342802],[-71.774761,-2.16979],[-72.325787,-2.434218],[-73.070392,-2.308954],[-73.659504,-1.260491],[-74.122395,-1.002833],[-74.441601,-0.53082],[-75.106625,-0.057205],[-75.373223,-0.152032],[-75.801466,0.084801],[-76.292314,0.416047],[-76.57638,0.256936],[-77.424984,0.395687],[-77.668613,0.825893],[-77.855061,0.809925],[-78.855259,1.380924],[-78.990935,1.69137],[-78.617831,1.766404],[-78.662118,2.267355],[-78.42761,2.629556],[-77.931543,2.696606],[-77.510431,3.325017],[-77.12769,3.849636],[-77.496272,4.087606],[-77.307601,4.667984],[-77.533221,5.582812],[-77.318815,5.845354],[-77.476661,6.691116],[-77.881571,7.223771],[-77.753414,7.70984],[-77.431108,7.638061],[-77.242566,7.935278],[-77.474723,8.524286],[-77.353361,8.670505],[-76.836674,8.638749],[-76.086384,9.336821],[-75.6746,9.443248],[-75.664704,9.774003],[-75.480426,10.61899],[-74.906895,11.083045],[-74.276753,11.102036],[-74.197223,11.310473],[-73.414764,11.227015],[-72.627835,11.731972],[-72.238195,11.95555],[-71.75409,12.437303],[-71.399822,12.376041],[-71.137461,12.112982],[-71.331584,11.776284],[-71.973922,11.608672],[-72.227575,11.108702],[-72.614658,10.821975],[-72.905286,10.450344],[-73.027604,9.73677],[-73.304952,9.152],[-72.78873,9.085027],[-72.660495,8.625288],[-72.439862,8.405275],[-72.360901,8.002638],[-72.479679,7.632506],[-72.444487,7.423785],[-72.198352,7.340431],[-71.960176,6.991615],[-70.674234,7.087785],[-70.093313,6.960376],[-69.38948,6.099861],[-68.985319,6.206805],[-68.265052,6.153268],[-67.695087,6.267318],[-67.34144,6.095468],[-67.521532,5.55687],[-67.744697,5.221129],[-67.823012,4.503937],[-67.621836,3.839482],[-67.337564,3.542342],[-67.303173,3.318454],[-67.809938,2.820655],[-67.447092,2.600281],[-67.181294,2.250638],[-66.876326,1.253361]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Costa Rica","SOV_A3":"CRI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Costa Rica","ADM0_A3":"CRI","GEOU_DIF":0,"GEOUNIT":"Costa Rica","GU_A3":"CRI","SU_DIF":0,"SUBUNIT":"Costa Rica","SU_A3":"CRI","BRK_DIFF":0,"NAME":"Costa Rica","NAME_LONG":"Costa Rica","BRK_A3":"CRI","BRK_NAME":"Costa Rica","BRK_GROUP":null,"ABBREV":"C.R.","POSTAL":"CR","FORMAL_EN":"Republic of Costa Rica","FORMAL_FR":null,"NAME_CIAWF":"Costa Rica","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Costa Rica","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":4,"MAPCOLOR13":2,"POP_EST":4930258,"POP_RANK":12,"GDP_MD_EST":79260,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"CS","ISO_A2":"CR","ISO_A3":"CRI","ISO_A3_EH":"CRI","ISO_N3":"188","UN_A3":"188","WB_A2":"CR","WB_A3":"CRI","WOE_ID":23424791,"WOE_ID_EH":23424791,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CRI","ADM0_A3_US":"CRI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-85.941725,8.225028,-82.546196,11.217119],"geometry":{"type":"Polygon","coordinates":[[[-82.965783,8.225028],[-83.508437,8.446927],[-83.711474,8.656836],[-83.596313,8.830443],[-83.632642,9.051386],[-83.909886,9.290803],[-84.303402,9.487354],[-84.647644,9.615537],[-84.713351,9.908052],[-84.97566,10.086723],[-84.911375,9.795992],[-85.110923,9.55704],[-85.339488,9.834542],[-85.660787,9.933347],[-85.797445,10.134886],[-85.791709,10.439337],[-85.659314,10.754331],[-85.941725,10.895278],[-85.71254,11.088445],[-85.561852,11.217119],[-84.903003,10.952303],[-84.673069,11.082657],[-84.355931,10.999226],[-84.190179,10.79345],[-83.895054,10.726839],[-83.655612,10.938764],[-83.40232,10.395438],[-83.015677,9.992982],[-82.546196,9.566135],[-82.932891,9.476812],[-82.927155,9.07433],[-82.719183,8.925709],[-82.868657,8.807266],[-82.829771,8.626295],[-82.913176,8.423517],[-82.965783,8.225028]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Cuba","SOV_A3":"CUB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Cuba","ADM0_A3":"CUB","GEOU_DIF":0,"GEOUNIT":"Cuba","GU_A3":"CUB","SU_DIF":0,"SUBUNIT":"Cuba","SU_A3":"CUB","BRK_DIFF":0,"NAME":"Cuba","NAME_LONG":"Cuba","BRK_A3":"CUB","BRK_NAME":"Cuba","BRK_GROUP":null,"ABBREV":"Cuba","POSTAL":"CU","FORMAL_EN":"Republic of Cuba","FORMAL_FR":null,"NAME_CIAWF":"Cuba","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Cuba","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":5,"MAPCOLOR9":3,"MAPCOLOR13":4,"POP_EST":11147407,"POP_RANK":14,"GDP_MD_EST":132900,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"CU","ISO_A2":"CU","ISO_A3":"CUB","ISO_A3_EH":"CUB","ISO_N3":"192","UN_A3":"192","WB_A2":"CU","WB_A3":"CUB","WOE_ID":23424793,"WOE_ID_EH":23424793,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CUB","ADM0_A3_US":"CUB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Caribbean","REGION_WB":"Latin America & Caribbean","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-84.974911,19.855481,-74.178025,23.188611],"geometry":{"type":"Polygon","coordinates":[[[-82.268151,23.188611],[-81.404457,23.117271],[-80.618769,23.10598],[-79.679524,22.765303],[-79.281486,22.399202],[-78.347434,22.512166],[-77.993296,22.277194],[-77.146422,21.657851],[-76.523825,21.20682],[-76.19462,21.220565],[-75.598222,21.016624],[-75.67106,20.735091],[-74.933896,20.693905],[-74.178025,20.284628],[-74.296648,20.050379],[-74.961595,19.923435],[-75.63468,19.873774],[-76.323656,19.952891],[-77.755481,19.855481],[-77.085108,20.413354],[-77.492655,20.673105],[-78.137292,20.739949],[-78.482827,21.028613],[-78.719867,21.598114],[-79.285,21.559175],[-80.217475,21.827324],[-80.517535,22.037079],[-81.820943,22.192057],[-82.169992,22.387109],[-81.795002,22.636965],[-82.775898,22.68815],[-83.494459,22.168518],[-83.9088,22.154565],[-84.052151,21.910575],[-84.54703,21.801228],[-84.974911,21.896028],[-84.447062,22.20495],[-84.230357,22.565755],[-83.77824,22.788118],[-83.267548,22.983042],[-82.510436,23.078747],[-82.268151,23.188611]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Northern Cyprus","SOV_A3":"CYN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Northern Cyprus","ADM0_A3":"CYN","GEOU_DIF":0,"GEOUNIT":"Northern Cyprus","GU_A3":"CYN","SU_DIF":0,"SUBUNIT":"Northern Cyprus","SU_A3":"CYN","BRK_DIFF":1,"NAME":"N. Cyprus","NAME_LONG":"Northern Cyprus","BRK_A3":"B20","BRK_NAME":"N. Cyprus","BRK_GROUP":null,"ABBREV":"N. Cy.","POSTAL":"CN","FORMAL_EN":"Turkish Republic of Northern Cyprus","FORMAL_FR":null,"NAME_CIAWF":null,"NOTE_ADM0":"Self admin.","NOTE_BRK":"Self admin.; Claimed by Cyprus","NAME_SORT":"Cyprus, Northern","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":1,"MAPCOLOR9":4,"MAPCOLOR13":8,"POP_EST":265100,"POP_RANK":10,"GDP_MD_EST":3600,"POP_YEAR":2013,"LASTCENSUS":-99,"GDP_YEAR":2013,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"-99","ISO_A2":"-99","ISO_A3":"-99","ISO_A3_EH":"-99","ISO_N3":"-99","UN_A3":"-099","WB_A2":"-99","WB_A3":"-99","WOE_ID":-90,"WOE_ID_EH":23424995,"WOE_NOTE":"WOE lists as subunit of united Cyprus","ADM0_A3_IS":"CYP","ADM0_A3_US":"CYP","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":9,"LONG_LEN":15,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":6,"MAX_LABEL":10},"bbox":[32.73178,35.000345,34.576474,35.671596],"geometry":{"type":"Polygon","coordinates":[[[32.73178,35.140026],[32.802474,35.145504],[32.946961,35.386703],[33.667227,35.373216],[34.576474,35.671596],[33.900804,35.245756],[33.973617,35.058506],[33.86644,35.093595],[33.675392,35.017863],[33.525685,35.038688],[33.475817,35.000345],[33.455922,35.101424],[33.383833,35.162712],[33.190977,35.173125],[32.919572,35.087833],[32.73178,35.140026]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Cyprus","SOV_A3":"CYP","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Cyprus","ADM0_A3":"CYP","GEOU_DIF":0,"GEOUNIT":"Cyprus","GU_A3":"CYP","SU_DIF":0,"SUBUNIT":"Cyprus","SU_A3":"CYP","BRK_DIFF":0,"NAME":"Cyprus","NAME_LONG":"Cyprus","BRK_A3":"CYP","BRK_NAME":"Cyprus","BRK_GROUP":null,"ABBREV":"Cyp.","POSTAL":"CY","FORMAL_EN":"Republic of Cyprus","FORMAL_FR":null,"NAME_CIAWF":"Cyprus","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Cyprus","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":2,"MAPCOLOR9":3,"MAPCOLOR13":7,"POP_EST":1221549,"POP_RANK":12,"GDP_MD_EST":29260,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"CY","ISO_A2":"CY","ISO_A3":"CYP","ISO_A3_EH":"CYP","ISO_N3":"196","UN_A3":"196","WB_A2":"CY","WB_A3":"CYP","WOE_ID":-90,"WOE_ID_EH":23424994,"WOE_NOTE":"WOE lists as subunit of united Cyprus","ADM0_A3_IS":"CYP","ADM0_A3_US":"CYP","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4.5,"MAX_LABEL":9.5},"bbox":[32.256667,34.571869,34.004881,35.173125],"geometry":{"type":"Polygon","coordinates":[[[32.73178,35.140026],[32.919572,35.087833],[33.190977,35.173125],[33.383833,35.162712],[33.455922,35.101424],[33.475817,35.000345],[33.525685,35.038688],[33.675392,35.017863],[33.86644,35.093595],[33.973617,35.058506],[34.004881,34.978098],[32.979827,34.571869],[32.490296,34.701655],[32.256667,35.103232],[32.73178,35.140026]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Czechia","SOV_A3":"CZE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Czechia","ADM0_A3":"CZE","GEOU_DIF":0,"GEOUNIT":"Czechia","GU_A3":"CZE","SU_DIF":0,"SUBUNIT":"Czechia","SU_A3":"CZE","BRK_DIFF":0,"NAME":"Czechia","NAME_LONG":"Czech Republic","BRK_A3":"CZE","BRK_NAME":"Czechia","BRK_GROUP":null,"ABBREV":"Cz.","POSTAL":"CZ","FORMAL_EN":"Czech Republic","FORMAL_FR":"la République tchèque","NAME_CIAWF":"Czechia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Czechia","NAME_ALT":"Česko","MAPCOLOR7":1,"MAPCOLOR8":1,"MAPCOLOR9":2,"MAPCOLOR13":6,"POP_EST":10674723,"POP_RANK":14,"GDP_MD_EST":350900,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"EZ","ISO_A2":"CZ","ISO_A3":"CZE","ISO_A3_EH":"CZE","ISO_N3":"203","UN_A3":"203","WB_A2":"CZ","WB_A3":"CZE","WOE_ID":23424810,"WOE_ID_EH":23424810,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"CZE","ADM0_A3_US":"CZE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":14,"ABBREV_LEN":3,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[12.240111,48.555305,18.853144,51.117268],"geometry":{"type":"Polygon","coordinates":[[[16.960288,48.596982],[16.499283,48.785808],[16.029647,48.733899],[15.253416,49.039074],[14.901447,48.964402],[14.338898,48.555305],[13.595946,48.877172],[13.031329,49.307068],[12.521024,49.547415],[12.415191,49.969121],[12.240111,50.266338],[12.966837,50.484076],[13.338132,50.733234],[14.056228,50.926918],[14.307013,51.117268],[14.570718,51.002339],[15.016996,51.106674],[15.490972,50.78473],[16.238627,50.697733],[16.176253,50.422607],[16.719476,50.215747],[16.868769,50.473974],[17.554567,50.362146],[17.649445,50.049038],[18.392914,49.988629],[18.853144,49.49623],[18.554971,49.495015],[18.399994,49.315001],[18.170498,49.271515],[18.104973,49.043983],[17.913512,48.996493],[17.886485,48.903475],[17.545007,48.800019],[17.101985,48.816969],[16.960288,48.596982]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Germany","SOV_A3":"DEU","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Germany","ADM0_A3":"DEU","GEOU_DIF":0,"GEOUNIT":"Germany","GU_A3":"DEU","SU_DIF":0,"SUBUNIT":"Germany","SU_A3":"DEU","BRK_DIFF":0,"NAME":"Germany","NAME_LONG":"Germany","BRK_A3":"DEU","BRK_NAME":"Germany","BRK_GROUP":null,"ABBREV":"Ger.","POSTAL":"D","FORMAL_EN":"Federal Republic of Germany","FORMAL_FR":null,"NAME_CIAWF":"Germany","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Germany","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":5,"MAPCOLOR9":5,"MAPCOLOR13":1,"POP_EST":80594017,"POP_RANK":16,"GDP_MD_EST":3979000,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"1. Developed region: G7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"GM","ISO_A2":"DE","ISO_A3":"DEU","ISO_A3_EH":"DEU","ISO_N3":"276","UN_A3":"276","WB_A2":"DE","WB_A3":"DEU","WOE_ID":23424829,"WOE_ID_EH":23424829,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"DEU","ADM0_A3_US":"DEU","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Western Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[5.988658,47.302488,15.016996,54.983104],"geometry":{"type":"Polygon","coordinates":[[[13.595946,48.877172],[13.243357,48.416115],[12.884103,48.289146],[13.025851,47.637584],[12.932627,47.467646],[12.62076,47.672388],[12.141357,47.703083],[11.426414,47.523766],[10.544504,47.566399],[10.402084,47.302488],[9.896068,47.580197],[9.594226,47.525058],[8.522612,47.830828],[8.317301,47.61358],[7.466759,47.620582],[7.593676,48.333019],[8.099279,49.017784],[6.65823,49.201958],[6.18632,49.463803],[6.242751,49.902226],[6.043073,50.128052],[6.156658,50.803721],[5.988658,51.851616],[6.589397,51.852029],[6.84287,52.22844],[7.092053,53.144043],[6.90514,53.482162],[7.100425,53.693932],[7.936239,53.748296],[8.121706,53.527792],[8.800734,54.020786],[8.572118,54.395646],[8.526229,54.962744],[9.282049,54.830865],[9.921906,54.983104],[9.93958,54.596642],[10.950112,54.363607],[10.939467,54.008693],[11.956252,54.196486],[12.51844,54.470371],[13.647467,54.075511],[14.119686,53.757029],[14.353315,53.248171],[14.074521,52.981263],[14.4376,52.62485],[14.685026,52.089947],[14.607098,51.745188],[15.016996,51.106674],[14.570718,51.002339],[14.307013,51.117268],[14.056228,50.926918],[13.338132,50.733234],[12.966837,50.484076],[12.240111,50.266338],[12.415191,49.969121],[12.521024,49.547415],[13.031329,49.307068],[13.595946,48.877172]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Djibouti","SOV_A3":"DJI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Djibouti","ADM0_A3":"DJI","GEOU_DIF":0,"GEOUNIT":"Djibouti","GU_A3":"DJI","SU_DIF":0,"SUBUNIT":"Djibouti","SU_A3":"DJI","BRK_DIFF":0,"NAME":"Djibouti","NAME_LONG":"Djibouti","BRK_A3":"DJI","BRK_NAME":"Djibouti","BRK_GROUP":null,"ABBREV":"Dji.","POSTAL":"DJ","FORMAL_EN":"Republic of Djibouti","FORMAL_FR":null,"NAME_CIAWF":"Djibouti","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Djibouti","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":2,"MAPCOLOR9":4,"MAPCOLOR13":8,"POP_EST":865267,"POP_RANK":11,"GDP_MD_EST":3345,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"DJ","ISO_A2":"DJ","ISO_A3":"DJI","ISO_A3_EH":"DJI","ISO_N3":"262","UN_A3":"262","WB_A2":"DJ","WB_A3":"DJI","WOE_ID":23424797,"WOE_ID_EH":23424797,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"DJI","ADM0_A3_US":"DJI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Middle East & North Africa","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[41.66176,10.926879,43.317852,12.699639],"geometry":{"type":"Polygon","coordinates":[[[43.081226,12.699639],[43.317852,12.390148],[43.286381,11.974928],[42.715874,11.735641],[43.145305,11.46204],[42.776852,10.926879],[42.55493,11.10511],[42.31414,11.0342],[41.75557,11.05091],[41.73959,11.35511],[41.66176,11.6312],[42,12.1],[42.35156,12.54223],[42.779642,12.455416],[43.081226,12.699639]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Denmark","SOV_A3":"DN1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"Denmark","ADM0_A3":"DNK","GEOU_DIF":0,"GEOUNIT":"Denmark","GU_A3":"DNK","SU_DIF":0,"SUBUNIT":"Denmark","SU_A3":"DNK","BRK_DIFF":0,"NAME":"Denmark","NAME_LONG":"Denmark","BRK_A3":"DNK","BRK_NAME":"Denmark","BRK_GROUP":null,"ABBREV":"Den.","POSTAL":"DK","FORMAL_EN":"Kingdom of Denmark","FORMAL_FR":null,"NAME_CIAWF":"Denmark","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Denmark","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":12,"POP_EST":5605948,"POP_RANK":13,"GDP_MD_EST":264800,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"DA","ISO_A2":"DK","ISO_A3":"DNK","ISO_A3_EH":"DNK","ISO_N3":"208","UN_A3":"208","WB_A2":"DK","WB_A3":"DNK","WOE_ID":23424796,"WOE_ID_EH":23424796,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"DNK","ADM0_A3_US":"DNK","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[8.089977,54.800015,12.690006,57.730017],"geometry":{"type":"MultiPolygon","coordinates":[[[[9.921906,54.983104],[9.282049,54.830865],[8.526229,54.962744],[8.120311,55.517723],[8.089977,56.540012],[8.256582,56.809969],[8.543438,57.110003],[9.424469,57.172066],[9.775559,57.447941],[10.580006,57.730017],[10.546106,57.215733],[10.25,56.890016],[10.369993,56.609982],[10.912182,56.458621],[10.667804,56.081383],[10.369993,56.190007],[9.649985,55.469999],[9.921906,54.983104]]],[[[12.690006,55.609991],[12.089991,54.800015],[11.043543,55.364864],[10.903914,55.779955],[12.370904,56.111407],[12.690006,55.609991]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Dominican Republic","SOV_A3":"DOM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Dominican Republic","ADM0_A3":"DOM","GEOU_DIF":0,"GEOUNIT":"Dominican Republic","GU_A3":"DOM","SU_DIF":0,"SUBUNIT":"Dominican Republic","SU_A3":"DOM","BRK_DIFF":0,"NAME":"Dominican Rep.","NAME_LONG":"Dominican Republic","BRK_A3":"DOM","BRK_NAME":"Dominican Rep.","BRK_GROUP":null,"ABBREV":"Dom. Rep.","POSTAL":"DO","FORMAL_EN":"Dominican Republic","FORMAL_FR":null,"NAME_CIAWF":"Dominican Republic","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Dominican Republic","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":2,"MAPCOLOR9":5,"MAPCOLOR13":7,"POP_EST":10734247,"POP_RANK":14,"GDP_MD_EST":161900,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"DR","ISO_A2":"DO","ISO_A3":"DOM","ISO_A3_EH":"DOM","ISO_N3":"214","UN_A3":"214","WB_A2":"DO","WB_A3":"DOM","WOE_ID":23424800,"WOE_ID_EH":23424800,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"DOM","ADM0_A3_US":"DOM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Caribbean","REGION_WB":"Latin America & Caribbean","NAME_LEN":14,"LONG_LEN":18,"ABBREV_LEN":9,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4.5,"MAX_LABEL":9.5},"bbox":[-71.945112,17.598564,-68.317943,19.884911],"geometry":{"type":"Polygon","coordinates":[[[-71.712361,19.714456],[-71.587304,19.884911],[-70.806706,19.880286],[-70.214365,19.622885],[-69.950815,19.648],[-69.76925,19.293267],[-69.222126,19.313214],[-69.254346,19.015196],[-68.809412,18.979074],[-68.317943,18.612198],[-68.689316,18.205142],[-69.164946,18.422648],[-69.623988,18.380713],[-69.952934,18.428307],[-70.133233,18.245915],[-70.517137,18.184291],[-70.669298,18.426886],[-70.99995,18.283329],[-71.40021,17.598564],[-71.657662,17.757573],[-71.708305,18.044997],[-71.687738,18.31666],[-71.945112,18.6169],[-71.701303,18.785417],[-71.624873,19.169838],[-71.712361,19.714456]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Algeria","SOV_A3":"DZA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Algeria","ADM0_A3":"DZA","GEOU_DIF":0,"GEOUNIT":"Algeria","GU_A3":"DZA","SU_DIF":0,"SUBUNIT":"Algeria","SU_A3":"DZA","BRK_DIFF":0,"NAME":"Algeria","NAME_LONG":"Algeria","BRK_A3":"DZA","BRK_NAME":"Algeria","BRK_GROUP":null,"ABBREV":"Alg.","POSTAL":"DZ","FORMAL_EN":"People's Democratic Republic of Algeria","FORMAL_FR":null,"NAME_CIAWF":"Algeria","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Algeria","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":1,"MAPCOLOR9":6,"MAPCOLOR13":3,"POP_EST":40969443,"POP_RANK":15,"GDP_MD_EST":609400,"POP_YEAR":2017,"LASTCENSUS":2008,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"AG","ISO_A2":"DZ","ISO_A3":"DZA","ISO_A3_EH":"DZA","ISO_N3":"012","UN_A3":"012","WB_A2":"DZ","WB_A3":"DZA","WOE_ID":23424740,"WOE_ID_EH":23424740,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"DZA","ADM0_A3_US":"DZA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Northern Africa","REGION_WB":"Middle East & North Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[-8.6844,19.057364,11.999506,37.118381],"geometry":{"type":"Polygon","coordinates":[[[4.267419,19.155265],[3.158133,19.057364],[3.146661,19.693579],[2.683588,19.85623],[2.060991,20.142233],[1.823228,20.610809],[-1.550055,22.792666],[-4.923337,24.974574],[-8.6844,27.395744],[-8.665124,27.589479],[-8.66559,27.656426],[-8.674116,28.841289],[-7.059228,29.579228],[-6.060632,29.7317],[-5.242129,30.000443],[-4.859646,30.501188],[-3.690441,30.896952],[-3.647498,31.637294],[-3.06898,31.724498],[-2.616605,32.094346],[-1.307899,32.262889],[-1.124551,32.651522],[-1.388049,32.864015],[-1.733455,33.919713],[-1.792986,34.527919],[-2.169914,35.168396],[-1.208603,35.714849],[-0.127454,35.888662],[0.503877,36.301273],[1.466919,36.605647],[3.161699,36.783905],[4.815758,36.865037],[5.32012,36.716519],[6.26182,37.110655],[7.330385,37.118381],[7.737078,36.885708],[8.420964,36.946427],[8.217824,36.433177],[8.376368,35.479876],[8.140981,34.655146],[7.524482,34.097376],[7.612642,33.344115],[8.430473,32.748337],[8.439103,32.506285],[9.055603,32.102692],[9.48214,30.307556],[9.805634,29.424638],[9.859998,28.95999],[9.683885,28.144174],[9.756128,27.688259],[9.629056,27.140953],[9.716286,26.512206],[9.319411,26.094325],[9.910693,25.365455],[9.948261,24.936954],[10.303847,24.379313],[10.771364,24.562532],[11.560669,24.097909],[11.999506,23.471668],[8.572893,21.565661],[5.677566,19.601207],[4.267419,19.155265]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Ecuador","SOV_A3":"ECU","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Ecuador","ADM0_A3":"ECU","GEOU_DIF":0,"GEOUNIT":"Ecuador","GU_A3":"ECU","SU_DIF":0,"SUBUNIT":"Ecuador","SU_A3":"ECU","BRK_DIFF":0,"NAME":"Ecuador","NAME_LONG":"Ecuador","BRK_A3":"ECU","BRK_NAME":"Ecuador","BRK_GROUP":null,"ABBREV":"Ecu.","POSTAL":"EC","FORMAL_EN":"Republic of Ecuador","FORMAL_FR":null,"NAME_CIAWF":"Ecuador","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Ecuador","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":5,"MAPCOLOR9":2,"MAPCOLOR13":12,"POP_EST":16290913,"POP_RANK":14,"GDP_MD_EST":182400,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"EC","ISO_A2":"EC","ISO_A3":"ECU","ISO_A3_EH":"ECU","ISO_N3":"218","UN_A3":"218","WB_A2":"EC","WB_A3":"ECU","WOE_ID":23424801,"WOE_ID_EH":23424801,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ECU","ADM0_A3_US":"ECU","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-80.967765,-4.959129,-75.233723,1.380924],"geometry":{"type":"Polygon","coordinates":[[[-78.855259,1.380924],[-77.855061,0.809925],[-77.668613,0.825893],[-77.424984,0.395687],[-76.57638,0.256936],[-76.292314,0.416047],[-75.801466,0.084801],[-75.373223,-0.152032],[-75.233723,-0.911417],[-75.544996,-1.56161],[-76.635394,-2.608678],[-77.837905,-3.003021],[-78.450684,-3.873097],[-78.639897,-4.547784],[-79.205289,-4.959129],[-79.624979,-4.454198],[-80.028908,-4.346091],[-80.442242,-4.425724],[-80.469295,-4.059287],[-80.184015,-3.821162],[-80.302561,-3.404856],[-79.770293,-2.657512],[-79.986559,-2.220794],[-80.368784,-2.685159],[-80.967765,-2.246943],[-80.764806,-1.965048],[-80.933659,-1.057455],[-80.58337,-0.906663],[-80.399325,-0.283703],[-80.020898,0.36034],[-80.09061,0.768429],[-79.542762,0.982938],[-78.855259,1.380924]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Egypt","SOV_A3":"EGY","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Egypt","ADM0_A3":"EGY","GEOU_DIF":0,"GEOUNIT":"Egypt","GU_A3":"EGY","SU_DIF":0,"SUBUNIT":"Egypt","SU_A3":"EGY","BRK_DIFF":0,"NAME":"Egypt","NAME_LONG":"Egypt","BRK_A3":"EGY","BRK_NAME":"Egypt","BRK_GROUP":null,"ABBREV":"Egypt","POSTAL":"EG","FORMAL_EN":"Arab Republic of Egypt","FORMAL_FR":null,"NAME_CIAWF":"Egypt","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Egypt, Arab Rep.","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":6,"MAPCOLOR9":7,"MAPCOLOR13":2,"POP_EST":97041072,"POP_RANK":16,"GDP_MD_EST":1105000,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"EG","ISO_A2":"EG","ISO_A3":"EGY","ISO_A3_EH":"EGY","ISO_N3":"818","UN_A3":"818","WB_A2":"EG","WB_A3":"EGY","WOE_ID":23424802,"WOE_ID_EH":23424802,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"EGY","ADM0_A3_US":"EGY","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Northern Africa","REGION_WB":"Middle East & North Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[24.70007,22,36.86623,31.58568],"geometry":{"type":"Polygon","coordinates":[[[36.86623,22],[32.9,22],[29.02,22],[25,22],[25,25.6825],[25,29.238655],[24.70007,30.04419],[24.95762,30.6616],[24.80287,31.08929],[25.16482,31.56915],[26.49533,31.58568],[27.45762,31.32126],[28.45048,31.02577],[28.91353,30.87005],[29.68342,31.18686],[30.09503,31.4734],[30.97693,31.55586],[31.68796,31.4296],[31.96041,30.9336],[32.19247,31.26034],[32.99392,31.02407],[33.7734,30.96746],[34.265435,31.219357],[34.26544,31.21936],[34.823243,29.761081],[34.9226,29.50133],[34.64174,29.09942],[34.42655,28.34399],[34.15451,27.8233],[33.92136,27.6487],[33.58811,27.97136],[33.13676,28.41765],[32.42323,29.85108],[32.32046,29.76043],[32.73482,28.70523],[33.34876,27.69989],[34.10455,26.14227],[34.47387,25.59856],[34.79507,25.03375],[35.69241,23.92671],[35.49372,23.75237],[35.52598,23.10244],[36.69069,22.20485],[36.86623,22]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Eritrea","SOV_A3":"ERI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Eritrea","ADM0_A3":"ERI","GEOU_DIF":0,"GEOUNIT":"Eritrea","GU_A3":"ERI","SU_DIF":0,"SUBUNIT":"Eritrea","SU_A3":"ERI","BRK_DIFF":0,"NAME":"Eritrea","NAME_LONG":"Eritrea","BRK_A3":"ERI","BRK_NAME":"Eritrea","BRK_GROUP":null,"ABBREV":"Erit.","POSTAL":"ER","FORMAL_EN":"State of Eritrea","FORMAL_FR":null,"NAME_CIAWF":"Eritrea","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Eritrea","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":1,"MAPCOLOR9":2,"MAPCOLOR13":12,"POP_EST":5918919,"POP_RANK":13,"GDP_MD_EST":9169,"POP_YEAR":2017,"LASTCENSUS":1984,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"ER","ISO_A2":"ER","ISO_A3":"ERI","ISO_A3_EH":"ERI","ISO_N3":"232","UN_A3":"232","WB_A2":"ER","WB_A3":"ERI","WOE_ID":23424806,"WOE_ID_EH":23424806,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ERI","ADM0_A3_US":"ERI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[36.32322,12.455416,43.081226,17.998307],"geometry":{"type":"Polygon","coordinates":[[[43.081226,12.699639],[42.779642,12.455416],[42.35156,12.54223],[42.00975,12.86582],[41.59856,13.45209],[41.1552,13.77333],[40.8966,14.11864],[40.02625,14.51959],[39.34061,14.53155],[39.0994,14.74064],[38.51295,14.50547],[37.90607,14.95943],[37.59377,14.2131],[36.42951,14.42211],[36.32322,14.82249],[36.75389,16.29186],[36.85253,16.95655],[37.16747,17.26314],[37.904,17.42754],[38.41009,17.998307],[38.990623,16.840626],[39.26611,15.922723],[39.814294,15.435647],[41.179275,14.49108],[41.734952,13.921037],[42.276831,13.343992],[42.589576,13.000421],[43.081226,12.699639]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Spain","SOV_A3":"ESP","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Spain","ADM0_A3":"ESP","GEOU_DIF":0,"GEOUNIT":"Spain","GU_A3":"ESP","SU_DIF":0,"SUBUNIT":"Spain","SU_A3":"ESP","BRK_DIFF":0,"NAME":"Spain","NAME_LONG":"Spain","BRK_A3":"ESP","BRK_NAME":"Spain","BRK_GROUP":null,"ABBREV":"Sp.","POSTAL":"E","FORMAL_EN":"Kingdom of Spain","FORMAL_FR":null,"NAME_CIAWF":"Spain","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Spain","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":5,"MAPCOLOR9":5,"MAPCOLOR13":5,"POP_EST":48958159,"POP_RANK":15,"GDP_MD_EST":1690000,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"SP","ISO_A2":"ES","ISO_A3":"ESP","ISO_A3_EH":"ESP","ISO_N3":"724","UN_A3":"724","WB_A2":"ES","WB_A3":"ESP","WOE_ID":23424950,"WOE_ID_EH":23424950,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ESP","ADM0_A3_US":"ESP","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":3,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[-9.392884,35.94685,3.039484,43.748338],"geometry":{"type":"Polygon","coordinates":[[[-9.034818,41.880571],[-8.984433,42.592775],[-9.392884,43.026625],[-7.97819,43.748338],[-6.754492,43.567909],[-5.411886,43.57424],[-4.347843,43.403449],[-3.517532,43.455901],[-1.901351,43.422802],[-1.502771,43.034014],[0.338047,42.579546],[0.701591,42.795734],[1.826793,42.343385],[2.985999,42.473015],[3.039484,41.89212],[2.091842,41.226089],[0.810525,41.014732],[0.721331,40.678318],[0.106692,40.123934],[-0.278711,39.309978],[0.111291,38.738514],[-0.467124,38.292366],[-0.683389,37.642354],[-1.438382,37.443064],[-2.146453,36.674144],[-3.415781,36.6589],[-4.368901,36.677839],[-4.995219,36.324708],[-5.37716,35.94685],[-5.866432,36.029817],[-6.236694,36.367677],[-6.520191,36.942913],[-7.453726,37.097788],[-7.537105,37.428904],[-7.166508,37.803894],[-7.029281,38.075764],[-7.374092,38.373059],[-7.098037,39.030073],[-7.498632,39.629571],[-7.066592,39.711892],[-7.026413,40.184524],[-6.86402,40.330872],[-6.851127,41.111083],[-6.389088,41.381815],[-6.668606,41.883387],[-7.251309,41.918346],[-7.422513,41.792075],[-8.013175,41.790886],[-8.263857,42.280469],[-8.671946,42.134689],[-9.034818,41.880571]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Estonia","SOV_A3":"EST","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Estonia","ADM0_A3":"EST","GEOU_DIF":0,"GEOUNIT":"Estonia","GU_A3":"EST","SU_DIF":0,"SUBUNIT":"Estonia","SU_A3":"EST","BRK_DIFF":0,"NAME":"Estonia","NAME_LONG":"Estonia","BRK_A3":"EST","BRK_NAME":"Estonia","BRK_GROUP":null,"ABBREV":"Est.","POSTAL":"EST","FORMAL_EN":"Republic of Estonia","FORMAL_FR":null,"NAME_CIAWF":"Estonia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Estonia","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":1,"MAPCOLOR13":10,"POP_EST":1251581,"POP_RANK":12,"GDP_MD_EST":38700,"POP_YEAR":2017,"LASTCENSUS":2000,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"EN","ISO_A2":"EE","ISO_A3":"EST","ISO_A3_EH":"EST","ISO_N3":"233","UN_A3":"233","WB_A2":"EE","WB_A3":"EST","WOE_ID":23424805,"WOE_ID_EH":23424805,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"EST","ADM0_A3_US":"EST","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[23.339795,57.474528,28.131699,59.61109],"geometry":{"type":"Polygon","coordinates":[[[24.312863,57.793424],[24.428928,58.383413],[24.061198,58.257375],[23.42656,58.612753],[23.339795,59.18724],[24.604214,59.465854],[25.864189,59.61109],[26.949136,59.445803],[27.981114,59.475388],[27.981127,59.475373],[27.98112,59.47537],[28.131699,59.300825],[27.42015,58.72457],[27.716686,57.791899],[27.288185,57.474528],[26.463532,57.476389],[25.60281,57.847529],[25.164594,57.970157],[24.312863,57.793424]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Ethiopia","SOV_A3":"ETH","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Ethiopia","ADM0_A3":"ETH","GEOU_DIF":0,"GEOUNIT":"Ethiopia","GU_A3":"ETH","SU_DIF":0,"SUBUNIT":"Ethiopia","SU_A3":"ETH","BRK_DIFF":0,"NAME":"Ethiopia","NAME_LONG":"Ethiopia","BRK_A3":"ETH","BRK_NAME":"Ethiopia","BRK_GROUP":null,"ABBREV":"Eth.","POSTAL":"ET","FORMAL_EN":"Federal Democratic Republic of Ethiopia","FORMAL_FR":null,"NAME_CIAWF":"Ethiopia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Ethiopia","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":13,"POP_EST":105350020,"POP_RANK":17,"GDP_MD_EST":174700,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"ET","ISO_A2":"ET","ISO_A3":"ETH","ISO_A3_EH":"ETH","ISO_N3":"231","UN_A3":"231","WB_A2":"ET","WB_A3":"ETH","WOE_ID":23424808,"WOE_ID_EH":23424808,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ETH","ADM0_A3_US":"ETH","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[32.95418,3.42206,47.78942,14.95943],"geometry":{"type":"Polygon","coordinates":[[[42.35156,12.54223],[42,12.1],[41.66176,11.6312],[41.73959,11.35511],[41.75557,11.05091],[42.31414,11.0342],[42.55493,11.10511],[42.776852,10.926879],[42.55876,10.57258],[42.92812,10.02194],[43.29699,9.54048],[43.67875,9.18358],[46.94834,7.99688],[47.78942,8.003],[44.9636,5.00162],[43.66087,4.95755],[42.76967,4.25259],[42.12861,4.23413],[41.855083,3.918912],[41.1718,3.91909],[40.76848,4.25702],[39.85494,3.83879],[39.559384,3.42206],[38.89251,3.50074],[38.67114,3.61607],[38.43697,3.58851],[38.120915,3.598605],[36.855093,4.447864],[36.159079,4.447864],[35.817448,4.776966],[35.817448,5.338232],[35.298007,5.506],[34.70702,6.59422],[34.25032,6.82607],[34.0751,7.22595],[33.56829,7.71334],[32.95418,7.78497],[33.2948,8.35458],[33.8255,8.37916],[33.97498,8.68456],[33.96162,9.58358],[34.25745,10.63009],[34.73115,10.91017],[34.83163,11.31896],[35.26049,12.08286],[35.86363,12.57828],[36.27022,13.56333],[36.42951,14.42211],[37.59377,14.2131],[37.90607,14.95943],[38.51295,14.50547],[39.0994,14.74064],[39.34061,14.53155],[40.02625,14.51959],[40.8966,14.11864],[41.1552,13.77333],[41.59856,13.45209],[42.00975,12.86582],[42.35156,12.54223]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Finland","SOV_A3":"FI1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"Finland","ADM0_A3":"FIN","GEOU_DIF":0,"GEOUNIT":"Finland","GU_A3":"FIN","SU_DIF":0,"SUBUNIT":"Finland","SU_A3":"FIN","BRK_DIFF":0,"NAME":"Finland","NAME_LONG":"Finland","BRK_A3":"FIN","BRK_NAME":"Finland","BRK_GROUP":null,"ABBREV":"Fin.","POSTAL":"FIN","FORMAL_EN":"Republic of Finland","FORMAL_FR":null,"NAME_CIAWF":"Finland","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Finland","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":1,"MAPCOLOR9":4,"MAPCOLOR13":6,"POP_EST":5491218,"POP_RANK":13,"GDP_MD_EST":224137,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"FI","ISO_A2":"FI","ISO_A3":"FIN","ISO_A3_EH":"FIN","ISO_N3":"246","UN_A3":"246","WB_A2":"FI","WB_A3":"FIN","WOE_ID":23424812,"WOE_ID_EH":23424812,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"FIN","ADM0_A3_US":"FIN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[20.645593,59.846373,31.516092,70.164193],"geometry":{"type":"Polygon","coordinates":[[[23.903379,66.006927],[23.56588,66.396051],[23.539473,67.936009],[21.978535,68.616846],[20.645593,69.106247],[21.244936,69.370443],[22.356238,68.841741],[23.66205,68.891247],[24.735679,68.649557],[25.689213,69.092114],[26.179622,69.825299],[27.732292,70.164193],[29.015573,69.766491],[28.59193,69.064777],[28.445944,68.364613],[29.977426,67.698297],[29.054589,66.944286],[30.21765,65.80598],[29.54443,64.948672],[30.444685,64.204453],[30.035872,63.552814],[31.516092,62.867687],[31.139991,62.357693],[30.211107,61.780028],[28.07,60.50352],[28.070002,60.503519],[28.069998,60.503517],[26.255173,60.423961],[24.496624,60.057316],[22.869695,59.846373],[22.290764,60.391921],[21.322244,60.72017],[21.544866,61.705329],[21.059211,62.607393],[21.536029,63.189735],[22.442744,63.81781],[24.730512,64.902344],[25.398068,65.111427],[25.294043,65.534346],[23.903379,66.006927]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Fiji","SOV_A3":"FJI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Fiji","ADM0_A3":"FJI","GEOU_DIF":0,"GEOUNIT":"Fiji","GU_A3":"FJI","SU_DIF":0,"SUBUNIT":"Fiji","SU_A3":"FJI","BRK_DIFF":0,"NAME":"Fiji","NAME_LONG":"Fiji","BRK_A3":"FJI","BRK_NAME":"Fiji","BRK_GROUP":null,"ABBREV":"Fiji","POSTAL":"FJ","FORMAL_EN":"Republic of Fiji","FORMAL_FR":null,"NAME_CIAWF":"Fiji","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Fiji","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":1,"MAPCOLOR9":2,"MAPCOLOR13":2,"POP_EST":920938,"POP_RANK":11,"GDP_MD_EST":8374,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"FJ","ISO_A2":"FJ","ISO_A3":"FJI","ISO_A3_EH":"FJI","ISO_N3":"242","UN_A3":"242","WB_A2":"FJ","WB_A3":"FJI","WOE_ID":23424813,"WOE_ID_EH":23424813,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"FJI","ADM0_A3_US":"FJI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Oceania","REGION_UN":"Oceania","SUBREGION":"Melanesia","REGION_WB":"East Asia & Pacific","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-180,-18.28799,180,-16.020882],"geometry":{"type":"MultiPolygon","coordinates":[[[[178.3736,-17.33992],[178.71806,-17.62846],[178.55271,-18.15059],[177.93266,-18.28799],[177.38146,-18.16432],[177.28504,-17.72465],[177.67087,-17.38114],[178.12557,-17.50481],[178.3736,-17.33992]]],[[[179.364143,-16.801354],[178.725059,-17.012042],[178.596839,-16.63915],[179.096609,-16.433984],[179.413509,-16.379054],[180,-16.067133],[180,-16.555217],[179.364143,-16.801354]]],[[[-179.917369,-16.501783],[-180,-16.555217],[-180,-16.067133],[-179.79332,-16.020882],[-179.917369,-16.501783]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"United Kingdom","SOV_A3":"GB1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Dependency","ADMIN":"Falkland Islands","ADM0_A3":"FLK","GEOU_DIF":0,"GEOUNIT":"Falkland Islands","GU_A3":"FLK","SU_DIF":0,"SUBUNIT":"Falkland Islands","SU_A3":"FLK","BRK_DIFF":1,"NAME":"Falkland Is.","NAME_LONG":"Falkland Islands","BRK_A3":"B12","BRK_NAME":"Falkland Is.","BRK_GROUP":null,"ABBREV":"Flk. Is.","POSTAL":"FK","FORMAL_EN":"Falkland Islands","FORMAL_FR":null,"NAME_CIAWF":"Falkland Islands (Islas Malvinas)","NOTE_ADM0":"U.K.","NOTE_BRK":"Admin. by U.K.; Claimed by Argentina","NAME_SORT":"Falkland Islands","NAME_ALT":"Islas Malvinas","MAPCOLOR7":6,"MAPCOLOR8":6,"MAPCOLOR9":6,"MAPCOLOR13":3,"POP_EST":2931,"POP_RANK":4,"GDP_MD_EST":281.8,"POP_YEAR":2014,"LASTCENSUS":-99,"GDP_YEAR":2012,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"FK","ISO_A2":"FK","ISO_A3":"FLK","ISO_A3_EH":"FLK","ISO_N3":"238","UN_A3":"238","WB_A2":"-99","WB_A3":"-99","WOE_ID":23424814,"WOE_ID_EH":23424814,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"FLK","ADM0_A3_US":"FLK","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":12,"LONG_LEN":16,"ABBREV_LEN":8,"TINY":-99,"HOMEPART":-99,"MIN_ZOOM":0,"MIN_LABEL":4.5,"MAX_LABEL":9},"bbox":[-61.2,-52.3,-57.75,-51.1],"geometry":{"type":"Polygon","coordinates":[[[-61.2,-51.85],[-60,-51.25],[-59.15,-51.5],[-58.55,-51.1],[-57.75,-51.55],[-58.05,-51.9],[-59.4,-52.2],[-59.85,-51.85],[-60.7,-52.3],[-61.2,-51.85]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"France","SOV_A3":"FR1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"France","ADM0_A3":"FRA","GEOU_DIF":0,"GEOUNIT":"France","GU_A3":"FRA","SU_DIF":0,"SUBUNIT":"France","SU_A3":"FRA","BRK_DIFF":0,"NAME":"France","NAME_LONG":"France","BRK_A3":"FRA","BRK_NAME":"France","BRK_GROUP":null,"ABBREV":"Fr.","POSTAL":"F","FORMAL_EN":"French Republic","FORMAL_FR":null,"NAME_CIAWF":"France","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"France","NAME_ALT":null,"MAPCOLOR7":7,"MAPCOLOR8":5,"MAPCOLOR9":9,"MAPCOLOR13":11,"POP_EST":67106161,"POP_RANK":16,"GDP_MD_EST":2699000,"POP_YEAR":2017,"LASTCENSUS":-99,"GDP_YEAR":2016,"ECONOMY":"1. Developed region: G7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"FR","ISO_A2":"-99","ISO_A3":"-99","ISO_A3_EH":"-99","ISO_N3":"250","UN_A3":"250","WB_A2":"FR","WB_A3":"FRA","WOE_ID":-90,"WOE_ID_EH":23424819,"WOE_NOTE":"Includes only Metropolitan France (including Corsica)","ADM0_A3_IS":"FRA","ADM0_A3_US":"FRA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Western Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":3,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[-54.524754,2.053389,9.560016,51.148506],"geometry":{"type":"MultiPolygon","coordinates":[[[[2.513573,51.148506],[2.658422,50.796848],[3.123252,50.780363],[3.588184,50.378992],[4.286023,49.907497],[4.799222,49.985373],[5.674052,49.529484],[5.897759,49.442667],[6.18632,49.463803],[6.65823,49.201958],[8.099279,49.017784],[7.593676,48.333019],[7.466759,47.620582],[7.192202,47.449766],[6.736571,47.541801],[6.768714,47.287708],[6.037389,46.725779],[6.022609,46.27299],[6.5001,46.429673],[6.843593,45.991147],[6.802355,45.70858],[7.096652,45.333099],[6.749955,45.028518],[7.007562,44.254767],[7.549596,44.127901],[7.435185,43.693845],[6.529245,43.128892],[4.556963,43.399651],[3.100411,43.075201],[2.985999,42.473015],[1.826793,42.343385],[0.701591,42.795734],[0.338047,42.579546],[-1.502771,43.034014],[-1.901351,43.422802],[-1.384225,44.02261],[-1.193798,46.014918],[-2.225724,47.064363],[-2.963276,47.570327],[-4.491555,47.954954],[-4.59235,48.68416],[-3.295814,48.901692],[-1.616511,48.644421],[-1.933494,49.776342],[-0.989469,49.347376],[1.338761,50.127173],[1.639001,50.946606],[2.513573,51.148506]]],[[[-51.657797,4.156232],[-52.249338,3.241094],[-52.556425,2.504705],[-52.939657,2.124858],[-53.418465,2.053389],[-53.554839,2.334897],[-53.778521,2.376703],[-54.088063,2.105557],[-54.524754,2.311849],[-54.27123,2.738748],[-54.184284,3.194172],[-54.011504,3.62257],[-54.399542,4.212611],[-54.478633,4.896756],[-53.958045,5.756548],[-53.618453,5.646529],[-52.882141,5.409851],[-51.823343,4.565768],[-51.657797,4.156232]]],[[[9.560016,42.152492],[9.229752,41.380007],[8.775723,41.583612],[8.544213,42.256517],[8.746009,42.628122],[9.390001,43.009985],[9.560016,42.152492]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Gabon","SOV_A3":"GAB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Gabon","ADM0_A3":"GAB","GEOU_DIF":0,"GEOUNIT":"Gabon","GU_A3":"GAB","SU_DIF":0,"SUBUNIT":"Gabon","SU_A3":"GAB","BRK_DIFF":0,"NAME":"Gabon","NAME_LONG":"Gabon","BRK_A3":"GAB","BRK_NAME":"Gabon","BRK_GROUP":null,"ABBREV":"Gabon","POSTAL":"GA","FORMAL_EN":"Gabonese Republic","FORMAL_FR":null,"NAME_CIAWF":"Gabon","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Gabon","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":2,"MAPCOLOR9":5,"MAPCOLOR13":5,"POP_EST":1772255,"POP_RANK":12,"GDP_MD_EST":35980,"POP_YEAR":2017,"LASTCENSUS":2003,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"GB","ISO_A2":"GA","ISO_A3":"GAB","ISO_A3_EH":"GAB","ISO_N3":"266","UN_A3":"266","WB_A2":"GA","WB_A3":"GAB","WOE_ID":23424822,"WOE_ID_EH":23424822,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GAB","ADM0_A3_US":"GAB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":3,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[8.797996,-3.978827,14.425456,2.326758],"geometry":{"type":"Polygon","coordinates":[[[11.276449,2.261051],[11.751665,2.326758],[12.35938,2.192812],[12.951334,2.321616],[13.075822,2.267097],[13.003114,1.830896],[13.282631,1.314184],[14.026669,1.395677],[14.276266,1.19693],[13.843321,0.038758],[14.316418,-0.552627],[14.425456,-1.333407],[14.29921,-1.998276],[13.992407,-2.470805],[13.109619,-2.42874],[12.575284,-1.948511],[12.495703,-2.391688],[11.820964,-2.514161],[11.478039,-2.765619],[11.855122,-3.426871],[11.093773,-3.978827],[10.066135,-2.969483],[9.405245,-2.144313],[8.797996,-1.111301],[8.830087,-0.779074],[9.04842,-0.459351],[9.291351,0.268666],[9.492889,1.01012],[9.830284,1.067894],[11.285079,1.057662],[11.276449,2.261051]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"United Kingdom","SOV_A3":"GB1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"United Kingdom","ADM0_A3":"GBR","GEOU_DIF":0,"GEOUNIT":"United Kingdom","GU_A3":"GBR","SU_DIF":0,"SUBUNIT":"United Kingdom","SU_A3":"GBR","BRK_DIFF":0,"NAME":"United Kingdom","NAME_LONG":"United Kingdom","BRK_A3":"GBR","BRK_NAME":"United Kingdom","BRK_GROUP":null,"ABBREV":"U.K.","POSTAL":"GB","FORMAL_EN":"United Kingdom of Great Britain and Northern Ireland","FORMAL_FR":null,"NAME_CIAWF":"United Kingdom","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"United Kingdom","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":6,"MAPCOLOR9":6,"MAPCOLOR13":3,"POP_EST":64769452,"POP_RANK":16,"GDP_MD_EST":2788000,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"1. Developed region: G7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"UK","ISO_A2":"GB","ISO_A3":"GBR","ISO_A3_EH":"GBR","ISO_N3":"826","UN_A3":"826","WB_A2":"GB","WB_A3":"GBR","WOE_ID":-90,"WOE_ID_EH":23424975,"WOE_NOTE":"Eh ID includes Channel Islands and Isle of Man. UK constituent countries of England (24554868), Wales (12578049), Scotland (12578048), and Northern Ireland (20070563).","ADM0_A3_IS":"GBR","ADM0_A3_US":"GBR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":14,"LONG_LEN":14,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[-7.572168,49.96,1.681531,58.635],"geometry":{"type":"MultiPolygon","coordinates":[[[[-6.197885,53.867565],[-6.95373,54.073702],[-7.572168,54.059956],[-7.366031,54.595841],[-7.572168,55.131622],[-6.733847,55.17286],[-5.661949,54.554603],[-6.197885,53.867565]]],[[[-3.005005,58.635],[-4.073828,57.553025],[-3.055002,57.690019],[-1.959281,57.6848],[-2.219988,56.870017],[-3.119003,55.973793],[-2.085009,55.909998],[-2.005676,55.804903],[-1.114991,54.624986],[-0.430485,54.464376],[0.184981,53.325014],[0.469977,52.929999],[1.681531,52.73952],[1.559988,52.099998],[1.050562,51.806761],[1.449865,51.289428],[0.550334,50.765739],[-0.787517,50.774989],[-2.489998,50.500019],[-2.956274,50.69688],[-3.617448,50.228356],[-4.542508,50.341837],[-5.245023,49.96],[-5.776567,50.159678],[-4.30999,51.210001],[-3.414851,51.426009],[-3.422719,51.426848],[-4.984367,51.593466],[-5.267296,51.9914],[-4.222347,52.301356],[-4.770013,52.840005],[-4.579999,53.495004],[-3.093831,53.404547],[-3.09208,53.404441],[-2.945009,53.985],[-3.614701,54.600937],[-3.630005,54.615013],[-4.844169,54.790971],[-5.082527,55.061601],[-4.719112,55.508473],[-5.047981,55.783986],[-5.586398,55.311146],[-5.644999,56.275015],[-6.149981,56.78501],[-5.786825,57.818848],[-5.009999,58.630013],[-4.211495,58.550845],[-3.005005,58.635]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Georgia","SOV_A3":"GEO","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Georgia","ADM0_A3":"GEO","GEOU_DIF":0,"GEOUNIT":"Georgia","GU_A3":"GEO","SU_DIF":0,"SUBUNIT":"Georgia","SU_A3":"GEO","BRK_DIFF":0,"NAME":"Georgia","NAME_LONG":"Georgia","BRK_A3":"GEO","BRK_NAME":"Georgia","BRK_GROUP":null,"ABBREV":"Geo.","POSTAL":"GE","FORMAL_EN":"Georgia","FORMAL_FR":null,"NAME_CIAWF":"Georgia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Georgia","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":2,"POP_EST":4926330,"POP_RANK":12,"GDP_MD_EST":37270,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"GG","ISO_A2":"GE","ISO_A3":"GEO","ISO_A3_EH":"GEO","ISO_N3":"268","UN_A3":"268","WB_A2":"GE","WB_A3":"GEO","WOE_ID":23424823,"WOE_ID_EH":23424823,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GEO","ADM0_A3_US":"GEO","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[39.955009,41.064445,46.637908,43.553104],"geometry":{"type":"Polygon","coordinates":[[[44.97248,41.248129],[43.582746,41.092143],[42.619549,41.583173],[41.554084,41.535656],[41.703171,41.962943],[41.45347,42.645123],[40.875469,43.013628],[40.321394,43.128634],[39.955009,43.434998],[40.076965,43.553104],[40.92219,43.38215],[42.3944,43.2203],[43.75599,42.74083],[43.93121,42.55496],[44.537623,42.711993],[45.470279,42.502781],[45.7764,42.09244],[46.404951,41.860675],[46.145432,41.722802],[46.637908,41.181673],[46.501637,41.064445],[45.962601,41.123873],[45.217426,41.411452],[44.97248,41.248129]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Ghana","SOV_A3":"GHA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Ghana","ADM0_A3":"GHA","GEOU_DIF":0,"GEOUNIT":"Ghana","GU_A3":"GHA","SU_DIF":0,"SUBUNIT":"Ghana","SU_A3":"GHA","BRK_DIFF":0,"NAME":"Ghana","NAME_LONG":"Ghana","BRK_A3":"GHA","BRK_NAME":"Ghana","BRK_GROUP":null,"ABBREV":"Ghana","POSTAL":"GH","FORMAL_EN":"Republic of Ghana","FORMAL_FR":null,"NAME_CIAWF":"Ghana","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Ghana","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":3,"MAPCOLOR9":1,"MAPCOLOR13":4,"POP_EST":27499924,"POP_RANK":15,"GDP_MD_EST":120800,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"GH","ISO_A2":"GH","ISO_A3":"GHA","ISO_A3_EH":"GHA","ISO_N3":"288","UN_A3":"288","WB_A2":"GH","WB_A3":"GHA","WOE_ID":23424824,"WOE_ID_EH":23424824,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GHA","ADM0_A3_US":"GHA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-3.24437,4.710462,1.060122,11.098341],"geometry":{"type":"Polygon","coordinates":[[[-2.827496,9.642461],[-2.963896,10.395335],[-2.940409,10.96269],[-1.203358,11.009819],[-0.761576,10.93693],[-0.438702,11.098341],[0.023803,11.018682],[-0.049785,10.706918],[0.36758,10.191213],[0.365901,9.465004],[0.461192,8.677223],[0.712029,8.312465],[0.490957,7.411744],[0.570384,6.914359],[0.836931,6.279979],[1.060122,5.928837],[-0.507638,5.343473],[-1.063625,5.000548],[-1.964707,4.710462],[-2.856125,4.994476],[-2.810701,5.389051],[-3.24437,6.250472],[-2.983585,7.379705],[-2.56219,8.219628],[-2.827496,9.642461]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Guinea","SOV_A3":"GIN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Guinea","ADM0_A3":"GIN","GEOU_DIF":0,"GEOUNIT":"Guinea","GU_A3":"GIN","SU_DIF":0,"SUBUNIT":"Guinea","SU_A3":"GIN","BRK_DIFF":0,"NAME":"Guinea","NAME_LONG":"Guinea","BRK_A3":"GIN","BRK_NAME":"Guinea","BRK_GROUP":null,"ABBREV":"Gin.","POSTAL":"GN","FORMAL_EN":"Republic of Guinea","FORMAL_FR":null,"NAME_CIAWF":"Guinea","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Guinea","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":3,"MAPCOLOR9":7,"MAPCOLOR13":2,"POP_EST":12413867,"POP_RANK":14,"GDP_MD_EST":16080,"POP_YEAR":2017,"LASTCENSUS":1996,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"GV","ISO_A2":"GN","ISO_A3":"GIN","ISO_A3_EH":"GIN","ISO_N3":"324","UN_A3":"324","WB_A2":"GN","WB_A3":"GIN","WOE_ID":23424835,"WOE_ID_EH":23424835,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GIN","ADM0_A3_US":"GIN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-15.130311,7.309037,-7.8321,12.586183],"geometry":{"type":"Polygon","coordinates":[[[-8.029944,10.206535],[-8.229337,10.12902],[-8.309616,9.789532],[-8.079114,9.376224],[-7.8321,8.575704],[-8.203499,8.455453],[-8.299049,8.316444],[-8.221792,8.123329],[-8.280703,7.68718],[-8.439298,7.686043],[-8.722124,7.711674],[-8.926065,7.309037],[-9.208786,7.313921],[-9.403348,7.526905],[-9.33728,7.928534],[-9.755342,8.541055],[-10.016567,8.428504],[-10.230094,8.406206],[-10.505477,8.348896],[-10.494315,8.715541],[-10.65477,8.977178],[-10.622395,9.26791],[-10.839152,9.688246],[-11.117481,10.045873],[-11.917277,10.046984],[-12.150338,9.858572],[-12.425929,9.835834],[-12.596719,9.620188],[-12.711958,9.342712],[-13.24655,8.903049],[-13.685154,9.494744],[-14.074045,9.886167],[-14.330076,10.01572],[-14.579699,10.214467],[-14.693232,10.656301],[-14.839554,10.876572],[-15.130311,11.040412],[-14.685687,11.527824],[-14.382192,11.509272],[-14.121406,11.677117],[-13.9008,11.678719],[-13.743161,11.811269],[-13.828272,12.142644],[-13.718744,12.247186],[-13.700476,12.586183],[-13.217818,12.575874],[-12.499051,12.33209],[-12.278599,12.35444],[-12.203565,12.465648],[-11.658301,12.386583],[-11.513943,12.442988],[-11.456169,12.076834],[-11.297574,12.077971],[-11.036556,12.211245],[-10.87083,12.177887],[-10.593224,11.923975],[-10.165214,11.844084],[-9.890993,12.060479],[-9.567912,12.194243],[-9.327616,12.334286],[-9.127474,12.30806],[-8.905265,12.088358],[-8.786099,11.812561],[-8.376305,11.393646],[-8.581305,11.136246],[-8.620321,10.810891],[-8.407311,10.909257],[-8.282357,10.792597],[-8.335377,10.494812],[-8.029944,10.206535]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Gambia","SOV_A3":"GMB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Gambia","ADM0_A3":"GMB","GEOU_DIF":0,"GEOUNIT":"Gambia","GU_A3":"GMB","SU_DIF":0,"SUBUNIT":"Gambia","SU_A3":"GMB","BRK_DIFF":0,"NAME":"Gambia","NAME_LONG":"The Gambia","BRK_A3":"GMB","BRK_NAME":"Gambia","BRK_GROUP":null,"ABBREV":"Gambia","POSTAL":"GM","FORMAL_EN":"Republic of the Gambia","FORMAL_FR":null,"NAME_CIAWF":"Gambia, The","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Gambia, The","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":8,"POP_EST":2051363,"POP_RANK":12,"GDP_MD_EST":3387,"POP_YEAR":2017,"LASTCENSUS":2003,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"GA","ISO_A2":"GM","ISO_A3":"GMB","ISO_A3_EH":"GMB","ISO_N3":"270","UN_A3":"270","WB_A2":"GM","WB_A3":"GMB","WOE_ID":23424821,"WOE_ID_EH":23424821,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GMB","ADM0_A3_US":"GMB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":6,"LONG_LEN":10,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[-16.841525,13.130284,-13.844963,13.876492],"geometry":{"type":"Polygon","coordinates":[[[-16.841525,13.151394],[-16.713729,13.594959],[-15.624596,13.623587],[-15.39877,13.860369],[-15.081735,13.876492],[-14.687031,13.630357],[-14.376714,13.62568],[-14.046992,13.794068],[-13.844963,13.505042],[-14.277702,13.280585],[-14.712197,13.298207],[-15.141163,13.509512],[-15.511813,13.27857],[-15.691001,13.270353],[-15.931296,13.130284],[-16.841525,13.151394]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Guinea-Bissau","SOV_A3":"GNB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Guinea-Bissau","ADM0_A3":"GNB","GEOU_DIF":0,"GEOUNIT":"Guinea-Bissau","GU_A3":"GNB","SU_DIF":0,"SUBUNIT":"Guinea-Bissau","SU_A3":"GNB","BRK_DIFF":0,"NAME":"Guinea-Bissau","NAME_LONG":"Guinea-Bissau","BRK_A3":"GNB","BRK_NAME":"Guinea-Bissau","BRK_GROUP":null,"ABBREV":"GnB.","POSTAL":"GW","FORMAL_EN":"Republic of Guinea-Bissau","FORMAL_FR":null,"NAME_CIAWF":"Guinea-Bissau","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Guinea-Bissau","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":5,"MAPCOLOR9":3,"MAPCOLOR13":4,"POP_EST":1792338,"POP_RANK":12,"GDP_MD_EST":2851,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"PU","ISO_A2":"GW","ISO_A3":"GNB","ISO_A3_EH":"GNB","ISO_N3":"624","UN_A3":"624","WB_A2":"GW","WB_A3":"GNB","WOE_ID":23424929,"WOE_ID_EH":23424929,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GNB","ADM0_A3_US":"GNB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":13,"LONG_LEN":13,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[-16.677452,11.040412,-13.700476,12.62817],"geometry":{"type":"Polygon","coordinates":[[[-13.700476,12.586183],[-13.718744,12.247186],[-13.828272,12.142644],[-13.743161,11.811269],[-13.9008,11.678719],[-14.121406,11.677117],[-14.382192,11.509272],[-14.685687,11.527824],[-15.130311,11.040412],[-15.66418,11.458474],[-16.085214,11.524594],[-16.314787,11.806515],[-16.308947,11.958702],[-16.613838,12.170911],[-16.677452,12.384852],[-16.147717,12.547762],[-15.816574,12.515567],[-15.548477,12.62817],[-13.700476,12.586183]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Equatorial Guinea","SOV_A3":"GNQ","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Equatorial Guinea","ADM0_A3":"GNQ","GEOU_DIF":0,"GEOUNIT":"Equatorial Guinea","GU_A3":"GNQ","SU_DIF":0,"SUBUNIT":"Equatorial Guinea","SU_A3":"GNQ","BRK_DIFF":0,"NAME":"Eq. Guinea","NAME_LONG":"Equatorial Guinea","BRK_A3":"GNQ","BRK_NAME":"Eq. Guinea","BRK_GROUP":null,"ABBREV":"Eq. G.","POSTAL":"GQ","FORMAL_EN":"Republic of Equatorial Guinea","FORMAL_FR":null,"NAME_CIAWF":"Equatorial Guinea","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Equatorial Guinea","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":1,"MAPCOLOR9":4,"MAPCOLOR13":8,"POP_EST":778358,"POP_RANK":11,"GDP_MD_EST":31770,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"EK","ISO_A2":"GQ","ISO_A3":"GNQ","ISO_A3_EH":"GNQ","ISO_N3":"226","UN_A3":"226","WB_A2":"GQ","WB_A3":"GNQ","WOE_ID":23424804,"WOE_ID_EH":23424804,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GNQ","ADM0_A3_US":"GNQ","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":10,"LONG_LEN":17,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[9.305613,1.01012,11.285079,2.283866],"geometry":{"type":"Polygon","coordinates":[[[9.649158,2.283866],[11.276449,2.261051],[11.285079,1.057662],[9.830284,1.067894],[9.492889,1.01012],[9.305613,1.160911],[9.649158,2.283866]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Greece","SOV_A3":"GRC","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Greece","ADM0_A3":"GRC","GEOU_DIF":0,"GEOUNIT":"Greece","GU_A3":"GRC","SU_DIF":0,"SUBUNIT":"Greece","SU_A3":"GRC","BRK_DIFF":0,"NAME":"Greece","NAME_LONG":"Greece","BRK_A3":"GRC","BRK_NAME":"Greece","BRK_GROUP":null,"ABBREV":"Greece","POSTAL":"GR","FORMAL_EN":"Hellenic Republic","FORMAL_FR":null,"NAME_CIAWF":"Greece","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Greece","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":9,"POP_EST":10768477,"POP_RANK":14,"GDP_MD_EST":290500,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"GR","ISO_A2":"GR","ISO_A3":"GRC","ISO_A3_EH":"GRC","ISO_N3":"300","UN_A3":"300","WB_A2":"GR","WB_A3":"GRC","WOE_ID":23424833,"WOE_ID_EH":23424833,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GRC","ADM0_A3_US":"GRC","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[20.150016,34.919988,26.604196,41.826905],"geometry":{"type":"MultiPolygon","coordinates":[[[[20.150016,39.624998],[20.615,40.110007],[20.674997,40.435],[20.99999,40.580004],[21.02004,40.842727],[21.674161,40.931275],[22.055378,41.149866],[22.597308,41.130487],[22.76177,41.3048],[22.952377,41.337994],[23.692074,41.309081],[24.492645,41.583896],[25.197201,41.234486],[26.106138,41.328899],[26.117042,41.826905],[26.604196,41.562115],[26.294602,40.936261],[26.056942,40.824123],[25.447677,40.852545],[24.925848,40.947062],[23.714811,40.687129],[24.407999,40.124993],[23.899968,39.962006],[23.342999,39.960998],[22.813988,40.476005],[22.626299,40.256561],[22.849748,39.659311],[23.350027,39.190011],[22.973099,38.970903],[23.530016,38.510001],[24.025025,38.219993],[24.040011,37.655015],[23.115003,37.920011],[23.409972,37.409991],[22.774972,37.30501],[23.154225,36.422506],[22.490028,36.41],[21.670026,36.844986],[21.295011,37.644989],[21.120034,38.310323],[20.730032,38.769985],[20.217712,39.340235],[20.150016,39.624998]]],[[[23.69998,35.705004],[24.246665,35.368022],[25.025015,35.424996],[25.769208,35.354018],[25.745023,35.179998],[26.290003,35.29999],[26.164998,35.004995],[24.724982,34.919988],[24.735007,35.084991],[23.514978,35.279992],[23.69998,35.705004]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Denmark","SOV_A3":"DN1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"Greenland","ADM0_A3":"GRL","GEOU_DIF":0,"GEOUNIT":"Greenland","GU_A3":"GRL","SU_DIF":0,"SUBUNIT":"Greenland","SU_A3":"GRL","BRK_DIFF":0,"NAME":"Greenland","NAME_LONG":"Greenland","BRK_A3":"GRL","BRK_NAME":"Greenland","BRK_GROUP":null,"ABBREV":"Grlnd.","POSTAL":"GL","FORMAL_EN":"Greenland","FORMAL_FR":null,"NAME_CIAWF":"Greenland","NOTE_ADM0":"Den.","NOTE_BRK":null,"NAME_SORT":"Greenland","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":12,"POP_EST":57713,"POP_RANK":8,"GDP_MD_EST":2173,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2015,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"GL","ISO_A2":"GL","ISO_A3":"GRL","ISO_A3_EH":"GRL","ISO_N3":"304","UN_A3":"304","WB_A2":"GL","WB_A3":"GRL","WOE_ID":23424828,"WOE_ID_EH":23424828,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GRL","ADM0_A3_US":"GRL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Northern America","REGION_WB":"Europe & Central Asia","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":-99,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[-73.297,60.03676,-12.20855,83.64513],"geometry":{"type":"Polygon","coordinates":[[[-46.76379,82.62796],[-43.40644,83.22516],[-39.89753,83.18018],[-38.62214,83.54905],[-35.08787,83.64513],[-27.10046,83.51966],[-20.84539,82.72669],[-22.69182,82.34165],[-26.51753,82.29765],[-31.9,82.2],[-31.39646,82.02154],[-27.85666,82.13178],[-24.84448,81.78697],[-22.90328,82.09317],[-22.07175,81.73449],[-23.16961,81.15271],[-20.62363,81.52462],[-15.76818,81.91245],[-12.77018,81.71885],[-12.20855,81.29154],[-16.28533,80.58004],[-16.85,80.35],[-20.04624,80.17708],[-17.73035,80.12912],[-18.9,79.4],[-19.70499,78.75128],[-19.67353,77.63859],[-18.47285,76.98565],[-20.03503,76.94434],[-21.67944,76.62795],[-19.83407,76.09808],[-19.59896,75.24838],[-20.66818,75.15585],[-19.37281,74.29561],[-21.59422,74.22382],[-20.43454,73.81713],[-20.76234,73.46436],[-22.17221,73.30955],[-23.56593,73.30663],[-22.31311,72.62928],[-22.29954,72.18409],[-24.27834,72.59788],[-24.79296,72.3302],[-23.44296,72.08016],[-22.13281,71.46898],[-21.75356,70.66369],[-23.53603,70.471],[-24.30702,70.85649],[-25.54341,71.43094],[-25.20135,70.75226],[-26.36276,70.22646],[-23.72742,70.18401],[-22.34902,70.12946],[-25.02927,69.2588],[-27.74737,68.47046],[-30.67371,68.12503],[-31.77665,68.12078],[-32.81105,67.73547],[-34.20196,66.67974],[-36.35284,65.9789],[-37.04378,65.93768],[-38.37505,65.69213],[-39.81222,65.45848],[-40.66899,64.83997],[-40.68281,64.13902],[-41.1887,63.48246],[-42.81938,62.68233],[-42.41666,61.90093],[-42.86619,61.07404],[-43.3784,60.09772],[-44.7875,60.03676],[-46.26364,60.85328],[-48.26294,60.85843],[-49.23308,61.40681],[-49.90039,62.38336],[-51.63325,63.62691],[-52.14014,64.27842],[-52.27659,65.1767],[-53.66166,66.09957],[-53.30161,66.8365],[-53.96911,67.18899],[-52.9804,68.35759],[-51.47536,68.72958],[-51.08041,69.14781],[-50.87122,69.9291],[-52.013585,69.574925],[-52.55792,69.42616],[-53.45629,69.283625],[-54.68336,69.61003],[-54.75001,70.28932],[-54.35884,70.821315],[-53.431315,70.835755],[-51.39014,70.56978],[-53.10937,71.20485],[-54.00422,71.54719],[-55,71.406537],[-55.83468,71.65444],[-54.71819,72.58625],[-55.32634,72.95861],[-56.12003,73.64977],[-57.32363,74.71026],[-58.59679,75.09861],[-58.58516,75.51727],[-61.26861,76.10238],[-63.39165,76.1752],[-66.06427,76.13486],[-68.50438,76.06141],[-69.66485,76.37975],[-71.40257,77.00857],[-68.77671,77.32312],[-66.76397,77.37595],[-71.04293,77.63595],[-73.297,78.04419],[-73.15938,78.43271],[-69.37345,78.91388],[-65.7107,79.39436],[-65.3239,79.75814],[-68.02298,80.11721],[-67.15129,80.51582],[-63.68925,81.21396],[-62.23444,81.3211],[-62.65116,81.77042],[-60.28249,82.03363],[-57.20744,82.19074],[-54.13442,82.19962],[-53.04328,81.88833],[-50.39061,82.43883],[-48.00386,82.06481],[-46.59984,81.985945],[-44.523,81.6607],[-46.9007,82.19979],[-46.76379,82.62796]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Guatemala","SOV_A3":"GTM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Guatemala","ADM0_A3":"GTM","GEOU_DIF":0,"GEOUNIT":"Guatemala","GU_A3":"GTM","SU_DIF":0,"SUBUNIT":"Guatemala","SU_A3":"GTM","BRK_DIFF":0,"NAME":"Guatemala","NAME_LONG":"Guatemala","BRK_A3":"GTM","BRK_NAME":"Guatemala","BRK_GROUP":null,"ABBREV":"Guat.","POSTAL":"GT","FORMAL_EN":"Republic of Guatemala","FORMAL_FR":null,"NAME_CIAWF":"Guatemala","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Guatemala","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":3,"MAPCOLOR9":3,"MAPCOLOR13":6,"POP_EST":15460732,"POP_RANK":14,"GDP_MD_EST":131800,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"GT","ISO_A2":"GT","ISO_A3":"GTM","ISO_A3_EH":"GTM","ISO_N3":"320","UN_A3":"320","WB_A2":"GT","WB_A3":"GTM","WOE_ID":23424834,"WOE_ID_EH":23424834,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GTM","ADM0_A3_US":"GTM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":5,"TINY":4,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-92.229249,13.735338,-88.225023,17.819326],"geometry":{"type":"Polygon","coordinates":[[[-89.14308,17.808319],[-89.150806,17.015577],[-89.229122,15.886938],[-88.930613,15.887273],[-88.604586,15.70638],[-88.518364,15.855389],[-88.225023,15.727722],[-88.68068,15.346247],[-89.154811,15.066419],[-89.22522,14.874286],[-89.145535,14.678019],[-89.353326,14.424133],[-89.587343,14.362586],[-89.534219,14.244816],[-89.721934,14.134228],[-90.064678,13.88197],[-90.095555,13.735338],[-90.608624,13.909771],[-91.23241,13.927832],[-91.689747,14.126218],[-92.22775,14.538829],[-92.20323,14.830103],[-92.087216,15.064585],[-92.229249,15.251447],[-91.74796,16.066565],[-90.464473,16.069562],[-90.438867,16.41011],[-90.600847,16.470778],[-90.711822,16.687483],[-91.08167,16.918477],[-91.453921,17.252177],[-91.002269,17.254658],[-91.00152,17.817595],[-90.067934,17.819326],[-89.14308,17.808319]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Guyana","SOV_A3":"GUY","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Guyana","ADM0_A3":"GUY","GEOU_DIF":0,"GEOUNIT":"Guyana","GU_A3":"GUY","SU_DIF":0,"SUBUNIT":"Guyana","SU_A3":"GUY","BRK_DIFF":0,"NAME":"Guyana","NAME_LONG":"Guyana","BRK_A3":"GUY","BRK_NAME":"Guyana","BRK_GROUP":null,"ABBREV":"Guy.","POSTAL":"GY","FORMAL_EN":"Co-operative Republic of Guyana","FORMAL_FR":null,"NAME_CIAWF":"Guyana","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Guyana","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":1,"MAPCOLOR9":4,"MAPCOLOR13":8,"POP_EST":737718,"POP_RANK":11,"GDP_MD_EST":6093,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"GY","ISO_A2":"GY","ISO_A3":"GUY","ISO_A3_EH":"GUY","ISO_N3":"328","UN_A3":"328","WB_A2":"GY","WB_A3":"GUY","WOE_ID":23424836,"WOE_ID_EH":23424836,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"GUY","ADM0_A3_US":"GUY","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-61.410303,1.268088,-56.539386,8.367035],"geometry":{"type":"Polygon","coordinates":[[[-56.539386,1.899523],[-56.782704,1.863711],[-57.335823,1.948538],[-57.660971,1.682585],[-58.11345,1.507195],[-58.429477,1.463942],[-58.540013,1.268088],[-59.030862,1.317698],[-59.646044,1.786894],[-59.718546,2.24963],[-59.974525,2.755233],[-59.815413,3.606499],[-59.53804,3.958803],[-59.767406,4.423503],[-60.111002,4.574967],[-59.980959,5.014061],[-60.213683,5.244486],[-60.733574,5.200277],[-61.410303,5.959068],[-61.139415,6.234297],[-61.159336,6.696077],[-60.543999,6.856584],[-60.295668,7.043911],[-60.637973,7.415],[-60.550588,7.779603],[-59.758285,8.367035],[-59.101684,7.999202],[-58.482962,7.347691],[-58.454876,6.832787],[-58.078103,6.809094],[-57.542219,6.321268],[-57.147436,5.97315],[-57.307246,5.073567],[-57.914289,4.812626],[-57.86021,4.576801],[-58.044694,4.060864],[-57.601569,3.334655],[-57.281433,3.333492],[-57.150098,2.768927],[-56.539386,1.899523]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Honduras","SOV_A3":"HND","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Honduras","ADM0_A3":"HND","GEOU_DIF":0,"GEOUNIT":"Honduras","GU_A3":"HND","SU_DIF":0,"SUBUNIT":"Honduras","SU_A3":"HND","BRK_DIFF":0,"NAME":"Honduras","NAME_LONG":"Honduras","BRK_A3":"HND","BRK_NAME":"Honduras","BRK_GROUP":null,"ABBREV":"Hond.","POSTAL":"HN","FORMAL_EN":"Republic of Honduras","FORMAL_FR":null,"NAME_CIAWF":"Honduras","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Honduras","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":5,"MAPCOLOR9":2,"MAPCOLOR13":5,"POP_EST":9038741,"POP_RANK":13,"GDP_MD_EST":43190,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"HO","ISO_A2":"HN","ISO_A3":"HND","ISO_A3_EH":"HND","ISO_N3":"340","UN_A3":"340","WB_A2":"HN","WB_A3":"HND","WOE_ID":23424841,"WOE_ID_EH":23424841,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"HND","ADM0_A3_US":"HND","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4.5,"MAX_LABEL":9.5},"bbox":[-89.353326,12.984686,-83.147219,16.005406],"geometry":{"type":"Polygon","coordinates":[[[-89.353326,14.424133],[-89.145535,14.678019],[-89.22522,14.874286],[-89.154811,15.066419],[-88.68068,15.346247],[-88.225023,15.727722],[-88.121153,15.688655],[-87.901813,15.864458],[-87.61568,15.878799],[-87.522921,15.797279],[-87.367762,15.84694],[-86.903191,15.756713],[-86.440946,15.782835],[-86.119234,15.893449],[-86.001954,16.005406],[-85.683317,15.953652],[-85.444004,15.885749],[-85.182444,15.909158],[-84.983722,15.995923],[-84.52698,15.857224],[-84.368256,15.835158],[-84.063055,15.648244],[-83.773977,15.424072],[-83.410381,15.270903],[-83.147219,14.995829],[-83.489989,15.016267],[-83.628585,14.880074],[-83.975721,14.749436],[-84.228342,14.748764],[-84.449336,14.621614],[-84.649582,14.666805],[-84.820037,14.819587],[-84.924501,14.790493],[-85.052787,14.551541],[-85.148751,14.560197],[-85.165365,14.35437],[-85.514413,14.079012],[-85.698665,13.960078],[-85.801295,13.836055],[-86.096264,14.038187],[-86.312142,13.771356],[-86.520708,13.778487],[-86.755087,13.754845],[-86.733822,13.263093],[-86.880557,13.254204],[-87.005769,13.025794],[-87.316654,12.984686],[-87.489409,13.297535],[-87.793111,13.38448],[-87.723503,13.78505],[-87.859515,13.893312],[-88.065343,13.964626],[-88.503998,13.845486],[-88.541231,13.980155],[-88.843073,14.140507],[-89.058512,14.340029],[-89.353326,14.424133]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Croatia","SOV_A3":"HRV","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Croatia","ADM0_A3":"HRV","GEOU_DIF":0,"GEOUNIT":"Croatia","GU_A3":"HRV","SU_DIF":0,"SUBUNIT":"Croatia","SU_A3":"HRV","BRK_DIFF":0,"NAME":"Croatia","NAME_LONG":"Croatia","BRK_A3":"HRV","BRK_NAME":"Croatia","BRK_GROUP":null,"ABBREV":"Cro.","POSTAL":"HR","FORMAL_EN":"Republic of Croatia","FORMAL_FR":null,"NAME_CIAWF":"Croatia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Croatia","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":4,"MAPCOLOR9":5,"MAPCOLOR13":1,"POP_EST":4292095,"POP_RANK":12,"GDP_MD_EST":94240,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"HR","ISO_A2":"HR","ISO_A3":"HRV","ISO_A3_EH":"HRV","ISO_N3":"191","UN_A3":"191","WB_A2":"HR","WB_A3":"HRV","WOE_ID":23424843,"WOE_ID_EH":23424843,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"HRV","ADM0_A3_US":"HRV","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[13.656976,42.479991,19.390476,46.503751],"geometry":{"type":"Polygon","coordinates":[[[19.005485,44.860234],[18.553214,45.08159],[17.861783,45.06774],[17.002146,45.233777],[16.534939,45.211608],[16.318157,45.004127],[15.959367,45.233777],[15.750026,44.818712],[16.23966,44.351143],[16.456443,44.04124],[16.916156,43.667722],[17.297373,43.446341],[17.674922,43.028563],[18.56,42.65],[18.450017,42.479992],[18.450016,42.479991],[17.50997,42.849995],[16.930006,43.209998],[16.015385,43.507215],[15.174454,44.243191],[15.37625,44.317915],[14.920309,44.738484],[14.901602,45.07606],[14.258748,45.233777],[13.952255,44.802124],[13.656976,45.136935],[13.679403,45.484149],[13.71506,45.500324],[14.411968,45.466166],[14.595109,45.634941],[14.935244,45.471695],[15.327675,45.452316],[15.323954,45.731783],[15.67153,45.834154],[15.768733,46.238108],[16.564808,46.503751],[16.882515,46.380632],[17.630066,45.951769],[18.456062,45.759481],[18.829825,45.908872],[19.072769,45.521511],[19.390476,45.236516],[19.005485,44.860234]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Haiti","SOV_A3":"HTI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Haiti","ADM0_A3":"HTI","GEOU_DIF":0,"GEOUNIT":"Haiti","GU_A3":"HTI","SU_DIF":0,"SUBUNIT":"Haiti","SU_A3":"HTI","BRK_DIFF":0,"NAME":"Haiti","NAME_LONG":"Haiti","BRK_A3":"HTI","BRK_NAME":"Haiti","BRK_GROUP":null,"ABBREV":"Haiti","POSTAL":"HT","FORMAL_EN":"Republic of Haiti","FORMAL_FR":null,"NAME_CIAWF":"Haiti","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Haiti","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":1,"MAPCOLOR9":7,"MAPCOLOR13":2,"POP_EST":10646714,"POP_RANK":14,"GDP_MD_EST":19340,"POP_YEAR":2017,"LASTCENSUS":2003,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"HA","ISO_A2":"HT","ISO_A3":"HTI","ISO_A3_EH":"HTI","ISO_N3":"332","UN_A3":"332","WB_A2":"HT","WB_A3":"HTI","WOE_ID":23424839,"WOE_ID_EH":23424839,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"HTI","ADM0_A3_US":"HTI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Caribbean","REGION_WB":"Latin America & Caribbean","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-74.458034,18.030993,-71.624873,19.915684],"geometry":{"type":"Polygon","coordinates":[[[-71.712361,19.714456],[-71.624873,19.169838],[-71.701303,18.785417],[-71.945112,18.6169],[-71.687738,18.31666],[-71.708305,18.044997],[-72.372476,18.214961],[-72.844411,18.145611],[-73.454555,18.217906],[-73.922433,18.030993],[-74.458034,18.34255],[-74.369925,18.664908],[-73.449542,18.526053],[-72.694937,18.445799],[-72.334882,18.668422],[-72.79165,19.101625],[-72.784105,19.483591],[-73.415022,19.639551],[-73.189791,19.915684],[-72.579673,19.871501],[-71.712361,19.714456]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Hungary","SOV_A3":"HUN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Hungary","ADM0_A3":"HUN","GEOU_DIF":0,"GEOUNIT":"Hungary","GU_A3":"HUN","SU_DIF":0,"SUBUNIT":"Hungary","SU_A3":"HUN","BRK_DIFF":0,"NAME":"Hungary","NAME_LONG":"Hungary","BRK_A3":"HUN","BRK_NAME":"Hungary","BRK_GROUP":null,"ABBREV":"Hun.","POSTAL":"HU","FORMAL_EN":"Republic of Hungary","FORMAL_FR":null,"NAME_CIAWF":"Hungary","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Hungary","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":6,"MAPCOLOR9":1,"MAPCOLOR13":5,"POP_EST":9850845,"POP_RANK":13,"GDP_MD_EST":267600,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"HU","ISO_A2":"HU","ISO_A3":"HUN","ISO_A3_EH":"HUN","ISO_N3":"348","UN_A3":"348","WB_A2":"HU","WB_A3":"HUN","WOE_ID":23424844,"WOE_ID_EH":23424844,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"HUN","ADM0_A3_US":"HUN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[16.202298,45.759481,22.710531,48.623854],"geometry":{"type":"Polygon","coordinates":[[[16.202298,46.852386],[16.534268,47.496171],[16.340584,47.712902],[16.903754,47.714866],[16.979667,48.123497],[17.488473,47.867466],[17.857133,47.758429],[18.696513,47.880954],[18.777025,48.081768],[19.174365,48.111379],[19.661364,48.266615],[19.769471,48.202691],[20.239054,48.327567],[20.473562,48.56285],[20.801294,48.623854],[21.872236,48.319971],[22.085608,48.422264],[22.64082,48.15024],[22.710531,47.882194],[22.099768,47.672439],[21.626515,46.994238],[21.021952,46.316088],[20.220192,46.127469],[19.596045,46.17173],[18.829838,45.908878],[18.829825,45.908872],[18.456062,45.759481],[17.630066,45.951769],[16.882515,46.380632],[16.564808,46.503751],[16.370505,46.841327],[16.202298,46.852386]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Indonesia","SOV_A3":"IDN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Indonesia","ADM0_A3":"IDN","GEOU_DIF":0,"GEOUNIT":"Indonesia","GU_A3":"IDN","SU_DIF":0,"SUBUNIT":"Indonesia","SU_A3":"IDN","BRK_DIFF":0,"NAME":"Indonesia","NAME_LONG":"Indonesia","BRK_A3":"IDN","BRK_NAME":"Indonesia","BRK_GROUP":null,"ABBREV":"Indo.","POSTAL":"INDO","FORMAL_EN":"Republic of Indonesia","FORMAL_FR":null,"NAME_CIAWF":"Indonesia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Indonesia","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":6,"MAPCOLOR9":6,"MAPCOLOR13":11,"POP_EST":260580739,"POP_RANK":17,"GDP_MD_EST":3028000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"4. Emerging region: MIKT","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"ID","ISO_A2":"ID","ISO_A3":"IDN","ISO_A3_EH":"IDN","ISO_N3":"360","UN_A3":"360","WB_A2":"ID","WB_A3":"IDN","WOE_ID":23424846,"WOE_ID_EH":23424846,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"IDN","ADM0_A3_US":"IDN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[95.293026,-10.359987,141.033852,5.479821],"geometry":{"type":"MultiPolygon","coordinates":[[[[120.715609,-10.239581],[120.295014,-10.25865],[118.967808,-9.557969],[119.90031,-9.36134],[120.425756,-9.665921],[120.775502,-9.969675],[120.715609,-10.239581]]],[[[124.968682,-8.89279],[125.07002,-9.089987],[125.08852,-9.393173],[124.43595,-10.140001],[123.579982,-10.359987],[123.459989,-10.239995],[123.550009,-9.900016],[123.980009,-9.290027],[124.968682,-8.89279]]],[[[117.900018,-8.095681],[118.260616,-8.362383],[118.87846,-8.280683],[119.126507,-8.705825],[117.970402,-8.906639],[117.277731,-9.040895],[116.740141,-9.032937],[117.083737,-8.457158],[117.632024,-8.449303],[117.900018,-8.095681]]],[[[122.903537,-8.094234],[122.756983,-8.649808],[121.254491,-8.933666],[119.924391,-8.810418],[119.920929,-8.444859],[120.715092,-8.236965],[121.341669,-8.53674],[122.007365,-8.46062],[122.903537,-8.094234]]],[[[108.623479,-6.777674],[110.539227,-6.877358],[110.759576,-6.465186],[112.614811,-6.946036],[112.978768,-7.594213],[114.478935,-7.776528],[115.705527,-8.370807],[114.564511,-8.751817],[113.464734,-8.348947],[112.559672,-8.376181],[111.522061,-8.302129],[110.58615,-8.122605],[109.427667,-7.740664],[108.693655,-7.6416],[108.277763,-7.766657],[106.454102,-7.3549],[106.280624,-6.9249],[105.365486,-6.851416],[106.051646,-5.895919],[107.265009,-5.954985],[108.072091,-6.345762],[108.486846,-6.421985],[108.623479,-6.777674]]],[[[134.724624,-6.214401],[134.210134,-6.895238],[134.112776,-6.142467],[134.290336,-5.783058],[134.499625,-5.445042],[134.727002,-5.737582],[134.724624,-6.214401]]],[[[127.249215,-3.459065],[126.874923,-3.790983],[126.183802,-3.607376],[125.989034,-3.177273],[127.000651,-3.129318],[127.249215,-3.459065]]],[[[130.471344,-3.093764],[130.834836,-3.858472],[129.990547,-3.446301],[129.155249,-3.362637],[128.590684,-3.428679],[127.898891,-3.393436],[128.135879,-2.84365],[129.370998,-2.802154],[130.471344,-3.093764]]],[[[141.00021,-2.600151],[141.017057,-5.859022],[141.033852,-9.117893],[140.143415,-8.297168],[139.127767,-8.096043],[138.881477,-8.380935],[137.614474,-8.411683],[138.039099,-7.597882],[138.668621,-7.320225],[138.407914,-6.232849],[137.92784,-5.393366],[135.98925,-4.546544],[135.164598,-4.462931],[133.66288,-3.538853],[133.367705,-4.024819],[132.983956,-4.112979],[132.756941,-3.746283],[132.753789,-3.311787],[131.989804,-2.820551],[133.066845,-2.460418],[133.780031,-2.479848],[133.696212,-2.214542],[132.232373,-2.212526],[131.836222,-1.617162],[130.94284,-1.432522],[130.519558,-0.93772],[131.867538,-0.695461],[132.380116,-0.369538],[133.985548,-0.78021],[134.143368,-1.151867],[134.422627,-2.769185],[135.457603,-3.367753],[136.293314,-2.307042],[137.440738,-1.703513],[138.329727,-1.702686],[139.184921,-2.051296],[139.926684,-2.409052],[141.00021,-2.600151]]],[[[125.240501,1.419836],[124.437035,0.427881],[123.685505,0.235593],[122.723083,0.431137],[121.056725,0.381217],[120.183083,0.237247],[120.04087,-0.519658],[120.935905,-1.408906],[121.475821,-0.955962],[123.340565,-0.615673],[123.258399,-1.076213],[122.822715,-0.930951],[122.38853,-1.516858],[121.508274,-1.904483],[122.454572,-3.186058],[122.271896,-3.5295],[123.170963,-4.683693],[123.162333,-5.340604],[122.628515,-5.634591],[122.236394,-5.282933],[122.719569,-4.464172],[121.738234,-4.851331],[121.489463,-4.574553],[121.619171,-4.188478],[120.898182,-3.602105],[120.972389,-2.627643],[120.305453,-2.931604],[120.390047,-4.097579],[120.430717,-5.528241],[119.796543,-5.6734],[119.366906,-5.379878],[119.653606,-4.459417],[119.498835,-3.494412],[119.078344,-3.487022],[118.767769,-2.801999],[119.180974,-2.147104],[119.323394,-1.353147],[119.825999,0.154254],[120.035702,0.566477],[120.885779,1.309223],[121.666817,1.013944],[122.927567,0.875192],[124.077522,0.917102],[125.065989,1.643259],[125.240501,1.419836]]],[[[128.688249,1.132386],[128.635952,0.258486],[128.12017,0.356413],[127.968034,-0.252077],[128.379999,-0.780004],[128.100016,-0.899996],[127.696475,-0.266598],[127.39949,1.011722],[127.600512,1.810691],[127.932378,2.174596],[128.004156,1.628531],[128.594559,1.540811],[128.688249,1.132386]]],[[[109.66326,2.006467],[109.830227,1.338136],[110.514061,0.773131],[111.159138,0.976478],[111.797548,0.904441],[112.380252,1.410121],[112.859809,1.49779],[113.80585,1.217549],[114.621355,1.430688],[115.134037,2.821482],[115.519078,3.169238],[115.865517,4.306559],[117.015214,4.306094],[117.882035,4.137551],[117.313232,3.234428],[118.04833,2.28769],[117.875627,1.827641],[118.996747,0.902219],[117.811858,0.784242],[117.478339,0.102475],[117.521644,-0.803723],[116.560048,-1.487661],[116.533797,-2.483517],[116.148084,-4.012726],[116.000858,-3.657037],[114.864803,-4.106984],[114.468652,-3.495704],[113.755672,-3.43917],[113.256994,-3.118776],[112.068126,-3.478392],[111.703291,-2.994442],[111.04824,-3.049426],[110.223846,-2.934032],[110.070936,-1.592874],[109.571948,-1.314907],[109.091874,-0.459507],[108.952658,0.415375],[109.069136,1.341934],[109.66326,2.006467]]],[[[105.817655,-5.852356],[104.710384,-5.873285],[103.868213,-5.037315],[102.584261,-4.220259],[102.156173,-3.614146],[101.399113,-2.799777],[100.902503,-2.050262],[100.141981,-0.650348],[99.26374,0.183142],[98.970011,1.042882],[98.601351,1.823507],[97.699598,2.453184],[97.176942,3.308791],[96.424017,3.86886],[95.380876,4.970782],[95.293026,5.479821],[95.936863,5.439513],[97.484882,5.246321],[98.369169,4.26837],[99.142559,3.59035],[99.693998,3.174329],[100.641434,2.099381],[101.658012,2.083697],[102.498271,1.3987],[103.07684,0.561361],[103.838396,0.104542],[103.437645,-0.711946],[104.010789,-1.059212],[104.369991,-1.084843],[104.53949,-1.782372],[104.887893,-2.340425],[105.622111,-2.428844],[106.108593,-3.061777],[105.857446,-4.305525],[105.817655,-5.852356]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"India","SOV_A3":"IND","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"India","ADM0_A3":"IND","GEOU_DIF":0,"GEOUNIT":"India","GU_A3":"IND","SU_DIF":0,"SUBUNIT":"India","SU_A3":"IND","BRK_DIFF":0,"NAME":"India","NAME_LONG":"India","BRK_A3":"IND","BRK_NAME":"India","BRK_GROUP":null,"ABBREV":"India","POSTAL":"IND","FORMAL_EN":"Republic of India","FORMAL_FR":null,"NAME_CIAWF":"India","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"India","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":3,"MAPCOLOR9":2,"MAPCOLOR13":2,"POP_EST":1281935911,"POP_RANK":18,"GDP_MD_EST":8721000,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"3. Emerging region: BRIC","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"IN","ISO_A2":"IN","ISO_A3":"IND","ISO_A3_EH":"IND","ISO_N3":"356","UN_A3":"356","WB_A2":"IN","WB_A3":"IND","WOE_ID":23424848,"WOE_ID_EH":23424848,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"IND","ADM0_A3_US":"IND","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"South Asia","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[68.176645,7.965535,97.402561,35.49401],"geometry":{"type":"Polygon","coordinates":[[[92.672721,22.041239],[92.146035,23.627499],[91.869928,23.624346],[91.706475,22.985264],[91.158963,23.503527],[91.46773,24.072639],[91.915093,24.130414],[92.376202,24.976693],[91.799596,25.147432],[90.872211,25.132601],[89.920693,25.26975],[89.832481,25.965082],[89.355094,26.014407],[88.563049,26.446526],[88.209789,25.768066],[88.931554,25.238692],[88.306373,24.866079],[88.084422,24.501657],[88.69994,24.233715],[88.52977,23.631142],[88.876312,22.879146],[89.031961,22.055708],[88.888766,21.690588],[88.208497,21.703172],[86.975704,21.495562],[87.033169,20.743308],[86.499351,20.151638],[85.060266,19.478579],[83.941006,18.30201],[83.189217,17.671221],[82.192792,17.016636],[82.191242,16.556664],[81.692719,16.310219],[80.791999,15.951972],[80.324896,15.899185],[80.025069,15.136415],[80.233274,13.835771],[80.286294,13.006261],[79.862547,12.056215],[79.857999,10.357275],[79.340512,10.308854],[78.885345,9.546136],[79.18972,9.216544],[78.277941,8.933047],[77.941165,8.252959],[77.539898,7.965535],[76.592979,8.899276],[76.130061,10.29963],[75.746467,11.308251],[75.396101,11.781245],[74.864816,12.741936],[74.616717,13.992583],[74.443859,14.617222],[73.534199,15.990652],[73.119909,17.92857],[72.820909,19.208234],[72.824475,20.419503],[72.630533,21.356009],[71.175273,20.757441],[70.470459,20.877331],[69.16413,22.089298],[69.644928,22.450775],[69.349597,22.84318],[68.176645,23.691965],[68.842599,24.359134],[71.04324,24.356524],[70.844699,25.215102],[70.282873,25.722229],[70.168927,26.491872],[69.514393,26.940966],[70.616496,27.989196],[71.777666,27.91318],[72.823752,28.961592],[73.450638,29.976413],[74.42138,30.979815],[74.405929,31.692639],[75.258642,32.271105],[74.451559,32.7649],[74.104294,33.441473],[73.749948,34.317699],[74.240203,34.748887],[75.757061,34.504923],[76.871722,34.653544],[77.837451,35.49401],[78.912269,34.321936],[78.811086,33.506198],[79.208892,32.994395],[79.176129,32.48378],[78.458446,32.618164],[78.738894,31.515906],[79.721367,30.882715],[81.111256,30.183481],[80.476721,29.729865],[80.088425,28.79447],[81.057203,28.416095],[81.999987,27.925479],[83.304249,27.364506],[84.675018,27.234901],[85.251779,26.726198],[86.024393,26.630985],[87.227472,26.397898],[88.060238,26.414615],[88.174804,26.810405],[88.043133,27.445819],[88.120441,27.876542],[88.730326,28.086865],[88.814248,27.299316],[88.835643,27.098966],[89.744528,26.719403],[90.373275,26.875724],[91.217513,26.808648],[92.033484,26.83831],[92.103712,27.452614],[91.696657,27.771742],[92.503119,27.896876],[93.413348,28.640629],[94.56599,29.277438],[95.404802,29.031717],[96.117679,29.452802],[96.586591,28.83098],[96.248833,28.411031],[97.327114,28.261583],[97.402561,27.882536],[97.051989,27.699059],[97.133999,27.083774],[96.419366,27.264589],[95.124768,26.573572],[95.155153,26.001307],[94.603249,25.162495],[94.552658,24.675238],[94.106742,23.850741],[93.325188,24.078556],[93.286327,23.043658],[93.060294,22.703111],[93.166128,22.27846],[92.672721,22.041239]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Ireland","SOV_A3":"IRL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Ireland","ADM0_A3":"IRL","GEOU_DIF":0,"GEOUNIT":"Ireland","GU_A3":"IRL","SU_DIF":0,"SUBUNIT":"Ireland","SU_A3":"IRL","BRK_DIFF":0,"NAME":"Ireland","NAME_LONG":"Ireland","BRK_A3":"IRL","BRK_NAME":"Ireland","BRK_GROUP":null,"ABBREV":"Ire.","POSTAL":"IRL","FORMAL_EN":"Ireland","FORMAL_FR":null,"NAME_CIAWF":"Ireland","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Ireland","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":3,"MAPCOLOR9":2,"MAPCOLOR13":2,"POP_EST":5011102,"POP_RANK":13,"GDP_MD_EST":322000,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"EI","ISO_A2":"IE","ISO_A3":"IRL","ISO_A3_EH":"IRL","ISO_N3":"372","UN_A3":"372","WB_A2":"IE","WB_A3":"IRL","WOE_ID":23424803,"WOE_ID_EH":23424803,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"IRL","ADM0_A3_US":"IRL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-9.977086,51.669301,-6.032985,55.131622],"geometry":{"type":"Polygon","coordinates":[[[-7.572168,55.131622],[-7.366031,54.595841],[-7.572168,54.059956],[-6.95373,54.073702],[-6.197885,53.867565],[-6.032985,53.153164],[-6.788857,52.260118],[-8.561617,51.669301],[-9.977086,51.820455],[-9.166283,52.864629],[-9.688525,53.881363],[-8.327987,54.664519],[-7.572168,55.131622]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Iran","SOV_A3":"IRN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Iran","ADM0_A3":"IRN","GEOU_DIF":0,"GEOUNIT":"Iran","GU_A3":"IRN","SU_DIF":0,"SUBUNIT":"Iran","SU_A3":"IRN","BRK_DIFF":0,"NAME":"Iran","NAME_LONG":"Iran","BRK_A3":"IRN","BRK_NAME":"Iran","BRK_GROUP":null,"ABBREV":"Iran","POSTAL":"IRN","FORMAL_EN":"Islamic Republic of Iran","FORMAL_FR":null,"NAME_CIAWF":"Iran","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Iran, Islamic Rep.","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":3,"MAPCOLOR9":4,"MAPCOLOR13":13,"POP_EST":82021564,"POP_RANK":16,"GDP_MD_EST":1459000,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"IR","ISO_A2":"IR","ISO_A3":"IRN","ISO_A3_EH":"IRN","ISO_N3":"364","UN_A3":"364","WB_A2":"IR","WB_A3":"IRN","WOE_ID":23424851,"WOE_ID_EH":23424851,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"IRN","ADM0_A3_US":"IRN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2.5,"MAX_LABEL":6.7},"bbox":[44.109225,25.078237,63.316632,39.713003],"geometry":{"type":"Polygon","coordinates":[[[61.210817,35.650072],[60.803193,34.404102],[60.52843,33.676446],[60.9637,33.528832],[60.536078,32.981269],[60.863655,32.18292],[60.941945,31.548075],[61.699314,31.379506],[61.781222,30.73585],[60.874248,29.829239],[61.369309,29.303276],[61.771868,28.699334],[62.72783,28.259645],[62.755426,27.378923],[63.233898,27.217047],[63.316632,26.756532],[61.874187,26.239975],[61.497363,25.078237],[59.616134,25.380157],[58.525761,25.609962],[57.397251,25.739902],[56.970766,26.966106],[56.492139,27.143305],[55.72371,26.964633],[54.71509,26.480658],[53.493097,26.812369],[52.483598,27.580849],[51.520763,27.86569],[50.852948,28.814521],[50.115009,30.147773],[49.57685,29.985715],[48.941333,30.31709],[48.567971,29.926778],[48.014568,30.452457],[48.004698,30.985137],[47.685286,30.984853],[47.849204,31.709176],[47.334661,32.469155],[46.109362,33.017287],[45.416691,33.967798],[45.64846,34.748138],[46.151788,35.093259],[46.07634,35.677383],[45.420618,35.977546],[44.772677,37.170437],[44.77267,37.17045],[44.225756,37.971584],[44.421403,38.281281],[44.109225,39.428136],[44.79399,39.713003],[44.952688,39.335765],[45.457722,38.874139],[46.143623,38.741201],[46.50572,38.770605],[47.685079,39.508364],[48.060095,39.582235],[48.355529,39.288765],[48.010744,38.794015],[48.634375,38.270378],[48.883249,38.320245],[49.199612,37.582874],[50.147771,37.374567],[50.842354,36.872814],[52.264025,36.700422],[53.82579,36.965031],[53.921598,37.198918],[54.800304,37.392421],[55.511578,37.964117],[56.180375,37.935127],[56.619366,38.121394],[57.330434,38.029229],[58.436154,37.522309],[59.234762,37.412988],[60.377638,36.527383],[61.123071,36.491597],[61.210817,35.650072]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Iraq","SOV_A3":"IRQ","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Iraq","ADM0_A3":"IRQ","GEOU_DIF":0,"GEOUNIT":"Iraq","GU_A3":"IRQ","SU_DIF":0,"SUBUNIT":"Iraq","SU_A3":"IRQ","BRK_DIFF":0,"NAME":"Iraq","NAME_LONG":"Iraq","BRK_A3":"IRQ","BRK_NAME":"Iraq","BRK_GROUP":null,"ABBREV":"Iraq","POSTAL":"IRQ","FORMAL_EN":"Republic of Iraq","FORMAL_FR":null,"NAME_CIAWF":"Iraq","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Iraq","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":3,"MAPCOLOR13":1,"POP_EST":39192111,"POP_RANK":15,"GDP_MD_EST":596700,"POP_YEAR":2017,"LASTCENSUS":1997,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"IZ","ISO_A2":"IQ","ISO_A3":"IRQ","ISO_A3_EH":"IRQ","ISO_N3":"368","UN_A3":"368","WB_A2":"IQ","WB_A3":"IRQ","WOE_ID":23424855,"WOE_ID_EH":23424855,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"IRQ","ADM0_A3_US":"IRQ","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7.5},"bbox":[38.792341,29.099025,48.567971,37.385264],"geometry":{"type":"Polygon","coordinates":[[[44.772677,37.170437],[45.420618,35.977546],[46.07634,35.677383],[46.151788,35.093259],[45.64846,34.748138],[45.416691,33.967798],[46.109362,33.017287],[47.334661,32.469155],[47.849204,31.709176],[47.685286,30.984853],[48.004698,30.985137],[48.014568,30.452457],[48.567971,29.926778],[47.974519,29.975819],[47.302622,30.05907],[46.568713,29.099025],[44.709499,29.178891],[41.889981,31.190009],[40.399994,31.889992],[39.195468,32.161009],[38.792341,33.378686],[41.006159,34.419372],[41.383965,35.628317],[41.289707,36.358815],[41.837064,36.605854],[42.349591,37.229873],[42.779126,37.385264],[43.942259,37.256228],[44.293452,37.001514],[44.772677,37.170437]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Iceland","SOV_A3":"ISL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Iceland","ADM0_A3":"ISL","GEOU_DIF":0,"GEOUNIT":"Iceland","GU_A3":"ISL","SU_DIF":0,"SUBUNIT":"Iceland","SU_A3":"ISL","BRK_DIFF":0,"NAME":"Iceland","NAME_LONG":"Iceland","BRK_A3":"ISL","BRK_NAME":"Iceland","BRK_GROUP":null,"ABBREV":"Iceland","POSTAL":"IS","FORMAL_EN":"Republic of Iceland","FORMAL_FR":null,"NAME_CIAWF":"Iceland","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Iceland","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":4,"MAPCOLOR13":9,"POP_EST":339747,"POP_RANK":10,"GDP_MD_EST":16150,"POP_YEAR":2017,"LASTCENSUS":-99,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"IC","ISO_A2":"IS","ISO_A3":"ISL","ISO_A3_EH":"ISL","ISO_N3":"352","UN_A3":"352","WB_A2":"IS","WB_A3":"ISL","WOE_ID":23424845,"WOE_ID_EH":23424845,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ISL","ADM0_A3_US":"ISL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":7,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[-24.326184,63.496383,-13.609732,66.526792],"geometry":{"type":"Polygon","coordinates":[[[-14.508695,66.455892],[-14.739637,65.808748],[-13.609732,65.126671],[-14.909834,64.364082],[-17.794438,63.678749],[-18.656246,63.496383],[-19.972755,63.643635],[-22.762972,63.960179],[-21.778484,64.402116],[-23.955044,64.89113],[-22.184403,65.084968],[-22.227423,65.378594],[-24.326184,65.611189],[-23.650515,66.262519],[-22.134922,66.410469],[-20.576284,65.732112],[-19.056842,66.276601],[-17.798624,65.993853],[-16.167819,66.526792],[-14.508695,66.455892]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Israel","SOV_A3":"IS1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"Israel","ADM0_A3":"ISR","GEOU_DIF":0,"GEOUNIT":"Israel","GU_A3":"ISR","SU_DIF":0,"SUBUNIT":"Israel","SU_A3":"ISR","BRK_DIFF":0,"NAME":"Israel","NAME_LONG":"Israel","BRK_A3":"ISR","BRK_NAME":"Israel","BRK_GROUP":null,"ABBREV":"Isr.","POSTAL":"IS","FORMAL_EN":"State of Israel","FORMAL_FR":null,"NAME_CIAWF":"Israel","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Israel","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":5,"MAPCOLOR13":9,"POP_EST":8299706,"POP_RANK":13,"GDP_MD_EST":297000,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"-99","ISO_A2":"IL","ISO_A3":"ISR","ISO_A3_EH":"ISR","ISO_N3":"376","UN_A3":"376","WB_A2":"IL","WB_A3":"ISR","WOE_ID":23424852,"WOE_ID_EH":23424852,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ISR","ADM0_A3_US":"ISR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[34.265433,29.501326,35.836397,33.277426],"geometry":{"type":"Polygon","coordinates":[[[34.823243,29.761081],[34.26544,31.21936],[34.265435,31.219357],[34.265433,31.219361],[34.556372,31.548824],[34.488107,31.605539],[34.752587,32.072926],[34.955417,32.827376],[35.098457,33.080539],[35.126053,33.0909],[35.460709,33.08904],[35.552797,33.264275],[35.821101,33.277426],[35.836397,32.868123],[35.700798,32.716014],[35.719918,32.709192],[35.545665,32.393992],[35.18393,32.532511],[34.974641,31.866582],[35.225892,31.754341],[34.970507,31.616778],[34.927408,31.353435],[35.397561,31.489086],[35.420918,31.100066],[34.922603,29.501326],[34.823243,29.761081]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Italy","SOV_A3":"ITA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Italy","ADM0_A3":"ITA","GEOU_DIF":0,"GEOUNIT":"Italy","GU_A3":"ITA","SU_DIF":0,"SUBUNIT":"Italy","SU_A3":"ITA","BRK_DIFF":0,"NAME":"Italy","NAME_LONG":"Italy","BRK_A3":"ITA","BRK_NAME":"Italy","BRK_GROUP":null,"ABBREV":"Italy","POSTAL":"I","FORMAL_EN":"Italian Republic","FORMAL_FR":null,"NAME_CIAWF":"Italy","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Italy","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":7,"MAPCOLOR9":8,"MAPCOLOR13":7,"POP_EST":62137802,"POP_RANK":16,"GDP_MD_EST":2221000,"POP_YEAR":2017,"LASTCENSUS":2012,"GDP_YEAR":2016,"ECONOMY":"1. Developed region: G7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"IT","ISO_A2":"IT","ISO_A3":"ITA","ISO_A3_EH":"ITA","ISO_N3":"380","UN_A3":"380","WB_A2":"IT","WB_A3":"ITA","WOE_ID":23424853,"WOE_ID_EH":23424853,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ITA","ADM0_A3_US":"ITA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[6.749955,36.619987,18.480247,47.115393],"geometry":{"type":"MultiPolygon","coordinates":[[[[10.442701,46.893546],[11.048556,46.751359],[11.164828,46.941579],[12.153088,47.115393],[12.376485,46.767559],[13.806475,46.509306],[13.69811,46.016778],[13.93763,45.591016],[13.141606,45.736692],[12.328581,45.381778],[12.383875,44.885374],[12.261453,44.600482],[12.589237,44.091366],[13.526906,43.587727],[14.029821,42.761008],[15.14257,41.95514],[15.926191,41.961315],[16.169897,41.740295],[15.889346,41.541082],[16.785002,41.179606],[17.519169,40.877143],[18.376687,40.355625],[18.480247,40.168866],[18.293385,39.810774],[17.73838,40.277671],[16.869596,40.442235],[16.448743,39.795401],[17.17149,39.4247],[17.052841,38.902871],[16.635088,38.843572],[16.100961,37.985899],[15.684087,37.908849],[15.687963,38.214593],[15.891981,38.750942],[16.109332,38.964547],[15.718814,39.544072],[15.413613,40.048357],[14.998496,40.172949],[14.703268,40.60455],[14.060672,40.786348],[13.627985,41.188287],[12.888082,41.25309],[12.106683,41.704535],[11.191906,42.355425],[10.511948,42.931463],[10.200029,43.920007],[9.702488,44.036279],[8.888946,44.366336],[8.428561,44.231228],[7.850767,43.767148],[7.435185,43.693845],[7.549596,44.127901],[7.007562,44.254767],[6.749955,45.028518],[7.096652,45.333099],[6.802355,45.70858],[6.843593,45.991147],[7.273851,45.776948],[7.755992,45.82449],[8.31663,46.163642],[8.489952,46.005151],[8.966306,46.036932],[9.182882,46.440215],[9.922837,46.314899],[10.363378,46.483571],[10.442701,46.893546]]],[[[15.520376,38.231155],[15.160243,37.444046],[15.309898,37.134219],[15.099988,36.619987],[14.335229,36.996631],[13.826733,37.104531],[12.431004,37.61295],[12.570944,38.126381],[13.741156,38.034966],[14.761249,38.143874],[15.520376,38.231155]]],[[[9.210012,41.209991],[9.809975,40.500009],[9.669519,39.177376],[9.214818,39.240473],[8.806936,38.906618],[8.428302,39.171847],[8.388253,40.378311],[8.159998,40.950007],[8.709991,40.899984],[9.210012,41.209991]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Jamaica","SOV_A3":"JAM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Jamaica","ADM0_A3":"JAM","GEOU_DIF":0,"GEOUNIT":"Jamaica","GU_A3":"JAM","SU_DIF":0,"SUBUNIT":"Jamaica","SU_A3":"JAM","BRK_DIFF":0,"NAME":"Jamaica","NAME_LONG":"Jamaica","BRK_A3":"JAM","BRK_NAME":"Jamaica","BRK_GROUP":null,"ABBREV":"Jam.","POSTAL":"J","FORMAL_EN":"Jamaica","FORMAL_FR":null,"NAME_CIAWF":"Jamaica","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Jamaica","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":2,"MAPCOLOR9":4,"MAPCOLOR13":10,"POP_EST":2990561,"POP_RANK":12,"GDP_MD_EST":25390,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"JM","ISO_A2":"JM","ISO_A3":"JAM","ISO_A3_EH":"JAM","ISO_N3":"388","UN_A3":"388","WB_A2":"JM","WB_A3":"JAM","WOE_ID":23424858,"WOE_ID_EH":23424858,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"JAM","ADM0_A3_US":"JAM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Caribbean","REGION_WB":"Latin America & Caribbean","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-78.337719,17.701116,-76.199659,18.524218],"geometry":{"type":"Polygon","coordinates":[[[-77.569601,18.490525],[-76.896619,18.400867],[-76.365359,18.160701],[-76.199659,17.886867],[-76.902561,17.868238],[-77.206341,17.701116],[-77.766023,17.861597],[-78.337719,18.225968],[-78.217727,18.454533],[-77.797365,18.524218],[-77.569601,18.490525]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Jordan","SOV_A3":"JOR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Jordan","ADM0_A3":"JOR","GEOU_DIF":0,"GEOUNIT":"Jordan","GU_A3":"JOR","SU_DIF":0,"SUBUNIT":"Jordan","SU_A3":"JOR","BRK_DIFF":0,"NAME":"Jordan","NAME_LONG":"Jordan","BRK_A3":"JOR","BRK_NAME":"Jordan","BRK_GROUP":null,"ABBREV":"Jord.","POSTAL":"J","FORMAL_EN":"Hashemite Kingdom of Jordan","FORMAL_FR":null,"NAME_CIAWF":"Jordan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Jordan","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":3,"MAPCOLOR9":4,"MAPCOLOR13":4,"POP_EST":10248069,"POP_RANK":14,"GDP_MD_EST":86190,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"JO","ISO_A2":"JO","ISO_A3":"JOR","ISO_A3_EH":"JOR","ISO_N3":"400","UN_A3":"400","WB_A2":"JO","WB_A3":"JOR","WOE_ID":23424860,"WOE_ID_EH":23424860,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"JOR","ADM0_A3_US":"JOR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[34.922603,29.197495,39.195468,33.378686],"geometry":{"type":"Polygon","coordinates":[[[38.792341,33.378686],[39.195468,32.161009],[39.004886,32.010217],[37.002166,31.508413],[37.998849,30.5085],[37.66812,30.338665],[37.503582,30.003776],[36.740528,29.865283],[36.501214,29.505254],[36.068941,29.197495],[34.956037,29.356555],[34.922603,29.501326],[35.420918,31.100066],[35.397561,31.489086],[35.545252,31.782505],[35.545665,32.393992],[35.719918,32.709192],[36.834062,32.312938],[38.792341,33.378686]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Japan","SOV_A3":"JPN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Japan","ADM0_A3":"JPN","GEOU_DIF":0,"GEOUNIT":"Japan","GU_A3":"JPN","SU_DIF":0,"SUBUNIT":"Japan","SU_A3":"JPN","BRK_DIFF":0,"NAME":"Japan","NAME_LONG":"Japan","BRK_A3":"JPN","BRK_NAME":"Japan","BRK_GROUP":null,"ABBREV":"Japan","POSTAL":"J","FORMAL_EN":"Japan","FORMAL_FR":null,"NAME_CIAWF":"Japan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Japan","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":3,"MAPCOLOR9":5,"MAPCOLOR13":4,"POP_EST":126451398,"POP_RANK":17,"GDP_MD_EST":4932000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"1. Developed region: G7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"JA","ISO_A2":"JP","ISO_A3":"JPN","ISO_A3_EH":"JPN","ISO_N3":"392","UN_A3":"392","WB_A2":"JP","WB_A3":"JPN","WOE_ID":23424856,"WOE_ID_EH":23424856,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"JPN","ADM0_A3_US":"JPN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":7},"bbox":[129.408463,31.029579,145.543137,45.551483],"geometry":{"type":"MultiPolygon","coordinates":[[[[134.638428,34.149234],[134.766379,33.806335],[134.203416,33.201178],[133.79295,33.521985],[133.280268,33.28957],[133.014858,32.704567],[132.363115,32.989382],[132.371176,33.463642],[132.924373,34.060299],[133.492968,33.944621],[133.904106,34.364931],[134.638428,34.149234]]],[[[140.976388,37.142074],[140.59977,36.343983],[140.774074,35.842877],[140.253279,35.138114],[138.975528,34.6676],[137.217599,34.606286],[135.792983,33.464805],[135.120983,33.849071],[135.079435,34.596545],[133.340316,34.375938],[132.156771,33.904933],[130.986145,33.885761],[132.000036,33.149992],[131.33279,31.450355],[130.686318,31.029579],[130.20242,31.418238],[130.447676,32.319475],[129.814692,32.61031],[129.408463,33.296056],[130.353935,33.604151],[130.878451,34.232743],[131.884229,34.749714],[132.617673,35.433393],[134.608301,35.731618],[135.677538,35.527134],[136.723831,37.304984],[137.390612,36.827391],[138.857602,37.827485],[139.426405,38.215962],[140.05479,39.438807],[139.883379,40.563312],[140.305783,41.195005],[141.368973,41.37856],[141.914263,39.991616],[141.884601,39.180865],[140.959489,38.174001],[140.976388,37.142074]]],[[[143.910162,44.1741],[144.613427,43.960883],[145.320825,44.384733],[145.543137,43.262088],[144.059662,42.988358],[143.18385,41.995215],[141.611491,42.678791],[141.067286,41.584594],[139.955106,41.569556],[139.817544,42.563759],[140.312087,43.333273],[141.380549,43.388825],[141.671952,44.772125],[141.967645,45.551483],[143.14287,44.510358],[143.910162,44.1741]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Kazakhstan","SOV_A3":"KAZ","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Kazakhstan","ADM0_A3":"KAZ","GEOU_DIF":0,"GEOUNIT":"Kazakhstan","GU_A3":"KAZ","SU_DIF":0,"SUBUNIT":"Kazakhstan","SU_A3":"KAZ","BRK_DIFF":0,"NAME":"Kazakhstan","NAME_LONG":"Kazakhstan","BRK_A3":"KAZ","BRK_NAME":"Kazakhstan","BRK_GROUP":null,"ABBREV":"Kaz.","POSTAL":"KZ","FORMAL_EN":"Republic of Kazakhstan","FORMAL_FR":null,"NAME_CIAWF":"Kazakhstan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Kazakhstan","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":1,"MAPCOLOR9":6,"MAPCOLOR13":1,"POP_EST":18556698,"POP_RANK":14,"GDP_MD_EST":460700,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"KZ","ISO_A2":"KZ","ISO_A3":"KAZ","ISO_A3_EH":"KAZ","ISO_N3":"398","UN_A3":"398","WB_A2":"KZ","WB_A3":"KAZ","WOE_ID":-90,"WOE_ID_EH":23424871,"WOE_NOTE":"Includes Baykonur Cosmodrome as an admin-1","ADM0_A3_IS":"KAZ","ADM0_A3_US":"KAZ","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Central Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[46.466446,40.662325,87.35997,55.38525],"geometry":{"type":"Polygon","coordinates":[[[87.35997,49.214981],[86.598776,48.549182],[85.768233,48.455751],[85.720484,47.452969],[85.16429,47.000956],[83.180484,47.330031],[82.458926,45.53965],[81.947071,45.317027],[79.966106,44.917517],[80.866206,43.180362],[80.18015,42.920068],[80.25999,42.349999],[79.643645,42.496683],[79.142177,42.856092],[77.658392,42.960686],[76.000354,42.988022],[75.636965,42.8779],[74.212866,43.298339],[73.645304,43.091272],[73.489758,42.500894],[71.844638,42.845395],[71.186281,42.704293],[70.962315,42.266154],[70.388965,42.081308],[69.070027,41.384244],[68.632483,40.668681],[68.259896,40.662325],[67.985856,41.135991],[66.714047,41.168444],[66.510649,41.987644],[66.023392,41.994646],[66.098012,42.99766],[64.900824,43.728081],[63.185787,43.650075],[62.0133,43.504477],[61.05832,44.405817],[60.239972,44.784037],[58.689989,45.500014],[58.503127,45.586804],[55.928917,44.995858],[55.968191,41.308642],[55.455251,41.259859],[54.755345,42.043971],[54.079418,42.324109],[52.944293,42.116034],[52.50246,41.783316],[52.446339,42.027151],[52.692112,42.443895],[52.501426,42.792298],[51.342427,43.132975],[50.891292,44.031034],[50.339129,44.284016],[50.305643,44.609836],[51.278503,44.514854],[51.316899,45.245998],[52.16739,45.408391],[53.040876,45.259047],[53.220866,46.234646],[53.042737,46.853006],[52.042023,46.804637],[51.191945,47.048705],[50.034083,46.60899],[49.10116,46.39933],[48.59325,46.56104],[48.694734,47.075628],[48.05725,47.74377],[47.31524,47.71585],[46.466446,48.394152],[47.043672,49.152039],[46.751596,49.356006],[47.54948,50.454698],[48.577841,49.87476],[48.702382,50.605128],[50.766648,51.692762],[52.328724,51.718652],[54.532878,51.02624],[55.71694,50.62171],[56.77798,51.04355],[58.36332,51.06364],[59.642282,50.545442],[59.932807,50.842194],[61.337424,50.79907],[61.588003,51.272659],[59.967534,51.96042],[60.927269,52.447548],[60.739993,52.719986],[61.699986,52.979996],[60.978066,53.664993],[61.4366,54.00625],[65.178534,54.354228],[65.66687,54.60125],[68.1691,54.970392],[69.068167,55.38525],[70.865267,55.169734],[71.180131,54.133285],[72.22415,54.376655],[73.508516,54.035617],[73.425679,53.48981],[74.38482,53.54685],[76.8911,54.490524],[76.525179,54.177003],[77.800916,53.404415],[80.03556,50.864751],[80.568447,51.388336],[81.945986,50.812196],[83.383004,51.069183],[83.935115,50.889246],[84.416377,50.3114],[85.11556,50.117303],[85.54127,49.692859],[86.829357,49.826675],[87.35997,49.214981]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Kenya","SOV_A3":"KEN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Kenya","ADM0_A3":"KEN","GEOU_DIF":0,"GEOUNIT":"Kenya","GU_A3":"KEN","SU_DIF":0,"SUBUNIT":"Kenya","SU_A3":"KEN","BRK_DIFF":0,"NAME":"Kenya","NAME_LONG":"Kenya","BRK_A3":"KEN","BRK_NAME":"Kenya","BRK_GROUP":null,"ABBREV":"Ken.","POSTAL":"KE","FORMAL_EN":"Republic of Kenya","FORMAL_FR":null,"NAME_CIAWF":"Kenya","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Kenya","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":2,"MAPCOLOR9":7,"MAPCOLOR13":3,"POP_EST":47615739,"POP_RANK":15,"GDP_MD_EST":152700,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"KE","ISO_A2":"KE","ISO_A3":"KEN","ISO_A3_EH":"KEN","ISO_N3":"404","UN_A3":"404","WB_A2":"KE","WB_A3":"KEN","WOE_ID":23424863,"WOE_ID_EH":23424863,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"KEN","ADM0_A3_US":"KEN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[33.893569,-4.67677,41.855083,5.506],"geometry":{"type":"Polygon","coordinates":[[[35.298007,5.506],[35.817448,5.338232],[35.817448,4.776966],[36.159079,4.447864],[36.855093,4.447864],[38.120915,3.598605],[38.43697,3.58851],[38.67114,3.61607],[38.89251,3.50074],[39.559384,3.42206],[39.85494,3.83879],[40.76848,4.25702],[41.1718,3.91909],[41.855083,3.918912],[40.98105,2.78452],[40.993,-0.85829],[41.58513,-1.68325],[40.88477,-2.08255],[40.63785,-2.49979],[40.26304,-2.57309],[40.12119,-3.27768],[39.80006,-3.68116],[39.60489,-4.34653],[39.20222,-4.67677],[37.7669,-3.67712],[37.69869,-3.09699],[34.07262,-1.05982],[33.903711,-0.95],[33.893569,0.109814],[34.18,0.515],[34.6721,1.17694],[35.03599,1.90584],[34.59607,3.05374],[34.47913,3.5556],[34.005,4.249885],[34.620196,4.847123],[35.298007,5.506]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Kyrgyzstan","SOV_A3":"KGZ","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Kyrgyzstan","ADM0_A3":"KGZ","GEOU_DIF":0,"GEOUNIT":"Kyrgyzstan","GU_A3":"KGZ","SU_DIF":0,"SUBUNIT":"Kyrgyzstan","SU_A3":"KGZ","BRK_DIFF":0,"NAME":"Kyrgyzstan","NAME_LONG":"Kyrgyzstan","BRK_A3":"KGZ","BRK_NAME":"Kyrgyzstan","BRK_GROUP":null,"ABBREV":"Kgz.","POSTAL":"KG","FORMAL_EN":"Kyrgyz Republic","FORMAL_FR":null,"NAME_CIAWF":"Kyrgyzstan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Kyrgyz Republic","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":7,"MAPCOLOR9":7,"MAPCOLOR13":6,"POP_EST":5789122,"POP_RANK":13,"GDP_MD_EST":21010,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"KG","ISO_A2":"KG","ISO_A3":"KGZ","ISO_A3_EH":"KGZ","ISO_N3":"417","UN_A3":"417","WB_A2":"KG","WB_A3":"KGZ","WOE_ID":23424864,"WOE_ID_EH":23424864,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"KGZ","ADM0_A3_US":"KGZ","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Central Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[69.464887,39.279463,80.25999,43.298339],"geometry":{"type":"Polygon","coordinates":[[[80.25999,42.349999],[80.11943,42.123941],[78.543661,41.582243],[78.187197,41.185316],[76.904484,41.066486],[76.526368,40.427946],[75.467828,40.562072],[74.776862,40.366425],[73.822244,39.893973],[73.960013,39.660008],[73.675379,39.431237],[71.784694,39.279463],[70.549162,39.604198],[69.464887,39.526683],[69.55961,40.103211],[70.648019,39.935754],[71.014198,40.244366],[71.774875,40.145844],[73.055417,40.866033],[71.870115,41.3929],[71.157859,41.143587],[70.420022,41.519998],[71.259248,42.167711],[70.962315,42.266154],[71.186281,42.704293],[71.844638,42.845395],[73.489758,42.500894],[73.645304,43.091272],[74.212866,43.298339],[75.636965,42.8779],[76.000354,42.988022],[77.658392,42.960686],[79.142177,42.856092],[79.643645,42.496683],[80.25999,42.349999]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Cambodia","SOV_A3":"KHM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Cambodia","ADM0_A3":"KHM","GEOU_DIF":0,"GEOUNIT":"Cambodia","GU_A3":"KHM","SU_DIF":0,"SUBUNIT":"Cambodia","SU_A3":"KHM","BRK_DIFF":0,"NAME":"Cambodia","NAME_LONG":"Cambodia","BRK_A3":"KHM","BRK_NAME":"Cambodia","BRK_GROUP":null,"ABBREV":"Camb.","POSTAL":"KH","FORMAL_EN":"Kingdom of Cambodia","FORMAL_FR":null,"NAME_CIAWF":"Cambodia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Cambodia","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":3,"MAPCOLOR9":6,"MAPCOLOR13":5,"POP_EST":16204486,"POP_RANK":14,"GDP_MD_EST":58940,"POP_YEAR":2017,"LASTCENSUS":2008,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"CB","ISO_A2":"KH","ISO_A3":"KHM","ISO_A3_EH":"KHM","ISO_N3":"116","UN_A3":"116","WB_A2":"KH","WB_A3":"KHM","WOE_ID":23424776,"WOE_ID_EH":23424776,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"KHM","ADM0_A3_US":"KHM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[102.348099,10.486544,107.614548,14.570584],"geometry":{"type":"Polygon","coordinates":[[[102.584932,12.186595],[102.348099,13.394247],[102.988422,14.225721],[104.281418,14.416743],[105.218777,14.273212],[106.043946,13.881091],[106.496373,14.570584],[107.382727,14.202441],[107.614548,13.535531],[107.491403,12.337206],[105.810524,11.567615],[106.24967,10.961812],[105.199915,10.88931],[104.334335,10.486544],[103.49728,10.632555],[103.09069,11.153661],[102.584932,12.186595]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"South Korea","SOV_A3":"KOR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"South Korea","ADM0_A3":"KOR","GEOU_DIF":0,"GEOUNIT":"South Korea","GU_A3":"KOR","SU_DIF":0,"SUBUNIT":"South Korea","SU_A3":"KOR","BRK_DIFF":0,"NAME":"South Korea","NAME_LONG":"Republic of Korea","BRK_A3":"KOR","BRK_NAME":"Republic of Korea","BRK_GROUP":null,"ABBREV":"S.K.","POSTAL":"KR","FORMAL_EN":"Republic of Korea","FORMAL_FR":null,"NAME_CIAWF":"Korea, South","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Korea, Rep.","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":1,"MAPCOLOR9":1,"MAPCOLOR13":5,"POP_EST":51181299,"POP_RANK":16,"GDP_MD_EST":1929000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"4. Emerging region: MIKT","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"KS","ISO_A2":"KR","ISO_A3":"KOR","ISO_A3_EH":"KOR","ISO_N3":"410","UN_A3":"410","WB_A2":"KR","WB_A3":"KOR","WOE_ID":23424868,"WOE_ID_EH":23424868,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"KOR","ADM0_A3_US":"KOR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":11,"LONG_LEN":17,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[126.117398,34.390046,129.468304,38.612243],"geometry":{"type":"Polygon","coordinates":[[[128.349716,38.612243],[129.21292,37.432392],[129.46045,36.784189],[129.468304,35.632141],[129.091377,35.082484],[128.18585,34.890377],[127.386519,34.475674],[126.485748,34.390046],[126.37392,34.93456],[126.559231,35.684541],[126.117398,36.725485],[126.860143,36.893924],[126.174759,37.749686],[126.237339,37.840378],[126.68372,37.804773],[127.073309,38.256115],[127.780035,38.304536],[128.205746,38.370397],[128.349716,38.612243]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Kosovo","SOV_A3":"KOS","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Kosovo","ADM0_A3":"KOS","GEOU_DIF":0,"GEOUNIT":"Kosovo","GU_A3":"KOS","SU_DIF":0,"SUBUNIT":"Kosovo","SU_A3":"KOS","BRK_DIFF":0,"NAME":"Kosovo","NAME_LONG":"Kosovo","BRK_A3":"KOS","BRK_NAME":"Kosovo","BRK_GROUP":null,"ABBREV":"Kos.","POSTAL":"KO","FORMAL_EN":"Republic of Kosovo","FORMAL_FR":null,"NAME_CIAWF":"Kosovo","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Kosovo","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":3,"MAPCOLOR13":11,"POP_EST":1895250,"POP_RANK":12,"GDP_MD_EST":18490,"POP_YEAR":2017,"LASTCENSUS":1981,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"KV","ISO_A2":"XK","ISO_A3":"-99","ISO_A3_EH":"-99","ISO_N3":"-99","UN_A3":"-099","WB_A2":"KV","WB_A3":"KSV","WOE_ID":-90,"WOE_ID_EH":29389201,"WOE_NOTE":"Subunit of Serbia in WOE still; should include 29389201, 29389207, 29389218, 29389209 and 29389214.","ADM0_A3_IS":"KOS","ADM0_A3_US":"KOS","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[20.0707,41.84711,21.77505,43.27205],"geometry":{"type":"Polygon","coordinates":[[[20.590247,41.855409],[20.52295,42.21787],[20.283755,42.32026],[20.0707,42.58863],[20.25758,42.81275],[20.49679,42.88469],[20.63508,43.21671],[20.81448,43.27205],[20.95651,43.13094],[21.143395,43.068685],[21.27421,42.90959],[21.43866,42.86255],[21.63302,42.67717],[21.77505,42.6827],[21.66292,42.43922],[21.54332,42.32025],[21.576636,42.245224],[21.3527,42.2068],[20.76216,42.05186],[20.71731,41.84711],[20.590247,41.855409]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Kuwait","SOV_A3":"KWT","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Kuwait","ADM0_A3":"KWT","GEOU_DIF":0,"GEOUNIT":"Kuwait","GU_A3":"KWT","SU_DIF":0,"SUBUNIT":"Kuwait","SU_A3":"KWT","BRK_DIFF":0,"NAME":"Kuwait","NAME_LONG":"Kuwait","BRK_A3":"KWT","BRK_NAME":"Kuwait","BRK_GROUP":null,"ABBREV":"Kwt.","POSTAL":"KW","FORMAL_EN":"State of Kuwait","FORMAL_FR":null,"NAME_CIAWF":"Kuwait","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Kuwait","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":2,"POP_EST":2875422,"POP_RANK":12,"GDP_MD_EST":301100,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"KU","ISO_A2":"KW","ISO_A3":"KWT","ISO_A3_EH":"KWT","ISO_N3":"414","UN_A3":"414","WB_A2":"KW","WB_A3":"KWT","WOE_ID":23424870,"WOE_ID_EH":23424870,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"KWT","ADM0_A3_US":"KWT","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[46.568713,28.526063,48.416094,30.05907],"geometry":{"type":"Polygon","coordinates":[[[46.568713,29.099025],[47.302622,30.05907],[47.974519,29.975819],[48.183189,29.534477],[48.093943,29.306299],[48.416094,28.552004],[47.708851,28.526063],[47.459822,29.002519],[46.568713,29.099025]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Laos","SOV_A3":"LAO","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Laos","ADM0_A3":"LAO","GEOU_DIF":0,"GEOUNIT":"Laos","GU_A3":"LAO","SU_DIF":0,"SUBUNIT":"Laos","SU_A3":"LAO","BRK_DIFF":0,"NAME":"Laos","NAME_LONG":"Lao PDR","BRK_A3":"LAO","BRK_NAME":"Laos","BRK_GROUP":null,"ABBREV":"Laos","POSTAL":"LA","FORMAL_EN":"Lao People's Democratic Republic","FORMAL_FR":null,"NAME_CIAWF":"Laos","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Lao PDR","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":1,"MAPCOLOR9":1,"MAPCOLOR13":9,"POP_EST":7126706,"POP_RANK":13,"GDP_MD_EST":40960,"POP_YEAR":2017,"LASTCENSUS":2005,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"LA","ISO_A2":"LA","ISO_A3":"LAO","ISO_A3_EH":"LAO","ISO_N3":"418","UN_A3":"418","WB_A2":"LA","WB_A3":"LAO","WOE_ID":23424872,"WOE_ID_EH":23424872,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LAO","ADM0_A3_US":"LAO","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":4,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[100.115988,13.881091,107.564525,22.464753],"geometry":{"type":"Polygon","coordinates":[[[101.180005,21.436573],[101.270026,21.201652],[101.80312,21.174367],[101.652018,22.318199],[102.170436,22.464753],[102.754896,21.675137],[103.203861,20.766562],[104.435,20.758733],[104.822574,19.886642],[104.183388,19.624668],[103.896532,19.265181],[105.094598,18.666975],[105.925762,17.485315],[106.556008,16.604284],[107.312706,15.908538],[107.564525,15.202173],[107.382727,14.202441],[106.496373,14.570584],[106.043946,13.881091],[105.218777,14.273212],[105.544338,14.723934],[105.589039,15.570316],[104.779321,16.441865],[104.716947,17.428859],[103.956477,18.240954],[103.200192,18.309632],[102.998706,17.961695],[102.413005,17.932782],[102.113592,18.109102],[101.059548,17.512497],[101.035931,18.408928],[101.282015,19.462585],[100.606294,19.508344],[100.548881,20.109238],[100.115988,20.41785],[100.329101,20.786122],[101.180005,21.436573]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Lebanon","SOV_A3":"LBN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Lebanon","ADM0_A3":"LBN","GEOU_DIF":0,"GEOUNIT":"Lebanon","GU_A3":"LBN","SU_DIF":0,"SUBUNIT":"Lebanon","SU_A3":"LBN","BRK_DIFF":0,"NAME":"Lebanon","NAME_LONG":"Lebanon","BRK_A3":"LBN","BRK_NAME":"Lebanon","BRK_GROUP":null,"ABBREV":"Leb.","POSTAL":"LB","FORMAL_EN":"Lebanese Republic","FORMAL_FR":null,"NAME_CIAWF":"Lebanon","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Lebanon","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":4,"MAPCOLOR9":4,"MAPCOLOR13":12,"POP_EST":6229794,"POP_RANK":13,"GDP_MD_EST":85160,"POP_YEAR":2017,"LASTCENSUS":1970,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"LE","ISO_A2":"LB","ISO_A3":"LBN","ISO_A3_EH":"LBN","ISO_N3":"422","UN_A3":"422","WB_A2":"LB","WB_A3":"LBN","WOE_ID":23424873,"WOE_ID_EH":23424873,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LBN","ADM0_A3_US":"LBN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":4,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[35.126053,33.08904,36.61175,34.644914],"geometry":{"type":"Polygon","coordinates":[[[35.821101,33.277426],[35.552797,33.264275],[35.460709,33.08904],[35.126053,33.0909],[35.482207,33.90545],[35.979592,34.610058],[35.998403,34.644914],[36.448194,34.593935],[36.61175,34.201789],[36.06646,33.824912],[35.821101,33.277426]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Liberia","SOV_A3":"LBR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Liberia","ADM0_A3":"LBR","GEOU_DIF":0,"GEOUNIT":"Liberia","GU_A3":"LBR","SU_DIF":0,"SUBUNIT":"Liberia","SU_A3":"LBR","BRK_DIFF":0,"NAME":"Liberia","NAME_LONG":"Liberia","BRK_A3":"LBR","BRK_NAME":"Liberia","BRK_GROUP":null,"ABBREV":"Liberia","POSTAL":"LR","FORMAL_EN":"Republic of Liberia","FORMAL_FR":null,"NAME_CIAWF":"Liberia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Liberia","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":3,"MAPCOLOR9":4,"MAPCOLOR13":9,"POP_EST":4689021,"POP_RANK":12,"GDP_MD_EST":3881,"POP_YEAR":2017,"LASTCENSUS":2008,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"LI","ISO_A2":"LR","ISO_A3":"LBR","ISO_A3_EH":"LBR","ISO_N3":"430","UN_A3":"430","WB_A2":"LR","WB_A3":"LBR","WOE_ID":23424876,"WOE_ID_EH":23424876,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LBR","ADM0_A3_US":"LBR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":7,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-11.438779,4.355755,-7.539715,8.541055],"geometry":{"type":"Polygon","coordinates":[[[-8.439298,7.686043],[-8.485446,7.395208],[-8.385452,6.911801],[-8.60288,6.467564],[-8.311348,6.193033],[-7.993693,6.12619],[-7.570153,5.707352],[-7.539715,5.313345],[-7.635368,5.188159],[-7.712159,4.364566],[-7.974107,4.355755],[-9.004794,4.832419],[-9.91342,5.593561],[-10.765384,6.140711],[-11.438779,6.785917],[-11.199802,7.105846],[-11.146704,7.396706],[-10.695595,7.939464],[-10.230094,8.406206],[-10.016567,8.428504],[-9.755342,8.541055],[-9.33728,7.928534],[-9.403348,7.526905],[-9.208786,7.313921],[-8.926065,7.309037],[-8.722124,7.711674],[-8.439298,7.686043]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Libya","SOV_A3":"LBY","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Libya","ADM0_A3":"LBY","GEOU_DIF":0,"GEOUNIT":"Libya","GU_A3":"LBY","SU_DIF":0,"SUBUNIT":"Libya","SU_A3":"LBY","BRK_DIFF":0,"NAME":"Libya","NAME_LONG":"Libya","BRK_A3":"LBY","BRK_NAME":"Libya","BRK_GROUP":null,"ABBREV":"Libya","POSTAL":"LY","FORMAL_EN":"Libya","FORMAL_FR":null,"NAME_CIAWF":"Libya","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Libya","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":11,"POP_EST":6653210,"POP_RANK":13,"GDP_MD_EST":90890,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"LY","ISO_A2":"LY","ISO_A3":"LBY","ISO_A3_EH":"LBY","ISO_N3":"434","UN_A3":"434","WB_A2":"LY","WB_A3":"LBY","WOE_ID":23424882,"WOE_ID_EH":23424882,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LBY","ADM0_A3_US":"LBY","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Northern Africa","REGION_WB":"Middle East & North Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[9.319411,19.58047,25.16482,33.136996],"geometry":{"type":"Polygon","coordinates":[[[11.999506,23.471668],[11.560669,24.097909],[10.771364,24.562532],[10.303847,24.379313],[9.948261,24.936954],[9.910693,25.365455],[9.319411,26.094325],[9.716286,26.512206],[9.629056,27.140953],[9.756128,27.688259],[9.683885,28.144174],[9.859998,28.95999],[9.805634,29.424638],[9.48214,30.307556],[9.970017,30.539325],[10.056575,30.961831],[9.950225,31.37607],[10.636901,31.761421],[10.94479,32.081815],[11.432253,32.368903],[11.488787,33.136996],[12.66331,32.79278],[13.08326,32.87882],[13.91868,32.71196],[15.24563,32.26508],[15.71394,31.37626],[16.61162,31.18218],[18.02109,30.76357],[19.08641,30.26639],[19.57404,30.52582],[20.05335,30.98576],[19.82033,31.75179],[20.13397,32.2382],[20.85452,32.7068],[21.54298,32.8432],[22.89576,32.63858],[23.2368,32.19149],[23.60913,32.18726],[23.9275,32.01667],[24.92114,31.89936],[25.16482,31.56915],[24.80287,31.08929],[24.95762,30.6616],[24.70007,30.04419],[25,29.238655],[25,25.6825],[25,22],[25,20.00304],[23.85,20],[23.83766,19.58047],[19.84926,21.49509],[15.86085,23.40972],[14.8513,22.86295],[14.143871,22.491289],[13.581425,23.040506],[11.999506,23.471668]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Sri Lanka","SOV_A3":"LKA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Sri Lanka","ADM0_A3":"LKA","GEOU_DIF":0,"GEOUNIT":"Sri Lanka","GU_A3":"LKA","SU_DIF":0,"SUBUNIT":"Sri Lanka","SU_A3":"LKA","BRK_DIFF":0,"NAME":"Sri Lanka","NAME_LONG":"Sri Lanka","BRK_A3":"LKA","BRK_NAME":"Sri Lanka","BRK_GROUP":null,"ABBREV":"Sri L.","POSTAL":"LK","FORMAL_EN":"Democratic Socialist Republic of Sri Lanka","FORMAL_FR":null,"NAME_CIAWF":"Sri Lanka","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Sri Lanka","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":5,"MAPCOLOR9":4,"MAPCOLOR13":9,"POP_EST":22409381,"POP_RANK":15,"GDP_MD_EST":236700,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"CE","ISO_A2":"LK","ISO_A3":"LKA","ISO_A3_EH":"LKA","ISO_N3":"144","UN_A3":"144","WB_A2":"LK","WB_A3":"LKA","WOE_ID":23424778,"WOE_ID_EH":23424778,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LKA","ADM0_A3_US":"LKA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"South Asia","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[79.695167,5.96837,81.787959,9.824078],"geometry":{"type":"Polygon","coordinates":[[[81.787959,7.523055],[81.637322,6.481775],[81.21802,6.197141],[80.348357,5.96837],[79.872469,6.763463],[79.695167,8.200843],[80.147801,9.824078],[80.838818,9.268427],[81.304319,8.564206],[81.787959,7.523055]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Lesotho","SOV_A3":"LSO","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Lesotho","ADM0_A3":"LSO","GEOU_DIF":0,"GEOUNIT":"Lesotho","GU_A3":"LSO","SU_DIF":0,"SUBUNIT":"Lesotho","SU_A3":"LSO","BRK_DIFF":0,"NAME":"Lesotho","NAME_LONG":"Lesotho","BRK_A3":"LSO","BRK_NAME":"Lesotho","BRK_GROUP":null,"ABBREV":"Les.","POSTAL":"LS","FORMAL_EN":"Kingdom of Lesotho","FORMAL_FR":null,"NAME_CIAWF":"Lesotho","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Lesotho","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":5,"MAPCOLOR9":2,"MAPCOLOR13":8,"POP_EST":1958042,"POP_RANK":12,"GDP_MD_EST":6019,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"LT","ISO_A2":"LS","ISO_A3":"LSO","ISO_A3_EH":"LSO","ISO_N3":"426","UN_A3":"426","WB_A2":"LS","WB_A3":"LSO","WOE_ID":23424880,"WOE_ID_EH":23424880,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LSO","ADM0_A3_US":"LSO","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Southern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[26.999262,-30.645106,29.325166,-28.647502],"geometry":{"type":"Polygon","coordinates":[[[28.978263,-28.955597],[29.325166,-29.257387],[29.018415,-29.743766],[28.8484,-30.070051],[28.291069,-30.226217],[28.107205,-30.545732],[27.749397,-30.645106],[26.999262,-29.875954],[27.532511,-29.242711],[28.074338,-28.851469],[28.5417,-28.647502],[28.978263,-28.955597]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Lithuania","SOV_A3":"LTU","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Lithuania","ADM0_A3":"LTU","GEOU_DIF":0,"GEOUNIT":"Lithuania","GU_A3":"LTU","SU_DIF":0,"SUBUNIT":"Lithuania","SU_A3":"LTU","BRK_DIFF":0,"NAME":"Lithuania","NAME_LONG":"Lithuania","BRK_A3":"LTU","BRK_NAME":"Lithuania","BRK_GROUP":null,"ABBREV":"Lith.","POSTAL":"LT","FORMAL_EN":"Republic of Lithuania","FORMAL_FR":null,"NAME_CIAWF":"Lithuania","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Lithuania","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":3,"MAPCOLOR9":3,"MAPCOLOR13":9,"POP_EST":2823859,"POP_RANK":12,"GDP_MD_EST":85620,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"LH","ISO_A2":"LT","ISO_A3":"LTU","ISO_A3_EH":"LTU","ISO_N3":"440","UN_A3":"440","WB_A2":"LT","WB_A3":"LTU","WOE_ID":23424875,"WOE_ID_EH":23424875,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LTU","ADM0_A3_US":"LTU","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[21.0558,53.905702,26.588279,56.372528],"geometry":{"type":"Polygon","coordinates":[[[26.494331,55.615107],[26.588279,55.167176],[25.768433,54.846963],[25.536354,54.282423],[24.450684,53.905702],[23.484128,53.912498],[23.243987,54.220567],[22.731099,54.327537],[22.651052,54.582741],[22.757764,54.856574],[22.315724,55.015299],[21.268449,55.190482],[21.0558,56.031076],[22.201157,56.337802],[23.878264,56.273671],[24.860684,56.372528],[25.000934,56.164531],[25.533047,56.100297],[26.494331,55.615107]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Luxembourg","SOV_A3":"LUX","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Luxembourg","ADM0_A3":"LUX","GEOU_DIF":0,"GEOUNIT":"Luxembourg","GU_A3":"LUX","SU_DIF":0,"SUBUNIT":"Luxembourg","SU_A3":"LUX","BRK_DIFF":0,"NAME":"Luxembourg","NAME_LONG":"Luxembourg","BRK_A3":"LUX","BRK_NAME":"Luxembourg","BRK_GROUP":null,"ABBREV":"Lux.","POSTAL":"L","FORMAL_EN":"Grand Duchy of Luxembourg","FORMAL_FR":null,"NAME_CIAWF":"Luxembourg","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Luxembourg","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":7,"MAPCOLOR9":3,"MAPCOLOR13":7,"POP_EST":594130,"POP_RANK":11,"GDP_MD_EST":58740,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"LU","ISO_A2":"LU","ISO_A3":"LUX","ISO_A3_EH":"LUX","ISO_N3":"442","UN_A3":"442","WB_A2":"LU","WB_A3":"LUX","WOE_ID":23424881,"WOE_ID_EH":23424881,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LUX","ADM0_A3_US":"LUX","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Western Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":5,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5.7,"MAX_LABEL":10},"bbox":[5.674052,49.442667,6.242751,50.128052],"geometry":{"type":"Polygon","coordinates":[[[5.674052,49.529484],[5.782417,50.090328],[6.043073,50.128052],[6.242751,49.902226],[6.18632,49.463803],[5.897759,49.442667],[5.674052,49.529484]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Latvia","SOV_A3":"LVA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Latvia","ADM0_A3":"LVA","GEOU_DIF":0,"GEOUNIT":"Latvia","GU_A3":"LVA","SU_DIF":0,"SUBUNIT":"Latvia","SU_A3":"LVA","BRK_DIFF":0,"NAME":"Latvia","NAME_LONG":"Latvia","BRK_A3":"LVA","BRK_NAME":"Latvia","BRK_GROUP":null,"ABBREV":"Lat.","POSTAL":"LV","FORMAL_EN":"Republic of Latvia","FORMAL_FR":null,"NAME_CIAWF":"Latvia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Latvia","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":7,"MAPCOLOR9":6,"MAPCOLOR13":13,"POP_EST":1944643,"POP_RANK":12,"GDP_MD_EST":50650,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"LG","ISO_A2":"LV","ISO_A3":"LVA","ISO_A3_EH":"LVA","ISO_N3":"428","UN_A3":"428","WB_A2":"LV","WB_A3":"LVA","WOE_ID":23424874,"WOE_ID_EH":23424874,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"LVA","ADM0_A3_US":"LVA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[21.0558,55.615107,28.176709,57.970157],"geometry":{"type":"Polygon","coordinates":[[[28.176709,56.16913],[27.10246,55.783314],[26.494331,55.615107],[25.533047,56.100297],[25.000934,56.164531],[24.860684,56.372528],[23.878264,56.273671],[22.201157,56.337802],[21.0558,56.031076],[21.090424,56.783873],[21.581866,57.411871],[22.524341,57.753374],[23.318453,57.006236],[24.12073,57.025693],[24.312863,57.793424],[25.164594,57.970157],[25.60281,57.847529],[26.463532,57.476389],[27.288185,57.474528],[27.770016,57.244258],[27.855282,56.759326],[28.176709,56.16913]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Morocco","SOV_A3":"MAR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Morocco","ADM0_A3":"MAR","GEOU_DIF":0,"GEOUNIT":"Morocco","GU_A3":"MAR","SU_DIF":0,"SUBUNIT":"Morocco","SU_A3":"MAR","BRK_DIFF":0,"NAME":"Morocco","NAME_LONG":"Morocco","BRK_A3":"MAR","BRK_NAME":"Morocco","BRK_GROUP":null,"ABBREV":"Mor.","POSTAL":"MA","FORMAL_EN":"Kingdom of Morocco","FORMAL_FR":null,"NAME_CIAWF":"Morocco","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Morocco","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":3,"MAPCOLOR13":9,"POP_EST":33986655,"POP_RANK":15,"GDP_MD_EST":282800,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"MO","ISO_A2":"MA","ISO_A3":"MAR","ISO_A3_EH":"MAR","ISO_N3":"504","UN_A3":"504","WB_A2":"MA","WB_A3":"MAR","WOE_ID":23424893,"WOE_ID_EH":23424893,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MAR","ADM0_A3_US":"MAR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Northern Africa","REGION_WB":"Middle East & North Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-17.020428,21.420734,-1.124551,35.759988],"geometry":{"type":"Polygon","coordinates":[[[-2.169914,35.168396],[-1.792986,34.527919],[-1.733455,33.919713],[-1.388049,32.864015],[-1.124551,32.651522],[-1.307899,32.262889],[-2.616605,32.094346],[-3.06898,31.724498],[-3.647498,31.637294],[-3.690441,30.896952],[-4.859646,30.501188],[-5.242129,30.000443],[-6.060632,29.7317],[-7.059228,29.579228],[-8.674116,28.841289],[-8.66559,27.656426],[-8.817828,27.656426],[-8.794884,27.120696],[-9.413037,27.088476],[-9.735343,26.860945],[-10.189424,26.860945],[-10.551263,26.990808],[-11.392555,26.883424],[-11.71822,26.104092],[-12.030759,26.030866],[-12.500963,24.770116],[-13.89111,23.691009],[-14.221168,22.310163],[-14.630833,21.86094],[-14.750955,21.5006],[-17.002962,21.420734],[-17.020428,21.42231],[-16.973248,21.885745],[-16.589137,22.158234],[-16.261922,22.67934],[-16.326414,23.017768],[-15.982611,23.723358],[-15.426004,24.359134],[-15.089332,24.520261],[-14.824645,25.103533],[-14.800926,25.636265],[-14.43994,26.254418],[-13.773805,26.618892],[-13.139942,27.640148],[-13.121613,27.654148],[-12.618837,28.038186],[-11.688919,28.148644],[-10.900957,28.832142],[-10.399592,29.098586],[-9.564811,29.933574],[-9.814718,31.177736],[-9.434793,32.038096],[-9.300693,32.564679],[-8.657476,33.240245],[-7.654178,33.697065],[-6.912544,34.110476],[-6.244342,35.145865],[-5.929994,35.759988],[-5.193863,35.755182],[-4.591006,35.330712],[-3.640057,35.399855],[-2.604306,35.179093],[-2.169914,35.168396]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Moldova","SOV_A3":"MDA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Moldova","ADM0_A3":"MDA","GEOU_DIF":0,"GEOUNIT":"Moldova","GU_A3":"MDA","SU_DIF":0,"SUBUNIT":"Moldova","SU_A3":"MDA","BRK_DIFF":0,"NAME":"Moldova","NAME_LONG":"Moldova","BRK_A3":"MDA","BRK_NAME":"Moldova","BRK_GROUP":null,"ABBREV":"Mda.","POSTAL":"MD","FORMAL_EN":"Republic of Moldova","FORMAL_FR":null,"NAME_CIAWF":"Moldova","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Moldova","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":5,"MAPCOLOR9":4,"MAPCOLOR13":12,"POP_EST":3474121,"POP_RANK":12,"GDP_MD_EST":18540,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"MD","ISO_A2":"MD","ISO_A3":"MDA","ISO_A3_EH":"MDA","ISO_N3":"498","UN_A3":"498","WB_A2":"MD","WB_A3":"MDA","WOE_ID":23424885,"WOE_ID_EH":23424885,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MDA","ADM0_A3_US":"MDA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[26.619337,45.488283,30.024659,48.467119],"geometry":{"type":"Polygon","coordinates":[[[26.619337,48.220726],[26.857824,48.368211],[27.522537,48.467119],[28.259547,48.155562],[28.670891,48.118149],[29.122698,47.849095],[29.050868,47.510227],[29.415135,47.346645],[29.559674,46.928583],[29.908852,46.674361],[29.83821,46.525326],[30.024659,46.423937],[29.759972,46.349988],[29.170654,46.379262],[29.072107,46.517678],[28.862972,46.437889],[28.933717,46.25883],[28.659987,45.939987],[28.485269,45.596907],[28.233554,45.488283],[28.054443,45.944586],[28.160018,46.371563],[28.12803,46.810476],[27.551166,47.405117],[27.233873,47.826771],[26.924176,48.123264],[26.619337,48.220726]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Madagascar","SOV_A3":"MDG","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Madagascar","ADM0_A3":"MDG","GEOU_DIF":0,"GEOUNIT":"Madagascar","GU_A3":"MDG","SU_DIF":0,"SUBUNIT":"Madagascar","SU_A3":"MDG","BRK_DIFF":0,"NAME":"Madagascar","NAME_LONG":"Madagascar","BRK_A3":"MDG","BRK_NAME":"Madagascar","BRK_GROUP":null,"ABBREV":"Mad.","POSTAL":"MG","FORMAL_EN":"Republic of Madagascar","FORMAL_FR":null,"NAME_CIAWF":"Madagascar","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Madagascar","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":5,"MAPCOLOR9":2,"MAPCOLOR13":3,"POP_EST":25054161,"POP_RANK":15,"GDP_MD_EST":36860,"POP_YEAR":2017,"LASTCENSUS":1993,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"MA","ISO_A2":"MG","ISO_A3":"MDG","ISO_A3_EH":"MDG","ISO_N3":"450","UN_A3":"450","WB_A2":"MG","WB_A3":"MDG","WOE_ID":23424883,"WOE_ID_EH":23424883,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MDG","ADM0_A3_US":"MDG","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[43.254187,-25.601434,50.476537,-12.040557],"geometry":{"type":"Polygon","coordinates":[[[49.543519,-12.469833],[49.808981,-12.895285],[50.056511,-13.555761],[50.217431,-14.758789],[50.476537,-15.226512],[50.377111,-15.706069],[50.200275,-16.000263],[49.860606,-15.414253],[49.672607,-15.710204],[49.863344,-16.451037],[49.774564,-16.875042],[49.498612,-17.106036],[49.435619,-17.953064],[49.041792,-19.118781],[48.548541,-20.496888],[47.930749,-22.391501],[47.547723,-23.781959],[47.095761,-24.94163],[46.282478,-25.178463],[45.409508,-25.601434],[44.833574,-25.346101],[44.03972,-24.988345],[43.763768,-24.460677],[43.697778,-23.574116],[43.345654,-22.776904],[43.254187,-22.057413],[43.433298,-21.336475],[43.893683,-21.163307],[43.89637,-20.830459],[44.374325,-20.072366],[44.464397,-19.435454],[44.232422,-18.961995],[44.042976,-18.331387],[43.963084,-17.409945],[44.312469,-16.850496],[44.446517,-16.216219],[44.944937,-16.179374],[45.502732,-15.974373],[45.872994,-15.793454],[46.312243,-15.780018],[46.882183,-15.210182],[47.70513,-14.594303],[48.005215,-14.091233],[47.869047,-13.663869],[48.293828,-13.784068],[48.84506,-13.089175],[48.863509,-12.487868],[49.194651,-12.040557],[49.543519,-12.469833]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Mexico","SOV_A3":"MEX","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Mexico","ADM0_A3":"MEX","GEOU_DIF":0,"GEOUNIT":"Mexico","GU_A3":"MEX","SU_DIF":0,"SUBUNIT":"Mexico","SU_A3":"MEX","BRK_DIFF":0,"NAME":"Mexico","NAME_LONG":"Mexico","BRK_A3":"MEX","BRK_NAME":"Mexico","BRK_GROUP":null,"ABBREV":"Mex.","POSTAL":"MX","FORMAL_EN":"United Mexican States","FORMAL_FR":null,"NAME_CIAWF":"Mexico","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Mexico","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":1,"MAPCOLOR9":7,"MAPCOLOR13":3,"POP_EST":124574795,"POP_RANK":17,"GDP_MD_EST":2307000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"4. Emerging region: MIKT","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"MX","ISO_A2":"MX","ISO_A3":"MEX","ISO_A3_EH":"MEX","ISO_N3":"484","UN_A3":"484","WB_A2":"MX","WB_A3":"MEX","WOE_ID":23424900,"WOE_ID_EH":23424900,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MEX","ADM0_A3_US":"MEX","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":6.7},"bbox":[-117.12776,14.538829,-86.811982,32.72083],"geometry":{"type":"Polygon","coordinates":[[[-88.300031,18.499982],[-88.490123,18.486831],[-88.848344,17.883198],[-89.029857,18.001511],[-89.150909,17.955468],[-89.14308,17.808319],[-90.067934,17.819326],[-91.00152,17.817595],[-91.002269,17.254658],[-91.453921,17.252177],[-91.08167,16.918477],[-90.711822,16.687483],[-90.600847,16.470778],[-90.438867,16.41011],[-90.464473,16.069562],[-91.74796,16.066565],[-92.229249,15.251447],[-92.087216,15.064585],[-92.20323,14.830103],[-92.22775,14.538829],[-93.359464,15.61543],[-93.875169,15.940164],[-94.691656,16.200975],[-95.250227,16.128318],[-96.053382,15.752088],[-96.557434,15.653515],[-97.263592,15.917065],[-98.01303,16.107312],[-98.947676,16.566043],[-99.697397,16.706164],[-100.829499,17.171071],[-101.666089,17.649026],[-101.918528,17.91609],[-102.478132,17.975751],[-103.50099,18.292295],[-103.917527,18.748572],[-104.99201,19.316134],[-105.493038,19.946767],[-105.731396,20.434102],[-105.397773,20.531719],[-105.500661,20.816895],[-105.270752,21.076285],[-105.265817,21.422104],[-105.603161,21.871146],[-105.693414,22.26908],[-106.028716,22.773752],[-106.90998,23.767774],[-107.915449,24.548915],[-108.401905,25.172314],[-109.260199,25.580609],[-109.444089,25.824884],[-109.291644,26.442934],[-109.801458,26.676176],[-110.391732,27.162115],[-110.641019,27.859876],[-111.178919,27.941241],[-111.759607,28.467953],[-112.228235,28.954409],[-112.271824,29.266844],[-112.809594,30.021114],[-113.163811,30.786881],[-113.148669,31.170966],[-113.871881,31.567608],[-114.205737,31.524045],[-114.776451,31.799532],[-114.9367,31.393485],[-114.771232,30.913617],[-114.673899,30.162681],[-114.330974,29.750432],[-113.588875,29.061611],[-113.424053,28.826174],[-113.271969,28.754783],[-113.140039,28.411289],[-112.962298,28.42519],[-112.761587,27.780217],[-112.457911,27.525814],[-112.244952,27.171727],[-111.616489,26.662817],[-111.284675,25.73259],[-110.987819,25.294606],[-110.710007,24.826004],[-110.655049,24.298595],[-110.172856,24.265548],[-109.771847,23.811183],[-109.409104,23.364672],[-109.433392,23.185588],[-109.854219,22.818272],[-110.031392,22.823078],[-110.295071,23.430973],[-110.949501,24.000964],[-111.670568,24.484423],[-112.182036,24.738413],[-112.148989,25.470125],[-112.300711,26.012004],[-112.777297,26.32196],[-113.464671,26.768186],[-113.59673,26.63946],[-113.848937,26.900064],[-114.465747,27.14209],[-115.055142,27.722727],[-114.982253,27.7982],[-114.570366,27.741485],[-114.199329,28.115003],[-114.162018,28.566112],[-114.931842,29.279479],[-115.518654,29.556362],[-115.887365,30.180794],[-116.25835,30.836464],[-116.721526,31.635744],[-117.12776,32.53534],[-115.99135,32.61239],[-114.72139,32.72083],[-114.815,32.52528],[-113.30498,32.03914],[-111.02361,31.33472],[-109.035,31.34194],[-108.24194,31.34222],[-108.24,31.754854],[-106.50759,31.75452],[-106.1429,31.39995],[-105.63159,31.08383],[-105.03737,30.64402],[-104.70575,30.12173],[-104.45697,29.57196],[-103.94,29.27],[-103.11,28.97],[-102.48,29.76],[-101.6624,29.7793],[-100.9576,29.38071],[-100.45584,28.69612],[-100.11,28.11],[-99.52,27.54],[-99.3,26.84],[-99.02,26.37],[-98.24,26.06],[-97.53,25.84],[-97.140008,25.869997],[-97.528072,24.992144],[-97.702946,24.272343],[-97.776042,22.93258],[-97.872367,22.444212],[-97.699044,21.898689],[-97.38896,21.411019],[-97.189333,20.635433],[-96.525576,19.890931],[-96.292127,19.320371],[-95.900885,18.828024],[-94.839063,18.562717],[-94.42573,18.144371],[-93.548651,18.423837],[-92.786114,18.524839],[-92.037348,18.704569],[-91.407903,18.876083],[-90.77187,19.28412],[-90.53359,19.867418],[-90.451476,20.707522],[-90.278618,20.999855],[-89.601321,21.261726],[-88.543866,21.493675],[-87.658417,21.458846],[-87.05189,21.543543],[-86.811982,21.331515],[-86.845908,20.849865],[-87.383291,20.255405],[-87.621054,19.646553],[-87.43675,19.472403],[-87.58656,19.04013],[-87.837191,18.259816],[-88.090664,18.516648],[-88.300031,18.499982]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Macedonia","SOV_A3":"MKD","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Macedonia","ADM0_A3":"MKD","GEOU_DIF":0,"GEOUNIT":"Macedonia","GU_A3":"MKD","SU_DIF":0,"SUBUNIT":"Macedonia","SU_A3":"MKD","BRK_DIFF":0,"NAME":"Macedonia","NAME_LONG":"Macedonia","BRK_A3":"MKD","BRK_NAME":"Macedonia","BRK_GROUP":null,"ABBREV":"Mkd.","POSTAL":"MK","FORMAL_EN":"Former Yugoslav Republic of Macedonia","FORMAL_FR":null,"NAME_CIAWF":"Macedonia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Macedonia, FYR","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":3,"MAPCOLOR9":7,"MAPCOLOR13":3,"POP_EST":2103721,"POP_RANK":12,"GDP_MD_EST":29520,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"MK","ISO_A2":"MK","ISO_A3":"MKD","ISO_A3_EH":"MKD","ISO_N3":"807","UN_A3":"807","WB_A2":"MK","WB_A3":"MKD","WOE_ID":23424890,"WOE_ID_EH":23424890,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MKD","ADM0_A3_US":"MKD","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[20.463175,40.842727,22.952377,42.32026],"geometry":{"type":"Polygon","coordinates":[[[21.02004,40.842727],[20.605182,41.086226],[20.463175,41.515089],[20.590247,41.855404],[20.590247,41.855409],[20.71731,41.84711],[20.76216,42.05186],[21.3527,42.2068],[21.576636,42.245224],[21.91708,42.30364],[22.380526,42.32026],[22.881374,41.999297],[22.952377,41.337994],[22.76177,41.3048],[22.597308,41.130487],[22.055378,41.149866],[21.674161,40.931275],[21.02004,40.842727]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Mali","SOV_A3":"MLI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Mali","ADM0_A3":"MLI","GEOU_DIF":0,"GEOUNIT":"Mali","GU_A3":"MLI","SU_DIF":0,"SUBUNIT":"Mali","SU_A3":"MLI","BRK_DIFF":0,"NAME":"Mali","NAME_LONG":"Mali","BRK_A3":"MLI","BRK_NAME":"Mali","BRK_GROUP":null,"ABBREV":"Mali","POSTAL":"ML","FORMAL_EN":"Republic of Mali","FORMAL_FR":null,"NAME_CIAWF":"Mali","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Mali","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":7,"POP_EST":17885245,"POP_RANK":14,"GDP_MD_EST":38090,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"ML","ISO_A2":"ML","ISO_A3":"MLI","ISO_A3_EH":"MLI","ISO_N3":"466","UN_A3":"466","WB_A2":"ML","WB_A3":"MLI","WOE_ID":23424891,"WOE_ID_EH":23424891,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MLI","ADM0_A3_US":"MLI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[-12.17075,10.096361,4.27021,24.974574],"geometry":{"type":"Polygon","coordinates":[[[0.374892,14.928908],[-0.266257,14.924309],[-0.515854,15.116158],[-1.066363,14.973815],[-2.001035,14.559008],[-2.191825,14.246418],[-2.967694,13.79815],[-3.103707,13.541267],[-3.522803,13.337662],[-4.006391,13.472485],[-4.280405,13.228444],[-4.427166,12.542646],[-5.220942,11.713859],[-5.197843,11.375146],[-5.470565,10.95127],[-5.404342,10.370737],[-5.816926,10.222555],[-6.050452,10.096361],[-6.205223,10.524061],[-6.493965,10.411303],[-6.666461,10.430811],[-6.850507,10.138994],[-7.622759,10.147236],[-7.89959,10.297382],[-8.029944,10.206535],[-8.335377,10.494812],[-8.282357,10.792597],[-8.407311,10.909257],[-8.620321,10.810891],[-8.581305,11.136246],[-8.376305,11.393646],[-8.786099,11.812561],[-8.905265,12.088358],[-9.127474,12.30806],[-9.327616,12.334286],[-9.567912,12.194243],[-9.890993,12.060479],[-10.165214,11.844084],[-10.593224,11.923975],[-10.87083,12.177887],[-11.036556,12.211245],[-11.297574,12.077971],[-11.456169,12.076834],[-11.513943,12.442988],[-11.467899,12.754519],[-11.553398,13.141214],[-11.927716,13.422075],[-12.124887,13.994727],[-12.17075,14.616834],[-11.834208,14.799097],[-11.666078,15.388208],[-11.349095,15.411256],[-10.650791,15.132746],[-10.086846,15.330486],[-9.700255,15.264107],[-9.550238,15.486497],[-5.537744,15.50169],[-5.315277,16.201854],[-5.488523,16.325102],[-5.971129,20.640833],[-6.453787,24.956591],[-4.923337,24.974574],[-1.550055,22.792666],[1.823228,20.610809],[2.060991,20.142233],[2.683588,19.85623],[3.146661,19.693579],[3.158133,19.057364],[4.267419,19.155265],[4.27021,16.852227],[3.723422,16.184284],[3.638259,15.56812],[2.749993,15.409525],[1.385528,15.323561],[1.015783,14.968182],[0.374892,14.928908]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Myanmar","SOV_A3":"MMR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Myanmar","ADM0_A3":"MMR","GEOU_DIF":0,"GEOUNIT":"Myanmar","GU_A3":"MMR","SU_DIF":0,"SUBUNIT":"Myanmar","SU_A3":"MMR","BRK_DIFF":0,"NAME":"Myanmar","NAME_LONG":"Myanmar","BRK_A3":"MMR","BRK_NAME":"Myanmar","BRK_GROUP":null,"ABBREV":"Myan.","POSTAL":"MM","FORMAL_EN":"Republic of the Union of Myanmar","FORMAL_FR":null,"NAME_CIAWF":"Burma","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Myanmar","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":5,"MAPCOLOR13":13,"POP_EST":55123814,"POP_RANK":16,"GDP_MD_EST":311100,"POP_YEAR":2017,"LASTCENSUS":1983,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"BM","ISO_A2":"MM","ISO_A3":"MMR","ISO_A3_EH":"MMR","ISO_N3":"104","UN_A3":"104","WB_A2":"MM","WB_A3":"MMR","WOE_ID":23424763,"WOE_ID_EH":23424763,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MMR","ADM0_A3_US":"MMR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[92.303234,9.93296,101.180005,28.335945],"geometry":{"type":"Polygon","coordinates":[[[92.368554,20.670883],[92.303234,21.475485],[92.652257,21.324048],[92.672721,22.041239],[93.166128,22.27846],[93.060294,22.703111],[93.286327,23.043658],[93.325188,24.078556],[94.106742,23.850741],[94.552658,24.675238],[94.603249,25.162495],[95.155153,26.001307],[95.124768,26.573572],[96.419366,27.264589],[97.133999,27.083774],[97.051989,27.699059],[97.402561,27.882536],[97.327114,28.261583],[97.911988,28.335945],[98.246231,27.747221],[98.68269,27.508812],[98.712094,26.743536],[98.671838,25.918703],[97.724609,25.083637],[97.60472,23.897405],[98.660262,24.063286],[98.898749,23.142722],[99.531992,22.949039],[99.240899,22.118314],[99.983489,21.742937],[100.416538,21.558839],[101.150033,21.849984],[101.180005,21.436573],[100.329101,20.786122],[100.115988,20.41785],[99.543309,20.186598],[98.959676,19.752981],[98.253724,19.708203],[97.797783,18.62708],[97.375896,18.445438],[97.859123,17.567946],[98.493761,16.837836],[98.903348,16.177824],[98.537376,15.308497],[98.192074,15.123703],[98.430819,14.622028],[99.097755,13.827503],[99.212012,13.269294],[99.196354,12.804748],[99.587286,11.892763],[99.038121,10.960546],[98.553551,9.93296],[98.457174,10.675266],[98.764546,11.441292],[98.428339,12.032987],[98.509574,13.122378],[98.103604,13.64046],[97.777732,14.837286],[97.597072,16.100568],[97.16454,16.928734],[96.505769,16.427241],[95.369352,15.71439],[94.808405,15.803454],[94.188804,16.037936],[94.533486,17.27724],[94.324817,18.213514],[93.540988,19.366493],[93.663255,19.726962],[93.078278,19.855145],[92.368554,20.670883]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Montenegro","SOV_A3":"MNE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Montenegro","ADM0_A3":"MNE","GEOU_DIF":0,"GEOUNIT":"Montenegro","GU_A3":"MNE","SU_DIF":0,"SUBUNIT":"Montenegro","SU_A3":"MNE","BRK_DIFF":0,"NAME":"Montenegro","NAME_LONG":"Montenegro","BRK_A3":"MNE","BRK_NAME":"Montenegro","BRK_GROUP":null,"ABBREV":"Mont.","POSTAL":"ME","FORMAL_EN":"Montenegro","FORMAL_FR":null,"NAME_CIAWF":"Montenegro","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Montenegro","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":1,"MAPCOLOR9":4,"MAPCOLOR13":5,"POP_EST":642550,"POP_RANK":11,"GDP_MD_EST":10610,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"MJ","ISO_A2":"ME","ISO_A3":"MNE","ISO_A3_EH":"MNE","ISO_N3":"499","UN_A3":"499","WB_A2":"ME","WB_A3":"MNE","WOE_ID":20069817,"WOE_ID_EH":20069817,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MNE","ADM0_A3_US":"MNE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[18.450017,41.877551,20.3398,43.52384],"geometry":{"type":"Polygon","coordinates":[[[20.0707,42.58863],[19.801613,42.500093],[19.738051,42.688247],[19.304486,42.195745],[19.371768,41.877551],[19.16246,41.95502],[18.88214,42.28151],[18.450017,42.479992],[18.56,42.65],[18.70648,43.20011],[19.03165,43.43253],[19.21852,43.52384],[19.48389,43.35229],[19.63,43.21378],[19.95857,43.10604],[20.3398,42.89852],[20.25758,42.81275],[20.0707,42.58863]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Mongolia","SOV_A3":"MNG","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Mongolia","ADM0_A3":"MNG","GEOU_DIF":0,"GEOUNIT":"Mongolia","GU_A3":"MNG","SU_DIF":0,"SUBUNIT":"Mongolia","SU_A3":"MNG","BRK_DIFF":0,"NAME":"Mongolia","NAME_LONG":"Mongolia","BRK_A3":"MNG","BRK_NAME":"Mongolia","BRK_GROUP":null,"ABBREV":"Mong.","POSTAL":"MN","FORMAL_EN":"Mongolia","FORMAL_FR":null,"NAME_CIAWF":"Mongolia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Mongolia","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":5,"MAPCOLOR9":5,"MAPCOLOR13":6,"POP_EST":3068243,"POP_RANK":12,"GDP_MD_EST":37000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"MG","ISO_A2":"MN","ISO_A3":"MNG","ISO_A3_EH":"MNG","ISO_N3":"496","UN_A3":"496","WB_A2":"MN","WB_A3":"MNG","WOE_ID":23424887,"WOE_ID_EH":23424887,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MNG","ADM0_A3_US":"MNG","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[87.751264,41.59741,119.772824,52.047366],"geometry":{"type":"Polygon","coordinates":[[[116.678801,49.888531],[116.191802,49.134598],[115.485282,48.135383],[115.742837,47.726545],[116.308953,47.85341],[117.295507,47.697709],[118.064143,48.06673],[118.866574,47.74706],[119.772824,47.048059],[119.66327,46.69268],[118.874326,46.805412],[117.421701,46.672733],[116.717868,46.388202],[115.985096,45.727235],[114.460332,45.339817],[113.463907,44.808893],[112.436062,45.011646],[111.873306,45.102079],[111.348377,44.457442],[111.667737,44.073176],[111.829588,43.743118],[111.129682,43.406834],[110.412103,42.871234],[109.243596,42.519446],[107.744773,42.481516],[106.129316,42.134328],[104.964994,41.59741],[104.522282,41.908347],[103.312278,41.907468],[101.83304,42.514873],[100.845866,42.663804],[99.515817,42.524691],[97.451757,42.74889],[96.349396,42.725635],[95.762455,43.319449],[95.306875,44.241331],[94.688929,44.352332],[93.480734,44.975472],[92.133891,45.115076],[90.94554,45.286073],[90.585768,45.719716],[90.970809,46.888146],[90.280826,47.693549],[88.854298,48.069082],[88.013832,48.599463],[87.751264,49.297198],[88.805567,49.470521],[90.713667,50.331812],[92.234712,50.802171],[93.10421,50.49529],[94.147566,50.480537],[94.815949,50.013433],[95.81402,49.97746],[97.25976,49.72605],[98.231762,50.422401],[97.82574,51.010995],[98.861491,52.047366],[99.981732,51.634006],[100.88948,51.516856],[102.06521,51.25991],[102.25589,50.51056],[103.676545,50.089966],[104.62158,50.27532],[105.886591,50.406019],[106.888804,50.274296],[107.868176,49.793705],[108.475167,49.282548],[109.402449,49.292961],[110.662011,49.130128],[111.581231,49.377968],[112.89774,49.543565],[114.362456,50.248303],[114.96211,50.140247],[115.485695,49.805177],[116.678801,49.888531]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Mozambique","SOV_A3":"MOZ","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Mozambique","ADM0_A3":"MOZ","GEOU_DIF":0,"GEOUNIT":"Mozambique","GU_A3":"MOZ","SU_DIF":0,"SUBUNIT":"Mozambique","SU_A3":"MOZ","BRK_DIFF":0,"NAME":"Mozambique","NAME_LONG":"Mozambique","BRK_A3":"MOZ","BRK_NAME":"Mozambique","BRK_GROUP":null,"ABBREV":"Moz.","POSTAL":"MZ","FORMAL_EN":"Republic of Mozambique","FORMAL_FR":null,"NAME_CIAWF":"Mozambique","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Mozambique","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":2,"MAPCOLOR9":1,"MAPCOLOR13":4,"POP_EST":26573706,"POP_RANK":15,"GDP_MD_EST":35010,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"MZ","ISO_A2":"MZ","ISO_A3":"MOZ","ISO_A3_EH":"MOZ","ISO_N3":"508","UN_A3":"508","WB_A2":"MZ","WB_A3":"MOZ","WOE_ID":23424902,"WOE_ID_EH":23424902,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MOZ","ADM0_A3_US":"MOZ","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[30.179481,-26.742192,40.775475,-10.317096],"geometry":{"type":"Polygon","coordinates":[[[34.559989,-11.52002],[35.312398,-11.439146],[36.514082,-11.720938],[36.775151,-11.594537],[37.47129,-11.56876],[37.82764,-11.26879],[38.427557,-11.285202],[39.521,-10.89688],[40.31659,-10.3171],[40.316586,-10.317098],[40.316589,-10.317096],[40.478387,-10.765441],[40.437253,-11.761711],[40.560811,-12.639177],[40.59962,-14.201975],[40.775475,-14.691764],[40.477251,-15.406294],[40.089264,-16.100774],[39.452559,-16.720891],[38.538351,-17.101023],[37.411133,-17.586368],[36.281279,-18.659688],[35.896497,-18.84226],[35.1984,-19.552811],[34.786383,-19.784012],[34.701893,-20.497043],[35.176127,-21.254361],[35.373428,-21.840837],[35.385848,-22.14],[35.562546,-22.09],[35.533935,-23.070788],[35.371774,-23.535359],[35.60747,-23.706563],[35.458746,-24.12261],[35.040735,-24.478351],[34.215824,-24.816314],[33.01321,-25.357573],[32.574632,-25.727318],[32.660363,-26.148584],[32.915955,-26.215867],[32.83012,-26.742192],[32.071665,-26.73382],[31.985779,-26.29178],[31.837778,-25.843332],[31.752408,-25.484284],[31.930589,-24.369417],[31.670398,-23.658969],[31.191409,-22.25151],[32.244988,-21.116489],[32.508693,-20.395292],[32.659743,-20.30429],[32.772708,-19.715592],[32.611994,-19.419383],[32.654886,-18.67209],[32.849861,-17.979057],[32.847639,-16.713398],[32.328239,-16.392074],[31.852041,-16.319417],[31.636498,-16.07199],[31.173064,-15.860944],[30.338955,-15.880839],[30.274256,-15.507787],[30.179481,-14.796099],[33.214025,-13.97186],[33.7897,-14.451831],[34.064825,-14.35995],[34.459633,-14.61301],[34.517666,-15.013709],[34.307291,-15.478641],[34.381292,-16.18356],[35.03381,-16.8013],[35.339063,-16.10744],[35.771905,-15.896859],[35.686845,-14.611046],[35.267956,-13.887834],[34.907151,-13.565425],[34.559989,-13.579998],[34.280006,-12.280025],[34.559989,-11.52002]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Mauritania","SOV_A3":"MRT","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Mauritania","ADM0_A3":"MRT","GEOU_DIF":0,"GEOUNIT":"Mauritania","GU_A3":"MRT","SU_DIF":0,"SUBUNIT":"Mauritania","SU_A3":"MRT","BRK_DIFF":0,"NAME":"Mauritania","NAME_LONG":"Mauritania","BRK_A3":"MRT","BRK_NAME":"Mauritania","BRK_GROUP":null,"ABBREV":"Mrt.","POSTAL":"MR","FORMAL_EN":"Islamic Republic of Mauritania","FORMAL_FR":null,"NAME_CIAWF":"Mauritania","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Mauritania","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":3,"MAPCOLOR9":2,"MAPCOLOR13":1,"POP_EST":3758571,"POP_RANK":12,"GDP_MD_EST":16710,"POP_YEAR":2017,"LASTCENSUS":2000,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"MR","ISO_A2":"MR","ISO_A3":"MRT","ISO_A3_EH":"MRT","ISO_N3":"478","UN_A3":"478","WB_A2":"MR","WB_A3":"MRT","WOE_ID":23424896,"WOE_ID_EH":23424896,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MRT","ADM0_A3_US":"MRT","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-17.063423,14.616834,-4.923337,27.395744],"geometry":{"type":"Polygon","coordinates":[[[-8.6844,27.395744],[-4.923337,24.974574],[-6.453787,24.956591],[-5.971129,20.640833],[-5.488523,16.325102],[-5.315277,16.201854],[-5.537744,15.50169],[-9.550238,15.486497],[-9.700255,15.264107],[-10.086846,15.330486],[-10.650791,15.132746],[-11.349095,15.411256],[-11.666078,15.388208],[-11.834208,14.799097],[-12.17075,14.616834],[-12.830658,15.303692],[-13.435738,16.039383],[-14.099521,16.304302],[-14.577348,16.598264],[-15.135737,16.587282],[-15.623666,16.369337],[-16.12069,16.455663],[-16.463098,16.135036],[-16.549708,16.673892],[-16.270552,17.166963],[-16.146347,18.108482],[-16.256883,19.096716],[-16.377651,19.593817],[-16.277838,20.092521],[-16.536324,20.567866],[-17.063423,20.999752],[-16.845194,21.333323],[-12.929102,21.327071],[-13.118754,22.77122],[-12.874222,23.284832],[-11.937224,23.374594],[-11.969419,25.933353],[-8.687294,25.881056],[-8.6844,27.395744]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Malawi","SOV_A3":"MWI","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Malawi","ADM0_A3":"MWI","GEOU_DIF":0,"GEOUNIT":"Malawi","GU_A3":"MWI","SU_DIF":0,"SUBUNIT":"Malawi","SU_A3":"MWI","BRK_DIFF":0,"NAME":"Malawi","NAME_LONG":"Malawi","BRK_A3":"MWI","BRK_NAME":"Malawi","BRK_GROUP":null,"ABBREV":"Mal.","POSTAL":"MW","FORMAL_EN":"Republic of Malawi","FORMAL_FR":null,"NAME_CIAWF":"Malawi","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Malawi","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":3,"MAPCOLOR9":4,"MAPCOLOR13":5,"POP_EST":19196246,"POP_RANK":14,"GDP_MD_EST":21200,"POP_YEAR":2017,"LASTCENSUS":2008,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"MI","ISO_A2":"MW","ISO_A3":"MWI","ISO_A3_EH":"MWI","ISO_N3":"454","UN_A3":"454","WB_A2":"MW","WB_A3":"MWI","WOE_ID":23424889,"WOE_ID_EH":23424889,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MWI","ADM0_A3_US":"MWI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[32.688165,-16.8013,35.771905,-9.230599],"geometry":{"type":"Polygon","coordinates":[[[34.559989,-11.52002],[34.280006,-12.280025],[34.559989,-13.579998],[34.907151,-13.565425],[35.267956,-13.887834],[35.686845,-14.611046],[35.771905,-15.896859],[35.339063,-16.10744],[35.03381,-16.8013],[34.381292,-16.18356],[34.307291,-15.478641],[34.517666,-15.013709],[34.459633,-14.61301],[34.064825,-14.35995],[33.7897,-14.451831],[33.214025,-13.97186],[32.688165,-13.712858],[32.991764,-12.783871],[33.306422,-12.435778],[33.114289,-11.607198],[33.31531,-10.79655],[33.485688,-10.525559],[33.231388,-9.676722],[32.759375,-9.230599],[33.73972,-9.41715],[33.940838,-9.693674],[34.28,-10.16],[34.559989,-11.52002]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Malaysia","SOV_A3":"MYS","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Malaysia","ADM0_A3":"MYS","GEOU_DIF":0,"GEOUNIT":"Malaysia","GU_A3":"MYS","SU_DIF":0,"SUBUNIT":"Malaysia","SU_A3":"MYS","BRK_DIFF":0,"NAME":"Malaysia","NAME_LONG":"Malaysia","BRK_A3":"MYS","BRK_NAME":"Malaysia","BRK_GROUP":null,"ABBREV":"Malay.","POSTAL":"MY","FORMAL_EN":"Malaysia","FORMAL_FR":null,"NAME_CIAWF":"Malaysia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Malaysia","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":4,"MAPCOLOR9":3,"MAPCOLOR13":6,"POP_EST":31381992,"POP_RANK":15,"GDP_MD_EST":863000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"MY","ISO_A2":"MY","ISO_A3":"MYS","ISO_A3_EH":"MYS","ISO_N3":"458","UN_A3":"458","WB_A2":"MY","WB_A3":"MYS","WOE_ID":23424901,"WOE_ID_EH":23424901,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MYS","ADM0_A3_US":"MYS","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[100.085757,0.773131,119.181904,6.928053],"geometry":{"type":"MultiPolygon","coordinates":[[[[114.204017,4.525874],[114.659596,4.007637],[114.869557,4.348314],[115.347461,4.316636],[115.4057,4.955228],[115.45071,5.44773],[116.220741,6.143191],[116.725103,6.924771],[117.129626,6.928053],[117.643393,6.422166],[117.689075,5.98749],[118.347691,5.708696],[119.181904,5.407836],[119.110694,5.016128],[118.439727,4.966519],[118.618321,4.478202],[117.882035,4.137551],[117.015214,4.306094],[115.865517,4.306559],[115.519078,3.169238],[115.134037,2.821482],[114.621355,1.430688],[113.80585,1.217549],[112.859809,1.49779],[112.380252,1.410121],[111.797548,0.904441],[111.159138,0.976478],[110.514061,0.773131],[109.830227,1.338136],[109.66326,2.006467],[110.396135,1.663775],[111.168853,1.850637],[111.370081,2.697303],[111.796928,2.885897],[112.995615,3.102395],[113.712935,3.893509],[114.204017,4.525874]]],[[[102.141187,6.221636],[102.371147,6.128205],[102.961705,5.524495],[103.381215,4.855001],[103.438575,4.181606],[103.332122,3.726698],[103.429429,3.382869],[103.502448,2.791019],[103.854674,2.515454],[104.247932,1.631141],[104.228811,1.293048],[103.519707,1.226334],[102.573615,1.967115],[101.390638,2.760814],[101.27354,3.270292],[100.695435,3.93914],[100.557408,4.76728],[100.196706,5.312493],[100.30626,6.040562],[100.085757,6.464489],[100.259596,6.642825],[101.075516,6.204867],[101.154219,5.691384],[101.814282,5.810808],[102.141187,6.221636]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Namibia","SOV_A3":"NAM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Namibia","ADM0_A3":"NAM","GEOU_DIF":0,"GEOUNIT":"Namibia","GU_A3":"NAM","SU_DIF":0,"SUBUNIT":"Namibia","SU_A3":"NAM","BRK_DIFF":0,"NAME":"Namibia","NAME_LONG":"Namibia","BRK_A3":"NAM","BRK_NAME":"Namibia","BRK_GROUP":null,"ABBREV":"Nam.","POSTAL":"NA","FORMAL_EN":"Republic of Namibia","FORMAL_FR":null,"NAME_CIAWF":"Namibia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Namibia","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":1,"MAPCOLOR9":1,"MAPCOLOR13":7,"POP_EST":2484780,"POP_RANK":12,"GDP_MD_EST":25990,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"WA","ISO_A2":"NA","ISO_A3":"NAM","ISO_A3_EH":"NAM","ISO_N3":"516","UN_A3":"516","WB_A2":"NA","WB_A3":"NAM","WOE_ID":23424987,"WOE_ID_EH":23424987,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"NAM","ADM0_A3_US":"NAM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Southern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7.5},"bbox":[11.734199,-29.045462,25.084443,-16.941343],"geometry":{"type":"Polygon","coordinates":[[[11.734199,-17.301889],[12.215461,-17.111668],[12.814081,-16.941343],[13.462362,-16.971212],[14.058501,-17.423381],[14.209707,-17.353101],[18.263309,-17.309951],[18.956187,-17.789095],[21.377176,-17.930636],[23.215048,-17.523116],[24.033862,-17.295843],[24.682349,-17.353411],[25.07695,-17.578823],[25.084443,-17.661816],[24.520705,-17.887125],[24.217365,-17.889347],[23.579006,-18.281261],[23.196858,-17.869038],[21.65504,-18.219146],[20.910641,-18.252219],[20.881134,-21.814327],[19.895458,-21.849157],[19.895768,-24.76779],[19.894734,-28.461105],[19.002127,-28.972443],[18.464899,-29.045462],[17.836152,-28.856378],[17.387497,-28.783514],[17.218929,-28.355943],[16.824017,-28.082162],[16.344977,-28.576705],[15.601818,-27.821247],[15.210472,-27.090956],[14.989711,-26.117372],[14.743214,-25.39292],[14.408144,-23.853014],[14.385717,-22.656653],[14.257714,-22.111208],[13.868642,-21.699037],[13.352498,-20.872834],[12.826845,-19.673166],[12.608564,-19.045349],[11.794919,-18.069129],[11.734199,-17.301889]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"France","SOV_A3":"FR1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Dependency","ADMIN":"New Caledonia","ADM0_A3":"NCL","GEOU_DIF":0,"GEOUNIT":"New Caledonia","GU_A3":"NCL","SU_DIF":0,"SUBUNIT":"New Caledonia","SU_A3":"NCL","BRK_DIFF":0,"NAME":"New Caledonia","NAME_LONG":"New Caledonia","BRK_A3":"NCL","BRK_NAME":"New Caledonia","BRK_GROUP":null,"ABBREV":"New C.","POSTAL":"NC","FORMAL_EN":"New Caledonia","FORMAL_FR":"Nouvelle-Calédonie","NAME_CIAWF":"New Caledonia","NOTE_ADM0":"Fr.","NOTE_BRK":null,"NAME_SORT":"New Caledonia","NAME_ALT":null,"MAPCOLOR7":7,"MAPCOLOR8":5,"MAPCOLOR9":9,"MAPCOLOR13":11,"POP_EST":279070,"POP_RANK":10,"GDP_MD_EST":10770,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"NC","ISO_A2":"NC","ISO_A3":"NCL","ISO_A3_EH":"NCL","ISO_N3":"540","UN_A3":"540","WB_A2":"NC","WB_A3":"NCL","WOE_ID":23424903,"WOE_ID_EH":23424903,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"NCL","ADM0_A3_US":"NCL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Oceania","REGION_UN":"Oceania","SUBREGION":"Melanesia","REGION_WB":"East Asia & Pacific","NAME_LEN":13,"LONG_LEN":13,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":-99,"MIN_ZOOM":0,"MIN_LABEL":4.6,"MAX_LABEL":8},"bbox":[164.029606,-22.399976,167.120011,-20.105646],"geometry":{"type":"Polygon","coordinates":[[[165.77999,-21.080005],[166.599991,-21.700019],[167.120011,-22.159991],[166.740035,-22.399976],[166.189732,-22.129708],[165.474375,-21.679607],[164.829815,-21.14982],[164.167995,-20.444747],[164.029606,-20.105646],[164.459967,-20.120012],[165.020036,-20.459991],[165.460009,-20.800022],[165.77999,-21.080005]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Niger","SOV_A3":"NER","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Niger","ADM0_A3":"NER","GEOU_DIF":0,"GEOUNIT":"Niger","GU_A3":"NER","SU_DIF":0,"SUBUNIT":"Niger","SU_A3":"NER","BRK_DIFF":0,"NAME":"Niger","NAME_LONG":"Niger","BRK_A3":"NER","BRK_NAME":"Niger","BRK_GROUP":null,"ABBREV":"Niger","POSTAL":"NE","FORMAL_EN":"Republic of Niger","FORMAL_FR":null,"NAME_CIAWF":"Niger","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Niger","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":5,"MAPCOLOR9":3,"MAPCOLOR13":13,"POP_EST":19245344,"POP_RANK":14,"GDP_MD_EST":20150,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"NG","ISO_A2":"NE","ISO_A3":"NER","ISO_A3_EH":"NER","ISO_N3":"562","UN_A3":"562","WB_A2":"NE","WB_A3":"NER","WOE_ID":23424906,"WOE_ID_EH":23424906,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"NER","ADM0_A3_US":"NER","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[0.295646,11.660167,15.903247,23.471668],"geometry":{"type":"Polygon","coordinates":[[[3.61118,11.660167],[2.848643,12.235636],[2.490164,12.233052],[2.154474,11.94015],[2.177108,12.625018],[1.024103,12.851826],[0.993046,13.33575],[0.429928,13.988733],[0.295646,14.444235],[0.374892,14.928908],[1.015783,14.968182],[1.385528,15.323561],[2.749993,15.409525],[3.638259,15.56812],[3.723422,16.184284],[4.27021,16.852227],[4.267419,19.155265],[5.677566,19.601207],[8.572893,21.565661],[11.999506,23.471668],[13.581425,23.040506],[14.143871,22.491289],[14.8513,22.86295],[15.096888,21.308519],[15.47106,21.04845],[15.487148,20.730415],[15.903247,20.387619],[15.685741,19.95718],[15.300441,17.92795],[15.247731,16.627306],[13.97217,15.68437],[13.540394,14.367134],[13.956699,13.996691],[13.954477,13.353449],[14.595781,13.330427],[14.495787,12.859396],[14.213531,12.802035],[14.181336,12.483657],[13.995353,12.461565],[13.318702,13.556356],[13.083987,13.596147],[12.302071,13.037189],[11.527803,13.32898],[10.989593,13.387323],[10.701032,13.246918],[10.114814,13.277252],[9.524928,12.851102],[9.014933,12.826659],[7.804671,13.343527],[7.330747,13.098038],[6.820442,13.115091],[6.445426,13.492768],[5.443058,13.865924],[4.368344,13.747482],[4.107946,13.531216],[3.967283,12.956109],[3.680634,12.552903],[3.61118,11.660167]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Nigeria","SOV_A3":"NGA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Nigeria","ADM0_A3":"NGA","GEOU_DIF":0,"GEOUNIT":"Nigeria","GU_A3":"NGA","SU_DIF":0,"SUBUNIT":"Nigeria","SU_A3":"NGA","BRK_DIFF":0,"NAME":"Nigeria","NAME_LONG":"Nigeria","BRK_A3":"NGA","BRK_NAME":"Nigeria","BRK_GROUP":null,"ABBREV":"Nigeria","POSTAL":"NG","FORMAL_EN":"Federal Republic of Nigeria","FORMAL_FR":null,"NAME_CIAWF":"Nigeria","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Nigeria","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":5,"MAPCOLOR13":2,"POP_EST":190632261,"POP_RANK":17,"GDP_MD_EST":1089000,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"NI","ISO_A2":"NG","ISO_A3":"NGA","ISO_A3_EH":"NGA","ISO_N3":"566","UN_A3":"566","WB_A2":"NG","WB_A3":"NGA","WOE_ID":23424908,"WOE_ID_EH":23424908,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"NGA","ADM0_A3_US":"NGA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":7,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[2.691702,4.240594,14.577178,13.865924],"geometry":{"type":"Polygon","coordinates":[[[2.691702,6.258817],[2.749063,7.870734],[2.723793,8.506845],[2.912308,9.137608],[3.220352,9.444153],[3.705438,10.06321],[3.60007,10.332186],[3.797112,10.734746],[3.572216,11.327939],[3.61118,11.660167],[3.680634,12.552903],[3.967283,12.956109],[4.107946,13.531216],[4.368344,13.747482],[5.443058,13.865924],[6.445426,13.492768],[6.820442,13.115091],[7.330747,13.098038],[7.804671,13.343527],[9.014933,12.826659],[9.524928,12.851102],[10.114814,13.277252],[10.701032,13.246918],[10.989593,13.387323],[11.527803,13.32898],[12.302071,13.037189],[13.083987,13.596147],[13.318702,13.556356],[13.995353,12.461565],[14.181336,12.483657],[14.577178,12.085361],[14.468192,11.904752],[14.415379,11.572369],[13.57295,10.798566],[13.308676,10.160362],[13.1676,9.640626],[12.955468,9.417772],[12.753672,8.717763],[12.218872,8.305824],[12.063946,7.799808],[11.839309,7.397042],[11.745774,6.981383],[11.058788,6.644427],[10.497375,7.055358],[10.118277,7.03877],[9.522706,6.453482],[9.233163,6.444491],[8.757533,5.479666],[8.500288,4.771983],[7.462108,4.412108],[7.082596,4.464689],[6.698072,4.240594],[5.898173,4.262453],[5.362805,4.887971],[5.033574,5.611802],[4.325607,6.270651],[3.57418,6.2583],[2.691702,6.258817]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Nicaragua","SOV_A3":"NIC","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Nicaragua","ADM0_A3":"NIC","GEOU_DIF":0,"GEOUNIT":"Nicaragua","GU_A3":"NIC","SU_DIF":0,"SUBUNIT":"Nicaragua","SU_A3":"NIC","BRK_DIFF":0,"NAME":"Nicaragua","NAME_LONG":"Nicaragua","BRK_A3":"NIC","BRK_NAME":"Nicaragua","BRK_GROUP":null,"ABBREV":"Nic.","POSTAL":"NI","FORMAL_EN":"Republic of Nicaragua","FORMAL_FR":null,"NAME_CIAWF":"Nicaragua","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Nicaragua","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":9,"POP_EST":6025951,"POP_RANK":13,"GDP_MD_EST":33550,"POP_YEAR":2017,"LASTCENSUS":2005,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"NU","ISO_A2":"NI","ISO_A3":"NIC","ISO_A3_EH":"NIC","ISO_N3":"558","UN_A3":"558","WB_A2":"NI","WB_A3":"NIC","WOE_ID":23424915,"WOE_ID_EH":23424915,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"NIC","ADM0_A3_US":"NIC","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-87.668493,10.726839,-83.147219,15.016267],"geometry":{"type":"Polygon","coordinates":[[[-83.655612,10.938764],[-83.895054,10.726839],[-84.190179,10.79345],[-84.355931,10.999226],[-84.673069,11.082657],[-84.903003,10.952303],[-85.561852,11.217119],[-85.71254,11.088445],[-86.058488,11.403439],[-86.52585,11.806877],[-86.745992,12.143962],[-87.167516,12.458258],[-87.668493,12.90991],[-87.557467,13.064552],[-87.392386,12.914018],[-87.316654,12.984686],[-87.005769,13.025794],[-86.880557,13.254204],[-86.733822,13.263093],[-86.755087,13.754845],[-86.520708,13.778487],[-86.312142,13.771356],[-86.096264,14.038187],[-85.801295,13.836055],[-85.698665,13.960078],[-85.514413,14.079012],[-85.165365,14.35437],[-85.148751,14.560197],[-85.052787,14.551541],[-84.924501,14.790493],[-84.820037,14.819587],[-84.649582,14.666805],[-84.449336,14.621614],[-84.228342,14.748764],[-83.975721,14.749436],[-83.628585,14.880074],[-83.489989,15.016267],[-83.147219,14.995829],[-83.233234,14.899866],[-83.284162,14.676624],[-83.182126,14.310703],[-83.4125,13.970078],[-83.519832,13.567699],[-83.552207,13.127054],[-83.498515,12.869292],[-83.473323,12.419087],[-83.626104,12.32085],[-83.719613,11.893124],[-83.650858,11.629032],[-83.85547,11.373311],[-83.808936,11.103044],[-83.655612,10.938764]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Netherlands","SOV_A3":"NL1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"Netherlands","ADM0_A3":"NLD","GEOU_DIF":0,"GEOUNIT":"Netherlands","GU_A3":"NLD","SU_DIF":0,"SUBUNIT":"Netherlands","SU_A3":"NLD","BRK_DIFF":0,"NAME":"Netherlands","NAME_LONG":"Netherlands","BRK_A3":"NLD","BRK_NAME":"Netherlands","BRK_GROUP":null,"ABBREV":"Neth.","POSTAL":"NL","FORMAL_EN":"Kingdom of the Netherlands","FORMAL_FR":null,"NAME_CIAWF":"Netherlands","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Netherlands","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":9,"POP_EST":17084719,"POP_RANK":14,"GDP_MD_EST":870800,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"NL","ISO_A2":"NL","ISO_A3":"NLD","ISO_A3_EH":"NLD","ISO_N3":"528","UN_A3":"528","WB_A2":"NL","WB_A3":"NLD","WOE_ID":-90,"WOE_ID_EH":23424909,"WOE_NOTE":"Doesn't include new former units of Netherlands Antilles (24549811, 24549808, and 24549809)","ADM0_A3_IS":"NLD","ADM0_A3_US":"NLD","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Western Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[3.314971,50.803721,7.092053,53.510403],"geometry":{"type":"Polygon","coordinates":[[[6.156658,50.803721],[5.606976,51.037298],[4.973991,51.475024],[4.047071,51.267259],[3.314971,51.345755],[3.315011,51.345777],[3.830289,51.620545],[4.705997,53.091798],[6.074183,53.510403],[6.90514,53.482162],[7.092053,53.144043],[6.84287,52.22844],[6.589397,51.852029],[5.988658,51.851616],[6.156658,50.803721]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Norway","SOV_A3":"NOR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Norway","ADM0_A3":"NOR","GEOU_DIF":0,"GEOUNIT":"Norway","GU_A3":"NOR","SU_DIF":0,"SUBUNIT":"Norway","SU_A3":"NOR","BRK_DIFF":0,"NAME":"Norway","NAME_LONG":"Norway","BRK_A3":"NOR","BRK_NAME":"Norway","BRK_GROUP":null,"ABBREV":"Nor.","POSTAL":"N","FORMAL_EN":"Kingdom of Norway","FORMAL_FR":null,"NAME_CIAWF":"Norway","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Norway","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":3,"MAPCOLOR9":8,"MAPCOLOR13":12,"POP_EST":5320045,"POP_RANK":13,"GDP_MD_EST":364700,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"-99","ISO_A2":"-99","ISO_A3":"-99","ISO_A3_EH":"-99","ISO_N3":"-99","UN_A3":"-99","WB_A2":"-99","WB_A3":"-99","WOE_ID":-90,"WOE_ID_EH":23424910,"WOE_NOTE":"Does not include Svalbard, Jan Mayen, or Bouvet Islands (28289410).","ADM0_A3_IS":"NOR","ADM0_A3_US":"NOR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[4.992078,58.078884,31.293418,80.657144],"geometry":{"type":"MultiPolygon","coordinates":[[[[28.59193,69.064777],[29.015573,69.766491],[27.732292,70.164193],[26.179622,69.825299],[25.689213,69.092114],[24.735679,68.649557],[23.66205,68.891247],[22.356238,68.841741],[21.244936,69.370443],[20.645593,69.106247],[20.025269,69.065139],[19.87856,68.407194],[17.993868,68.567391],[17.729182,68.010552],[16.768879,68.013937],[16.108712,67.302456],[15.108411,66.193867],[13.55569,64.787028],[13.919905,64.445421],[13.571916,64.049114],[12.579935,64.066219],[11.930569,63.128318],[11.992064,61.800362],[12.631147,61.293572],[12.300366,60.117933],[11.468272,59.432393],[11.027369,58.856149],[10.356557,59.469807],[8.382,58.313288],[7.048748,58.078884],[5.665835,58.588155],[5.308234,59.663232],[4.992078,61.970998],[5.9129,62.614473],[8.553411,63.454008],[10.527709,64.486038],[12.358347,65.879726],[14.761146,67.810642],[16.435927,68.563205],[19.184028,69.817444],[21.378416,70.255169],[23.023742,70.202072],[24.546543,71.030497],[26.37005,70.986262],[28.165547,71.185474],[31.293418,70.453788],[30.005435,70.186259],[31.101042,69.558101],[29.39955,69.15692],[28.59193,69.064777]]],[[[24.72412,77.85385],[22.49032,77.44493],[20.72601,77.67704],[21.41611,77.93504],[20.8119,78.25463],[22.88426,78.45494],[23.28134,78.07954],[24.72412,77.85385]]],[[[18.25183,79.70175],[21.54383,78.95611],[19.02737,78.5626],[18.47172,77.82669],[17.59441,77.63796],[17.1182,76.80941],[15.91315,76.77045],[13.76259,77.38035],[14.66956,77.73565],[13.1706,78.02493],[11.22231,78.8693],[10.44453,79.65239],[13.17077,80.01046],[13.71852,79.66039],[15.14282,79.67431],[15.52255,80.01608],[16.99085,80.05086],[18.25183,79.70175]]],[[[25.447625,80.40734],[27.407506,80.056406],[25.924651,79.517834],[23.024466,79.400012],[20.075188,79.566823],[19.897266,79.842362],[18.462264,79.85988],[17.368015,80.318896],[20.455992,80.598156],[21.907945,80.357679],[22.919253,80.657144],[25.447625,80.40734]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Nepal","SOV_A3":"NPL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Nepal","ADM0_A3":"NPL","GEOU_DIF":0,"GEOUNIT":"Nepal","GU_A3":"NPL","SU_DIF":0,"SUBUNIT":"Nepal","SU_A3":"NPL","BRK_DIFF":0,"NAME":"Nepal","NAME_LONG":"Nepal","BRK_A3":"NPL","BRK_NAME":"Nepal","BRK_GROUP":null,"ABBREV":"Nepal","POSTAL":"NP","FORMAL_EN":"Nepal","FORMAL_FR":null,"NAME_CIAWF":"Nepal","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Nepal","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":3,"MAPCOLOR13":12,"POP_EST":29384297,"POP_RANK":15,"GDP_MD_EST":71520,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"NP","ISO_A2":"NP","ISO_A3":"NPL","ISO_A3_EH":"NPL","ISO_N3":"524","UN_A3":"524","WB_A2":"NP","WB_A3":"NPL","WOE_ID":23424911,"WOE_ID_EH":23424911,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"NPL","ADM0_A3_US":"NPL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"South Asia","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[80.088425,26.397898,88.174804,30.422717],"geometry":{"type":"Polygon","coordinates":[[[81.111256,30.183481],[81.525804,30.422717],[82.327513,30.115268],[83.337115,29.463732],[83.898993,29.320226],[84.23458,28.839894],[85.011638,28.642774],[85.82332,28.203576],[86.954517,27.974262],[88.120441,27.876542],[88.043133,27.445819],[88.174804,26.810405],[88.060238,26.414615],[87.227472,26.397898],[86.024393,26.630985],[85.251779,26.726198],[84.675018,27.234901],[83.304249,27.364506],[81.999987,27.925479],[81.057203,28.416095],[80.088425,28.79447],[80.476721,29.729865],[81.111256,30.183481]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"New Zealand","SOV_A3":"NZ1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"New Zealand","ADM0_A3":"NZL","GEOU_DIF":0,"GEOUNIT":"New Zealand","GU_A3":"NZL","SU_DIF":0,"SUBUNIT":"New Zealand","SU_A3":"NZL","BRK_DIFF":0,"NAME":"New Zealand","NAME_LONG":"New Zealand","BRK_A3":"NZL","BRK_NAME":"New Zealand","BRK_GROUP":null,"ABBREV":"N.Z.","POSTAL":"NZ","FORMAL_EN":"New Zealand","FORMAL_FR":null,"NAME_CIAWF":"New Zealand","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"New Zealand","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":3,"MAPCOLOR9":4,"MAPCOLOR13":4,"POP_EST":4510327,"POP_RANK":12,"GDP_MD_EST":174800,"POP_YEAR":2017,"LASTCENSUS":2006,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"NZ","ISO_A2":"NZ","ISO_A3":"NZL","ISO_A3_EH":"NZL","ISO_N3":"554","UN_A3":"554","WB_A2":"NZ","WB_A3":"NZL","WOE_ID":23424916,"WOE_ID_EH":23424916,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"NZL","ADM0_A3_US":"NZL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Oceania","REGION_UN":"Oceania","SUBREGION":"Australia and New Zealand","REGION_WB":"East Asia & Pacific","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":6.7},"bbox":[166.509144,-46.641235,178.517094,-34.450662],"geometry":{"type":"MultiPolygon","coordinates":[[[[173.020375,-40.919052],[173.247234,-41.331999],[173.958405,-40.926701],[174.247587,-41.349155],[174.248517,-41.770008],[173.876447,-42.233184],[173.22274,-42.970038],[172.711246,-43.372288],[173.080113,-43.853344],[172.308584,-43.865694],[171.452925,-44.242519],[171.185138,-44.897104],[170.616697,-45.908929],[169.831422,-46.355775],[169.332331,-46.641235],[168.411354,-46.619945],[167.763745,-46.290197],[166.676886,-46.219917],[166.509144,-45.852705],[167.046424,-45.110941],[168.303763,-44.123973],[168.949409,-43.935819],[169.667815,-43.555326],[170.52492,-43.031688],[171.12509,-42.512754],[171.569714,-41.767424],[171.948709,-41.514417],[172.097227,-40.956104],[172.79858,-40.493962],[173.020375,-40.919052]]],[[[174.612009,-36.156397],[175.336616,-37.209098],[175.357596,-36.526194],[175.808887,-36.798942],[175.95849,-37.555382],[176.763195,-37.881253],[177.438813,-37.961248],[178.010354,-37.579825],[178.517094,-37.695373],[178.274731,-38.582813],[177.97046,-39.166343],[177.206993,-39.145776],[176.939981,-39.449736],[177.032946,-39.879943],[176.885824,-40.065978],[176.508017,-40.604808],[176.01244,-41.289624],[175.239567,-41.688308],[175.067898,-41.425895],[174.650973,-41.281821],[175.22763,-40.459236],[174.900157,-39.908933],[173.824047,-39.508854],[173.852262,-39.146602],[174.574802,-38.797683],[174.743474,-38.027808],[174.697017,-37.381129],[174.292028,-36.711092],[174.319004,-36.534824],[173.840997,-36.121981],[173.054171,-35.237125],[172.636005,-34.529107],[173.007042,-34.450662],[173.551298,-35.006183],[174.32939,-35.265496],[174.612009,-36.156397]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Oman","SOV_A3":"OMN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Oman","ADM0_A3":"OMN","GEOU_DIF":0,"GEOUNIT":"Oman","GU_A3":"OMN","SU_DIF":0,"SUBUNIT":"Oman","SU_A3":"OMN","BRK_DIFF":0,"NAME":"Oman","NAME_LONG":"Oman","BRK_A3":"OMN","BRK_NAME":"Oman","BRK_GROUP":null,"ABBREV":"Oman","POSTAL":"OM","FORMAL_EN":"Sultanate of Oman","FORMAL_FR":null,"NAME_CIAWF":"Oman","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Oman","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":6,"POP_EST":3424386,"POP_RANK":12,"GDP_MD_EST":173100,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"MU","ISO_A2":"OM","ISO_A3":"OMN","ISO_A3_EH":"OMN","ISO_N3":"512","UN_A3":"512","WB_A2":"OM","WB_A3":"OMN","WOE_ID":23424898,"WOE_ID_EH":23424898,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"OMN","ADM0_A3_US":"OMN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[52.00001,16.651051,59.80806,26.395934],"geometry":{"type":"MultiPolygon","coordinates":[[[[56.261042,25.714606],[56.070821,26.055464],[56.362017,26.395934],[56.485679,26.309118],[56.391421,25.895991],[56.261042,25.714606]]],[[[55.208341,22.70833],[55.234489,23.110993],[55.525841,23.524869],[55.528632,23.933604],[55.981214,24.130543],[55.804119,24.269604],[55.886233,24.920831],[56.396847,24.924732],[56.84514,24.241673],[57.403453,23.878594],[58.136948,23.747931],[58.729211,23.565668],[59.180502,22.992395],[59.450098,22.660271],[59.80806,22.533612],[59.806148,22.310525],[59.442191,21.714541],[59.282408,21.433886],[58.861141,21.114035],[58.487986,20.428986],[58.034318,20.481437],[57.826373,20.243002],[57.665762,19.736005],[57.7887,19.06757],[57.694391,18.94471],[57.234264,18.947991],[56.609651,18.574267],[56.512189,18.087113],[56.283521,17.876067],[55.661492,17.884128],[55.269939,17.632309],[55.2749,17.228354],[54.791002,16.950697],[54.239253,17.044981],[53.570508,16.707663],[53.108573,16.651051],[52.782184,17.349742],[52.00001,19.000003],[54.999982,19.999994],[55.666659,22.000001],[55.208341,22.70833]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Pakistan","SOV_A3":"PAK","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Pakistan","ADM0_A3":"PAK","GEOU_DIF":0,"GEOUNIT":"Pakistan","GU_A3":"PAK","SU_DIF":0,"SUBUNIT":"Pakistan","SU_A3":"PAK","BRK_DIFF":0,"NAME":"Pakistan","NAME_LONG":"Pakistan","BRK_A3":"PAK","BRK_NAME":"Pakistan","BRK_GROUP":null,"ABBREV":"Pak.","POSTAL":"PK","FORMAL_EN":"Islamic Republic of Pakistan","FORMAL_FR":null,"NAME_CIAWF":"Pakistan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Pakistan","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":3,"MAPCOLOR13":11,"POP_EST":204924861,"POP_RANK":17,"GDP_MD_EST":988200,"POP_YEAR":2017,"LASTCENSUS":1998,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"PK","ISO_A2":"PK","ISO_A3":"PAK","ISO_A3_EH":"PAK","ISO_N3":"586","UN_A3":"586","WB_A2":"PK","WB_A3":"PAK","WOE_ID":23424922,"WOE_ID_EH":23424922,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PAK","ADM0_A3_US":"PAK","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Southern Asia","REGION_WB":"South Asia","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[60.874248,23.691965,77.837451,37.133031],"geometry":{"type":"Polygon","coordinates":[[[60.874248,29.829239],[62.549857,29.318572],[63.550261,29.468331],[64.148002,29.340819],[64.350419,29.560031],[65.046862,29.472181],[66.346473,29.887943],[66.381458,30.738899],[66.938891,31.304911],[67.683394,31.303154],[67.792689,31.58293],[68.556932,31.71331],[68.926677,31.620189],[69.317764,31.901412],[69.262522,32.501944],[69.687147,33.105499],[70.323594,33.358533],[69.930543,34.02012],[70.881803,33.988856],[71.156773,34.348911],[71.115019,34.733126],[71.613076,35.153203],[71.498768,35.650563],[71.262348,36.074388],[71.846292,36.509942],[72.920025,36.720007],[74.067552,36.836176],[74.575893,37.020841],[75.158028,37.133031],[75.896897,36.666806],[76.192848,35.898403],[77.837451,35.49401],[76.871722,34.653544],[75.757061,34.504923],[74.240203,34.748887],[73.749948,34.317699],[74.104294,33.441473],[74.451559,32.7649],[75.258642,32.271105],[74.405929,31.692639],[74.42138,30.979815],[73.450638,29.976413],[72.823752,28.961592],[71.777666,27.91318],[70.616496,27.989196],[69.514393,26.940966],[70.168927,26.491872],[70.282873,25.722229],[70.844699,25.215102],[71.04324,24.356524],[68.842599,24.359134],[68.176645,23.691965],[67.443667,23.944844],[67.145442,24.663611],[66.372828,25.425141],[64.530408,25.237039],[62.905701,25.218409],[61.497363,25.078237],[61.874187,26.239975],[63.316632,26.756532],[63.233898,27.217047],[62.755426,27.378923],[62.72783,28.259645],[61.771868,28.699334],[61.369309,29.303276],[60.874248,29.829239]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Panama","SOV_A3":"PAN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Panama","ADM0_A3":"PAN","GEOU_DIF":0,"GEOUNIT":"Panama","GU_A3":"PAN","SU_DIF":0,"SUBUNIT":"Panama","SU_A3":"PAN","BRK_DIFF":0,"NAME":"Panama","NAME_LONG":"Panama","BRK_A3":"PAN","BRK_NAME":"Panama","BRK_GROUP":null,"ABBREV":"Pan.","POSTAL":"PA","FORMAL_EN":"Republic of Panama","FORMAL_FR":null,"NAME_CIAWF":"Panama","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Panama","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":4,"MAPCOLOR9":6,"MAPCOLOR13":3,"POP_EST":3753142,"POP_RANK":12,"GDP_MD_EST":93120,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"PM","ISO_A2":"PA","ISO_A3":"PAN","ISO_A3_EH":"PAN","ISO_N3":"591","UN_A3":"591","WB_A2":"PA","WB_A3":"PAN","WOE_ID":23424924,"WOE_ID_EH":23424924,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PAN","ADM0_A3_US":"PAN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-82.965783,7.220541,-77.242566,9.61161],"geometry":{"type":"Polygon","coordinates":[[[-77.353361,8.670505],[-77.474723,8.524286],[-77.242566,7.935278],[-77.431108,7.638061],[-77.753414,7.70984],[-77.881571,7.223771],[-78.214936,7.512255],[-78.429161,8.052041],[-78.182096,8.319182],[-78.435465,8.387705],[-78.622121,8.718124],[-79.120307,8.996092],[-79.557877,8.932375],[-79.760578,8.584515],[-80.164481,8.333316],[-80.382659,8.298409],[-80.480689,8.090308],[-80.00369,7.547524],[-80.276671,7.419754],[-80.421158,7.271572],[-80.886401,7.220541],[-81.059543,7.817921],[-81.189716,7.647906],[-81.519515,7.70661],[-81.721311,8.108963],[-82.131441,8.175393],[-82.390934,8.292362],[-82.820081,8.290864],[-82.850958,8.073823],[-82.965783,8.225028],[-82.913176,8.423517],[-82.829771,8.626295],[-82.868657,8.807266],[-82.719183,8.925709],[-82.927155,9.07433],[-82.932891,9.476812],[-82.546196,9.566135],[-82.187123,9.207449],[-82.207586,8.995575],[-81.808567,8.950617],[-81.714154,9.031955],[-81.439287,8.786234],[-80.947302,8.858504],[-80.521901,9.111072],[-79.9146,9.312765],[-79.573303,9.61161],[-79.021192,9.552931],[-79.05845,9.454565],[-78.500888,9.420459],[-78.055928,9.24773],[-77.729514,8.946844],[-77.353361,8.670505]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Peru","SOV_A3":"PER","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Peru","ADM0_A3":"PER","GEOU_DIF":0,"GEOUNIT":"Peru","GU_A3":"PER","SU_DIF":0,"SUBUNIT":"Peru","SU_A3":"PER","BRK_DIFF":0,"NAME":"Peru","NAME_LONG":"Peru","BRK_A3":"PER","BRK_NAME":"Peru","BRK_GROUP":null,"ABBREV":"Peru","POSTAL":"PE","FORMAL_EN":"Republic of Peru","FORMAL_FR":null,"NAME_CIAWF":"Peru","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Peru","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":4,"MAPCOLOR9":4,"MAPCOLOR13":11,"POP_EST":31036656,"POP_RANK":15,"GDP_MD_EST":410400,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"PE","ISO_A2":"PE","ISO_A3":"PER","ISO_A3_EH":"PER","ISO_N3":"604","UN_A3":"604","WB_A2":"PE","WB_A3":"PER","WOE_ID":23424919,"WOE_ID_EH":23424919,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PER","ADM0_A3_US":"PER","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[-81.410943,-18.347975,-68.66508,-0.057205],"geometry":{"type":"Polygon","coordinates":[[[-69.529678,-10.951734],[-68.66508,-12.5613],[-68.88008,-12.899729],[-68.929224,-13.602684],[-68.948887,-14.453639],[-69.339535,-14.953195],[-69.160347,-15.323974],[-69.389764,-15.660129],[-68.959635,-16.500698],[-69.590424,-17.580012],[-69.858444,-18.092694],[-70.372572,-18.347975],[-71.37525,-17.773799],[-71.462041,-17.363488],[-73.44453,-16.359363],[-75.237883,-15.265683],[-76.009205,-14.649286],[-76.423469,-13.823187],[-76.259242,-13.535039],[-77.106192,-12.222716],[-78.092153,-10.377712],[-79.036953,-8.386568],[-79.44592,-7.930833],[-79.760578,-7.194341],[-80.537482,-6.541668],[-81.249996,-6.136834],[-80.926347,-5.690557],[-81.410943,-4.736765],[-81.09967,-4.036394],[-80.302561,-3.404856],[-80.184015,-3.821162],[-80.469295,-4.059287],[-80.442242,-4.425724],[-80.028908,-4.346091],[-79.624979,-4.454198],[-79.205289,-4.959129],[-78.639897,-4.547784],[-78.450684,-3.873097],[-77.837905,-3.003021],[-76.635394,-2.608678],[-75.544996,-1.56161],[-75.233723,-0.911417],[-75.373223,-0.152032],[-75.106625,-0.057205],[-74.441601,-0.53082],[-74.122395,-1.002833],[-73.659504,-1.260491],[-73.070392,-2.308954],[-72.325787,-2.434218],[-71.774761,-2.16979],[-71.413646,-2.342802],[-70.813476,-2.256865],[-70.047709,-2.725156],[-70.692682,-3.742872],[-70.394044,-3.766591],[-69.893635,-4.298187],[-70.794769,-4.251265],[-70.928843,-4.401591],[-71.748406,-4.593983],[-72.891928,-5.274561],[-72.964507,-5.741251],[-73.219711,-6.089189],[-73.120027,-6.629931],[-73.724487,-6.918595],[-73.723401,-7.340999],[-73.987235,-7.52383],[-73.571059,-8.424447],[-73.015383,-9.032833],[-73.226713,-9.462213],[-72.563033,-9.520194],[-72.184891,-10.053598],[-71.302412,-10.079436],[-70.481894,-9.490118],[-70.548686,-11.009147],[-70.093752,-11.123972],[-69.529678,-10.951734]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Philippines","SOV_A3":"PHL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Philippines","ADM0_A3":"PHL","GEOU_DIF":0,"GEOUNIT":"Philippines","GU_A3":"PHL","SU_DIF":0,"SUBUNIT":"Philippines","SU_A3":"PHL","BRK_DIFF":0,"NAME":"Philippines","NAME_LONG":"Philippines","BRK_A3":"PHL","BRK_NAME":"Philippines","BRK_GROUP":null,"ABBREV":"Phil.","POSTAL":"PH","FORMAL_EN":"Republic of the Philippines","FORMAL_FR":null,"NAME_CIAWF":"Philippines","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Philippines","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":8,"POP_EST":104256076,"POP_RANK":17,"GDP_MD_EST":801900,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"RP","ISO_A2":"PH","ISO_A3":"PHL","ISO_A3_EH":"PHL","ISO_N3":"608","UN_A3":"608","WB_A2":"PH","WB_A3":"PHL","WOE_ID":23424934,"WOE_ID_EH":23424934,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PHL","ADM0_A3_US":"PHL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[117.174275,5.581003,126.537424,18.505227],"geometry":{"type":"MultiPolygon","coordinates":[[[[126.376814,8.414706],[126.478513,7.750354],[126.537424,7.189381],[126.196773,6.274294],[125.831421,7.293715],[125.363852,6.786485],[125.683161,6.049657],[125.396512,5.581003],[124.219788,6.161355],[123.93872,6.885136],[124.243662,7.36061],[123.610212,7.833527],[123.296071,7.418876],[122.825506,7.457375],[122.085499,6.899424],[121.919928,7.192119],[122.312359,8.034962],[122.942398,8.316237],[123.487688,8.69301],[123.841154,8.240324],[124.60147,8.514158],[124.764612,8.960409],[125.471391,8.986997],[125.412118,9.760335],[126.222714,9.286074],[126.306637,8.782487],[126.376814,8.414706]]],[[[123.982438,10.278779],[123.623183,9.950091],[123.309921,9.318269],[122.995883,9.022189],[122.380055,9.713361],[122.586089,9.981045],[122.837081,10.261157],[122.947411,10.881868],[123.49885,10.940624],[123.337774,10.267384],[124.077936,11.232726],[123.982438,10.278779]]],[[[118.504581,9.316383],[117.174275,8.3675],[117.664477,9.066889],[118.386914,9.6845],[118.987342,10.376292],[119.511496,11.369668],[119.689677,10.554291],[119.029458,10.003653],[118.504581,9.316383]]],[[[121.883548,11.891755],[122.483821,11.582187],[123.120217,11.58366],[123.100838,11.165934],[122.637714,10.741308],[122.00261,10.441017],[121.967367,10.905691],[122.03837,11.415841],[121.883548,11.891755]]],[[[125.502552,12.162695],[125.783465,11.046122],[125.011884,11.311455],[125.032761,10.975816],[125.277449,10.358722],[124.801819,10.134679],[124.760168,10.837995],[124.459101,10.88993],[124.302522,11.495371],[124.891013,11.415583],[124.87799,11.79419],[124.266762,12.557761],[125.227116,12.535721],[125.502552,12.162695]]],[[[121.527394,13.06959],[121.26219,12.20556],[120.833896,12.704496],[120.323436,13.466413],[121.180128,13.429697],[121.527394,13.06959]]],[[[121.321308,18.504065],[121.937601,18.218552],[122.246006,18.47895],[122.336957,18.224883],[122.174279,17.810283],[122.515654,17.093505],[122.252311,16.262444],[121.662786,15.931018],[121.50507,15.124814],[121.728829,14.328376],[122.258925,14.218202],[122.701276,14.336541],[123.950295,13.782131],[123.855107,13.237771],[124.181289,12.997527],[124.077419,12.536677],[123.298035,13.027526],[122.928652,13.55292],[122.671355,13.185836],[122.03465,13.784482],[121.126385,13.636687],[120.628637,13.857656],[120.679384,14.271016],[120.991819,14.525393],[120.693336,14.756671],[120.564145,14.396279],[120.070429,14.970869],[119.920929,15.406347],[119.883773,16.363704],[120.286488,16.034629],[120.390047,17.599081],[120.715867,18.505227],[121.321308,18.504065]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Papua New Guinea","SOV_A3":"PNG","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Papua New Guinea","ADM0_A3":"PNG","GEOU_DIF":0,"GEOUNIT":"Papua New Guinea","GU_A3":"PNG","SU_DIF":1,"SUBUNIT":"Papua New Guinea","SU_A3":"PN1","BRK_DIFF":0,"NAME":"Papua New Guinea","NAME_LONG":"Papua New Guinea","BRK_A3":"PN1","BRK_NAME":"Papua New Guinea","BRK_GROUP":null,"ABBREV":"P.N.G.","POSTAL":"PG","FORMAL_EN":"Independent State of Papua New Guinea","FORMAL_FR":null,"NAME_CIAWF":"Papua New Guinea","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Papua New Guinea","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":2,"MAPCOLOR9":3,"MAPCOLOR13":1,"POP_EST":6909701,"POP_RANK":13,"GDP_MD_EST":28020,"POP_YEAR":2017,"LASTCENSUS":2000,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"PP","ISO_A2":"PG","ISO_A3":"PNG","ISO_A3_EH":"PNG","ISO_N3":"598","UN_A3":"598","WB_A2":"PG","WB_A3":"PNG","WOE_ID":23424926,"WOE_ID_EH":23424926,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PNG","ADM0_A3_US":"PNG","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Oceania","REGION_UN":"Oceania","SUBREGION":"Melanesia","REGION_WB":"East Asia & Pacific","NAME_LEN":16,"LONG_LEN":16,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2.5,"MAX_LABEL":7.5},"bbox":[141.00021,-10.652476,156.019965,-2.500002],"geometry":{"type":"MultiPolygon","coordinates":[[[[141.033852,-9.117893],[141.017057,-5.859022],[141.00021,-2.600151],[142.735247,-3.289153],[144.583971,-3.861418],[145.27318,-4.373738],[145.829786,-4.876498],[145.981922,-5.465609],[147.648073,-6.083659],[147.891108,-6.614015],[146.970905,-6.721657],[147.191874,-7.388024],[148.084636,-8.044108],[148.734105,-9.104664],[149.306835,-9.071436],[149.266631,-9.514406],[150.038728,-9.684318],[149.738798,-9.872937],[150.801628,-10.293687],[150.690575,-10.582713],[150.028393,-10.652476],[149.78231,-10.393267],[148.923138,-10.280923],[147.913018,-10.130441],[147.135443,-9.492444],[146.567881,-8.942555],[146.048481,-8.067414],[144.744168,-7.630128],[143.897088,-7.91533],[143.286376,-8.245491],[143.413913,-8.983069],[142.628431,-9.326821],[142.068259,-9.159596],[141.033852,-9.117893]]],[[[155.880026,-6.819997],[155.599991,-6.919991],[155.166994,-6.535931],[154.729192,-5.900828],[154.514114,-5.139118],[154.652504,-5.042431],[154.759991,-5.339984],[155.062918,-5.566792],[155.547746,-6.200655],[156.019965,-6.540014],[155.880026,-6.819997]]],[[[151.982796,-5.478063],[151.459107,-5.56028],[151.30139,-5.840728],[150.754447,-6.083763],[150.241197,-6.317754],[149.709963,-6.316513],[148.890065,-6.02604],[148.318937,-5.747142],[148.401826,-5.437756],[149.298412,-5.583742],[149.845562,-5.505503],[149.99625,-5.026101],[150.139756,-5.001348],[150.236908,-5.53222],[150.807467,-5.455842],[151.089672,-5.113693],[151.647881,-4.757074],[151.537862,-4.167807],[152.136792,-4.14879],[152.338743,-4.312966],[152.318693,-4.867661],[151.982796,-5.478063]]],[[[153.140038,-4.499983],[152.827292,-4.766427],[152.638673,-4.176127],[152.406026,-3.789743],[151.953237,-3.462062],[151.384279,-3.035422],[150.66205,-2.741486],[150.939965,-2.500002],[151.479984,-2.779985],[151.820015,-2.999972],[152.239989,-3.240009],[152.640017,-3.659983],[153.019994,-3.980015],[153.140038,-4.499983]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Poland","SOV_A3":"POL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Poland","ADM0_A3":"POL","GEOU_DIF":0,"GEOUNIT":"Poland","GU_A3":"POL","SU_DIF":0,"SUBUNIT":"Poland","SU_A3":"POL","BRK_DIFF":0,"NAME":"Poland","NAME_LONG":"Poland","BRK_A3":"POL","BRK_NAME":"Poland","BRK_GROUP":null,"ABBREV":"Pol.","POSTAL":"PL","FORMAL_EN":"Republic of Poland","FORMAL_FR":null,"NAME_CIAWF":"Poland","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Poland","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":7,"MAPCOLOR9":1,"MAPCOLOR13":2,"POP_EST":38476269,"POP_RANK":15,"GDP_MD_EST":1052000,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"PL","ISO_A2":"PL","ISO_A3":"POL","ISO_A3_EH":"POL","ISO_N3":"616","UN_A3":"616","WB_A2":"PL","WB_A3":"POL","WOE_ID":23424923,"WOE_ID_EH":23424923,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"POL","ADM0_A3_US":"POL","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[14.074521,49.027395,24.029986,54.851536],"geometry":{"type":"Polygon","coordinates":[[[23.484128,53.912498],[23.527536,53.470122],[23.804935,53.089731],[23.799199,52.691099],[23.199494,52.486977],[23.508002,52.023647],[23.527071,51.578454],[24.029986,50.705407],[23.922757,50.424881],[23.426508,50.308506],[22.51845,49.476774],[22.776419,49.027395],[22.558138,49.085738],[21.607808,49.470107],[20.887955,49.328772],[20.415839,49.431453],[19.825023,49.217125],[19.320713,49.571574],[18.909575,49.435846],[18.853144,49.49623],[18.392914,49.988629],[17.649445,50.049038],[17.554567,50.362146],[16.868769,50.473974],[16.719476,50.215747],[16.176253,50.422607],[16.238627,50.697733],[15.490972,50.78473],[15.016996,51.106674],[14.607098,51.745188],[14.685026,52.089947],[14.4376,52.62485],[14.074521,52.981263],[14.353315,53.248171],[14.119686,53.757029],[14.8029,54.050706],[16.363477,54.513159],[17.622832,54.851536],[18.620859,54.682606],[18.696255,54.438719],[19.66064,54.426084],[20.892245,54.312525],[22.731099,54.327537],[23.243987,54.220567],[23.484128,53.912498]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"United States of America","SOV_A3":"US1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Dependency","ADMIN":"Puerto Rico","ADM0_A3":"PRI","GEOU_DIF":0,"GEOUNIT":"Puerto Rico","GU_A3":"PRI","SU_DIF":0,"SUBUNIT":"Puerto Rico","SU_A3":"PRI","BRK_DIFF":0,"NAME":"Puerto Rico","NAME_LONG":"Puerto Rico","BRK_A3":"PRI","BRK_NAME":"Puerto Rico","BRK_GROUP":null,"ABBREV":"P.R.","POSTAL":"PR","FORMAL_EN":"Commonwealth of Puerto Rico","FORMAL_FR":null,"NAME_CIAWF":"Puerto Rico","NOTE_ADM0":"Commonwealth of U.S.A.","NOTE_BRK":null,"NAME_SORT":"Puerto Rico","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":5,"MAPCOLOR9":1,"MAPCOLOR13":1,"POP_EST":3351827,"POP_RANK":12,"GDP_MD_EST":131000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"RQ","ISO_A2":"PR","ISO_A3":"PRI","ISO_A3_EH":"PRI","ISO_N3":"630","UN_A3":"630","WB_A2":"PR","WB_A3":"PRI","WOE_ID":23424935,"WOE_ID_EH":23424935,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PRI","ADM0_A3_US":"PRI","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Caribbean","REGION_WB":"Latin America & Caribbean","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":-99,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-67.242428,17.946553,-65.591004,18.520601],"geometry":{"type":"Polygon","coordinates":[[[-66.282434,18.514762],[-65.771303,18.426679],[-65.591004,18.228035],[-65.847164,17.975906],[-66.599934,17.981823],[-67.184162,17.946553],[-67.242428,18.37446],[-67.100679,18.520601],[-66.282434,18.514762]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"North Korea","SOV_A3":"PRK","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"North Korea","ADM0_A3":"PRK","GEOU_DIF":0,"GEOUNIT":"North Korea","GU_A3":"PRK","SU_DIF":0,"SUBUNIT":"North Korea","SU_A3":"PRK","BRK_DIFF":0,"NAME":"North Korea","NAME_LONG":"Dem. Rep. Korea","BRK_A3":"PRK","BRK_NAME":"Dem. Rep. Korea","BRK_GROUP":null,"ABBREV":"N.K.","POSTAL":"KP","FORMAL_EN":"Democratic People's Republic of Korea","FORMAL_FR":null,"NAME_CIAWF":"Korea, North","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Korea, Dem. Rep.","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":5,"MAPCOLOR9":3,"MAPCOLOR13":9,"POP_EST":25248140,"POP_RANK":15,"GDP_MD_EST":40000,"POP_YEAR":2013,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"KN","ISO_A2":"KP","ISO_A3":"PRK","ISO_A3_EH":"PRK","ISO_N3":"408","UN_A3":"408","WB_A2":"KP","WB_A3":"PRK","WOE_ID":23424865,"WOE_ID_EH":23424865,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PRK","ADM0_A3_US":"PRK","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":11,"LONG_LEN":15,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[124.265625,37.669071,130.780007,42.985387],"geometry":{"type":"MultiPolygon","coordinates":[[[[124.265625,39.928493],[125.079942,40.569824],[126.182045,41.107336],[126.869083,41.816569],[127.343783,41.503152],[128.208433,41.466772],[128.052215,41.994285],[129.596669,42.424982],[129.994267,42.985387],[130.64,42.395024],[130.64,42.395],[130.779992,42.22001],[130.400031,42.280004],[129.965949,41.941368],[129.667362,41.601104],[129.705189,40.882828],[129.188115,40.661808],[129.0104,40.485436],[128.633368,40.189847],[127.967414,40.025413],[127.533436,39.75685],[127.50212,39.323931],[127.385434,39.213472],[127.783343,39.050898],[128.349716,38.612243],[128.205746,38.370397],[127.780035,38.304536],[127.073309,38.256115],[126.68372,37.804773],[126.237339,37.840378],[126.174759,37.749686],[125.689104,37.94001],[125.568439,37.752089],[125.27533,37.669071],[125.240087,37.857224],[124.981033,37.948821],[124.712161,38.108346],[124.985994,38.548474],[125.221949,38.665857],[125.132859,38.848559],[125.38659,39.387958],[125.321116,39.551385],[124.737482,39.660344],[124.265625,39.928493]]],[[[130.780005,42.22001],[130.780007,42.220007],[130.780004,42.220008],[130.780005,42.22001]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Portugal","SOV_A3":"PRT","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Portugal","ADM0_A3":"PRT","GEOU_DIF":0,"GEOUNIT":"Portugal","GU_A3":"PRT","SU_DIF":1,"SUBUNIT":"Portugal","SU_A3":"PR1","BRK_DIFF":0,"NAME":"Portugal","NAME_LONG":"Portugal","BRK_A3":"PR1","BRK_NAME":"Portugal","BRK_GROUP":null,"ABBREV":"Port.","POSTAL":"P","FORMAL_EN":"Portuguese Republic","FORMAL_FR":null,"NAME_CIAWF":"Portugal","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Portugal","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":7,"MAPCOLOR9":1,"MAPCOLOR13":4,"POP_EST":10839514,"POP_RANK":14,"GDP_MD_EST":297100,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"PO","ISO_A2":"PT","ISO_A3":"PRT","ISO_A3_EH":"PRT","ISO_N3":"620","UN_A3":"620","WB_A2":"PT","WB_A3":"PRT","WOE_ID":23424925,"WOE_ID_EH":23424925,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PRT","ADM0_A3_US":"PRT","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-9.526571,36.838269,-6.389088,42.280469],"geometry":{"type":"Polygon","coordinates":[[[-9.034818,41.880571],[-8.671946,42.134689],[-8.263857,42.280469],[-8.013175,41.790886],[-7.422513,41.792075],[-7.251309,41.918346],[-6.668606,41.883387],[-6.389088,41.381815],[-6.851127,41.111083],[-6.86402,40.330872],[-7.026413,40.184524],[-7.066592,39.711892],[-7.498632,39.629571],[-7.098037,39.030073],[-7.374092,38.373059],[-7.029281,38.075764],[-7.166508,37.803894],[-7.537105,37.428904],[-7.453726,37.097788],[-7.855613,36.838269],[-8.382816,36.97888],[-8.898857,36.868809],[-8.746101,37.651346],[-8.839998,38.266243],[-9.287464,38.358486],[-9.526571,38.737429],[-9.446989,39.392066],[-9.048305,39.755093],[-8.977353,40.159306],[-8.768684,40.760639],[-8.790853,41.184334],[-8.990789,41.543459],[-9.034818,41.880571]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Paraguay","SOV_A3":"PRY","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Paraguay","ADM0_A3":"PRY","GEOU_DIF":0,"GEOUNIT":"Paraguay","GU_A3":"PRY","SU_DIF":0,"SUBUNIT":"Paraguay","SU_A3":"PRY","BRK_DIFF":0,"NAME":"Paraguay","NAME_LONG":"Paraguay","BRK_A3":"PRY","BRK_NAME":"Paraguay","BRK_GROUP":null,"ABBREV":"Para.","POSTAL":"PY","FORMAL_EN":"Republic of Paraguay","FORMAL_FR":null,"NAME_CIAWF":"Paraguay","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Paraguay","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":3,"MAPCOLOR9":6,"MAPCOLOR13":2,"POP_EST":6943739,"POP_RANK":13,"GDP_MD_EST":64670,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"PA","ISO_A2":"PY","ISO_A3":"PRY","ISO_A3_EH":"PRY","ISO_N3":"600","UN_A3":"600","WB_A2":"PY","WB_A3":"PRY","WOE_ID":23424917,"WOE_ID_EH":23424917,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PRY","ADM0_A3_US":"PRY","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-62.685057,-27.548499,-54.29296,-19.342747],"geometry":{"type":"Polygon","coordinates":[[[-54.625291,-25.739255],[-54.788795,-26.621786],[-55.695846,-27.387837],[-56.486702,-27.548499],[-57.60976,-27.395899],[-58.618174,-27.123719],[-57.63366,-25.603657],[-57.777217,-25.16234],[-58.807128,-24.771459],[-60.028966,-24.032796],[-60.846565,-23.880713],[-62.685057,-22.249029],[-62.291179,-21.051635],[-62.265961,-20.513735],[-61.786326,-19.633737],[-60.043565,-19.342747],[-59.115042,-19.356906],[-58.183471,-19.868399],[-58.166392,-20.176701],[-57.870674,-20.732688],[-57.937156,-22.090176],[-56.88151,-22.282154],[-56.473317,-22.0863],[-55.797958,-22.35693],[-55.610683,-22.655619],[-55.517639,-23.571998],[-55.400747,-23.956935],[-55.027902,-24.001274],[-54.652834,-23.839578],[-54.29296,-24.021014],[-54.293476,-24.5708],[-54.428946,-25.162185],[-54.625291,-25.739255]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Israel","SOV_A3":"IS1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Disputed","ADMIN":"Palestine","ADM0_A3":"PSX","GEOU_DIF":0,"GEOUNIT":"Palestine","GU_A3":"PSX","SU_DIF":0,"SUBUNIT":"Palestine","SU_A3":"PSX","BRK_DIFF":0,"NAME":"Palestine","NAME_LONG":"Palestine","BRK_A3":"PSX","BRK_NAME":"Palestine","BRK_GROUP":null,"ABBREV":"Pal.","POSTAL":"PAL","FORMAL_EN":"West Bank and Gaza","FORMAL_FR":null,"NAME_CIAWF":null,"NOTE_ADM0":"Partial self-admin.","NOTE_BRK":"Partial self-admin.","NAME_SORT":"Palestine (West Bank and Gaza)","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":5,"MAPCOLOR13":8,"POP_EST":4543126,"POP_RANK":12,"GDP_MD_EST":21220.77,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"-99","ISO_A2":"PS","ISO_A3":"PSE","ISO_A3_EH":"PSE","ISO_N3":"275","UN_A3":"275","WB_A2":"GZ","WB_A3":"WBG","WOE_ID":28289408,"WOE_ID_EH":28289408,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"PSE","ADM0_A3_US":"PSX","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":-99,"MIN_ZOOM":7,"MIN_LABEL":4.5,"MAX_LABEL":9.5},"bbox":[34.927408,31.353435,35.545665,32.532511],"geometry":{"type":"Polygon","coordinates":[[[35.397561,31.489086],[34.927408,31.353435],[34.970507,31.616778],[35.225892,31.754341],[34.974641,31.866582],[35.18393,32.532511],[35.545665,32.393992],[35.545252,31.782505],[35.397561,31.489086]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Qatar","SOV_A3":"QAT","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Qatar","ADM0_A3":"QAT","GEOU_DIF":0,"GEOUNIT":"Qatar","GU_A3":"QAT","SU_DIF":0,"SUBUNIT":"Qatar","SU_A3":"QAT","BRK_DIFF":0,"NAME":"Qatar","NAME_LONG":"Qatar","BRK_A3":"QAT","BRK_NAME":"Qatar","BRK_GROUP":null,"ABBREV":"Qatar","POSTAL":"QA","FORMAL_EN":"State of Qatar","FORMAL_FR":null,"NAME_CIAWF":"Qatar","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Qatar","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":6,"MAPCOLOR9":2,"MAPCOLOR13":4,"POP_EST":2314307,"POP_RANK":12,"GDP_MD_EST":334500,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"QA","ISO_A2":"QA","ISO_A3":"QAT","ISO_A3_EH":"QAT","ISO_N3":"634","UN_A3":"634","WB_A2":"QA","WB_A3":"QAT","WOE_ID":23424930,"WOE_ID_EH":23424930,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"QAT","ADM0_A3_US":"QAT","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[50.743911,24.556331,51.6067,26.114582],"geometry":{"type":"Polygon","coordinates":[[[50.810108,24.754743],[50.743911,25.482424],[51.013352,26.006992],[51.286462,26.114582],[51.589079,25.801113],[51.6067,25.21567],[51.389608,24.627386],[51.112415,24.556331],[50.810108,24.754743]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Romania","SOV_A3":"ROU","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Romania","ADM0_A3":"ROU","GEOU_DIF":0,"GEOUNIT":"Romania","GU_A3":"ROU","SU_DIF":0,"SUBUNIT":"Romania","SU_A3":"ROU","BRK_DIFF":0,"NAME":"Romania","NAME_LONG":"Romania","BRK_A3":"ROU","BRK_NAME":"Romania","BRK_GROUP":null,"ABBREV":"Rom.","POSTAL":"RO","FORMAL_EN":"Romania","FORMAL_FR":null,"NAME_CIAWF":"Romania","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Romania","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":3,"MAPCOLOR13":13,"POP_EST":21529967,"POP_RANK":15,"GDP_MD_EST":441000,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"RO","ISO_A2":"RO","ISO_A3":"ROU","ISO_A3_EH":"ROU","ISO_N3":"642","UN_A3":"642","WB_A2":"RO","WB_A3":"ROM","WOE_ID":23424933,"WOE_ID_EH":23424933,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ROU","ADM0_A3_US":"ROU","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[20.220192,43.688445,29.626543,48.220881],"geometry":{"type":"Polygon","coordinates":[[[28.558081,43.707462],[27.970107,43.812468],[27.2424,44.175986],[26.065159,43.943494],[25.569272,43.688445],[24.100679,43.741051],[23.332302,43.897011],[22.944832,43.823785],[22.65715,44.234923],[22.474008,44.409228],[22.705726,44.578003],[22.459022,44.702517],[22.145088,44.478422],[21.562023,44.768947],[21.483526,45.18117],[20.874313,45.416375],[20.762175,45.734573],[20.220192,46.127469],[21.021952,46.316088],[21.626515,46.994238],[22.099768,47.672439],[22.710531,47.882194],[23.142236,48.096341],[23.760958,47.985598],[24.402056,47.981878],[24.866317,47.737526],[25.207743,47.891056],[25.945941,47.987149],[26.19745,48.220881],[26.619337,48.220726],[26.924176,48.123264],[27.233873,47.826771],[27.551166,47.405117],[28.12803,46.810476],[28.160018,46.371563],[28.054443,45.944586],[28.233554,45.488283],[28.679779,45.304031],[29.149725,45.464925],[29.603289,45.293308],[29.626543,45.035391],[29.141612,44.82021],[28.837858,44.913874],[28.558081,43.707462]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Russia","SOV_A3":"RUS","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Russia","ADM0_A3":"RUS","GEOU_DIF":0,"GEOUNIT":"Russia","GU_A3":"RUS","SU_DIF":0,"SUBUNIT":"Russia","SU_A3":"RUS","BRK_DIFF":0,"NAME":"Russia","NAME_LONG":"Russian Federation","BRK_A3":"RUS","BRK_NAME":"Russia","BRK_GROUP":null,"ABBREV":"Rus.","POSTAL":"RUS","FORMAL_EN":"Russian Federation","FORMAL_FR":null,"NAME_CIAWF":"Russia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Russian Federation","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":5,"MAPCOLOR9":7,"MAPCOLOR13":7,"POP_EST":142257519,"POP_RANK":17,"GDP_MD_EST":3745000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"3. Emerging region: BRIC","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"RS","ISO_A2":"RU","ISO_A3":"RUS","ISO_A3_EH":"RUS","ISO_N3":"643","UN_A3":"643","WB_A2":"RU","WB_A3":"RUS","WOE_ID":23424936,"WOE_ID_EH":23424936,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"RUS","ADM0_A3_US":"RUS","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":18,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":5.2},"bbox":[-180,41.151416,180,81.2504],"geometry":{"type":"MultiPolygon","coordinates":[[[[48.584353,41.808869],[47.987283,41.405819],[47.815666,41.151416],[47.373315,41.219732],[46.686071,41.827137],[46.404951,41.860675],[45.7764,42.09244],[45.470279,42.502781],[44.537623,42.711993],[43.93121,42.55496],[43.75599,42.74083],[42.3944,43.2203],[40.92219,43.38215],[40.076965,43.553104],[39.955009,43.434998],[38.68,44.28],[37.53912,44.65721],[36.67546,45.24469],[37.40317,45.40451],[38.23295,46.24087],[37.67372,46.63657],[39.14767,47.04475],[39.1212,47.26336],[38.223538,47.10219],[38.255112,47.5464],[38.77057,47.82562],[39.738278,47.898937],[39.89562,48.23241],[39.67465,48.78382],[40.080789,49.30743],[40.06904,49.60105],[38.594988,49.926462],[38.010631,49.915662],[37.39346,50.383953],[36.626168,50.225591],[35.356116,50.577197],[35.37791,50.77394],[35.022183,51.207572],[34.224816,51.255993],[34.141978,51.566413],[34.391731,51.768882],[33.7527,52.335075],[32.715761,52.238465],[32.412058,52.288695],[32.15944,52.06125],[31.785992,52.101678],[31.78597,52.10168],[31.540018,52.742052],[31.305201,53.073996],[31.49764,53.16743],[32.304519,53.132726],[32.693643,53.351421],[32.405599,53.618045],[31.731273,53.794029],[31.791424,53.974639],[31.384472,54.157056],[30.757534,54.811771],[30.971836,55.081548],[30.873909,55.550976],[29.896294,55.789463],[29.371572,55.670091],[29.229513,55.918344],[28.176709,56.16913],[27.855282,56.759326],[27.770016,57.244258],[27.288185,57.474528],[27.716686,57.791899],[27.42015,58.72457],[28.131699,59.300825],[27.98112,59.47537],[27.981127,59.475373],[29.1177,60.02805],[28.070002,60.503519],[28.07,60.50352],[30.211107,61.780028],[31.139991,62.357693],[31.516092,62.867687],[30.035872,63.552814],[30.444685,64.204453],[29.54443,64.948672],[30.21765,65.80598],[29.054589,66.944286],[29.977426,67.698297],[28.445944,68.364613],[28.59193,69.064777],[29.39955,69.15692],[31.101042,69.558101],[31.10108,69.55811],[32.13272,69.90595],[33.77547,69.30142],[36.51396,69.06342],[40.29234,67.9324],[41.05987,67.45713],[41.12595,66.79158],[40.01583,66.26618],[38.38295,65.99953],[33.91871,66.75961],[33.18444,66.63253],[34.81477,65.90015],[34.878574,65.436213],[34.94391,64.41437],[36.23129,64.10945],[37.01273,63.84983],[37.14197,64.33471],[36.539579,64.76446],[37.17604,65.14322],[39.59345,64.52079],[40.4356,64.76446],[39.7626,65.49682],[42.09309,66.47623],[43.01604,66.41858],[43.94975,66.06908],[44.53226,66.75634],[43.69839,67.35245],[44.18795,67.95051],[43.45282,68.57079],[46.25,68.25],[46.82134,67.68997],[45.55517,67.56652],[45.56202,67.01005],[46.34915,66.66767],[47.89416,66.88455],[48.13876,67.52238],[50.22766,67.99867],[53.71743,68.85738],[54.47171,68.80815],[53.48582,68.20131],[54.72628,68.09702],[55.44268,68.43866],[57.31702,68.46628],[58.802,68.88082],[59.94142,68.27844],[61.07784,68.94069],[60.03,69.52],[60.55,69.85],[63.504,69.54739],[64.888115,69.234835],[68.51216,68.09233],[69.18068,68.61563],[68.16444,69.14436],[68.13522,69.35649],[66.93008,69.45461],[67.25976,69.92873],[66.72492,70.70889],[66.69466,71.02897],[68.54006,71.9345],[69.19636,72.84336],[69.94,73.04],[72.58754,72.77629],[72.79603,72.22006],[71.84811,71.40898],[72.47011,71.09019],[72.79188,70.39114],[72.5647,69.02085],[73.66787,68.4079],[73.2387,67.7404],[71.28,66.32],[72.42301,66.17267],[72.82077,66.53267],[73.92099,66.78946],[74.18651,67.28429],[75.052,67.76047],[74.46926,68.32899],[74.93584,68.98918],[73.84236,69.07146],[73.60187,69.62763],[74.3998,70.63175],[73.1011,71.44717],[74.89082,72.12119],[74.65926,72.83227],[75.15801,72.85497],[75.68351,72.30056],[75.28898,71.33556],[76.35911,71.15287],[75.90313,71.87401],[77.57665,72.26717],[79.65202,72.32011],[81.5,71.75],[80.61071,72.58285],[80.51109,73.6482],[82.25,73.85],[84.65526,73.80591],[86.8223,73.93688],[86.00956,74.45967],[87.16682,75.11643],[88.31571,75.14393],[90.26,75.64],[92.90058,75.77333],[93.23421,76.0472],[95.86,76.14],[96.67821,75.91548],[98.92254,76.44689],[100.75967,76.43028],[101.03532,76.86189],[101.99084,77.28754],[104.3516,77.69792],[106.06664,77.37389],[104.705,77.1274],[106.97013,76.97419],[107.24,76.48],[108.1538,76.72335],[111.07726,76.71],[113.33151,76.22224],[114.13417,75.84764],[113.88539,75.32779],[112.77918,75.03186],[110.15125,74.47673],[109.4,74.18],[110.64,74.04],[112.11919,73.78774],[113.01954,73.97693],[113.52958,73.33505],[113.96881,73.59488],[115.56782,73.75285],[118.77633,73.58772],[119.02,73.12],[123.20066,72.97122],[123.25777,73.73503],[125.38,73.56],[126.97644,73.56549],[128.59126,73.03871],[129.05157,72.39872],[128.46,71.98],[129.71599,71.19304],[131.28858,70.78699],[132.2535,71.8363],[133.85766,71.38642],[135.56193,71.65525],[137.49755,71.34763],[138.23409,71.62803],[139.86983,71.48783],[139.14791,72.41619],[140.46817,72.84941],[149.5,72.2],[150.35118,71.60643],[152.9689,70.84222],[157.00688,71.03141],[158.99779,70.86672],[159.83031,70.45324],[159.70866,69.72198],[160.94053,69.43728],[162.27907,69.64204],[164.05248,69.66823],[165.94037,69.47199],[167.83567,69.58269],[169.57763,68.6938],[170.81688,69.01363],[170.0082,69.65276],[170.45345,70.09703],[173.64391,69.81743],[175.72403,69.87725],[178.6,69.4],[180,68.963636],[180,64.979709],[179.99281,64.97433],[178.7072,64.53493],[177.41128,64.60821],[178.313,64.07593],[178.90825,63.25197],[179.37034,62.98262],[179.48636,62.56894],[179.22825,62.3041],[177.3643,62.5219],[174.56929,61.76915],[173.68013,61.65261],[172.15,60.95],[170.6985,60.33618],[170.33085,59.88177],[168.90046,60.57355],[166.29498,59.78855],[165.84,60.16],[164.87674,59.7316],[163.53929,59.86871],[163.21711,59.21101],[162.01733,58.24328],[162.05297,57.83912],[163.19191,57.61503],[163.05794,56.15924],[162.12958,56.12219],[161.70146,55.28568],[162.11749,54.85514],[160.36877,54.34433],[160.02173,53.20257],[158.53094,52.95868],[158.23118,51.94269],[156.78979,51.01105],[156.42,51.7],[155.99182,53.15895],[155.43366,55.38103],[155.91442,56.76792],[156.75815,57.3647],[156.81035,57.83204],[158.36433,58.05575],[160.15064,59.31477],[161.87204,60.343],[163.66969,61.1409],[164.47355,62.55061],[163.25842,62.46627],[162.65791,61.6425],[160.12148,60.54423],[159.30232,61.77396],[156.72068,61.43442],[154.21806,59.75818],[155.04375,59.14495],[152.81185,58.88385],[151.26573,58.78089],[151.33815,59.50396],[149.78371,59.65573],[148.54481,59.16448],[145.48722,59.33637],[142.19782,59.03998],[138.95848,57.08805],[135.12619,54.72959],[136.70171,54.60355],[137.19342,53.97732],[138.1647,53.75501],[138.80463,54.25455],[139.90151,54.18968],[141.34531,53.08957],[141.37923,52.23877],[140.59742,51.23967],[140.51308,50.04553],[140.06193,48.44671],[138.55472,46.99965],[138.21971,46.30795],[136.86232,45.1435],[135.51535,43.989],[134.86939,43.39821],[133.53687,42.81147],[132.90627,42.79849],[132.27807,43.28456],[130.93587,42.55274],[130.780005,42.22001],[130.780004,42.220008],[130.78,42.22],[130.779992,42.22001],[130.64,42.395],[130.64,42.395024],[130.633866,42.903015],[131.144688,42.92999],[131.288555,44.11152],[131.02519,44.96796],[131.883454,45.321162],[133.09712,45.14409],[133.769644,46.116927],[134.11235,47.21248],[134.50081,47.57845],[135.026311,48.47823],[133.373596,48.183442],[132.50669,47.78896],[130.98726,47.79013],[130.582293,48.729687],[129.397818,49.4406],[127.6574,49.76027],[127.287456,50.739797],[126.939157,51.353894],[126.564399,51.784255],[125.946349,52.792799],[125.068211,53.161045],[123.57147,53.4588],[122.245748,53.431726],[121.003085,53.251401],[120.177089,52.753886],[120.725789,52.516226],[120.7382,51.96411],[120.18208,51.64355],[119.27939,50.58292],[119.288461,50.142883],[117.879244,49.510983],[116.678801,49.888531],[115.485695,49.805177],[114.96211,50.140247],[114.362456,50.248303],[112.89774,49.543565],[111.581231,49.377968],[110.662011,49.130128],[109.402449,49.292961],[108.475167,49.282548],[107.868176,49.793705],[106.888804,50.274296],[105.886591,50.406019],[104.62158,50.27532],[103.676545,50.089966],[102.25589,50.51056],[102.06521,51.25991],[100.88948,51.516856],[99.981732,51.634006],[98.861491,52.047366],[97.82574,51.010995],[98.231762,50.422401],[97.25976,49.72605],[95.81402,49.97746],[94.815949,50.013433],[94.147566,50.480537],[93.10421,50.49529],[92.234712,50.802171],[90.713667,50.331812],[88.805567,49.470521],[87.751264,49.297198],[87.35997,49.214981],[86.829357,49.826675],[85.54127,49.692859],[85.11556,50.117303],[84.416377,50.3114],[83.935115,50.889246],[83.383004,51.069183],[81.945986,50.812196],[80.568447,51.388336],[80.03556,50.864751],[77.800916,53.404415],[76.525179,54.177003],[76.8911,54.490524],[74.38482,53.54685],[73.425679,53.48981],[73.508516,54.035617],[72.22415,54.376655],[71.180131,54.133285],[70.865267,55.169734],[69.068167,55.38525],[68.1691,54.970392],[65.66687,54.60125],[65.178534,54.354228],[61.4366,54.00625],[60.978066,53.664993],[61.699986,52.979996],[60.739993,52.719986],[60.927269,52.447548],[59.967534,51.96042],[61.588003,51.272659],[61.337424,50.79907],[59.932807,50.842194],[59.642282,50.545442],[58.36332,51.06364],[56.77798,51.04355],[55.71694,50.62171],[54.532878,51.02624],[52.328724,51.718652],[50.766648,51.692762],[48.702382,50.605128],[48.577841,49.87476],[47.54948,50.454698],[46.751596,49.356006],[47.043672,49.152039],[46.466446,48.394152],[47.31524,47.71585],[48.05725,47.74377],[48.694734,47.075628],[48.59325,46.56104],[49.10116,46.39933],[48.64541,45.80629],[47.67591,45.64149],[46.68201,44.6092],[47.59094,43.66016],[47.49252,42.98658],[48.58437,41.80888],[48.584353,41.808869]]],[[[21.268449,55.190482],[22.315724,55.015299],[22.757764,54.856574],[22.651052,54.582741],[22.731099,54.327537],[20.892245,54.312525],[19.66064,54.426084],[19.888481,54.86616],[21.268449,55.190482]]],[[[33.435988,45.971917],[33.699462,46.219573],[34.410402,46.005162],[34.732017,45.965666],[34.861792,45.768182],[35.012659,45.737725],[35.020788,45.651219],[35.510009,45.409993],[36.529998,45.46999],[36.334713,45.113216],[35.239999,44.939996],[33.882511,44.361479],[33.326421,44.564877],[33.546924,45.034771],[32.454174,45.327466],[32.630804,45.519186],[33.588162,45.851569],[33.435988,45.971917]]],[[[143.648007,50.7476],[144.654148,48.976391],[143.173928,49.306551],[142.558668,47.861575],[143.533492,46.836728],[143.505277,46.137908],[142.747701,46.740765],[142.09203,45.966755],[141.906925,46.805929],[142.018443,47.780133],[141.904445,48.859189],[142.1358,49.615163],[142.179983,50.952342],[141.594076,51.935435],[141.682546,53.301966],[142.606934,53.762145],[142.209749,54.225476],[142.654786,54.365881],[142.914616,53.704578],[143.260848,52.74076],[143.235268,51.75666],[143.648007,50.7476]]],[[[-175.01425,66.58435],[-174.33983,66.33556],[-174.57182,67.06219],[-171.85731,66.91308],[-169.89958,65.97724],[-170.89107,65.54139],[-172.53025,65.43791],[-172.555,64.46079],[-172.95533,64.25269],[-173.89184,64.2826],[-174.65392,64.63125],[-175.98353,64.92288],[-176.20716,65.35667],[-177.22266,65.52024],[-178.35993,65.39052],[-178.90332,65.74044],[-178.68611,66.11211],[-179.88377,65.87456],[-179.43268,65.40411],[-180,64.979709],[-180,68.963636],[-177.55,68.2],[-174.92825,67.20589],[-175.01425,66.58435]]],[[[180,70.832199],[178.903425,70.78114],[178.7253,71.0988],[180,71.515714],[180,70.832199]]],[[[-178.69378,70.89302],[-180,70.832199],[-180,71.515714],[-179.871875,71.55762],[-179.02433,71.55553],[-177.577945,71.26948],[-177.663575,71.13277],[-178.69378,70.89302]]],[[[143.60385,73.21244],[142.08763,73.20544],[140.038155,73.31692],[139.86312,73.36983],[140.81171,73.76506],[142.06207,73.85758],[143.48283,73.47525],[143.60385,73.21244]]],[[[150.73167,75.08406],[149.575925,74.68892],[147.977465,74.778355],[146.11919,75.17298],[146.358485,75.49682],[148.22223,75.345845],[150.73167,75.08406]]],[[[145.086285,75.562625],[144.3,74.82],[140.61381,74.84768],[138.95544,74.61148],[136.97439,75.26167],[137.51176,75.94917],[138.831075,76.13676],[141.471615,76.09289],[145.086285,75.562625]]],[[[57.535693,70.720464],[56.944979,70.632743],[53.677375,70.762658],[53.412017,71.206662],[51.601895,71.474759],[51.455754,72.014881],[52.478275,72.229442],[52.444169,72.774731],[54.427614,73.627548],[53.50829,73.749814],[55.902459,74.627486],[55.631933,75.081412],[57.868644,75.60939],[61.170044,76.251883],[64.498368,76.439055],[66.210977,76.809782],[68.15706,76.939697],[68.852211,76.544811],[68.180573,76.233642],[64.637326,75.737755],[61.583508,75.260885],[58.477082,74.309056],[56.986786,73.333044],[55.419336,72.371268],[55.622838,71.540595],[57.535693,70.720464]]],[[[105.07547,78.30689],[99.43814,77.921],[101.2649,79.23399],[102.08635,79.34641],[102.837815,79.28129],[105.37243,78.71334],[105.07547,78.30689]]],[[[51.136187,80.54728],[49.793685,80.415428],[48.894411,80.339567],[48.754937,80.175468],[47.586119,80.010181],[46.502826,80.247247],[47.072455,80.559424],[44.846958,80.58981],[46.799139,80.771918],[48.318477,80.78401],[48.522806,80.514569],[49.09719,80.753986],[50.039768,80.918885],[51.522933,80.699726],[51.136187,80.54728]]],[[[99.93976,78.88094],[97.75794,78.7562],[94.97259,79.044745],[93.31288,79.4265],[92.5454,80.14379],[91.18107,80.34146],[93.77766,81.0246],[95.940895,81.2504],[97.88385,80.746975],[100.186655,79.780135],[99.93976,78.88094]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Rwanda","SOV_A3":"RWA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Rwanda","ADM0_A3":"RWA","GEOU_DIF":0,"GEOUNIT":"Rwanda","GU_A3":"RWA","SU_DIF":0,"SUBUNIT":"Rwanda","SU_A3":"RWA","BRK_DIFF":0,"NAME":"Rwanda","NAME_LONG":"Rwanda","BRK_A3":"RWA","BRK_NAME":"Rwanda","BRK_GROUP":null,"ABBREV":"Rwa.","POSTAL":"RW","FORMAL_EN":"Republic of Rwanda","FORMAL_FR":null,"NAME_CIAWF":"Rwanda","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Rwanda","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":2,"MAPCOLOR9":3,"MAPCOLOR13":10,"POP_EST":11901484,"POP_RANK":14,"GDP_MD_EST":21970,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"RW","ISO_A2":"RW","ISO_A3":"RWA","ISO_A3_EH":"RWA","ISO_N3":"646","UN_A3":"646","WB_A2":"RW","WB_A3":"RWA","WOE_ID":23424937,"WOE_ID_EH":23424937,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"RWA","ADM0_A3_US":"RWA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[29.024926,-2.917858,30.816135,-1.134659],"geometry":{"type":"Polygon","coordinates":[[[30.469674,-2.413855],[29.938359,-2.348487],[29.632176,-2.917858],[29.024926,-2.839258],[29.117479,-2.292211],[29.254835,-2.21511],[29.291887,-1.620056],[29.579466,-1.341313],[29.821519,-1.443322],[30.419105,-1.134659],[30.816135,-1.698914],[30.758309,-2.28725],[30.46967,-2.41383],[30.469674,-2.413855]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":7,"SOVEREIGNT":"Western Sahara","SOV_A3":"SAH","ADM0_DIF":0,"LEVEL":2,"TYPE":"Indeterminate","ADMIN":"Western Sahara","ADM0_A3":"SAH","GEOU_DIF":0,"GEOUNIT":"Western Sahara","GU_A3":"SAH","SU_DIF":0,"SUBUNIT":"Western Sahara","SU_A3":"SAH","BRK_DIFF":1,"NAME":"W. Sahara","NAME_LONG":"Western Sahara","BRK_A3":"B28","BRK_NAME":"W. Sahara","BRK_GROUP":null,"ABBREV":"W. Sah.","POSTAL":"WS","FORMAL_EN":"Sahrawi Arab Democratic Republic","FORMAL_FR":null,"NAME_CIAWF":"Western Sahara","NOTE_ADM0":"Self admin.","NOTE_BRK":"Self admin.; Claimed by Morocco","NAME_SORT":"Western Sahara","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":7,"MAPCOLOR9":4,"MAPCOLOR13":4,"POP_EST":603253,"POP_RANK":11,"GDP_MD_EST":906.5,"POP_YEAR":2017,"LASTCENSUS":-99,"GDP_YEAR":2007,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"WI","ISO_A2":"EH","ISO_A3":"ESH","ISO_A3_EH":"ESH","ISO_N3":"732","UN_A3":"732","WB_A2":"-99","WB_A3":"-99","WOE_ID":23424990,"WOE_ID_EH":23424990,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"MAR","ADM0_A3_US":"SAH","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Northern Africa","REGION_WB":"Middle East & North Africa","NAME_LEN":9,"LONG_LEN":14,"ABBREV_LEN":7,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":4.7,"MIN_LABEL":6,"MAX_LABEL":11},"bbox":[-17.063423,20.999752,-8.665124,27.656426],"geometry":{"type":"Polygon","coordinates":[[[-8.66559,27.656426],[-8.665124,27.589479],[-8.6844,27.395744],[-8.687294,25.881056],[-11.969419,25.933353],[-11.937224,23.374594],[-12.874222,23.284832],[-13.118754,22.77122],[-12.929102,21.327071],[-16.845194,21.333323],[-17.063423,20.999752],[-17.020428,21.42231],[-17.002962,21.420734],[-14.750955,21.5006],[-14.630833,21.86094],[-14.221168,22.310163],[-13.89111,23.691009],[-12.500963,24.770116],[-12.030759,26.030866],[-11.71822,26.104092],[-11.392555,26.883424],[-10.551263,26.990808],[-10.189424,26.860945],[-9.735343,26.860945],[-9.413037,27.088476],[-8.794884,27.120696],[-8.817828,27.656426],[-8.66559,27.656426]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Saudi Arabia","SOV_A3":"SAU","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Saudi Arabia","ADM0_A3":"SAU","GEOU_DIF":0,"GEOUNIT":"Saudi Arabia","GU_A3":"SAU","SU_DIF":0,"SUBUNIT":"Saudi Arabia","SU_A3":"SAU","BRK_DIFF":0,"NAME":"Saudi Arabia","NAME_LONG":"Saudi Arabia","BRK_A3":"SAU","BRK_NAME":"Saudi Arabia","BRK_GROUP":null,"ABBREV":"Saud.","POSTAL":"SA","FORMAL_EN":"Kingdom of Saudi Arabia","FORMAL_FR":null,"NAME_CIAWF":"Saudi Arabia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Saudi Arabia","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":1,"MAPCOLOR9":6,"MAPCOLOR13":7,"POP_EST":28571770,"POP_RANK":15,"GDP_MD_EST":1731000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"SA","ISO_A2":"SA","ISO_A3":"SAU","ISO_A3_EH":"SAU","ISO_N3":"682","UN_A3":"682","WB_A2":"SA","WB_A3":"SAU","WOE_ID":23424938,"WOE_ID_EH":23424938,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SAU","ADM0_A3_US":"SAU","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":12,"LONG_LEN":12,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2.7,"MAX_LABEL":7},"bbox":[34.632336,16.347891,55.666659,32.161009],"geometry":{"type":"Polygon","coordinates":[[[51.579519,24.245497],[51.617708,24.014219],[52.000733,23.001154],[55.006803,22.496948],[55.208341,22.70833],[55.666659,22.000001],[54.999982,19.999994],[52.00001,19.000003],[49.116672,18.616668],[48.183344,18.166669],[47.466695,17.116682],[47.000005,16.949999],[46.749994,17.283338],[46.366659,17.233315],[45.399999,17.333335],[45.216651,17.433329],[44.062613,17.410359],[43.791519,17.319977],[43.380794,17.579987],[43.115798,17.08844],[43.218375,16.66689],[42.779332,16.347891],[42.649573,16.774635],[42.347989,17.075806],[42.270888,17.474722],[41.754382,17.833046],[41.221391,18.6716],[40.939341,19.486485],[40.247652,20.174635],[39.801685,20.338862],[39.139399,21.291905],[39.023696,21.986875],[39.066329,22.579656],[38.492772,23.688451],[38.02386,24.078686],[37.483635,24.285495],[37.154818,24.858483],[37.209491,25.084542],[36.931627,25.602959],[36.639604,25.826228],[36.249137,26.570136],[35.640182,27.37652],[35.130187,28.063352],[34.632336,28.058546],[34.787779,28.607427],[34.83222,28.957483],[34.956037,29.356555],[36.068941,29.197495],[36.501214,29.505254],[36.740528,29.865283],[37.503582,30.003776],[37.66812,30.338665],[37.998849,30.5085],[37.002166,31.508413],[39.004886,32.010217],[39.195468,32.161009],[40.399994,31.889992],[41.889981,31.190009],[44.709499,29.178891],[46.568713,29.099025],[47.459822,29.002519],[47.708851,28.526063],[48.416094,28.552004],[48.807595,27.689628],[49.299554,27.461218],[49.470914,27.109999],[50.152422,26.689663],[50.212935,26.277027],[50.113303,25.943972],[50.239859,25.60805],[50.527387,25.327808],[50.660557,24.999896],[50.810108,24.754743],[51.112415,24.556331],[51.389608,24.627386],[51.579519,24.245497]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Sudan","SOV_A3":"SDN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Sudan","ADM0_A3":"SDN","GEOU_DIF":0,"GEOUNIT":"Sudan","GU_A3":"SDN","SU_DIF":0,"SUBUNIT":"Sudan","SU_A3":"SDN","BRK_DIFF":0,"NAME":"Sudan","NAME_LONG":"Sudan","BRK_A3":"SDN","BRK_NAME":"Sudan","BRK_GROUP":null,"ABBREV":"Sudan","POSTAL":"SD","FORMAL_EN":"Republic of the Sudan","FORMAL_FR":null,"NAME_CIAWF":"Sudan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Sudan","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":6,"MAPCOLOR9":4,"MAPCOLOR13":1,"POP_EST":37345935,"POP_RANK":15,"GDP_MD_EST":176300,"POP_YEAR":2017,"LASTCENSUS":2008,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"SU","ISO_A2":"SD","ISO_A3":"SDN","ISO_A3_EH":"SDN","ISO_N3":"729","UN_A3":"729","WB_A2":"SD","WB_A3":"SDN","WOE_ID":-90,"WOE_ID_EH":23424952,"WOE_NOTE":"Almost all FLickr photos are in the north.","ADM0_A3_IS":"SDN","ADM0_A3_US":"SDN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Northern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[21.93681,8.229188,38.41009,22],"geometry":{"type":"Polygon","coordinates":[[[24.567369,8.229188],[23.805813,8.666319],[23.459013,8.954286],[23.394779,9.265068],[23.55725,9.681218],[23.554304,10.089255],[22.977544,10.714463],[22.864165,11.142395],[22.87622,11.38461],[22.50869,11.67936],[22.49762,12.26024],[22.28801,12.64605],[21.93681,12.58818],[22.03759,12.95546],[22.29658,13.37232],[22.18329,13.78648],[22.51202,14.09318],[22.30351,14.32682],[22.56795,14.94429],[23.02459,15.68072],[23.88689,15.61084],[23.83766,19.58047],[23.85,20],[25,20.00304],[25,22],[29.02,22],[32.9,22],[36.86623,22],[37.18872,21.01885],[36.96941,20.83744],[37.1147,19.80796],[37.48179,18.61409],[37.86276,18.36786],[38.41009,17.998307],[37.904,17.42754],[37.16747,17.26314],[36.85253,16.95655],[36.75389,16.29186],[36.32322,14.82249],[36.42951,14.42211],[36.27022,13.56333],[35.86363,12.57828],[35.26049,12.08286],[34.83163,11.31896],[34.73115,10.91017],[34.25745,10.63009],[33.96162,9.58358],[33.97498,8.68456],[33.963393,9.464285],[33.824963,9.484061],[33.842131,9.981915],[33.721959,10.325262],[33.206938,10.720112],[33.086766,11.441141],[33.206938,12.179338],[32.743419,12.248008],[32.67475,12.024832],[32.073892,11.97333],[32.314235,11.681484],[32.400072,11.080626],[31.850716,10.531271],[31.352862,9.810241],[30.837841,9.707237],[29.996639,10.290927],[29.618957,10.084919],[29.515953,9.793074],[29.000932,9.604232],[28.966597,9.398224],[27.97089,9.398224],[27.833551,9.604232],[27.112521,9.638567],[26.752006,9.466893],[26.477328,9.55273],[25.962307,10.136421],[25.790633,10.411099],[25.069604,10.27376],[24.794926,9.810241],[24.537415,8.917538],[24.194068,8.728696],[23.88698,8.61973],[24.567369,8.229188]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"South Sudan","SOV_A3":"SDS","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"South Sudan","ADM0_A3":"SDS","GEOU_DIF":0,"GEOUNIT":"South Sudan","GU_A3":"SDS","SU_DIF":0,"SUBUNIT":"South Sudan","SU_A3":"SDS","BRK_DIFF":0,"NAME":"S. Sudan","NAME_LONG":"South Sudan","BRK_A3":"SDS","BRK_NAME":"S. Sudan","BRK_GROUP":null,"ABBREV":"S. Sud.","POSTAL":"SS","FORMAL_EN":"Republic of South Sudan","FORMAL_FR":null,"NAME_CIAWF":"South Sudan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"South Sudan","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":3,"MAPCOLOR9":3,"MAPCOLOR13":5,"POP_EST":13026129,"POP_RANK":14,"GDP_MD_EST":20880,"POP_YEAR":2017,"LASTCENSUS":2008,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"-99","ISO_A2":"SS","ISO_A3":"SSD","ISO_A3_EH":"SSD","ISO_N3":"728","UN_A3":"728","WB_A2":"SS","WB_A3":"SSD","WOE_ID":-99,"WOE_ID_EH":-99,"WOE_NOTE":"Includes states of 20069899, 20069897, 20069898, 20069901, 20069909, and 20069908 but maybe more?","ADM0_A3_IS":"SSD","ADM0_A3_US":"SDS","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":8,"LONG_LEN":11,"ABBREV_LEN":7,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[23.88698,3.509172,35.298007,12.248008],"geometry":{"type":"Polygon","coordinates":[[[27.374226,5.233944],[27.213409,5.550953],[26.465909,5.946717],[26.213418,6.546603],[25.796648,6.979316],[25.124131,7.500085],[25.114932,7.825104],[24.567369,8.229188],[23.88698,8.61973],[24.194068,8.728696],[24.537415,8.917538],[24.794926,9.810241],[25.069604,10.27376],[25.790633,10.411099],[25.962307,10.136421],[26.477328,9.55273],[26.752006,9.466893],[27.112521,9.638567],[27.833551,9.604232],[27.97089,9.398224],[28.966597,9.398224],[29.000932,9.604232],[29.515953,9.793074],[29.618957,10.084919],[29.996639,10.290927],[30.837841,9.707237],[31.352862,9.810241],[31.850716,10.531271],[32.400072,11.080626],[32.314235,11.681484],[32.073892,11.97333],[32.67475,12.024832],[32.743419,12.248008],[33.206938,12.179338],[33.086766,11.441141],[33.206938,10.720112],[33.721959,10.325262],[33.842131,9.981915],[33.824963,9.484061],[33.963393,9.464285],[33.97498,8.68456],[33.8255,8.37916],[33.2948,8.35458],[32.95418,7.78497],[33.56829,7.71334],[34.0751,7.22595],[34.25032,6.82607],[34.70702,6.59422],[35.298007,5.506],[34.620196,4.847123],[34.005,4.249885],[33.39,3.79],[32.68642,3.79232],[31.88145,3.55827],[31.24556,3.7819],[30.833852,3.509172],[29.9535,4.173699],[29.715995,4.600805],[29.159078,4.389267],[28.696678,4.455077],[28.428994,4.287155],[27.979977,4.408413],[27.374226,5.233944]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Senegal","SOV_A3":"SEN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Senegal","ADM0_A3":"SEN","GEOU_DIF":0,"GEOUNIT":"Senegal","GU_A3":"SEN","SU_DIF":0,"SUBUNIT":"Senegal","SU_A3":"SEN","BRK_DIFF":0,"NAME":"Senegal","NAME_LONG":"Senegal","BRK_A3":"SEN","BRK_NAME":"Senegal","BRK_GROUP":null,"ABBREV":"Sen.","POSTAL":"SN","FORMAL_EN":"Republic of Senegal","FORMAL_FR":null,"NAME_CIAWF":"Senegal","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Senegal","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":6,"MAPCOLOR9":5,"MAPCOLOR13":5,"POP_EST":14668522,"POP_RANK":14,"GDP_MD_EST":39720,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"SG","ISO_A2":"SN","ISO_A3":"SEN","ISO_A3_EH":"SEN","ISO_N3":"686","UN_A3":"686","WB_A2":"SN","WB_A3":"SEN","WOE_ID":23424943,"WOE_ID_EH":23424943,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SEN","ADM0_A3_US":"SEN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-17.625043,12.33209,-11.467899,16.598264],"geometry":{"type":"Polygon","coordinates":[[[-11.513943,12.442988],[-11.658301,12.386583],[-12.203565,12.465648],[-12.278599,12.35444],[-12.499051,12.33209],[-13.217818,12.575874],[-13.700476,12.586183],[-15.548477,12.62817],[-15.816574,12.515567],[-16.147717,12.547762],[-16.677452,12.384852],[-16.841525,13.151394],[-15.931296,13.130284],[-15.691001,13.270353],[-15.511813,13.27857],[-15.141163,13.509512],[-14.712197,13.298207],[-14.277702,13.280585],[-13.844963,13.505042],[-14.046992,13.794068],[-14.376714,13.62568],[-14.687031,13.630357],[-15.081735,13.876492],[-15.39877,13.860369],[-15.624596,13.623587],[-16.713729,13.594959],[-17.126107,14.373516],[-17.625043,14.729541],[-17.185173,14.919477],[-16.700706,15.621527],[-16.463098,16.135036],[-16.12069,16.455663],[-15.623666,16.369337],[-15.135737,16.587282],[-14.577348,16.598264],[-14.099521,16.304302],[-13.435738,16.039383],[-12.830658,15.303692],[-12.17075,14.616834],[-12.124887,13.994727],[-11.927716,13.422075],[-11.553398,13.141214],[-11.467899,12.754519],[-11.513943,12.442988]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Solomon Islands","SOV_A3":"SLB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Solomon Islands","ADM0_A3":"SLB","GEOU_DIF":0,"GEOUNIT":"Solomon Islands","GU_A3":"SLB","SU_DIF":0,"SUBUNIT":"Solomon Islands","SU_A3":"SLB","BRK_DIFF":0,"NAME":"Solomon Is.","NAME_LONG":"Solomon Islands","BRK_A3":"SLB","BRK_NAME":"Solomon Is.","BRK_GROUP":null,"ABBREV":"S. Is.","POSTAL":"SB","FORMAL_EN":null,"FORMAL_FR":null,"NAME_CIAWF":"Solomon Islands","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Solomon Islands","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":6,"POP_EST":647581,"POP_RANK":11,"GDP_MD_EST":1198,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"BP","ISO_A2":"SB","ISO_A3":"SLB","ISO_A3_EH":"SLB","ISO_N3":"090","UN_A3":"090","WB_A2":"SB","WB_A3":"SLB","WOE_ID":23424766,"WOE_ID_EH":23424766,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SLB","ADM0_A3_US":"SLB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Oceania","REGION_UN":"Oceania","SUBREGION":"Melanesia","REGION_WB":"East Asia & Pacific","NAME_LEN":11,"LONG_LEN":15,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[156.491358,-10.826367,162.398646,-6.599338],"geometry":{"type":"MultiPolygon","coordinates":[[[[162.119025,-10.482719],[162.398646,-10.826367],[161.700032,-10.820011],[161.319797,-10.204751],[161.917383,-10.446701],[162.119025,-10.482719]]],[[[160.852229,-9.872937],[160.462588,-9.89521],[159.849447,-9.794027],[159.640003,-9.63998],[159.702945,-9.24295],[160.362956,-9.400304],[160.688518,-9.610162],[160.852229,-9.872937]]],[[[161.679982,-9.599982],[161.529397,-9.784312],[160.788253,-8.917543],[160.579997,-8.320009],[160.920028,-8.320009],[161.280006,-9.120011],[161.679982,-9.599982]]],[[[159.875027,-8.33732],[159.917402,-8.53829],[159.133677,-8.114181],[158.586114,-7.754824],[158.21115,-7.421872],[158.359978,-7.320018],[158.820001,-7.560003],[159.640003,-8.020027],[159.875027,-8.33732]]],[[[157.538426,-7.34782],[157.33942,-7.404767],[156.90203,-7.176874],[156.491358,-6.765943],[156.542828,-6.599338],[157.14,-7.021638],[157.538426,-7.34782]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Sierra Leone","SOV_A3":"SLE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Sierra Leone","ADM0_A3":"SLE","GEOU_DIF":0,"GEOUNIT":"Sierra Leone","GU_A3":"SLE","SU_DIF":0,"SUBUNIT":"Sierra Leone","SU_A3":"SLE","BRK_DIFF":0,"NAME":"Sierra Leone","NAME_LONG":"Sierra Leone","BRK_A3":"SLE","BRK_NAME":"Sierra Leone","BRK_GROUP":null,"ABBREV":"S.L.","POSTAL":"SL","FORMAL_EN":"Republic of Sierra Leone","FORMAL_FR":null,"NAME_CIAWF":"Sierra Leone","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Sierra Leone","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":1,"MAPCOLOR13":7,"POP_EST":6163195,"POP_RANK":13,"GDP_MD_EST":10640,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"SL","ISO_A2":"SL","ISO_A3":"SLE","ISO_A3_EH":"SLE","ISO_N3":"694","UN_A3":"694","WB_A2":"SL","WB_A3":"SLE","WOE_ID":23424946,"WOE_ID_EH":23424946,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SLE","ADM0_A3_US":"SLE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":12,"LONG_LEN":12,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-13.24655,6.785917,-10.230094,10.046984],"geometry":{"type":"Polygon","coordinates":[[[-13.24655,8.903049],[-12.711958,9.342712],[-12.596719,9.620188],[-12.425929,9.835834],[-12.150338,9.858572],[-11.917277,10.046984],[-11.117481,10.045873],[-10.839152,9.688246],[-10.622395,9.26791],[-10.65477,8.977178],[-10.494315,8.715541],[-10.505477,8.348896],[-10.230094,8.406206],[-10.695595,7.939464],[-11.146704,7.396706],[-11.199802,7.105846],[-11.438779,6.785917],[-11.708195,6.860098],[-12.428099,7.262942],[-12.949049,7.798646],[-13.124025,8.163946],[-13.24655,8.903049]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"El Salvador","SOV_A3":"SLV","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"El Salvador","ADM0_A3":"SLV","GEOU_DIF":0,"GEOUNIT":"El Salvador","GU_A3":"SLV","SU_DIF":0,"SUBUNIT":"El Salvador","SU_A3":"SLV","BRK_DIFF":0,"NAME":"El Salvador","NAME_LONG":"El Salvador","BRK_A3":"SLV","BRK_NAME":"El Salvador","BRK_GROUP":null,"ABBREV":"El. S.","POSTAL":"SV","FORMAL_EN":"Republic of El Salvador","FORMAL_FR":null,"NAME_CIAWF":"El Salvador","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"El Salvador","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":6,"MAPCOLOR13":8,"POP_EST":6172011,"POP_RANK":13,"GDP_MD_EST":54790,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"ES","ISO_A2":"SV","ISO_A3":"SLV","ISO_A3_EH":"SLV","ISO_N3":"222","UN_A3":"222","WB_A2":"SV","WB_A3":"SLV","WOE_ID":23424807,"WOE_ID_EH":23424807,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SLV","ADM0_A3_US":"SLV","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Central America","REGION_WB":"Latin America & Caribbean","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[-90.095555,13.149017,-87.723503,14.424133],"geometry":{"type":"Polygon","coordinates":[[[-90.095555,13.735338],[-90.064678,13.88197],[-89.721934,14.134228],[-89.534219,14.244816],[-89.587343,14.362586],[-89.353326,14.424133],[-89.058512,14.340029],[-88.843073,14.140507],[-88.541231,13.980155],[-88.503998,13.845486],[-88.065343,13.964626],[-87.859515,13.893312],[-87.723503,13.78505],[-87.793111,13.38448],[-87.904112,13.149017],[-88.483302,13.163951],[-88.843228,13.259734],[-89.256743,13.458533],[-89.812394,13.520622],[-90.095555,13.735338]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Somaliland","SOV_A3":"SOL","ADM0_DIF":0,"LEVEL":2,"TYPE":"Indeterminate","ADMIN":"Somaliland","ADM0_A3":"SOL","GEOU_DIF":0,"GEOUNIT":"Somaliland","GU_A3":"SOL","SU_DIF":0,"SUBUNIT":"Somaliland","SU_A3":"SOL","BRK_DIFF":1,"NAME":"Somaliland","NAME_LONG":"Somaliland","BRK_A3":"B30","BRK_NAME":"Somaliland","BRK_GROUP":null,"ABBREV":"Solnd.","POSTAL":"SL","FORMAL_EN":"Republic of Somaliland","FORMAL_FR":null,"NAME_CIAWF":null,"NOTE_ADM0":"Self admin.","NOTE_BRK":"Self admin.; Claimed by Somalia","NAME_SORT":"Somaliland","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":6,"MAPCOLOR9":5,"MAPCOLOR13":2,"POP_EST":3500000,"POP_RANK":12,"GDP_MD_EST":12250,"POP_YEAR":2013,"LASTCENSUS":-99,"GDP_YEAR":2013,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"-99","ISO_A2":"-99","ISO_A3":"-99","ISO_A3_EH":"-99","ISO_N3":"-99","UN_A3":"-099","WB_A2":"-99","WB_A3":"-99","WOE_ID":-99,"WOE_ID_EH":-99,"WOE_NOTE":"Includes old states of 2347021, 2347020, 2347017 and portion of 2347016.","ADM0_A3_IS":"SOM","ADM0_A3_US":"SOM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":4,"MIN_LABEL":4.5,"MAX_LABEL":9},"bbox":[42.55876,7.99688,48.948206,11.46204],"geometry":{"type":"Polygon","coordinates":[[[42.776852,10.926879],[43.145305,11.46204],[43.47066,11.27771],[43.666668,10.864169],[44.117804,10.445538],[44.614259,10.442205],[45.556941,10.698029],[46.645401,10.816549],[47.525658,11.127228],[48.021596,11.193064],[48.378784,11.375482],[48.948206,11.410622],[48.948205,11.410617],[48.948205,11.410617],[48.942005,11.394266],[48.938491,10.982327],[48.938233,9.9735],[48.93813,9.451749],[48.486736,8.837626],[47.78942,8.003],[46.94834,7.99688],[43.67875,9.18358],[43.29699,9.54048],[42.92812,10.02194],[42.55876,10.57258],[42.776852,10.926879]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Somalia","SOV_A3":"SOM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Somalia","ADM0_A3":"SOM","GEOU_DIF":0,"GEOUNIT":"Somalia","GU_A3":"SOM","SU_DIF":0,"SUBUNIT":"Somalia","SU_A3":"SOM","BRK_DIFF":0,"NAME":"Somalia","NAME_LONG":"Somalia","BRK_A3":"SOM","BRK_NAME":"Somalia","BRK_GROUP":null,"ABBREV":"Som.","POSTAL":"SO","FORMAL_EN":"Federal Republic of Somalia","FORMAL_FR":null,"NAME_CIAWF":"Somalia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Somalia","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":8,"MAPCOLOR9":6,"MAPCOLOR13":7,"POP_EST":7531386,"POP_RANK":13,"GDP_MD_EST":4719,"POP_YEAR":2017,"LASTCENSUS":1987,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"SO","ISO_A2":"SO","ISO_A3":"SOM","ISO_A3_EH":"SOM","ISO_N3":"706","UN_A3":"706","WB_A2":"SO","WB_A3":"SOM","WOE_ID":-90,"WOE_ID_EH":23424949,"WOE_NOTE":"Includes Somaliland (2347021, 2347020, 2347017 and portion of 2347016)","ADM0_A3_IS":"SOM","ADM0_A3_US":"SOM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[40.98105,-1.68325,51.13387,12.02464],"geometry":{"type":"Polygon","coordinates":[[[41.855083,3.918912],[42.12861,4.23413],[42.76967,4.25259],[43.66087,4.95755],[44.9636,5.00162],[47.78942,8.003],[48.486736,8.837626],[48.93813,9.451749],[48.938233,9.9735],[48.938491,10.982327],[48.942005,11.394266],[48.948205,11.410617],[48.948205,11.410617],[49.26776,11.43033],[49.72862,11.5789],[50.25878,11.67957],[50.73202,12.0219],[51.1112,12.02464],[51.13387,11.74815],[51.04153,11.16651],[51.04531,10.6409],[50.83418,10.27972],[50.55239,9.19874],[50.07092,8.08173],[49.4527,6.80466],[48.59455,5.33911],[47.74079,4.2194],[46.56476,2.85529],[45.56399,2.04576],[44.06815,1.05283],[43.13597,0.2922],[42.04157,-0.91916],[41.81095,-1.44647],[41.58513,-1.68325],[40.993,-0.85829],[40.98105,2.78452],[41.855083,3.918912]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Republic of Serbia","SOV_A3":"SRB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Republic of Serbia","ADM0_A3":"SRB","GEOU_DIF":0,"GEOUNIT":"Republic of Serbia","GU_A3":"SRB","SU_DIF":0,"SUBUNIT":"Republic of Serbia","SU_A3":"SRB","BRK_DIFF":0,"NAME":"Serbia","NAME_LONG":"Serbia","BRK_A3":"SRB","BRK_NAME":"Serbia","BRK_GROUP":null,"ABBREV":"Serb.","POSTAL":"RS","FORMAL_EN":"Republic of Serbia","FORMAL_FR":null,"NAME_CIAWF":"Serbia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Serbia","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":3,"MAPCOLOR9":2,"MAPCOLOR13":10,"POP_EST":7111024,"POP_RANK":13,"GDP_MD_EST":101800,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"RI","ISO_A2":"RS","ISO_A3":"SRB","ISO_A3_EH":"SRB","ISO_N3":"688","UN_A3":"688","WB_A2":"YF","WB_A3":"SRB","WOE_ID":-90,"WOE_ID_EH":20069818,"WOE_NOTE":"Expired WOE also contains Kosovo.","ADM0_A3_IS":"SRB","ADM0_A3_US":"SRB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":7},"bbox":[18.829825,42.245224,22.986019,46.17173],"geometry":{"type":"Polygon","coordinates":[[[22.65715,44.234923],[22.410446,44.008063],[22.500157,43.642814],[22.986019,43.211161],[22.604801,42.898519],[22.436595,42.580321],[22.545012,42.461362],[22.380526,42.32026],[21.91708,42.30364],[21.576636,42.245224],[21.54332,42.32025],[21.66292,42.43922],[21.77505,42.6827],[21.63302,42.67717],[21.43866,42.86255],[21.27421,42.90959],[21.143395,43.068685],[20.95651,43.13094],[20.81448,43.27205],[20.63508,43.21671],[20.49679,42.88469],[20.25758,42.81275],[20.3398,42.89852],[19.95857,43.10604],[19.63,43.21378],[19.48389,43.35229],[19.21852,43.52384],[19.454,43.5681],[19.59976,44.03847],[19.11761,44.42307],[19.36803,44.863],[19.00548,44.86023],[19.005485,44.860234],[19.390476,45.236516],[19.072769,45.521511],[18.829825,45.908872],[18.829838,45.908878],[19.596045,46.17173],[20.220192,46.127469],[20.762175,45.734573],[20.874313,45.416375],[21.483526,45.18117],[21.562023,44.768947],[22.145088,44.478422],[22.459022,44.702517],[22.705726,44.578003],[22.474008,44.409228],[22.65715,44.234923]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Suriname","SOV_A3":"SUR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Suriname","ADM0_A3":"SUR","GEOU_DIF":0,"GEOUNIT":"Suriname","GU_A3":"SUR","SU_DIF":0,"SUBUNIT":"Suriname","SU_A3":"SUR","BRK_DIFF":0,"NAME":"Suriname","NAME_LONG":"Suriname","BRK_A3":"SUR","BRK_NAME":"Suriname","BRK_GROUP":null,"ABBREV":"Sur.","POSTAL":"SR","FORMAL_EN":"Republic of Suriname","FORMAL_FR":null,"NAME_CIAWF":"Suriname","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Suriname","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":7,"MAPCOLOR13":6,"POP_EST":591919,"POP_RANK":11,"GDP_MD_EST":8547,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"NS","ISO_A2":"SR","ISO_A3":"SUR","ISO_A3_EH":"SUR","ISO_N3":"740","UN_A3":"740","WB_A2":"SR","WB_A3":"SUR","WOE_ID":23424913,"WOE_ID_EH":23424913,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SUR","ADM0_A3_US":"SUR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[-58.044694,1.817667,-53.958045,6.025291],"geometry":{"type":"Polygon","coordinates":[[[-54.524754,2.311849],[-55.097587,2.523748],[-55.569755,2.421506],[-55.973322,2.510364],[-56.073342,2.220795],[-55.9056,2.021996],[-55.995698,1.817667],[-56.539386,1.899523],[-57.150098,2.768927],[-57.281433,3.333492],[-57.601569,3.334655],[-58.044694,4.060864],[-57.86021,4.576801],[-57.914289,4.812626],[-57.307246,5.073567],[-57.147436,5.97315],[-55.949318,5.772878],[-55.84178,5.953125],[-55.03325,6.025291],[-53.958045,5.756548],[-54.478633,4.896756],[-54.399542,4.212611],[-54.011504,3.62257],[-54.184284,3.194172],[-54.27123,2.738748],[-54.524754,2.311849]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Slovakia","SOV_A3":"SVK","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Slovakia","ADM0_A3":"SVK","GEOU_DIF":0,"GEOUNIT":"Slovakia","GU_A3":"SVK","SU_DIF":0,"SUBUNIT":"Slovakia","SU_A3":"SVK","BRK_DIFF":0,"NAME":"Slovakia","NAME_LONG":"Slovakia","BRK_A3":"SVK","BRK_NAME":"Slovakia","BRK_GROUP":null,"ABBREV":"Svk.","POSTAL":"SK","FORMAL_EN":"Slovak Republic","FORMAL_FR":null,"NAME_CIAWF":"Slovakia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Slovak Republic","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":4,"MAPCOLOR9":4,"MAPCOLOR13":9,"POP_EST":5445829,"POP_RANK":13,"GDP_MD_EST":168800,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"LO","ISO_A2":"SK","ISO_A3":"SVK","ISO_A3_EH":"SVK","ISO_N3":"703","UN_A3":"703","WB_A2":"SK","WB_A3":"SVK","WOE_ID":23424877,"WOE_ID_EH":23424877,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SVK","ADM0_A3_US":"SVK","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[16.879983,47.758429,22.558138,49.571574],"geometry":{"type":"Polygon","coordinates":[[[16.979667,48.123497],[16.879983,48.470013],[16.960288,48.596982],[17.101985,48.816969],[17.545007,48.800019],[17.886485,48.903475],[17.913512,48.996493],[18.104973,49.043983],[18.170498,49.271515],[18.399994,49.315001],[18.554971,49.495015],[18.853144,49.49623],[18.909575,49.435846],[19.320713,49.571574],[19.825023,49.217125],[20.415839,49.431453],[20.887955,49.328772],[21.607808,49.470107],[22.558138,49.085738],[22.280842,48.825392],[22.085608,48.422264],[21.872236,48.319971],[20.801294,48.623854],[20.473562,48.56285],[20.239054,48.327567],[19.769471,48.202691],[19.661364,48.266615],[19.174365,48.111379],[18.777025,48.081768],[18.696513,47.880954],[17.857133,47.758429],[17.488473,47.867466],[16.979667,48.123497]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Slovenia","SOV_A3":"SVN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Slovenia","ADM0_A3":"SVN","GEOU_DIF":0,"GEOUNIT":"Slovenia","GU_A3":"SVN","SU_DIF":0,"SUBUNIT":"Slovenia","SU_A3":"SVN","BRK_DIFF":0,"NAME":"Slovenia","NAME_LONG":"Slovenia","BRK_A3":"SVN","BRK_NAME":"Slovenia","BRK_GROUP":null,"ABBREV":"Slo.","POSTAL":"SLO","FORMAL_EN":"Republic of Slovenia","FORMAL_FR":null,"NAME_CIAWF":"Slovenia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Slovenia","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":3,"MAPCOLOR9":2,"MAPCOLOR13":12,"POP_EST":1972126,"POP_RANK":12,"GDP_MD_EST":68350,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"SI","ISO_A2":"SI","ISO_A3":"SVN","ISO_A3_EH":"SVN","ISO_N3":"705","UN_A3":"705","WB_A2":"SI","WB_A3":"SVN","WOE_ID":23424945,"WOE_ID_EH":23424945,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SVN","ADM0_A3_US":"SVN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Southern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[13.69811,45.452316,16.564808,46.852386],"geometry":{"type":"Polygon","coordinates":[[[13.806475,46.509306],[14.632472,46.431817],[15.137092,46.658703],[16.011664,46.683611],[16.202298,46.852386],[16.370505,46.841327],[16.564808,46.503751],[15.768733,46.238108],[15.67153,45.834154],[15.323954,45.731783],[15.327675,45.452316],[14.935244,45.471695],[14.595109,45.634941],[14.411968,45.466166],[13.71506,45.500324],[13.93763,45.591016],[13.69811,46.016778],[13.806475,46.509306]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Sweden","SOV_A3":"SWE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Sweden","ADM0_A3":"SWE","GEOU_DIF":0,"GEOUNIT":"Sweden","GU_A3":"SWE","SU_DIF":0,"SUBUNIT":"Sweden","SU_A3":"SWE","BRK_DIFF":0,"NAME":"Sweden","NAME_LONG":"Sweden","BRK_A3":"SWE","BRK_NAME":"Sweden","BRK_GROUP":null,"ABBREV":"Swe.","POSTAL":"S","FORMAL_EN":"Kingdom of Sweden","FORMAL_FR":null,"NAME_CIAWF":"Sweden","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Sweden","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":4,"MAPCOLOR9":2,"MAPCOLOR13":4,"POP_EST":9960487,"POP_RANK":13,"GDP_MD_EST":498100,"POP_YEAR":2017,"LASTCENSUS":-99,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":-99,"FIPS_10_":"SW","ISO_A2":"SE","ISO_A3":"SWE","ISO_A3_EH":"SWE","ISO_N3":"752","UN_A3":"752","WB_A2":"SE","WB_A3":"SWE","WOE_ID":23424954,"WOE_ID_EH":23424954,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SWE","ADM0_A3_US":"SWE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Northern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[11.027369,55.361737,23.903379,69.106247],"geometry":{"type":"Polygon","coordinates":[[[20.645593,69.106247],[21.978535,68.616846],[23.539473,67.936009],[23.56588,66.396051],[23.903379,66.006927],[22.183173,65.723741],[21.213517,65.026005],[21.369631,64.413588],[19.778876,63.609554],[17.847779,62.7494],[17.119555,61.341166],[17.831346,60.636583],[18.787722,60.081914],[17.869225,58.953766],[16.829185,58.719827],[16.44771,57.041118],[15.879786,56.104302],[14.666681,56.200885],[14.100721,55.407781],[12.942911,55.361737],[12.625101,56.30708],[11.787942,57.441817],[11.027369,58.856149],[11.468272,59.432393],[12.300366,60.117933],[12.631147,61.293572],[11.992064,61.800362],[11.930569,63.128318],[12.579935,64.066219],[13.571916,64.049114],[13.919905,64.445421],[13.55569,64.787028],[15.108411,66.193867],[16.108712,67.302456],[16.768879,68.013937],[17.729182,68.010552],[17.993868,68.567391],[19.87856,68.407194],[20.025269,69.065139],[20.645593,69.106247]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Swaziland","SOV_A3":"SWZ","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Swaziland","ADM0_A3":"SWZ","GEOU_DIF":0,"GEOUNIT":"Swaziland","GU_A3":"SWZ","SU_DIF":0,"SUBUNIT":"Swaziland","SU_A3":"SWZ","BRK_DIFF":0,"NAME":"Swaziland","NAME_LONG":"Swaziland","BRK_A3":"SWZ","BRK_NAME":"Swaziland","BRK_GROUP":null,"ABBREV":"Swz.","POSTAL":"SW","FORMAL_EN":"Kingdom of Swaziland","FORMAL_FR":null,"NAME_CIAWF":"Swaziland","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Swaziland","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":6,"MAPCOLOR9":2,"MAPCOLOR13":5,"POP_EST":1467152,"POP_RANK":12,"GDP_MD_EST":11060,"POP_YEAR":2017,"LASTCENSUS":2007,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"WZ","ISO_A2":"SZ","ISO_A3":"SWZ","ISO_A3_EH":"SWZ","ISO_N3":"748","UN_A3":"748","WB_A2":"SZ","WB_A3":"SWZ","WOE_ID":23424993,"WOE_ID_EH":23424993,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SWZ","ADM0_A3_US":"SWZ","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Southern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[30.676609,-27.285879,32.071665,-25.660191],"geometry":{"type":"Polygon","coordinates":[[[31.837778,-25.843332],[31.985779,-26.29178],[32.071665,-26.73382],[31.86806,-27.177927],[31.282773,-27.285879],[30.685962,-26.743845],[30.676609,-26.398078],[30.949667,-26.022649],[31.04408,-25.731452],[31.333158,-25.660191],[31.837778,-25.843332]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Syria","SOV_A3":"SYR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Syria","ADM0_A3":"SYR","GEOU_DIF":0,"GEOUNIT":"Syria","GU_A3":"SYR","SU_DIF":0,"SUBUNIT":"Syria","SU_A3":"SYR","BRK_DIFF":0,"NAME":"Syria","NAME_LONG":"Syria","BRK_A3":"SYR","BRK_NAME":"Syria","BRK_GROUP":null,"ABBREV":"Syria","POSTAL":"SYR","FORMAL_EN":"Syrian Arab Republic","FORMAL_FR":null,"NAME_CIAWF":"Syria","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Syrian Arab Republic","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":6,"MAPCOLOR9":2,"MAPCOLOR13":6,"POP_EST":18028549,"POP_RANK":14,"GDP_MD_EST":50280,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2015,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"SY","ISO_A2":"SY","ISO_A3":"SYR","ISO_A3_EH":"SYR","ISO_N3":"760","UN_A3":"760","WB_A2":"SY","WB_A3":"SYR","WOE_ID":23424956,"WOE_ID_EH":23424956,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"SYR","ADM0_A3_US":"SYR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[35.700798,32.312938,42.349591,37.229873],"geometry":{"type":"Polygon","coordinates":[[[42.349591,37.229873],[41.837064,36.605854],[41.289707,36.358815],[41.383965,35.628317],[41.006159,34.419372],[38.792341,33.378686],[36.834062,32.312938],[35.719918,32.709192],[35.700798,32.716014],[35.836397,32.868123],[35.821101,33.277426],[36.06646,33.824912],[36.61175,34.201789],[36.448194,34.593935],[35.998403,34.644914],[35.905023,35.410009],[36.149763,35.821535],[36.41755,36.040617],[36.685389,36.259699],[36.739494,36.81752],[37.066761,36.623036],[38.167727,36.90121],[38.699891,36.712927],[39.52258,36.716054],[40.673259,37.091276],[41.212089,37.074352],[42.349591,37.229873]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Chad","SOV_A3":"TCD","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Chad","ADM0_A3":"TCD","GEOU_DIF":0,"GEOUNIT":"Chad","GU_A3":"TCD","SU_DIF":0,"SUBUNIT":"Chad","SU_A3":"TCD","BRK_DIFF":0,"NAME":"Chad","NAME_LONG":"Chad","BRK_A3":"TCD","BRK_NAME":"Chad","BRK_GROUP":null,"ABBREV":"Chad","POSTAL":"TD","FORMAL_EN":"Republic of Chad","FORMAL_FR":null,"NAME_CIAWF":"Chad","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Chad","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":1,"MAPCOLOR9":8,"MAPCOLOR13":6,"POP_EST":12075985,"POP_RANK":14,"GDP_MD_EST":30590,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"CD","ISO_A2":"TD","ISO_A3":"TCD","ISO_A3_EH":"TCD","ISO_N3":"148","UN_A3":"148","WB_A2":"TD","WB_A3":"TCD","WOE_ID":23424777,"WOE_ID_EH":23424777,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TCD","ADM0_A3_US":"TCD","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Middle Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[13.540394,7.421925,23.88689,23.40972],"geometry":{"type":"Polygon","coordinates":[[[22.864165,11.142395],[22.231129,10.971889],[21.723822,10.567056],[21.000868,9.475985],[20.059685,9.012706],[19.094008,9.074847],[18.81201,8.982915],[18.911022,8.630895],[18.389555,8.281304],[17.96493,7.890914],[16.705988,7.508328],[16.456185,7.734774],[16.290562,7.754307],[16.106232,7.497088],[15.27946,7.421925],[15.436092,7.692812],[15.120866,8.38215],[14.979996,8.796104],[14.544467,8.965861],[13.954218,9.549495],[14.171466,10.021378],[14.627201,9.920919],[14.909354,9.992129],[15.467873,9.982337],[14.923565,10.891325],[14.960152,11.555574],[14.89336,12.21905],[14.495787,12.859396],[14.595781,13.330427],[13.954477,13.353449],[13.956699,13.996691],[13.540394,14.367134],[13.97217,15.68437],[15.247731,16.627306],[15.300441,17.92795],[15.685741,19.95718],[15.903247,20.387619],[15.487148,20.730415],[15.47106,21.04845],[15.096888,21.308519],[14.8513,22.86295],[15.86085,23.40972],[19.84926,21.49509],[23.83766,19.58047],[23.88689,15.61084],[23.02459,15.68072],[22.56795,14.94429],[22.30351,14.32682],[22.51202,14.09318],[22.18329,13.78648],[22.29658,13.37232],[22.03759,12.95546],[21.93681,12.58818],[22.28801,12.64605],[22.49762,12.26024],[22.50869,11.67936],[22.87622,11.38461],[22.864165,11.142395]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":6,"SOVEREIGNT":"Togo","SOV_A3":"TGO","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Togo","ADM0_A3":"TGO","GEOU_DIF":0,"GEOUNIT":"Togo","GU_A3":"TGO","SU_DIF":0,"SUBUNIT":"Togo","SU_A3":"TGO","BRK_DIFF":0,"NAME":"Togo","NAME_LONG":"Togo","BRK_A3":"TGO","BRK_NAME":"Togo","BRK_GROUP":null,"ABBREV":"Togo","POSTAL":"TG","FORMAL_EN":"Togolese Republic","FORMAL_FR":"République Togolaise","NAME_CIAWF":"Togo","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Togo","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":1,"MAPCOLOR9":3,"MAPCOLOR13":5,"POP_EST":7965055,"POP_RANK":13,"GDP_MD_EST":11610,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"TO","ISO_A2":"TG","ISO_A3":"TGO","ISO_A3_EH":"TGO","ISO_N3":"768","UN_A3":"768","WB_A2":"TG","WB_A3":"TGO","WOE_ID":23424965,"WOE_ID_EH":23424965,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TGO","ADM0_A3_US":"TGO","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Western Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":4,"LONG_LEN":4,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":5,"MAX_LABEL":10},"bbox":[-0.049785,5.928837,1.865241,11.018682],"geometry":{"type":"Polygon","coordinates":[[[0.899563,10.997339],[0.772336,10.470808],[1.077795,10.175607],[1.425061,9.825395],[1.463043,9.334624],[1.664478,9.12859],[1.618951,6.832038],[1.865241,6.142158],[1.060122,5.928837],[0.836931,6.279979],[0.570384,6.914359],[0.490957,7.411744],[0.712029,8.312465],[0.461192,8.677223],[0.365901,9.465004],[0.36758,10.191213],[-0.049785,10.706918],[0.023803,11.018682],[0.899563,10.997339]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Thailand","SOV_A3":"THA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Thailand","ADM0_A3":"THA","GEOU_DIF":0,"GEOUNIT":"Thailand","GU_A3":"THA","SU_DIF":0,"SUBUNIT":"Thailand","SU_A3":"THA","BRK_DIFF":0,"NAME":"Thailand","NAME_LONG":"Thailand","BRK_A3":"THA","BRK_NAME":"Thailand","BRK_GROUP":null,"ABBREV":"Thai.","POSTAL":"TH","FORMAL_EN":"Kingdom of Thailand","FORMAL_FR":null,"NAME_CIAWF":"Thailand","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Thailand","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":6,"MAPCOLOR9":8,"MAPCOLOR13":1,"POP_EST":68414135,"POP_RANK":16,"GDP_MD_EST":1161000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"TH","ISO_A2":"TH","ISO_A3":"THA","ISO_A3_EH":"THA","ISO_N3":"764","UN_A3":"764","WB_A2":"TH","WB_A3":"THA","WOE_ID":23424960,"WOE_ID_EH":23424960,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"THA","ADM0_A3_US":"THA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[97.375896,5.691384,105.589039,20.41785],"geometry":{"type":"Polygon","coordinates":[[[105.218777,14.273212],[104.281418,14.416743],[102.988422,14.225721],[102.348099,13.394247],[102.584932,12.186595],[101.687158,12.64574],[100.83181,12.627085],[100.978467,13.412722],[100.097797,13.406856],[100.018733,12.307001],[99.478921,10.846367],[99.153772,9.963061],[99.222399,9.239255],[99.873832,9.207862],[100.279647,8.295153],[100.459274,7.429573],[101.017328,6.856869],[101.623079,6.740622],[102.141187,6.221636],[101.814282,5.810808],[101.154219,5.691384],[101.075516,6.204867],[100.259596,6.642825],[100.085757,6.464489],[99.690691,6.848213],[99.519642,7.343454],[98.988253,7.907993],[98.503786,8.382305],[98.339662,7.794512],[98.150009,8.350007],[98.25915,8.973923],[98.553551,9.93296],[99.038121,10.960546],[99.587286,11.892763],[99.196354,12.804748],[99.212012,13.269294],[99.097755,13.827503],[98.430819,14.622028],[98.192074,15.123703],[98.537376,15.308497],[98.903348,16.177824],[98.493761,16.837836],[97.859123,17.567946],[97.375896,18.445438],[97.797783,18.62708],[98.253724,19.708203],[98.959676,19.752981],[99.543309,20.186598],[100.115988,20.41785],[100.548881,20.109238],[100.606294,19.508344],[101.282015,19.462585],[101.035931,18.408928],[101.059548,17.512497],[102.113592,18.109102],[102.413005,17.932782],[102.998706,17.961695],[103.200192,18.309632],[103.956477,18.240954],[104.716947,17.428859],[104.779321,16.441865],[105.589039,15.570316],[105.544338,14.723934],[105.218777,14.273212]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Tajikistan","SOV_A3":"TJK","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Tajikistan","ADM0_A3":"TJK","GEOU_DIF":0,"GEOUNIT":"Tajikistan","GU_A3":"TJK","SU_DIF":0,"SUBUNIT":"Tajikistan","SU_A3":"TJK","BRK_DIFF":0,"NAME":"Tajikistan","NAME_LONG":"Tajikistan","BRK_A3":"TJK","BRK_NAME":"Tajikistan","BRK_GROUP":null,"ABBREV":"Tjk.","POSTAL":"TJ","FORMAL_EN":"Republic of Tajikistan","FORMAL_FR":null,"NAME_CIAWF":"Tajikistan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Tajikistan","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":6,"MAPCOLOR9":2,"MAPCOLOR13":5,"POP_EST":8468555,"POP_RANK":13,"GDP_MD_EST":25810,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"TI","ISO_A2":"TJ","ISO_A3":"TJK","ISO_A3_EH":"TJK","ISO_N3":"762","UN_A3":"762","WB_A2":"TJ","WB_A3":"TJK","WOE_ID":23424961,"WOE_ID_EH":23424961,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TJK","ADM0_A3_US":"TJK","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Central Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[67.44222,36.738171,74.980002,40.960213],"geometry":{"type":"Polygon","coordinates":[[[74.980002,37.41999],[73.948696,37.421566],[73.260056,37.495257],[72.63689,37.047558],[72.193041,36.948288],[71.844638,36.738171],[71.448693,37.065645],[71.541918,37.905774],[71.239404,37.953265],[71.348131,38.258905],[70.806821,38.486282],[70.376304,38.138396],[70.270574,37.735165],[70.116578,37.588223],[69.518785,37.608997],[69.196273,37.151144],[68.859446,37.344336],[68.135562,37.023115],[67.83,37.144994],[68.392033,38.157025],[68.176025,38.901553],[67.44222,39.140144],[67.701429,39.580478],[68.536416,39.533453],[69.011633,40.086158],[69.329495,40.727824],[70.666622,40.960213],[70.45816,40.496495],[70.601407,40.218527],[71.014198,40.244366],[70.648019,39.935754],[69.55961,40.103211],[69.464887,39.526683],[70.549162,39.604198],[71.784694,39.279463],[73.675379,39.431237],[73.928852,38.505815],[74.257514,38.606507],[74.864816,38.378846],[74.829986,37.990007],[74.980002,37.41999]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Turkmenistan","SOV_A3":"TKM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Turkmenistan","ADM0_A3":"TKM","GEOU_DIF":0,"GEOUNIT":"Turkmenistan","GU_A3":"TKM","SU_DIF":0,"SUBUNIT":"Turkmenistan","SU_A3":"TKM","BRK_DIFF":0,"NAME":"Turkmenistan","NAME_LONG":"Turkmenistan","BRK_A3":"TKM","BRK_NAME":"Turkmenistan","BRK_GROUP":null,"ABBREV":"Turkm.","POSTAL":"TM","FORMAL_EN":"Turkmenistan","FORMAL_FR":null,"NAME_CIAWF":"Turkmenistan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Turkmenistan","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":2,"MAPCOLOR9":1,"MAPCOLOR13":9,"POP_EST":5351277,"POP_RANK":13,"GDP_MD_EST":94720,"POP_YEAR":2017,"LASTCENSUS":1995,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"TX","ISO_A2":"TM","ISO_A3":"TKM","ISO_A3_EH":"TKM","ISO_N3":"795","UN_A3":"795","WB_A2":"TM","WB_A3":"TKM","WOE_ID":23424972,"WOE_ID_EH":23424972,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TKM","ADM0_A3_US":"TKM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Central Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":12,"LONG_LEN":12,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[52.50246,35.270664,66.54615,42.751551],"geometry":{"type":"Polygon","coordinates":[[[66.518607,37.362784],[66.217385,37.39379],[65.745631,37.661164],[65.588948,37.305217],[64.746105,37.111818],[64.546479,36.312073],[63.982896,36.007957],[63.193538,35.857166],[62.984662,35.404041],[62.230651,35.270664],[61.210817,35.650072],[61.123071,36.491597],[60.377638,36.527383],[59.234762,37.412988],[58.436154,37.522309],[57.330434,38.029229],[56.619366,38.121394],[56.180375,37.935127],[55.511578,37.964117],[54.800304,37.392421],[53.921598,37.198918],[53.735511,37.906136],[53.880929,38.952093],[53.101028,39.290574],[53.357808,39.975286],[52.693973,40.033629],[52.915251,40.876523],[53.858139,40.631034],[54.736845,40.951015],[54.008311,41.551211],[53.721713,42.123191],[52.91675,41.868117],[52.814689,41.135371],[52.50246,41.783316],[52.944293,42.116034],[54.079418,42.324109],[54.755345,42.043971],[55.455251,41.259859],[55.968191,41.308642],[57.096391,41.32231],[56.932215,41.826026],[57.78653,42.170553],[58.629011,42.751551],[59.976422,42.223082],[60.083341,41.425146],[60.465953,41.220327],[61.547179,41.26637],[61.882714,41.084857],[62.37426,40.053886],[63.518015,39.363257],[64.170223,38.892407],[65.215999,38.402695],[66.54615,37.974685],[66.518607,37.362784]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"East Timor","SOV_A3":"TLS","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"East Timor","ADM0_A3":"TLS","GEOU_DIF":0,"GEOUNIT":"East Timor","GU_A3":"TLS","SU_DIF":0,"SUBUNIT":"East Timor","SU_A3":"TLS","BRK_DIFF":0,"NAME":"Timor-Leste","NAME_LONG":"Timor-Leste","BRK_A3":"TLS","BRK_NAME":"Timor-Leste","BRK_GROUP":null,"ABBREV":"T.L.","POSTAL":"TL","FORMAL_EN":"Democratic Republic of Timor-Leste","FORMAL_FR":null,"NAME_CIAWF":"Timor-Leste","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Timor-Leste","NAME_ALT":"East Timor","MAPCOLOR7":2,"MAPCOLOR8":2,"MAPCOLOR9":4,"MAPCOLOR13":3,"POP_EST":1291358,"POP_RANK":12,"GDP_MD_EST":4975,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"TT","ISO_A2":"TL","ISO_A3":"TLS","ISO_A3_EH":"TLS","ISO_N3":"626","UN_A3":"626","WB_A2":"TP","WB_A3":"TMP","WOE_ID":23424968,"WOE_ID_EH":23424968,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TLS","ADM0_A3_US":"TLS","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":11,"LONG_LEN":11,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[124.968682,-9.393173,127.335928,-8.273345],"geometry":{"type":"Polygon","coordinates":[[[125.08852,-9.393173],[125.07002,-9.089987],[124.968682,-8.89279],[125.086246,-8.656887],[125.947072,-8.432095],[126.644704,-8.398247],[126.957243,-8.273345],[127.335928,-8.397317],[126.967992,-8.668256],[125.925885,-9.106007],[125.08852,-9.393173]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":5,"SOVEREIGNT":"Trinidad and Tobago","SOV_A3":"TTO","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Trinidad and Tobago","ADM0_A3":"TTO","GEOU_DIF":0,"GEOUNIT":"Trinidad and Tobago","GU_A3":"TTO","SU_DIF":0,"SUBUNIT":"Trinidad and Tobago","SU_A3":"TTO","BRK_DIFF":0,"NAME":"Trinidad and Tobago","NAME_LONG":"Trinidad and Tobago","BRK_A3":"TTO","BRK_NAME":"Trinidad and Tobago","BRK_GROUP":null,"ABBREV":"Tr.T.","POSTAL":"TT","FORMAL_EN":"Republic of Trinidad and Tobago","FORMAL_FR":null,"NAME_CIAWF":"Trinidad and Tobago","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Trinidad and Tobago","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":6,"MAPCOLOR9":2,"MAPCOLOR13":5,"POP_EST":1218208,"POP_RANK":12,"GDP_MD_EST":43570,"POP_YEAR":2017,"LASTCENSUS":2011,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"TD","ISO_A2":"TT","ISO_A3":"TTO","ISO_A3_EH":"TTO","ISO_N3":"780","UN_A3":"780","WB_A2":"TT","WB_A3":"TTO","WOE_ID":23424958,"WOE_ID_EH":23424958,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TTO","ADM0_A3_US":"TTO","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Caribbean","REGION_WB":"Latin America & Caribbean","NAME_LEN":19,"LONG_LEN":19,"ABBREV_LEN":5,"TINY":2,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4.5,"MAX_LABEL":9.5},"bbox":[-61.95,10,-60.895,10.89],"geometry":{"type":"Polygon","coordinates":[[[-61.68,10.76],[-61.105,10.89],[-60.895,10.855],[-60.935,10.11],[-61.77,10],[-61.95,10.09],[-61.66,10.365],[-61.68,10.76]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Tunisia","SOV_A3":"TUN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Tunisia","ADM0_A3":"TUN","GEOU_DIF":0,"GEOUNIT":"Tunisia","GU_A3":"TUN","SU_DIF":0,"SUBUNIT":"Tunisia","SU_A3":"TUN","BRK_DIFF":0,"NAME":"Tunisia","NAME_LONG":"Tunisia","BRK_A3":"TUN","BRK_NAME":"Tunisia","BRK_GROUP":null,"ABBREV":"Tun.","POSTAL":"TN","FORMAL_EN":"Republic of Tunisia","FORMAL_FR":null,"NAME_CIAWF":"Tunisia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Tunisia","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":3,"MAPCOLOR9":3,"MAPCOLOR13":2,"POP_EST":11403800,"POP_RANK":14,"GDP_MD_EST":130800,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"TS","ISO_A2":"TN","ISO_A3":"TUN","ISO_A3_EH":"TUN","ISO_N3":"788","UN_A3":"788","WB_A2":"TN","WB_A3":"TUN","WOE_ID":23424967,"WOE_ID_EH":23424967,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TUN","ADM0_A3_US":"TUN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Northern Africa","REGION_WB":"Middle East & North Africa","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[7.524482,30.307556,11.488787,37.349994],"geometry":{"type":"Polygon","coordinates":[[[9.48214,30.307556],[9.055603,32.102692],[8.439103,32.506285],[8.430473,32.748337],[7.612642,33.344115],[7.524482,34.097376],[8.140981,34.655146],[8.376368,35.479876],[8.217824,36.433177],[8.420964,36.946427],[9.509994,37.349994],[10.210002,37.230002],[10.18065,36.724038],[11.028867,37.092103],[11.100026,36.899996],[10.600005,36.41],[10.593287,35.947444],[10.939519,35.698984],[10.807847,34.833507],[10.149593,34.330773],[10.339659,33.785742],[10.856836,33.76874],[11.108501,33.293343],[11.488787,33.136996],[11.432253,32.368903],[10.94479,32.081815],[10.636901,31.761421],[9.950225,31.37607],[10.056575,30.961831],[9.970017,30.539325],[9.48214,30.307556]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Turkey","SOV_A3":"TUR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Turkey","ADM0_A3":"TUR","GEOU_DIF":0,"GEOUNIT":"Turkey","GU_A3":"TUR","SU_DIF":0,"SUBUNIT":"Turkey","SU_A3":"TUR","BRK_DIFF":0,"NAME":"Turkey","NAME_LONG":"Turkey","BRK_A3":"TUR","BRK_NAME":"Turkey","BRK_GROUP":null,"ABBREV":"Tur.","POSTAL":"TR","FORMAL_EN":"Republic of Turkey","FORMAL_FR":null,"NAME_CIAWF":"Turkey","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Turkey","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":3,"MAPCOLOR9":8,"MAPCOLOR13":4,"POP_EST":80845215,"POP_RANK":16,"GDP_MD_EST":1670000,"POP_YEAR":2017,"LASTCENSUS":2000,"GDP_YEAR":2016,"ECONOMY":"4. Emerging region: MIKT","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"TU","ISO_A2":"TR","ISO_A3":"TUR","ISO_A3_EH":"TUR","ISO_N3":"792","UN_A3":"792","WB_A2":"TR","WB_A3":"TUR","WOE_ID":23424969,"WOE_ID_EH":23424969,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TUR","ADM0_A3_US":"TUR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[26.043351,35.821535,44.79399,42.141485],"geometry":{"type":"MultiPolygon","coordinates":[[[[43.582746,41.092143],[43.752658,40.740201],[43.656436,40.253564],[44.400009,40.005],[44.79399,39.713003],[44.109225,39.428136],[44.421403,38.281281],[44.225756,37.971584],[44.77267,37.17045],[44.772677,37.170437],[44.293452,37.001514],[43.942259,37.256228],[42.779126,37.385264],[42.349591,37.229873],[41.212089,37.074352],[40.673259,37.091276],[39.52258,36.716054],[38.699891,36.712927],[38.167727,36.90121],[37.066761,36.623036],[36.739494,36.81752],[36.685389,36.259699],[36.41755,36.040617],[36.149763,35.821535],[35.782085,36.274995],[36.160822,36.650606],[35.550936,36.565443],[34.714553,36.795532],[34.026895,36.21996],[32.509158,36.107564],[31.699595,36.644275],[30.621625,36.677865],[30.391096,36.262981],[29.699976,36.144357],[28.732903,36.676831],[27.641187,36.658822],[27.048768,37.653361],[26.318218,38.208133],[26.8047,38.98576],[26.170785,39.463612],[27.28002,40.420014],[28.819978,40.460011],[29.240004,41.219991],[31.145934,41.087622],[32.347979,41.736264],[33.513283,42.01896],[35.167704,42.040225],[36.913127,41.335358],[38.347665,40.948586],[39.512607,41.102763],[40.373433,41.013673],[41.554084,41.535656],[42.619549,41.583173],[43.582746,41.092143]]],[[[26.117042,41.826905],[27.135739,42.141485],[27.99672,42.007359],[28.115525,41.622886],[28.988443,41.299934],[28.806438,41.054962],[27.619017,40.999823],[27.192377,40.690566],[26.358009,40.151994],[26.043351,40.617754],[26.056942,40.824123],[26.294602,40.936261],[26.604196,41.562115],[26.117042,41.826905]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Taiwan","SOV_A3":"TWN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Taiwan","ADM0_A3":"TWN","GEOU_DIF":0,"GEOUNIT":"Taiwan","GU_A3":"TWN","SU_DIF":0,"SUBUNIT":"Taiwan","SU_A3":"TWN","BRK_DIFF":1,"NAME":"Taiwan","NAME_LONG":"Taiwan","BRK_A3":"B77","BRK_NAME":"Taiwan","BRK_GROUP":null,"ABBREV":"Taiwan","POSTAL":"TW","FORMAL_EN":null,"FORMAL_FR":null,"NAME_CIAWF":"Taiwan","NOTE_ADM0":null,"NOTE_BRK":"Self admin.; Claimed by China","NAME_SORT":"Taiwan","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":5,"MAPCOLOR9":7,"MAPCOLOR13":2,"POP_EST":23508428,"POP_RANK":15,"GDP_MD_EST":1127000,"POP_YEAR":2017,"LASTCENSUS":-99,"GDP_YEAR":2016,"ECONOMY":"2. Developed region: nonG7","INCOME_GRP":"2. High income: nonOECD","WIKIPEDIA":-99,"FIPS_10_":"TW","ISO_A2":"TW","ISO_A3":"TWN","ISO_A3_EH":"TWN","ISO_N3":"158","UN_A3":"-099","WB_A2":"-99","WB_A3":"-99","WOE_ID":23424971,"WOE_ID_EH":23424971,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TWN","ADM0_A3_US":"TWN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[120.106189,21.970571,121.951244,25.295459],"geometry":{"type":"Polygon","coordinates":[[[121.777818,24.394274],[121.175632,22.790857],[120.74708,21.970571],[120.220083,22.814861],[120.106189,23.556263],[120.69468,24.538451],[121.495044,25.295459],[121.951244,24.997596],[121.777818,24.394274]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"United Republic of Tanzania","SOV_A3":"TZA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"United Republic of Tanzania","ADM0_A3":"TZA","GEOU_DIF":0,"GEOUNIT":"Tanzania","GU_A3":"TZA","SU_DIF":0,"SUBUNIT":"Tanzania","SU_A3":"TZA","BRK_DIFF":0,"NAME":"Tanzania","NAME_LONG":"Tanzania","BRK_A3":"TZA","BRK_NAME":"Tanzania","BRK_GROUP":null,"ABBREV":"Tanz.","POSTAL":"TZ","FORMAL_EN":"United Republic of Tanzania","FORMAL_FR":null,"NAME_CIAWF":"Tanzania","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Tanzania","NAME_ALT":null,"MAPCOLOR7":3,"MAPCOLOR8":6,"MAPCOLOR9":2,"MAPCOLOR13":2,"POP_EST":53950935,"POP_RANK":16,"GDP_MD_EST":150600,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"TZ","ISO_A2":"TZ","ISO_A3":"TZA","ISO_A3_EH":"TZA","ISO_N3":"834","UN_A3":"834","WB_A2":"TZ","WB_A3":"TZA","WOE_ID":23424973,"WOE_ID_EH":23424973,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"TZA","ADM0_A3_US":"TZA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[29.339998,-11.720938,40.31659,-0.95],"geometry":{"type":"Polygon","coordinates":[[[29.339998,-4.499983],[29.753512,-4.452389],[30.11632,-4.09012],[30.50554,-3.56858],[30.75224,-3.35931],[30.74301,-3.03431],[30.52766,-2.80762],[30.469674,-2.413855],[30.46967,-2.41383],[30.758309,-2.28725],[30.816135,-1.698914],[30.419105,-1.134659],[30.76986,-1.01455],[31.86617,-1.02736],[33.903711,-0.95],[34.07262,-1.05982],[37.69869,-3.09699],[37.7669,-3.67712],[39.20222,-4.67677],[38.74054,-5.90895],[38.79977,-6.47566],[39.44,-6.84],[39.47,-7.1],[39.19469,-7.7039],[39.25203,-8.00781],[39.18652,-8.48551],[39.53574,-9.11237],[39.9496,-10.0984],[40.316586,-10.317098],[40.31659,-10.3171],[39.521,-10.89688],[38.427557,-11.285202],[37.82764,-11.26879],[37.47129,-11.56876],[36.775151,-11.594537],[36.514082,-11.720938],[35.312398,-11.439146],[34.559989,-11.52002],[34.28,-10.16],[33.940838,-9.693674],[33.73972,-9.41715],[32.759375,-9.230599],[32.191865,-8.930359],[31.556348,-8.762049],[31.157751,-8.594579],[30.74001,-8.340006],[30.740015,-8.340007],[30.199997,-7.079981],[29.620032,-6.520015],[29.419993,-5.939999],[29.519987,-5.419979],[29.339998,-4.499983]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Uganda","SOV_A3":"UGA","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Uganda","ADM0_A3":"UGA","GEOU_DIF":0,"GEOUNIT":"Uganda","GU_A3":"UGA","SU_DIF":0,"SUBUNIT":"Uganda","SU_A3":"UGA","BRK_DIFF":0,"NAME":"Uganda","NAME_LONG":"Uganda","BRK_A3":"UGA","BRK_NAME":"Uganda","BRK_GROUP":null,"ABBREV":"Uga.","POSTAL":"UG","FORMAL_EN":"Republic of Uganda","FORMAL_FR":null,"NAME_CIAWF":"Uganda","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Uganda","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":3,"MAPCOLOR9":6,"MAPCOLOR13":4,"POP_EST":39570125,"POP_RANK":15,"GDP_MD_EST":84930,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"UG","ISO_A2":"UG","ISO_A3":"UGA","ISO_A3_EH":"UGA","ISO_N3":"800","UN_A3":"800","WB_A2":"UG","WB_A3":"UGA","WOE_ID":23424974,"WOE_ID_EH":23424974,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"UGA","ADM0_A3_US":"UGA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[29.579466,-1.443322,35.03599,4.249885],"geometry":{"type":"Polygon","coordinates":[[[29.579466,-1.341313],[29.587838,-0.587406],[29.819503,-0.20531],[29.875779,0.59738],[30.086154,1.062313],[30.468508,1.583805],[30.85267,1.849396],[31.174149,2.204465],[30.773347,2.339883],[30.83386,3.509166],[30.833852,3.509172],[31.24556,3.7819],[31.88145,3.55827],[32.68642,3.79232],[33.39,3.79],[34.005,4.249885],[34.47913,3.5556],[34.59607,3.05374],[35.03599,1.90584],[34.6721,1.17694],[34.18,0.515],[33.893569,0.109814],[33.903711,-0.95],[31.86617,-1.02736],[30.76986,-1.01455],[30.419105,-1.134659],[29.821519,-1.443322],[29.579466,-1.341313]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Ukraine","SOV_A3":"UKR","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Ukraine","ADM0_A3":"UKR","GEOU_DIF":0,"GEOUNIT":"Ukraine","GU_A3":"UKR","SU_DIF":0,"SUBUNIT":"Ukraine","SU_A3":"UKR","BRK_DIFF":0,"NAME":"Ukraine","NAME_LONG":"Ukraine","BRK_A3":"UKR","BRK_NAME":"Ukraine","BRK_GROUP":null,"ABBREV":"Ukr.","POSTAL":"UA","FORMAL_EN":"Ukraine","FORMAL_FR":null,"NAME_CIAWF":"Ukraine","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Ukraine","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":1,"MAPCOLOR9":6,"MAPCOLOR13":3,"POP_EST":44033874,"POP_RANK":15,"GDP_MD_EST":352600,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"UP","ISO_A2":"UA","ISO_A3":"UKR","ISO_A3_EH":"UKR","ISO_N3":"804","UN_A3":"804","WB_A2":"UA","WB_A3":"UKR","WOE_ID":23424976,"WOE_ID_EH":23424976,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"UKR","ADM0_A3_US":"UKR","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Europe","REGION_UN":"Europe","SUBREGION":"Eastern Europe","REGION_WB":"Europe & Central Asia","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7},"bbox":[22.085608,45.293308,40.080789,52.335075],"geometry":{"type":"Polygon","coordinates":[[[23.527071,51.578454],[24.005078,51.617444],[24.553106,51.888461],[25.327788,51.910656],[26.337959,51.832289],[27.454066,51.592303],[28.241615,51.572227],[28.617613,51.427714],[28.992835,51.602044],[29.254938,51.368234],[30.157364,51.416138],[30.555117,51.319503],[30.619454,51.822806],[30.927549,52.042353],[31.785992,52.101678],[32.15944,52.06125],[32.412058,52.288695],[32.715761,52.238465],[33.7527,52.335075],[34.391731,51.768882],[34.141978,51.566413],[34.224816,51.255993],[35.022183,51.207572],[35.37791,50.77394],[35.356116,50.577197],[36.626168,50.225591],[37.39346,50.383953],[38.010631,49.915662],[38.594988,49.926462],[40.06904,49.60105],[40.080789,49.30743],[39.67465,48.78382],[39.89562,48.23241],[39.738278,47.898937],[38.77057,47.82562],[38.255112,47.5464],[38.223538,47.10219],[37.425137,47.022221],[36.759855,46.6987],[35.823685,46.645964],[34.962342,46.273197],[35.012659,45.737725],[34.861792,45.768182],[34.732017,45.965666],[34.410402,46.005162],[33.699462,46.219573],[33.435988,45.971917],[33.298567,46.080598],[31.74414,46.333348],[31.675307,46.706245],[30.748749,46.5831],[30.377609,46.03241],[29.603289,45.293308],[29.149725,45.464925],[28.679779,45.304031],[28.233554,45.488283],[28.485269,45.596907],[28.659987,45.939987],[28.933717,46.25883],[28.862972,46.437889],[29.072107,46.517678],[29.170654,46.379262],[29.759972,46.349988],[30.024659,46.423937],[29.83821,46.525326],[29.908852,46.674361],[29.559674,46.928583],[29.415135,47.346645],[29.050868,47.510227],[29.122698,47.849095],[28.670891,48.118149],[28.259547,48.155562],[27.522537,48.467119],[26.857824,48.368211],[26.619337,48.220726],[26.19745,48.220881],[25.945941,47.987149],[25.207743,47.891056],[24.866317,47.737526],[24.402056,47.981878],[23.760958,47.985598],[23.142236,48.096341],[22.710531,47.882194],[22.64082,48.15024],[22.085608,48.422264],[22.280842,48.825392],[22.558138,49.085738],[22.776419,49.027395],[22.51845,49.476774],[23.426508,50.308506],[23.922757,50.424881],[24.029986,50.705407],[23.527071,51.578454]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Uruguay","SOV_A3":"URY","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Uruguay","ADM0_A3":"URY","GEOU_DIF":0,"GEOUNIT":"Uruguay","GU_A3":"URY","SU_DIF":0,"SUBUNIT":"Uruguay","SU_A3":"URY","BRK_DIFF":0,"NAME":"Uruguay","NAME_LONG":"Uruguay","BRK_A3":"URY","BRK_NAME":"Uruguay","BRK_GROUP":null,"ABBREV":"Ury.","POSTAL":"UY","FORMAL_EN":"Oriental Republic of Uruguay","FORMAL_FR":null,"NAME_CIAWF":"Uruguay","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Uruguay","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":2,"MAPCOLOR9":2,"MAPCOLOR13":10,"POP_EST":3360148,"POP_RANK":12,"GDP_MD_EST":73250,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"UY","ISO_A2":"UY","ISO_A3":"URY","ISO_A3_EH":"URY","ISO_N3":"858","UN_A3":"858","WB_A2":"UY","WB_A3":"URY","WOE_ID":23424979,"WOE_ID_EH":23424979,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"URY","ADM0_A3_US":"URY","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[-58.427074,-34.952647,-53.209589,-30.109686],"geometry":{"type":"Polygon","coordinates":[[[-58.427074,-33.909454],[-58.349611,-33.263189],[-58.132648,-33.040567],[-58.14244,-32.044504],[-57.874937,-31.016556],[-57.625133,-30.216295],[-56.976026,-30.109686],[-55.973245,-30.883076],[-55.60151,-30.853879],[-54.572452,-31.494511],[-53.787952,-32.047243],[-53.209589,-32.727666],[-53.650544,-33.202004],[-53.373662,-33.768378],[-53.806426,-34.396815],[-54.935866,-34.952647],[-55.67409,-34.752659],[-56.215297,-34.859836],[-57.139685,-34.430456],[-57.817861,-34.462547],[-58.427074,-33.909454]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"United States of America","SOV_A3":"US1","ADM0_DIF":1,"LEVEL":2,"TYPE":"Country","ADMIN":"United States of America","ADM0_A3":"USA","GEOU_DIF":0,"GEOUNIT":"United States of America","GU_A3":"USA","SU_DIF":0,"SUBUNIT":"United States","SU_A3":"USA","BRK_DIFF":0,"NAME":"United States of America","NAME_LONG":"United States","BRK_A3":"USA","BRK_NAME":"United States","BRK_GROUP":null,"ABBREV":"U.S.A.","POSTAL":"US","FORMAL_EN":"United States of America","FORMAL_FR":null,"NAME_CIAWF":"United States","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"United States of America","NAME_ALT":null,"MAPCOLOR7":4,"MAPCOLOR8":5,"MAPCOLOR9":1,"MAPCOLOR13":1,"POP_EST":326625791,"POP_RANK":17,"GDP_MD_EST":18560000,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"1. Developed region: G7","INCOME_GRP":"1. High income: OECD","WIKIPEDIA":0,"FIPS_10_":"US","ISO_A2":"US","ISO_A3":"USA","ISO_A3_EH":"USA","ISO_N3":"840","UN_A3":"840","WB_A2":"US","WB_A3":"USA","WOE_ID":23424977,"WOE_ID_EH":23424977,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"USA","ADM0_A3_US":"USA","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"North America","REGION_UN":"Americas","SUBREGION":"Northern America","REGION_WB":"North America","NAME_LEN":24,"LONG_LEN":13,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":5.7},"bbox":[-171.791111,18.91619,-66.96466,71.357764],"geometry":{"type":"MultiPolygon","coordinates":[[[[-122.84,49],[-120,49],[-117.03121,49],[-116.04818,49],[-113,49],[-110.05,49],[-107.05,49],[-104.04826,48.99986],[-100.65,49],[-97.22872,49.0007],[-95.15907,49],[-95.15609,49.38425],[-94.81758,49.38905],[-94.64,48.84],[-94.32914,48.67074],[-93.63087,48.60926],[-92.61,48.45],[-91.64,48.14],[-90.83,48.27],[-89.6,48.01],[-89.272917,48.019808],[-88.378114,48.302918],[-87.439793,47.94],[-86.461991,47.553338],[-85.652363,47.220219],[-84.87608,46.900083],[-84.779238,46.637102],[-84.543749,46.538684],[-84.6049,46.4396],[-84.3367,46.40877],[-84.14212,46.512226],[-84.091851,46.275419],[-83.890765,46.116927],[-83.616131,46.116927],[-83.469551,45.994686],[-83.592851,45.816894],[-82.550925,45.347517],[-82.337763,44.44],[-82.137642,43.571088],[-82.43,42.98],[-82.9,42.43],[-83.12,42.08],[-83.142,41.975681],[-83.02981,41.832796],[-82.690089,41.675105],[-82.439278,41.675105],[-81.277747,42.209026],[-80.247448,42.3662],[-78.939362,42.863611],[-78.92,42.965],[-79.01,43.27],[-79.171674,43.466339],[-78.72028,43.625089],[-77.737885,43.629056],[-76.820034,43.628784],[-76.5,44.018459],[-76.375,44.09631],[-75.31821,44.81645],[-74.867,45.00048],[-73.34783,45.00738],[-71.50506,45.0082],[-71.405,45.255],[-71.08482,45.30524],[-70.66,45.46],[-70.305,45.915],[-69.99997,46.69307],[-69.237216,47.447781],[-68.905,47.185],[-68.23444,47.35486],[-67.79046,47.06636],[-67.79134,45.70281],[-67.13741,45.13753],[-66.96466,44.8097],[-68.03252,44.3252],[-69.06,43.98],[-70.11617,43.68405],[-70.645476,43.090238],[-70.81489,42.8653],[-70.825,42.335],[-70.495,41.805],[-70.08,41.78],[-70.185,42.145],[-69.88497,41.92283],[-69.96503,41.63717],[-70.64,41.475],[-71.12039,41.49445],[-71.86,41.32],[-72.295,41.27],[-72.87643,41.22065],[-73.71,40.931102],[-72.24126,41.11948],[-71.945,40.93],[-73.345,40.63],[-73.982,40.628],[-73.952325,40.75075],[-74.25671,40.47351],[-73.96244,40.42763],[-74.17838,39.70926],[-74.90604,38.93954],[-74.98041,39.1964],[-75.20002,39.24845],[-75.52805,39.4985],[-75.32,38.96],[-75.071835,38.782032],[-75.05673,38.40412],[-75.37747,38.01551],[-75.94023,37.21689],[-76.03127,37.2566],[-75.72205,37.93705],[-76.23287,38.319215],[-76.35,39.15],[-76.542725,38.717615],[-76.32933,38.08326],[-76.989998,38.239992],[-76.30162,37.917945],[-76.25874,36.9664],[-75.9718,36.89726],[-75.86804,36.55125],[-75.72749,35.55074],[-76.36318,34.80854],[-77.397635,34.51201],[-78.05496,33.92547],[-78.55435,33.86133],[-79.06067,33.49395],[-79.20357,33.15839],[-80.301325,32.509355],[-80.86498,32.0333],[-81.33629,31.44049],[-81.49042,30.72999],[-81.31371,30.03552],[-80.98,29.18],[-80.535585,28.47213],[-80.53,28.04],[-80.056539,26.88],[-80.088015,26.205765],[-80.13156,25.816775],[-80.38103,25.20616],[-80.68,25.08],[-81.17213,25.20126],[-81.33,25.64],[-81.71,25.87],[-82.24,26.73],[-82.70515,27.49504],[-82.85526,27.88624],[-82.65,28.55],[-82.93,29.1],[-83.70959,29.93656],[-84.1,30.09],[-85.10882,29.63615],[-85.28784,29.68612],[-85.7731,30.15261],[-86.4,30.4],[-87.53036,30.27433],[-88.41782,30.3849],[-89.18049,30.31598],[-89.593831,30.159994],[-89.413735,29.89419],[-89.43,29.48864],[-89.21767,29.29108],[-89.40823,29.15961],[-89.77928,29.30714],[-90.15463,29.11743],[-90.880225,29.148535],[-91.626785,29.677],[-92.49906,29.5523],[-93.22637,29.78375],[-93.84842,29.71363],[-94.69,29.48],[-95.60026,28.73863],[-96.59404,28.30748],[-97.14,27.83],[-97.37,27.38],[-97.38,26.69],[-97.33,26.21],[-97.14,25.87],[-97.53,25.84],[-98.24,26.06],[-99.02,26.37],[-99.3,26.84],[-99.52,27.54],[-100.11,28.11],[-100.45584,28.69612],[-100.9576,29.38071],[-101.6624,29.7793],[-102.48,29.76],[-103.11,28.97],[-103.94,29.27],[-104.45697,29.57196],[-104.70575,30.12173],[-105.03737,30.64402],[-105.63159,31.08383],[-106.1429,31.39995],[-106.50759,31.75452],[-108.24,31.754854],[-108.24194,31.34222],[-109.035,31.34194],[-111.02361,31.33472],[-113.30498,32.03914],[-114.815,32.52528],[-114.72139,32.72083],[-115.99135,32.61239],[-117.12776,32.53534],[-117.295938,33.046225],[-117.944,33.621236],[-118.410602,33.740909],[-118.519895,34.027782],[-119.081,34.078],[-119.438841,34.348477],[-120.36778,34.44711],[-120.62286,34.60855],[-120.74433,35.15686],[-121.71457,36.16153],[-122.54747,37.55176],[-122.51201,37.78339],[-122.95319,38.11371],[-123.7272,38.95166],[-123.86517,39.76699],[-124.39807,40.3132],[-124.17886,41.14202],[-124.2137,41.99964],[-124.53284,42.76599],[-124.14214,43.70838],[-124.020535,44.615895],[-123.89893,45.52341],[-124.079635,46.86475],[-124.39567,47.72017],[-124.68721,48.184433],[-124.566101,48.379715],[-123.12,48.04],[-122.58736,47.096],[-122.34,47.36],[-122.5,48.18],[-122.84,49]]],[[[-140.985988,69.711998],[-140.986,69.712],[-140.9925,66.00003],[-140.99778,60.30639],[-140.013,60.27682],[-139.039,60],[-138.34089,59.56211],[-137.4525,58.905],[-136.47972,59.46389],[-135.47583,59.78778],[-134.945,59.27056],[-134.27111,58.86111],[-133.35556,58.41028],[-132.73042,57.69289],[-131.70781,56.55212],[-130.00778,55.91583],[-129.98,55.285],[-130.53611,54.80278],[-130.536109,54.802754],[-130.53611,54.802753],[-131.085818,55.178906],[-131.967211,55.497776],[-132.250011,56.369996],[-133.539181,57.178887],[-134.078063,58.123068],[-135.038211,58.187715],[-136.628062,58.212209],[-137.800006,58.499995],[-139.867787,59.537762],[-140.825274,59.727517],[-142.574444,60.084447],[-143.958881,59.99918],[-145.925557,60.45861],[-147.114374,60.884656],[-148.224306,60.672989],[-148.018066,59.978329],[-148.570823,59.914173],[-149.727858,59.705658],[-150.608243,59.368211],[-151.716393,59.155821],[-151.859433,59.744984],[-151.409719,60.725803],[-150.346941,61.033588],[-150.621111,61.284425],[-151.895839,60.727198],[-152.57833,60.061657],[-154.019172,59.350279],[-153.287511,58.864728],[-154.232492,58.146374],[-155.307491,57.727795],[-156.308335,57.422774],[-156.556097,56.979985],[-158.117217,56.463608],[-158.433321,55.994154],[-159.603327,55.566686],[-160.28972,55.643581],[-161.223048,55.364735],[-162.237766,55.024187],[-163.069447,54.689737],[-164.785569,54.404173],[-164.942226,54.572225],[-163.84834,55.039431],[-162.870001,55.348043],[-161.804175,55.894986],[-160.563605,56.008055],[-160.07056,56.418055],[-158.684443,57.016675],[-158.461097,57.216921],[-157.72277,57.570001],[-157.550274,58.328326],[-157.041675,58.918885],[-158.194731,58.615802],[-158.517218,58.787781],[-159.058606,58.424186],[-159.711667,58.93139],[-159.981289,58.572549],[-160.355271,59.071123],[-161.355003,58.670838],[-161.968894,58.671665],[-162.054987,59.266925],[-161.874171,59.633621],[-162.518059,59.989724],[-163.818341,59.798056],[-164.662218,60.267484],[-165.346388,60.507496],[-165.350832,61.073895],[-166.121379,61.500019],[-165.734452,62.074997],[-164.919179,62.633076],[-164.562508,63.146378],[-163.753332,63.219449],[-163.067224,63.059459],[-162.260555,63.541936],[-161.53445,63.455817],[-160.772507,63.766108],[-160.958335,64.222799],[-161.518068,64.402788],[-160.777778,64.788604],[-161.391926,64.777235],[-162.45305,64.559445],[-162.757786,64.338605],[-163.546394,64.55916],[-164.96083,64.446945],[-166.425288,64.686672],[-166.845004,65.088896],[-168.11056,65.669997],[-166.705271,66.088318],[-164.47471,66.57666],[-163.652512,66.57666],[-163.788602,66.077207],[-161.677774,66.11612],[-162.489715,66.735565],[-163.719717,67.116395],[-164.430991,67.616338],[-165.390287,68.042772],[-166.764441,68.358877],[-166.204707,68.883031],[-164.430811,68.915535],[-163.168614,69.371115],[-162.930566,69.858062],[-161.908897,70.33333],[-160.934797,70.44769],[-159.039176,70.891642],[-158.119723,70.824721],[-156.580825,71.357764],[-155.06779,71.147776],[-154.344165,70.696409],[-153.900006,70.889989],[-152.210006,70.829992],[-152.270002,70.600006],[-150.739992,70.430017],[-149.720003,70.53001],[-147.613362,70.214035],[-145.68999,70.12001],[-144.920011,69.989992],[-143.589446,70.152514],[-142.07251,69.851938],[-140.985988,69.711998],[-140.985988,69.711998]]],[[[-155.54211,19.08348],[-155.68817,18.91619],[-155.93665,19.05939],[-155.90806,19.33888],[-156.07347,19.70294],[-156.02368,19.81422],[-155.85008,19.97729],[-155.91907,20.17395],[-155.86108,20.26721],[-155.78505,20.2487],[-155.40214,20.07975],[-155.22452,19.99302],[-155.06226,19.8591],[-154.80741,19.50871],[-154.83147,19.45328],[-155.22217,19.23972],[-155.54211,19.08348]]],[[[-156.07926,20.64397],[-156.41445,20.57241],[-156.58673,20.783],[-156.70167,20.8643],[-156.71055,20.92676],[-156.61258,21.01249],[-156.25711,20.91745],[-155.99566,20.76404],[-156.07926,20.64397]]],[[[-156.75824,21.17684],[-156.78933,21.06873],[-157.32521,21.09777],[-157.25027,21.21958],[-156.75824,21.17684]]],[[[-157.65283,21.32217],[-157.70703,21.26442],[-157.7786,21.27729],[-158.12667,21.31244],[-158.2538,21.53919],[-158.29265,21.57912],[-158.0252,21.71696],[-157.94161,21.65272],[-157.65283,21.32217]]],[[[-159.34512,21.982],[-159.46372,21.88299],[-159.80051,22.06533],[-159.74877,22.1382],[-159.5962,22.23618],[-159.36569,22.21494],[-159.34512,21.982]]],[[[-153.006314,57.115842],[-154.00509,56.734677],[-154.516403,56.992749],[-154.670993,57.461196],[-153.76278,57.816575],[-153.228729,57.968968],[-152.564791,57.901427],[-152.141147,57.591059],[-153.006314,57.115842]]],[[[-165.579164,59.909987],[-166.19277,59.754441],[-166.848337,59.941406],[-167.455277,60.213069],[-166.467792,60.38417],[-165.67443,60.293607],[-165.579164,59.909987]]],[[[-171.731657,63.782515],[-171.114434,63.592191],[-170.491112,63.694975],[-169.682505,63.431116],[-168.689439,63.297506],[-168.771941,63.188598],[-169.52944,62.976931],[-170.290556,63.194438],[-170.671386,63.375822],[-171.553063,63.317789],[-171.791111,63.405846],[-171.731657,63.782515]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Uzbekistan","SOV_A3":"UZB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Uzbekistan","ADM0_A3":"UZB","GEOU_DIF":0,"GEOUNIT":"Uzbekistan","GU_A3":"UZB","SU_DIF":0,"SUBUNIT":"Uzbekistan","SU_A3":"UZB","BRK_DIFF":0,"NAME":"Uzbekistan","NAME_LONG":"Uzbekistan","BRK_A3":"UZB","BRK_NAME":"Uzbekistan","BRK_GROUP":null,"ABBREV":"Uzb.","POSTAL":"UZ","FORMAL_EN":"Republic of Uzbekistan","FORMAL_FR":null,"NAME_CIAWF":"Uzbekistan","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Uzbekistan","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":3,"MAPCOLOR9":5,"MAPCOLOR13":4,"POP_EST":29748859,"POP_RANK":15,"GDP_MD_EST":202300,"POP_YEAR":2017,"LASTCENSUS":1989,"GDP_YEAR":2016,"ECONOMY":"6. Developing region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"UZ","ISO_A2":"UZ","ISO_A3":"UZB","ISO_A3_EH":"UZB","ISO_N3":"860","UN_A3":"860","WB_A2":"UZ","WB_A3":"UZB","WOE_ID":23424980,"WOE_ID_EH":23424980,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"UZB","ADM0_A3_US":"UZB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Central Asia","REGION_WB":"Europe & Central Asia","NAME_LEN":10,"LONG_LEN":10,"ABBREV_LEN":4,"TINY":5,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[55.928917,37.144994,73.055417,45.586804],"geometry":{"type":"Polygon","coordinates":[[[67.83,37.144994],[67.075782,37.356144],[66.518607,37.362784],[66.54615,37.974685],[65.215999,38.402695],[64.170223,38.892407],[63.518015,39.363257],[62.37426,40.053886],[61.882714,41.084857],[61.547179,41.26637],[60.465953,41.220327],[60.083341,41.425146],[59.976422,42.223082],[58.629011,42.751551],[57.78653,42.170553],[56.932215,41.826026],[57.096391,41.32231],[55.968191,41.308642],[55.928917,44.995858],[58.503127,45.586804],[58.689989,45.500014],[60.239972,44.784037],[61.05832,44.405817],[62.0133,43.504477],[63.185787,43.650075],[64.900824,43.728081],[66.098012,42.99766],[66.023392,41.994646],[66.510649,41.987644],[66.714047,41.168444],[67.985856,41.135991],[68.259896,40.662325],[68.632483,40.668681],[69.070027,41.384244],[70.388965,42.081308],[70.962315,42.266154],[71.259248,42.167711],[70.420022,41.519998],[71.157859,41.143587],[71.870115,41.3929],[73.055417,40.866033],[71.774875,40.145844],[71.014198,40.244366],[70.601407,40.218527],[70.45816,40.496495],[70.666622,40.960213],[69.329495,40.727824],[69.011633,40.086158],[68.536416,39.533453],[67.701429,39.580478],[67.44222,39.140144],[68.176025,38.901553],[68.392033,38.157025],[67.83,37.144994]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Venezuela","SOV_A3":"VEN","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Venezuela","ADM0_A3":"VEN","GEOU_DIF":0,"GEOUNIT":"Venezuela","GU_A3":"VEN","SU_DIF":0,"SUBUNIT":"Venezuela","SU_A3":"VEN","BRK_DIFF":0,"NAME":"Venezuela","NAME_LONG":"Venezuela","BRK_A3":"VEN","BRK_NAME":"Venezuela","BRK_GROUP":null,"ABBREV":"Ven.","POSTAL":"VE","FORMAL_EN":"Bolivarian Republic of Venezuela","FORMAL_FR":"República Bolivariana de Venezuela","NAME_CIAWF":"Venezuela","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Venezuela, RB","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":3,"MAPCOLOR9":1,"MAPCOLOR13":4,"POP_EST":31304016,"POP_RANK":15,"GDP_MD_EST":468600,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"VE","ISO_A2":"VE","ISO_A3":"VEN","ISO_A3_EH":"VEN","ISO_N3":"862","UN_A3":"862","WB_A2":"VE","WB_A3":"VEN","WOE_ID":23424982,"WOE_ID_EH":23424982,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"VEN","ADM0_A3_US":"VEN","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"South America","REGION_UN":"Americas","SUBREGION":"South America","REGION_WB":"Latin America & Caribbean","NAME_LEN":9,"LONG_LEN":9,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":7.5},"bbox":[-73.304952,0.724452,-59.758285,12.162307],"geometry":{"type":"Polygon","coordinates":[[[-60.733574,5.200277],[-60.601179,4.918098],[-60.966893,4.536468],[-62.08543,4.162124],[-62.804533,4.006965],[-63.093198,3.770571],[-63.888343,4.02053],[-64.628659,4.148481],[-64.816064,4.056445],[-64.368494,3.79721],[-64.408828,3.126786],[-64.269999,2.497006],[-63.422867,2.411068],[-63.368788,2.2009],[-64.083085,1.916369],[-64.199306,1.492855],[-64.611012,1.328731],[-65.354713,1.095282],[-65.548267,0.789254],[-66.325765,0.724452],[-66.876326,1.253361],[-67.181294,2.250638],[-67.447092,2.600281],[-67.809938,2.820655],[-67.303173,3.318454],[-67.337564,3.542342],[-67.621836,3.839482],[-67.823012,4.503937],[-67.744697,5.221129],[-67.521532,5.55687],[-67.34144,6.095468],[-67.695087,6.267318],[-68.265052,6.153268],[-68.985319,6.206805],[-69.38948,6.099861],[-70.093313,6.960376],[-70.674234,7.087785],[-71.960176,6.991615],[-72.198352,7.340431],[-72.444487,7.423785],[-72.479679,7.632506],[-72.360901,8.002638],[-72.439862,8.405275],[-72.660495,8.625288],[-72.78873,9.085027],[-73.304952,9.152],[-73.027604,9.73677],[-72.905286,10.450344],[-72.614658,10.821975],[-72.227575,11.108702],[-71.973922,11.608672],[-71.331584,11.776284],[-71.360006,11.539994],[-71.94705,11.423282],[-71.620868,10.96946],[-71.633064,10.446494],[-72.074174,9.865651],[-71.695644,9.072263],[-71.264559,9.137195],[-71.039999,9.859993],[-71.350084,10.211935],[-71.400623,10.968969],[-70.155299,11.375482],[-70.293843,11.846822],[-69.943245,12.162307],[-69.5843,11.459611],[-68.882999,11.443385],[-68.233271,10.885744],[-68.194127,10.554653],[-67.296249,10.545868],[-66.227864,10.648627],[-65.655238,10.200799],[-64.890452,10.077215],[-64.329479,10.389599],[-64.318007,10.641418],[-63.079322,10.701724],[-61.880946,10.715625],[-62.730119,10.420269],[-62.388512,9.948204],[-61.588767,9.873067],[-60.830597,9.38134],[-60.671252,8.580174],[-60.150096,8.602757],[-59.758285,8.367035],[-60.550588,7.779603],[-60.637973,7.415],[-60.295668,7.043911],[-60.543999,6.856584],[-61.159336,6.696077],[-61.139415,6.234297],[-61.410303,5.959068],[-60.733574,5.200277]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"Vietnam","SOV_A3":"VNM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Vietnam","ADM0_A3":"VNM","GEOU_DIF":0,"GEOUNIT":"Vietnam","GU_A3":"VNM","SU_DIF":0,"SUBUNIT":"Vietnam","SU_A3":"VNM","BRK_DIFF":0,"NAME":"Vietnam","NAME_LONG":"Vietnam","BRK_A3":"VNM","BRK_NAME":"Vietnam","BRK_GROUP":null,"ABBREV":"Viet.","POSTAL":"VN","FORMAL_EN":"Socialist Republic of Vietnam","FORMAL_FR":null,"NAME_CIAWF":"Vietnam","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Vietnam","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":6,"MAPCOLOR9":5,"MAPCOLOR13":4,"POP_EST":96160163,"POP_RANK":16,"GDP_MD_EST":594900,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"VM","ISO_A2":"VN","ISO_A3":"VNM","ISO_A3_EH":"VNM","ISO_N3":"704","UN_A3":"704","WB_A2":"VN","WB_A3":"VNM","WOE_ID":23424984,"WOE_ID_EH":23424984,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"VNM","ADM0_A3_US":"VNM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"South-Eastern Asia","REGION_WB":"East Asia & Pacific","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":5,"TINY":2,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":2,"MAX_LABEL":7},"bbox":[102.170436,8.59976,109.33527,23.352063],"geometry":{"type":"Polygon","coordinates":[[[102.170436,22.464753],[102.706992,22.708795],[103.504515,22.703757],[104.476858,22.81915],[105.329209,23.352063],[105.811247,22.976892],[106.725403,22.794268],[106.567273,22.218205],[107.04342,21.811899],[108.05018,21.55238],[106.715068,20.696851],[105.881682,19.75205],[105.662006,19.058165],[106.426817,18.004121],[107.361954,16.697457],[108.269495,16.079742],[108.877107,15.276691],[109.33527,13.426028],[109.200136,11.666859],[108.36613,11.008321],[107.220929,10.364484],[106.405113,9.53084],[105.158264,8.59976],[104.795185,9.241038],[105.076202,9.918491],[104.334335,10.486544],[105.199915,10.88931],[106.24967,10.961812],[105.810524,11.567615],[107.491403,12.337206],[107.614548,13.535531],[107.382727,14.202441],[107.564525,15.202173],[107.312706,15.908538],[106.556008,16.604284],[105.925762,17.485315],[105.094598,18.666975],[103.896532,19.265181],[104.183388,19.624668],[104.822574,19.886642],[104.435,20.758733],[103.203861,20.766562],[102.754896,21.675137],[102.170436,22.464753]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":4,"SOVEREIGNT":"Vanuatu","SOV_A3":"VUT","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Vanuatu","ADM0_A3":"VUT","GEOU_DIF":0,"GEOUNIT":"Vanuatu","GU_A3":"VUT","SU_DIF":0,"SUBUNIT":"Vanuatu","SU_A3":"VUT","BRK_DIFF":0,"NAME":"Vanuatu","NAME_LONG":"Vanuatu","BRK_A3":"VUT","BRK_NAME":"Vanuatu","BRK_GROUP":null,"ABBREV":"Van.","POSTAL":"VU","FORMAL_EN":"Republic of Vanuatu","FORMAL_FR":null,"NAME_CIAWF":"Vanuatu","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Vanuatu","NAME_ALT":null,"MAPCOLOR7":6,"MAPCOLOR8":3,"MAPCOLOR9":7,"MAPCOLOR13":3,"POP_EST":282814,"POP_RANK":10,"GDP_MD_EST":723,"POP_YEAR":2017,"LASTCENSUS":2009,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"NH","ISO_A2":"VU","ISO_A3":"VUT","ISO_A3_EH":"VUT","ISO_N3":"548","UN_A3":"548","WB_A2":"VU","WB_A3":"VUT","WOE_ID":23424907,"WOE_ID_EH":23424907,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"VUT","ADM0_A3_US":"VUT","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Oceania","REGION_UN":"Oceania","SUBREGION":"Melanesia","REGION_WB":"East Asia & Pacific","NAME_LEN":7,"LONG_LEN":7,"ABBREV_LEN":4,"TINY":2,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":4,"MAX_LABEL":9},"bbox":[166.629137,-16.59785,167.844877,-14.626497],"geometry":{"type":"MultiPolygon","coordinates":[[[[167.844877,-16.466333],[167.515181,-16.59785],[167.180008,-16.159995],[167.216801,-15.891846],[167.844877,-16.466333]]],[[[167.107712,-14.93392],[167.270028,-15.740021],[167.001207,-15.614602],[166.793158,-15.668811],[166.649859,-15.392704],[166.629137,-14.626497],[167.107712,-14.93392]]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Yemen","SOV_A3":"YEM","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Yemen","ADM0_A3":"YEM","GEOU_DIF":0,"GEOUNIT":"Yemen","GU_A3":"YEM","SU_DIF":0,"SUBUNIT":"Yemen","SU_A3":"YEM","BRK_DIFF":0,"NAME":"Yemen","NAME_LONG":"Yemen","BRK_A3":"YEM","BRK_NAME":"Yemen","BRK_GROUP":null,"ABBREV":"Yem.","POSTAL":"YE","FORMAL_EN":"Republic of Yemen","FORMAL_FR":null,"NAME_CIAWF":"Yemen","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Yemen, Rep.","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":3,"MAPCOLOR9":3,"MAPCOLOR13":11,"POP_EST":28036829,"POP_RANK":15,"GDP_MD_EST":73450,"POP_YEAR":2017,"LASTCENSUS":2004,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"YM","ISO_A2":"YE","ISO_A3":"YEM","ISO_A3_EH":"YEM","ISO_N3":"887","UN_A3":"887","WB_A2":"RY","WB_A3":"YEM","WOE_ID":23425002,"WOE_ID_EH":23425002,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"YEM","ADM0_A3_US":"YEM","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Asia","REGION_UN":"Asia","SUBREGION":"Western Asia","REGION_WB":"Middle East & North Africa","NAME_LEN":5,"LONG_LEN":5,"ABBREV_LEN":4,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[42.604873,12.58595,53.108573,19.000003],"geometry":{"type":"Polygon","coordinates":[[[52.00001,19.000003],[52.782184,17.349742],[53.108573,16.651051],[52.385206,16.382411],[52.191729,15.938433],[52.168165,15.59742],[51.172515,15.17525],[49.574576,14.708767],[48.679231,14.003202],[48.238947,13.94809],[47.938914,14.007233],[47.354454,13.59222],[46.717076,13.399699],[45.877593,13.347764],[45.62505,13.290946],[45.406459,13.026905],[45.144356,12.953938],[44.989533,12.699587],[44.494576,12.721653],[44.175113,12.58595],[43.482959,12.6368],[43.222871,13.22095],[43.251448,13.767584],[43.087944,14.06263],[42.892245,14.802249],[42.604873,15.213335],[42.805015,15.261963],[42.702438,15.718886],[42.823671,15.911742],[42.779332,16.347891],[43.218375,16.66689],[43.115798,17.08844],[43.380794,17.579987],[43.791519,17.319977],[44.062613,17.410359],[45.216651,17.433329],[45.399999,17.333335],[46.366659,17.233315],[46.749994,17.283338],[47.000005,16.949999],[47.466695,17.116682],[48.183344,18.166669],[49.116672,18.616668],[52.00001,19.000003]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":2,"SOVEREIGNT":"South Africa","SOV_A3":"ZAF","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"South Africa","ADM0_A3":"ZAF","GEOU_DIF":0,"GEOUNIT":"South Africa","GU_A3":"ZAF","SU_DIF":0,"SUBUNIT":"South Africa","SU_A3":"ZAF","BRK_DIFF":0,"NAME":"South Africa","NAME_LONG":"South Africa","BRK_A3":"ZAF","BRK_NAME":"South Africa","BRK_GROUP":null,"ABBREV":"S.Af.","POSTAL":"ZA","FORMAL_EN":"Republic of South Africa","FORMAL_FR":null,"NAME_CIAWF":"South Africa","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"South Africa","NAME_ALT":null,"MAPCOLOR7":2,"MAPCOLOR8":3,"MAPCOLOR9":4,"MAPCOLOR13":2,"POP_EST":54841552,"POP_RANK":16,"GDP_MD_EST":739100,"POP_YEAR":2017,"LASTCENSUS":2001,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"3. Upper middle income","WIKIPEDIA":-99,"FIPS_10_":"SF","ISO_A2":"ZA","ISO_A3":"ZAF","ISO_A3_EH":"ZAF","ISO_N3":"710","UN_A3":"710","WB_A2":"ZA","WB_A3":"ZAF","WOE_ID":23424942,"WOE_ID_EH":23424942,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ZAF","ADM0_A3_US":"ZAF","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Southern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":12,"LONG_LEN":12,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":1.7,"MAX_LABEL":6.7},"bbox":[16.344977,-34.819166,32.83012,-22.091313],"geometry":{"type":"Polygon","coordinates":[[[19.895768,-24.76779],[20.165726,-24.917962],[20.758609,-25.868136],[20.66647,-26.477453],[20.889609,-26.828543],[21.605896,-26.726534],[22.105969,-26.280256],[22.579532,-25.979448],[22.824271,-25.500459],[23.312097,-25.26869],[23.73357,-25.390129],[24.211267,-25.670216],[25.025171,-25.71967],[25.664666,-25.486816],[25.765849,-25.174845],[25.941652,-24.696373],[26.485753,-24.616327],[26.786407,-24.240691],[27.11941,-23.574323],[28.017236,-22.827754],[29.432188,-22.091313],[29.839037,-22.102216],[30.322883,-22.271612],[30.659865,-22.151567],[31.191409,-22.25151],[31.670398,-23.658969],[31.930589,-24.369417],[31.752408,-25.484284],[31.837778,-25.843332],[31.333158,-25.660191],[31.04408,-25.731452],[30.949667,-26.022649],[30.676609,-26.398078],[30.685962,-26.743845],[31.282773,-27.285879],[31.86806,-27.177927],[32.071665,-26.73382],[32.83012,-26.742192],[32.580265,-27.470158],[32.462133,-28.301011],[32.203389,-28.752405],[31.521001,-29.257387],[31.325561,-29.401978],[30.901763,-29.909957],[30.622813,-30.423776],[30.055716,-31.140269],[28.925553,-32.172041],[28.219756,-32.771953],[27.464608,-33.226964],[26.419452,-33.61495],[25.909664,-33.66704],[25.780628,-33.944646],[25.172862,-33.796851],[24.677853,-33.987176],[23.594043,-33.794474],[22.988189,-33.916431],[22.574157,-33.864083],[21.542799,-34.258839],[20.689053,-34.417175],[20.071261,-34.795137],[19.616405,-34.819166],[19.193278,-34.462599],[18.855315,-34.444306],[18.424643,-33.997873],[18.377411,-34.136521],[18.244499,-33.867752],[18.25008,-33.281431],[17.92519,-32.611291],[18.24791,-32.429131],[18.221762,-31.661633],[17.566918,-30.725721],[17.064416,-29.878641],[17.062918,-29.875954],[16.344977,-28.576705],[16.824017,-28.082162],[17.218929,-28.355943],[17.387497,-28.783514],[17.836152,-28.856378],[18.464899,-29.045462],[19.002127,-28.972443],[19.894734,-28.461105],[19.895768,-24.76779]],[[28.978263,-28.955597],[28.5417,-28.647502],[28.074338,-28.851469],[27.532511,-29.242711],[26.999262,-29.875954],[27.749397,-30.645106],[28.107205,-30.545732],[28.291069,-30.226217],[28.8484,-30.070051],[29.018415,-29.743766],[29.325166,-29.257387],[28.978263,-28.955597]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Zambia","SOV_A3":"ZMB","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Zambia","ADM0_A3":"ZMB","GEOU_DIF":0,"GEOUNIT":"Zambia","GU_A3":"ZMB","SU_DIF":0,"SUBUNIT":"Zambia","SU_A3":"ZMB","BRK_DIFF":0,"NAME":"Zambia","NAME_LONG":"Zambia","BRK_A3":"ZMB","BRK_NAME":"Zambia","BRK_GROUP":null,"ABBREV":"Zambia","POSTAL":"ZM","FORMAL_EN":"Republic of Zambia","FORMAL_FR":null,"NAME_CIAWF":"Zambia","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Zambia","NAME_ALT":null,"MAPCOLOR7":5,"MAPCOLOR8":8,"MAPCOLOR9":5,"MAPCOLOR13":13,"POP_EST":15972000,"POP_RANK":14,"GDP_MD_EST":65170,"POP_YEAR":2017,"LASTCENSUS":2010,"GDP_YEAR":2016,"ECONOMY":"7. Least developed region","INCOME_GRP":"4. Lower middle income","WIKIPEDIA":-99,"FIPS_10_":"ZA","ISO_A2":"ZM","ISO_A3":"ZMB","ISO_A3_EH":"ZMB","ISO_N3":"894","UN_A3":"894","WB_A2":"ZM","WB_A3":"ZMB","WOE_ID":23425003,"WOE_ID_EH":23425003,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ZMB","ADM0_A3_US":"ZMB","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":6,"LONG_LEN":6,"ABBREV_LEN":6,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[21.887843,-17.961229,33.485688,-8.238257],"geometry":{"type":"Polygon","coordinates":[[[23.215048,-17.523116],[22.562478,-16.898451],[21.887843,-16.08031],[21.933886,-12.898437],[24.016137,-12.911046],[23.930922,-12.565848],[24.079905,-12.191297],[23.904154,-11.722282],[24.017894,-11.237298],[23.912215,-10.926826],[24.257155,-10.951993],[24.314516,-11.262826],[24.78317,-11.238694],[25.418118,-11.330936],[25.75231,-11.784965],[26.553088,-11.92444],[27.16442,-11.608748],[27.388799,-12.132747],[28.155109,-12.272481],[28.523562,-12.698604],[28.934286,-13.248958],[29.699614,-13.257227],[29.616001,-12.178895],[29.341548,-12.360744],[28.642417,-11.971569],[28.372253,-11.793647],[28.49607,-10.789884],[28.673682,-9.605925],[28.449871,-9.164918],[28.734867,-8.526559],[29.002912,-8.407032],[30.346086,-8.238257],[30.74001,-8.340006],[31.157751,-8.594579],[31.556348,-8.762049],[32.191865,-8.930359],[32.759375,-9.230599],[33.231388,-9.676722],[33.485688,-10.525559],[33.31531,-10.79655],[33.114289,-11.607198],[33.306422,-12.435778],[32.991764,-12.783871],[32.688165,-13.712858],[33.214025,-13.97186],[30.179481,-14.796099],[30.274256,-15.507787],[29.516834,-15.644678],[28.947463,-16.043051],[28.825869,-16.389749],[28.467906,-16.4684],[27.598243,-17.290831],[27.044427,-17.938026],[26.706773,-17.961229],[26.381935,-17.846042],[25.264226,-17.73654],[25.084443,-17.661816],[25.07695,-17.578823],[24.682349,-17.353411],[24.033862,-17.295843],[23.215048,-17.523116]]]}},{"type":"Feature","properties":{"scalerank":1,"featurecla":"Admin-0 country","LABELRANK":3,"SOVEREIGNT":"Zimbabwe","SOV_A3":"ZWE","ADM0_DIF":0,"LEVEL":2,"TYPE":"Sovereign country","ADMIN":"Zimbabwe","ADM0_A3":"ZWE","GEOU_DIF":0,"GEOUNIT":"Zimbabwe","GU_A3":"ZWE","SU_DIF":0,"SUBUNIT":"Zimbabwe","SU_A3":"ZWE","BRK_DIFF":0,"NAME":"Zimbabwe","NAME_LONG":"Zimbabwe","BRK_A3":"ZWE","BRK_NAME":"Zimbabwe","BRK_GROUP":null,"ABBREV":"Zimb.","POSTAL":"ZW","FORMAL_EN":"Republic of Zimbabwe","FORMAL_FR":null,"NAME_CIAWF":"Zimbabwe","NOTE_ADM0":null,"NOTE_BRK":null,"NAME_SORT":"Zimbabwe","NAME_ALT":null,"MAPCOLOR7":1,"MAPCOLOR8":5,"MAPCOLOR9":3,"MAPCOLOR13":9,"POP_EST":13805084,"POP_RANK":14,"GDP_MD_EST":28330,"POP_YEAR":2017,"LASTCENSUS":2002,"GDP_YEAR":2016,"ECONOMY":"5. Emerging region: G20","INCOME_GRP":"5. Low income","WIKIPEDIA":-99,"FIPS_10_":"ZI","ISO_A2":"ZW","ISO_A3":"ZWE","ISO_A3_EH":"ZWE","ISO_N3":"716","UN_A3":"716","WB_A2":"ZW","WB_A3":"ZWE","WOE_ID":23425004,"WOE_ID_EH":23425004,"WOE_NOTE":"Exact WOE match as country","ADM0_A3_IS":"ZWE","ADM0_A3_US":"ZWE","ADM0_A3_UN":-99,"ADM0_A3_WB":-99,"CONTINENT":"Africa","REGION_UN":"Africa","SUBREGION":"Eastern Africa","REGION_WB":"Sub-Saharan Africa","NAME_LEN":8,"LONG_LEN":8,"ABBREV_LEN":5,"TINY":-99,"HOMEPART":1,"MIN_ZOOM":0,"MIN_LABEL":3,"MAX_LABEL":8},"bbox":[25.264226,-22.271612,32.849861,-15.507787],"geometry":{"type":"Polygon","coordinates":[[[29.432188,-22.091313],[28.794656,-21.639454],[28.02137,-21.485975],[27.727228,-20.851802],[27.724747,-20.499059],[27.296505,-20.39152],[26.164791,-19.293086],[25.850391,-18.714413],[25.649163,-18.536026],[25.264226,-17.73654],[26.381935,-17.846042],[26.706773,-17.961229],[27.044427,-17.938026],[27.598243,-17.290831],[28.467906,-16.4684],[28.825869,-16.389749],[28.947463,-16.043051],[29.516834,-15.644678],[30.274256,-15.507787],[30.338955,-15.880839],[31.173064,-15.860944],[31.636498,-16.07199],[31.852041,-16.319417],[32.328239,-16.392074],[32.847639,-16.713398],[32.849861,-17.979057],[32.654886,-18.67209],[32.611994,-19.419383],[32.772708,-19.715592],[32.659743,-20.30429],[32.508693,-20.395292],[32.244988,-21.116489],[31.191409,-22.25151],[30.659865,-22.151567],[30.322883,-22.271612],[29.839037,-22.102216],[29.432188,-22.091313]]]}}],"bbox":[-180,-90,180,83.64513]} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.tsx index f308606f..838142f9 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/globe/Globe.tsx @@ -14,16 +14,49 @@ const RING_COLOR = '#e5e5e5' const RING_MAX_RADIUS = 4 const RING_PROPAGATION_SPEED = 4 const RING_REPEAT_PERIOD = 0 +const COUNTRIES_URL = '/world-countries-110m.geo.json' +const COUNTRY_OUTLINE_COLOR = '#404040' +const COUNTRY_FILL_TRANSPARENT = 'rgba(0,0,0,0)' +const COUNTRY_ALTITUDE = 0.005 + +interface CountryFeature { + type: 'Feature' + properties: Record + geometry: { + type: string + coordinates: unknown + } +} + +interface CountriesData { + type: 'FeatureCollection' + features: CountryFeature[] +} export const Globe = memo(function Globe(): React.ReactElement { const wrapRef = useRef(null) const globeRef = useRef(undefined) const [size, setSize] = useState({ w: 0, h: 0 }) + const [countries, setCountries] = useState([]) const points = useGlobePoints() const rings = useGlobeRings() const focusEvent = useGlobeEvents((s) => s.focusEvent) + useEffect(() => { + let cancelled = false + fetch(COUNTRIES_URL) + .then((res) => res.json() as Promise) + .then((fc) => { + if (cancelled) return + setCountries(fc.features) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + useEffect(() => { const wrap = wrapRef.current if (!wrap) return @@ -58,6 +91,12 @@ export const Globe = memo(function Globe(): React.ReactElement { ref={globeRef} width={size.w} height={size.h} + polygonsData={countries} + polygonAltitude={COUNTRY_ALTITUDE} + polygonCapColor={() => COUNTRY_FILL_TRANSPARENT} + polygonSideColor={() => COUNTRY_FILL_TRANSPARENT} + polygonStrokeColor={() => COUNTRY_OUTLINE_COLOR} + polygonsTransitionDuration={0} pointsData={points} pointsMerge pointLat="lat" From 0a41102e753d45efc2abb26e2d504698acadbbbf Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 17:56:50 -0400 Subject: [PATCH 097/109] fix(monitor/frontend): globe snapshot seed + pushPoint dedup-by-id (instant eq+iss dots, single moving iss) --- .../frontend/src/pages/dashboard/lifecycle.ts | 45 ++++++++++++++++++- .../frontend/src/stores/globeEvents.ts | 7 +-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts index df0b215e..20cc1092 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts @@ -3,7 +3,7 @@ import { type QueryClient, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef } from 'react' -import { SNAPSHOT_KEY, useSnapshot } from '@/api/snapshot' +import { SNAPSHOT_KEY, type Snapshot, useSnapshot } from '@/api/snapshot' import { browserDriver, createDashboardWS, type WSEvent } from '@/api/ws' import { unlockOnFirstGesture } from '@/lib/audio' import { type CveEvent, useCveStore } from '@/stores/cve' @@ -71,11 +71,18 @@ export function useDashboardLifecycle(): void { const { data: snapshot, isSuccess } = useSnapshot() const queryClient = useQueryClient() const wsRef = useRef | null>(null) + const globeSeededRef = useRef(false) useEffect(() => { unlockOnFirstGesture() }, []) + useEffect(() => { + if (!snapshot || globeSeededRef.current) return + globeSeededRef.current = true + seedGlobeFromSnapshot(snapshot) + }, [snapshot]) + useEffect(() => { if (!isSuccess) return @@ -236,3 +243,39 @@ function mergeIntoSnapshot( [topic]: data, })) } + +function seedGlobeFromSnapshot(snap: Snapshot): void { + const eq = snap.earthquake as EarthquakePayload | undefined + if (eq) { + const coords = eq.geometry?.coordinates + if (Array.isArray(coords)) { + const lng = coords[0] + const lat = coords[1] + if (typeof lat === 'number' && typeof lng === 'number') { + useGlobeEvents.getState().pushPoint({ + id: `eq-${eq.id}`, + type: 'earthquake', + lat, + lng, + emittedAt: Date.now(), + meta: eq.properties, + }) + } + } + } + + const iss = snap.iss_position as IssPositionPayload | undefined + if ( + iss && + typeof iss.latitude === 'number' && + typeof iss.longitude === 'number' + ) { + useGlobeEvents.getState().pushPoint({ + id: 'iss-current', + type: 'iss', + lat: iss.latitude, + lng: iss.longitude, + emittedAt: Date.now(), + }) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts index 807376a2..7dc5f16b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/stores/globeEvents.ts @@ -46,9 +46,10 @@ export const useGlobeEvents = create((set) => ({ rings: [], focusEvent: null, pushPoint: (p) => - set((s) => ({ - points: [...s.points.slice(-POINT_CAP + 1), p], - })), + set((s) => { + const filtered = s.points.filter((existing) => existing.id !== p.id) + return { points: [p, ...filtered].slice(0, POINT_CAP) } + }), pushRing: (r) => set((s) => ({ rings: [...s.rings, r], From e690b30e9241e3b719a111145f820eede1dfa62f Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 18:11:15 -0400 Subject: [PATCH 098/109] feat(monitor/frontend): country-centroid plotting for ransomware + internet_outage on globe --- .../frontend/src/lib/countryCentroids.ts | 194 ++++++++++++++++++ .../frontend/src/pages/dashboard/lifecycle.ts | 64 +++++- 2 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts new file mode 100644 index 00000000..2104b40c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts @@ -0,0 +1,194 @@ +// ©AngelaMos | 2026 +// countryCentroids.ts + +export interface Centroid { + lat: number + lng: number +} + +const ISO_ALIASES: Record = { + UK: 'GB', +} + +export const COUNTRY_CENTROIDS: Record = { + AE: { lat: 24.2762, lng: 53.9882 }, + AF: { lat: 33.9024, lng: 67.8432 }, + AL: { lat: 41.1566, lng: 20.1623 }, + AM: { lat: 39.9947, lng: 45.0442 }, + AO: { lat: -11.8974, lng: 17.86 }, + AQ: { lat: -76.6353, lng: 0.0 }, + AR: { lat: -37.0911, lng: -63.5219 }, + AT: { lat: 47.7354, lng: 13.2298 }, + AU: { lat: -24.852, lng: 133.4542 }, + AZ: { lat: 40.0655, lng: 47.6827 }, + BA: { lat: 43.9419, lng: 17.6749 }, + BD: { lat: 23.5587, lng: 90.3786 }, + BE: { lat: 50.5023, lng: 4.3351 }, + BF: { lat: 12.3635, lng: -1.6467 }, + BG: { lat: 42.7347, lng: 25.4693 }, + BI: { lat: -3.4242, lng: 29.8886 }, + BJ: { lat: 9.1889, lng: 2.2847 }, + BN: { lat: 4.7277, lng: 114.8274 }, + BO: { lat: -16.3175, lng: -63.5444 }, + BR: { lat: -14.2619, lng: -54.3586 }, + BS: { lat: 24.4601, lng: -77.9716 }, + BT: { lat: 27.5079, lng: 90.459 }, + BW: { lat: -22.2452, lng: 24.6638 }, + BY: { lat: 53.7443, lng: 27.9466 }, + BZ: { lat: 17.1935, lng: -88.668 }, + CA: { lat: 56.7978, lng: -98.3406 }, + CD: { lat: -4.0006, lng: 21.6782 }, + CF: { lat: 6.705, lng: 20.9168 }, + CG: { lat: -0.6549, lng: 14.7734 }, + CH: { lat: 46.8039, lng: 8.2327 }, + CI: { lat: 7.4312, lng: -5.5825 }, + CL: { lat: -35.7182, lng: -71.3148 }, + CM: { lat: 7.2935, lng: 12.2508 }, + CN: { lat: 36.8706, lng: 104.3508 }, + CO: { lat: 4.0696, lng: -72.9336 }, + CR: { lat: 9.7211, lng: -84.244 }, + CU: { lat: 21.522, lng: -79.5765 }, + CY: { lat: 34.8725, lng: 33.1308 }, + CZ: { lat: 49.8363, lng: 15.5466 }, + DE: { lat: 51.1428, lng: 10.5028 }, + DJ: { lat: 11.8133, lng: 42.4898 }, + DK: { lat: 56.2804, lng: 9.5011 }, + DO: { lat: 18.7417, lng: -70.1315 }, + DZ: { lat: 28.0879, lng: 1.6576 }, + EC: { lat: -1.7891, lng: -78.1007 }, + EE: { lat: 58.5428, lng: 25.7357 }, + EG: { lat: 26.7928, lng: 30.7831 }, + EH: { lat: 24.3281, lng: -12.8643 }, + ER: { lat: 15.2269, lng: 39.7022 }, + ES: { lat: 39.8476, lng: -3.1767 }, + ET: { lat: 9.1907, lng: 40.3718 }, + FI: { lat: 65.0053, lng: 26.0808 }, + FJ: { lat: -17.814, lng: 178.0016 }, + FK: { lat: -51.7, lng: -59.475 }, + FR: { lat: 46.7459, lng: 1.7535 }, + GA: { lat: -0.826, lng: 11.6117 }, + GB: { lat: 54.2975, lng: -2.2342 }, + GE: { lat: 42.3088, lng: 43.2965 }, + GH: { lat: 7.9044, lng: -1.0921 }, + GL: { lat: 71.8409, lng: -42.7528 }, + GM: { lat: 13.5034, lng: -15.3432 }, + GN: { lat: 9.9476, lng: -11.4812 }, + GQ: { lat: 1.647, lng: 10.2953 }, + GR: { lat: 39.1185, lng: 23.3771 }, + GT: { lat: 15.7773, lng: -90.2271 }, + GW: { lat: 11.8343, lng: -15.189 }, + GY: { lat: 4.8176, lng: -58.9748 }, + HN: { lat: 14.495, lng: -86.2503 }, + HR: { lat: 44.4919, lng: 16.5237 }, + HT: { lat: 18.9733, lng: -73.0415 }, + HU: { lat: 47.1917, lng: 19.4564 }, + ID: { lat: -2.0151, lng: 122.0041 }, + IE: { lat: 53.4005, lng: -8.005 }, + IL: { lat: 31.3894, lng: 35.0509 }, + IN: { lat: 21.7298, lng: 82.7896 }, + IQ: { lat: 33.2421, lng: 43.6802 }, + IR: { lat: 32.3956, lng: 53.7129 }, + IS: { lat: 65.0116, lng: -18.968 }, + IT: { lat: 42.5121, lng: 12.6151 }, + JM: { lat: 18.1127, lng: -77.2687 }, + JO: { lat: 31.2881, lng: 37.059 }, + JP: { lat: 36.2041, lng: 135.6614 }, + KE: { lat: 0.4146, lng: 37.8743 }, + KG: { lat: 41.2889, lng: 74.8624 }, + KH: { lat: 12.5286, lng: 104.9813 }, + KP: { lat: 40.3272, lng: 127.5228 }, + KR: { lat: 36.5011, lng: 127.7929 }, + KW: { lat: 29.2926, lng: 47.4924 }, + KZ: { lat: 48.0238, lng: 66.9132 }, + LA: { lat: 18.1729, lng: 103.8403 }, + LB: { lat: 33.867, lng: 35.8689 }, + LK: { lat: 7.8962, lng: 80.7416 }, + LR: { lat: 6.4484, lng: -9.4892 }, + LS: { lat: -29.6463, lng: 28.1622 }, + LT: { lat: 55.1391, lng: 23.822 }, + LU: { lat: 49.7854, lng: 5.9584 }, + LV: { lat: 56.7926, lng: 24.6163 }, + LY: { lat: 26.3587, lng: 17.2421 }, + MA: { lat: 28.5904, lng: -9.0725 }, + MD: { lat: 46.9777, lng: 28.322 }, + ME: { lat: 42.7007, lng: 19.3949 }, + MG: { lat: -18.821, lng: 46.8654 }, + MK: { lat: 41.5815, lng: 21.7078 }, + ML: { lat: 17.5355, lng: -3.9503 }, + MM: { lat: 19.1345, lng: 96.7416 }, + MN: { lat: 46.8224, lng: 103.762 }, + MR: { lat: 21.0063, lng: -10.9934 }, + MW: { lat: -13.0159, lng: 34.23 }, + MX: { lat: 23.6298, lng: -101.9699 }, + MY: { lat: 3.8506, lng: 114.4226 }, + MZ: { lat: -18.5296, lng: 35.4775 }, + NA: { lat: -22.9934, lng: 18.4093 }, + NC: { lat: -21.2528, lng: 165.5748 }, + NE: { lat: 17.5659, lng: 8.0994 }, + NG: { lat: 9.0533, lng: 8.6344 }, + NI: { lat: 12.8716, lng: -85.4079 }, + NL: { lat: 52.1571, lng: 5.2035 }, + NP: { lat: 28.4103, lng: 84.1316 }, + NZ: { lat: -38.0695, lng: 175.5765 }, + OM: { lat: 20.7879, lng: 55.904 }, + PA: { lat: 8.4161, lng: -80.1042 }, + PE: { lat: -9.2026, lng: -75.038 }, + PG: { lat: -6.6263, lng: 145.9009 }, + PH: { lat: 15.521, lng: 122.0325 }, + PK: { lat: 30.4125, lng: 69.3558 }, + PL: { lat: 51.9395, lng: 19.0523 }, + PR: { lat: 18.2336, lng: -66.4167 }, + PS: { lat: 31.943, lng: 35.2365 }, + PT: { lat: 39.5594, lng: -7.9578 }, + PY: { lat: -23.4456, lng: -58.489 }, + QA: { lat: 25.3355, lng: 51.1753 }, + RO: { lat: 45.9547, lng: 24.9234 }, + RS: { lat: 44.2085, lng: 20.9079 }, + RU: { lat: 59.4247, lng: 103.6441 }, + RW: { lat: -2.0263, lng: 29.9205 }, + SA: { lat: 24.2544, lng: 45.1495 }, + SB: { lat: -7.9292, lng: 159.0643 }, + SD: { lat: 15.1146, lng: 30.1734 }, + SE: { lat: 62.234, lng: 17.4654 }, + SI: { lat: 46.1524, lng: 15.1315 }, + SK: { lat: 48.665, lng: 19.7191 }, + SL: { lat: 8.4165, lng: -11.7383 }, + SN: { lat: 14.4652, lng: -14.5465 }, + SO: { lat: 5.1707, lng: 46.0575 }, + SR: { lat: 3.9215, lng: -56.0014 }, + SS: { lat: 7.8786, lng: 29.5925 }, + SV: { lat: 13.7866, lng: -88.9095 }, + SY: { lat: 34.7714, lng: 39.0252 }, + SZ: { lat: -26.473, lng: 31.3741 }, + TD: { lat: 15.4158, lng: 18.7136 }, + TF: { lat: -49.2, lng: 69.64 }, + TG: { lat: 8.4738, lng: 0.9077 }, + TH: { lat: 13.0546, lng: 101.4825 }, + TJ: { lat: 38.8492, lng: 71.2111 }, + TL: { lat: -8.8333, lng: 126.1523 }, + TM: { lat: 39.0111, lng: 59.5243 }, + TN: { lat: 33.8288, lng: 9.5066 }, + TR: { lat: 38.9309, lng: 35.4824 }, + TT: { lat: 10.445, lng: -61.4225 }, + TW: { lat: 23.633, lng: 121.0287 }, + TZ: { lat: -6.3355, lng: 34.8283 }, + UA: { lat: 48.8142, lng: 31.0832 }, + UG: { lat: 1.4033, lng: 32.3077 }, + US: { lat: 37.2345, lng: -95.8259 }, + UY: { lat: -32.5312, lng: -55.8183 }, + UZ: { lat: 41.3659, lng: 64.4922 }, + VE: { lat: 6.4434, lng: -66.5316 }, + VN: { lat: 15.9759, lng: 105.7529 }, + VU: { lat: -15.1833, lng: 166.9496 }, + XK: { lat: 42.5596, lng: 20.9229 }, + YE: { lat: 15.793, lng: 47.8567 }, + ZA: { lat: -28.4552, lng: 24.5875 }, + ZM: { lat: -13.0997, lng: 27.6868 }, + ZW: { lat: -18.8897, lng: 29.057 }, +} + +export function getCentroid(iso2: string): Centroid | null { + if (!iso2) return null + const code = ISO_ALIASES[iso2] ?? iso2 + return COUNTRY_CENTROIDS[code] ?? null +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts index 20cc1092..98719391 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/lifecycle.ts @@ -6,11 +6,16 @@ import { useEffect, useRef } from 'react' import { SNAPSHOT_KEY, type Snapshot, useSnapshot } from '@/api/snapshot' import { browserDriver, createDashboardWS, type WSEvent } from '@/api/ws' import { unlockOnFirstGesture } from '@/lib/audio' +import { getCentroid } from '@/lib/countryCentroids' import { type CveEvent, useCveStore } from '@/stores/cve' import { useGlobeEvents } from '@/stores/globeEvents' import { type KevEntry, useKevStore } from '@/stores/kev' import { usePrices } from '@/stores/prices' -import { type RansomwareVictim, useRansomwareStore } from '@/stores/ransomware' +import { + type RansomwareVictim, + useRansomwareStore, + victimKey, +} from '@/stores/ransomware' import { useTicker } from '@/stores/ticker' const ALL_TOPICS: readonly string[] = [ @@ -67,6 +72,16 @@ interface GdeltSpikePayload { zscore: number } +interface OutagePayload { + id: string + startDate?: string + endDate?: string | null + locations?: string[] + asns?: number[] + reason?: string + outageType?: string +} + export function useDashboardLifecycle(): void { const { data: snapshot, isSuccess } = useSnapshot() const queryClient = useQueryClient() @@ -140,8 +155,10 @@ function routeEvent(ev: WSEvent, queryClient: QueryClient): void { case 'gdelt_spike': handleGdelt(data as GdeltSpikePayload) break - case 'space_weather': case 'internet_outage': + handleOutage(data as OutagePayload, queryClient) + break + case 'space_weather': case 'bgp_hijack': case 'scan_firehose': mergeIntoSnapshot(queryClient, ev.topic, data) @@ -164,6 +181,43 @@ function handleKev(p: KevEntry): void { function handleRansomware(p: RansomwareVictim): void { if (!p.post_title) return useRansomwareStore.getState().push(p) + pushRansomwarePoint(p) +} + +function pushRansomwarePoint(p: RansomwareVictim): void { + if (!p.country) return + const c = getCentroid(p.country) + if (!c) return + useGlobeEvents.getState().pushPoint({ + id: `rw-${victimKey(p)}`, + type: 'ransomware', + lat: c.lat, + lng: c.lng, + emittedAt: Date.now(), + meta: { group_name: p.group_name, post_title: p.post_title }, + }) +} + +function handleOutage(p: OutagePayload, queryClient: QueryClient): void { + pushOutagePoints(p) + mergeIntoSnapshot(queryClient, 'internet_outage', p) +} + +function pushOutagePoints(p: OutagePayload): void { + if (!p.id || !Array.isArray(p.locations)) return + const now = Date.now() + for (const loc of p.locations) { + const c = getCentroid(loc) + if (!c) continue + useGlobeEvents.getState().pushPoint({ + id: `outage-${p.id}-${loc}`, + type: 'outage', + lat: c.lat, + lng: c.lng, + emittedAt: now, + meta: { reason: p.reason, type: p.outageType }, + }) + } } function handleCoinbase(p: CoinbaseTickPayload): void { @@ -278,4 +332,10 @@ function seedGlobeFromSnapshot(snap: Snapshot): void { emittedAt: Date.now(), }) } + + const rw = snap.ransomware_victim as RansomwareVictim | undefined + if (rw) pushRansomwarePoint(rw) + + const outage = snap.internet_outage as OutagePayload | undefined + if (outage) pushOutagePoints(outage) } From e216331aeb6a9e153ee081d5e98271126dbe22de Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 18:20:15 -0400 Subject: [PATCH 099/109] feat(monitor/backend): abuseipdb enrichment for bgp hijacks (country + abuse_score in payload) --- .../.env.example | 1 + .../monitor-the-situation-dashboard/README.md | 2 +- .../backend/cmd/api/main.go | 24 ++++ .../internal/collectors/cfradar/collector.go | 41 ++++++- .../collectors/cfradar/collector_test.go | 101 +++++++++++++++++ .../backend/internal/config/config.go | 8 ++ .../internal/enrich/abuseipdb/client.go | 106 ++++++++++++++++++ .../internal/enrich/abuseipdb/client_test.go | 61 ++++++++++ .../abuseipdb/testdata/check_lookup.json | 19 ++++ 9 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/testdata/check_lookup.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example index 003d47e0..d597abf8 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example @@ -22,6 +22,7 @@ NOTIFICATION_ENCRYPTION_KEY= NVD_API_KEY= CF_RADAR_TOKEN= GREYNOISE_API_KEY= +ABUSEIPDB_API_KEY= CLOUDFLARE_TUNNEL_TOKEN= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md index e04ffd14..6b62b30c 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md @@ -33,7 +33,7 @@ Operator-grade real-time situational awareness dashboard. Single-binary Go backe | Space weather | NOAA SWPC (5 endpoints) | 1m / 3h | none | | World events | Wikipedia ITN + GDELT v2 API | 5m / 15m | none | | ISS position | wheretheiss.at + CelesTrak | 10s / 24h | none | -| IP enrichment | GreyNoise Community | on-demand | `GREYNOISE_API_KEY` (optional, free tier) | +| IP enrichment (BGP hijacks) | AbuseIPDB | on-demand | `ABUSEIPDB_API_KEY` (optional, free tier 1k/day) | ## Quickstart (development) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index b872c50a..364dd9a3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -35,6 +35,7 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia" "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb" "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" "github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring" @@ -48,6 +49,22 @@ const ( drainDelay = 5 * time.Second ) +type abuseipdbEnricher struct { + client *abuseipdb.Client +} + +func (a abuseipdbEnricher) Lookup(ctx context.Context, ip string) (cfradar.Enrichment, error) { + v, err := a.client.Lookup(ctx, ip) + if err != nil { + return cfradar.Enrichment{}, err + } + return cfradar.Enrichment{ + Country: v.CountryCode, + AbuseConfidence: v.AbuseConfidenceScore, + ISP: v.ISP, + }, nil +} + func main() { configPath := flag.String("config", "config.yaml", "path to config file") flag.Parse() @@ -178,6 +195,12 @@ func run(configPath string) error { } if cfg.Collectors.CFRadar.Enabled { + var enricher cfradar.Enricher + if cfg.Collectors.AbuseIPDB.Enabled && cfg.Collectors.AbuseIPDB.APIKey != "" { + enricher = abuseipdbEnricher{ + client: abuseipdb.NewClient(abuseipdb.ClientConfig{APIKey: cfg.Collectors.AbuseIPDB.APIKey}), + } + } coll := cfradar.NewCollector(cfradar.CollectorConfig{ Interval: cfg.Collectors.CFRadar.Interval, MinConfidence: cfg.Collectors.CFRadar.MinConfidence, @@ -185,6 +208,7 @@ func run(configPath string) error { Repo: cfradar.NewRepo(db.DB), Emitter: eventBus, State: collectorState, + Enricher: enricher, Logger: logger.With("collector", "cfradar"), }) collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go index d8c9c09a..add1eca8 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "log/slog" + "net/netip" "time" "github.com/lib/pq" @@ -41,6 +42,22 @@ type StateRecorder interface { RecordError(ctx context.Context, name, errMsg string) error } +type Enricher interface { + Lookup(ctx context.Context, ip string) (Enrichment, error) +} + +type Enrichment struct { + Country string `json:"country,omitempty"` + AbuseConfidence int `json:"abuse_confidence,omitempty"` + ISP string `json:"isp,omitempty"` + CheckedIP string `json:"checked_ip,omitempty"` +} + +type EnrichedHijack struct { + HijackEvent + Enrichment *Enrichment `json:"enrichment,omitempty"` +} + type CollectorConfig struct { Interval time.Duration MinConfidence int @@ -48,6 +65,7 @@ type CollectorConfig struct { Repo Repository Emitter Emitter State StateRecorder + Enricher Enricher Logger *slog.Logger } @@ -183,7 +201,20 @@ func (c *Collector) tickHijacks(ctx context.Context) (int64, error) { if known[e.ID] { continue } - rawBytes, _ := json.Marshal(e) + enriched := EnrichedHijack{HijackEvent: e} + if c.cfg.Enricher != nil && len(e.Prefixes) > 0 { + if ip, ok := representativeIP(e.Prefixes[0]); ok { + if v, lerr := c.cfg.Enricher.Lookup(ctx, ip); lerr == nil { + enriched.Enrichment = &Enrichment{ + Country: v.Country, + AbuseConfidence: v.AbuseConfidence, + ISP: v.ISP, + CheckedIP: ip, + } + } + } + } + rawBytes, _ := json.Marshal(enriched) raw := json.RawMessage(rawBytes) row := HijackRow{ ID: e.ID, @@ -210,3 +241,11 @@ func (c *Collector) tickHijacks(ctx context.Context) (int64, error) { } return emitted, nil } + +func representativeIP(cidr string) (string, bool) { + p, err := netip.ParsePrefix(cidr) + if err != nil { + return "", false + } + return p.Addr().String(), true +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go index c0f93853..9304271e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go @@ -95,6 +95,22 @@ type noopState struct{} func (noopState) RecordSuccess(context.Context, string, int64) error { return nil } func (noopState) RecordError(context.Context, string, string) error { return nil } +type fakeEnricher struct { + calls int + verdict cfradar.Enrichment + lastIP string + returnFn func(ip string) (cfradar.Enrichment, error) +} + +func (e *fakeEnricher) Lookup(_ context.Context, ip string) (cfradar.Enrichment, error) { + e.calls++ + e.lastIP = ip + if e.returnFn != nil { + return e.returnFn(ip) + } + return e.verdict, nil +} + func TestCollector_OnlyEmitsNetNew(t *testing.T) { now := time.Now().UTC() ftch := &fakeFetcher{ @@ -146,6 +162,91 @@ func TestCollector_OnlyEmitsNetNew(t *testing.T) { require.GreaterOrEqual(t, hijackEvents, 1) } +func TestCollector_EnrichesHijackPayloadWhenEnricherProvided(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ + { + ID: 501, + DetectedAt: now, + StartedAt: now, + Confidence: 9, + HijackerASN: 4242, + Prefixes: []string{"203.0.113.0/24"}, + }, + }}, + } + repo := &fakeRepo{knownHijacks: map[int64]bool{}} + emt := &fakeEmitter{} + enr := &fakeEnricher{verdict: cfradar.Enrichment{ + Country: "RU", + AbuseConfidence: 95, + ISP: "ExampleNet", + }} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 30 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: noopState{}, + Enricher: enr, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + require.GreaterOrEqual(t, enr.calls, 1) + require.Equal(t, "203.0.113.0", enr.lastIP) + + var found bool + for _, ev := range emt.Events() { + if ev.Topic != events.TopicBGPHijack { + continue + } + body, _ := json.Marshal(ev.Payload) + require.Contains(t, string(body), `"country":"RU"`) + require.Contains(t, string(body), `"abuse_confidence":95`) + require.Contains(t, string(body), `"checked_ip":"203.0.113.0"`) + found = true + } + require.True(t, found, "expected at least one hijack event with enrichment") +} + +func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) { + now := time.Now().UTC() + ftch := &fakeFetcher{ + hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ + {ID: 777, DetectedAt: now, StartedAt: now, Confidence: 9, Prefixes: []string{"198.51.100.0/24"}}, + }}, + } + repo := &fakeRepo{knownHijacks: map[int64]bool{}} + emt := &fakeEmitter{} + + c := cfradar.NewCollector(cfradar.CollectorConfig{ + Interval: 30 * time.Millisecond, + MinConfidence: 7, + Fetcher: ftch, + Repo: repo, + Emitter: emt, + State: noopState{}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _ = c.Run(ctx) + + for _, ev := range emt.Events() { + if ev.Topic != events.TopicBGPHijack { + continue + } + body, _ := json.Marshal(ev.Payload) + require.NotContains(t, string(body), `"enrichment"`) + } +} + func TestCollector_RepeatedTickIsIdempotent(t *testing.T) { now := time.Now().UTC() ftch := &fakeFetcher{ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go index c26931d3..5e69a76d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -34,6 +34,7 @@ type CollectorsConfig struct { KEV SourceConfig `koanf:"kev"` Ransomware SourceConfig `koanf:"ransomware"` GreyNoise GreyNoiseConfig `koanf:"greynoise"` + AbuseIPDB AbuseIPDBConfig `koanf:"abuseipdb"` Coinbase CoinbaseConfig `koanf:"coinbase"` USGS SourceConfig `koanf:"usgs"` SWPC SWPCConfig `koanf:"swpc"` @@ -66,6 +67,11 @@ type GreyNoiseConfig struct { APIKey string `koanf:"api_key"` } +type AbuseIPDBConfig struct { + Enabled bool `koanf:"enabled"` + APIKey string `koanf:"api_key"` +} + type CoinbaseConfig struct { Enabled bool `koanf:"enabled"` URL string `koanf:"url"` @@ -275,6 +281,7 @@ func loadDefaults(k *koanf.Koanf) error { "collectors.ransomware.enabled": true, "collectors.ransomware.interval": "15m", "collectors.greynoise.enabled": true, + "collectors.abuseipdb.enabled": true, "collectors.coinbase.enabled": true, "collectors.coinbase.url": "wss://advanced-trade-ws.coinbase.com", "collectors.coinbase.product_ids": []string{"BTC-USD", "ETH-USD"}, @@ -328,6 +335,7 @@ var envKeyMap = map[string]string{ "NVD_API_KEY": "collectors.cve.nvd_api_key", "CF_RADAR_TOKEN": "collectors.cfradar.bearer_token", "GREYNOISE_API_KEY": "collectors.greynoise.api_key", + "ABUSEIPDB_API_KEY": "collectors.abuseipdb.api_key", } func envKeyReplacer(s string) string { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client.go new file mode 100644 index 00000000..64c3ecaf --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client.go @@ -0,0 +1,106 @@ +// ©AngelaMos | 2026 +// client.go + +package abuseipdb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + defaultAIDBBaseURL = "https://api.abuseipdb.com" + pathV2Check = "/api/v2/check" + abuseipdbAPIHeader = "Key" + defaultAIDBRate = 1 * time.Second + defaultAIDBBudget = 3 + defaultAIDBBreakerWin = 5 * time.Minute + defaultAIDBRequestTO = 10 * time.Second + defaultAIDBMaxAgeDays = 90 +) + +var ErrUnknownIP = errors.New("abuseipdb: unknown ip") + +type ClientConfig struct { + BaseURL string + APIKey string +} + +type Client struct { + hx *httpx.Client +} + +func NewClient(cfg ClientConfig) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultAIDBBaseURL + } + return &Client{ + hx: httpx.New(httpx.Config{ + Name: "abuseipdb", + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + APIKeyHeader: abuseipdbAPIHeader, + Rate: rate.Every(defaultAIDBRate), + Burst: 1, + ConsecutiveFailureBudget: defaultAIDBBudget, + BreakerTimeout: defaultAIDBBreakerWin, + RequestTimeout: defaultAIDBRequestTO, + }), + } +} + +type checkEnvelope struct { + Data Verdict `json:"data"` +} + +type Verdict struct { + IPAddress string `json:"ipAddress"` + IsPublic bool `json:"isPublic"` + IPVersion int `json:"ipVersion"` + IsWhitelisted bool `json:"isWhitelisted"` + AbuseConfidenceScore int `json:"abuseConfidenceScore"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + UsageType string `json:"usageType"` + ISP string `json:"isp"` + Domain string `json:"domain"` + IsTor bool `json:"isTor"` + TotalReports int `json:"totalReports"` + NumDistinctUsers int `json:"numDistinctUsers"` + LastReportedAt string `json:"lastReportedAt"` +} + +func (c *Client) Lookup(ctx context.Context, ip string) (Verdict, error) { + q := url.Values{ + "ipAddress": []string{ip}, + "maxAgeInDays": []string{strconv.Itoa(defaultAIDBMaxAgeDays)}, + } + resp, err := c.hx.Get(ctx, pathV2Check, q) + if err != nil { + var se *httpx.StatusError + if errors.As(err, &se) && se.Code == http.StatusNotFound { + return Verdict{}, ErrUnknownIP + } + return Verdict{}, fmt.Errorf("abuseipdb lookup %s: %w", ip, err) + } + defer resp.Body.Close() + + var env checkEnvelope + if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { + return Verdict{}, fmt.Errorf("abuseipdb decode: %w", err) + } + if env.Data.IPAddress == "" { + return Verdict{}, ErrUnknownIP + } + return env.Data, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go new file mode 100644 index 00000000..c3b7332d --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go @@ -0,0 +1,61 @@ +// ©AngelaMos | 2026 +// client_test.go + +package abuseipdb_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb" +) + +func TestClient_LookupDecodesVerdict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v2/check", r.URL.Path) + require.Equal(t, "118.25.6.39", r.URL.Query().Get("ipAddress")) + require.Equal(t, "90", r.URL.Query().Get("maxAgeInDays")) + require.Equal(t, "test-key", r.Header.Get("Key")) + body, err := os.ReadFile("testdata/check_lookup.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}) + v, err := c.Lookup(context.Background(), "118.25.6.39") + require.NoError(t, err) + require.Equal(t, "118.25.6.39", v.IPAddress) + require.Equal(t, "CN", v.CountryCode) + require.Equal(t, 100, v.AbuseConfidenceScore) + require.Equal(t, "Tencent Cloud Computing", v.ISP) +} + +func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}) + _, err := c.Lookup(context.Background(), "10.0.0.1") + require.ErrorIs(t, err, abuseipdb.ErrUnknownIP) +} + +func TestClient_LookupReturnsErrUnknownIPOnEmptyData(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}) + _, err := c.Lookup(context.Background(), "10.0.0.1") + require.ErrorIs(t, err, abuseipdb.ErrUnknownIP) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/testdata/check_lookup.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/testdata/check_lookup.json new file mode 100644 index 00000000..5d96ff02 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/testdata/check_lookup.json @@ -0,0 +1,19 @@ +{ + "data": { + "ipAddress": "118.25.6.39", + "isPublic": true, + "ipVersion": 4, + "isWhitelisted": false, + "abuseConfidenceScore": 100, + "countryCode": "CN", + "countryName": "China", + "usageType": "Data Center/Web Hosting/Transit", + "isp": "Tencent Cloud Computing", + "domain": "tencent.com", + "hostnames": [], + "isTor": false, + "totalReports": 1, + "numDistinctUsers": 1, + "lastReportedAt": "2026-04-30T12:34:56+00:00" + } +} From 038de64605d673e5e1c1e83d9a6d12cc6cc32816 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 18:42:27 -0400 Subject: [PATCH 100/109] fix(monitor/backend): gdelt switch to timelinevol mode (article-snippet source of malformed escapes is gone) --- .../backend/internal/collectors/gdelt/client.go | 2 +- .../backend/internal/collectors/gdelt/client_test.go | 3 ++- .../gdelt/testdata/{timelinevolinfo.json => timelinevol.json} | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/{timelinevolinfo.json => timelinevol.json} (100%) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go index 745a3742..76d6d1e5 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go @@ -77,7 +77,7 @@ type rawTimeline struct { func (c *Client) FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, error) { q := url.Values{} q.Set("query", "theme:"+theme) - q.Set("mode", "timelinevolinfo") + q.Set("mode", "timelinevol") q.Set("TIMELINESMOOTH", "5") q.Set("format", "json") q.Set("timespan", "1d") diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go index 0f7e4c26..b3f02d6e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go @@ -17,12 +17,13 @@ import ( ) func TestClient_FetchThemeDecodesBuckets(t *testing.T) { - body, err := os.ReadFile("testdata/timelinevolinfo.json") + body, err := os.ReadFile("testdata/timelinevol.json") require.NoError(t, err) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/api/v2/doc/doc", r.URL.Path) require.Equal(t, "theme:NATURAL_DISASTER", r.URL.Query().Get("query")) + require.Equal(t, "timelinevol", r.URL.Query().Get("mode")) w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) })) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevolinfo.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevol.json similarity index 100% rename from PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevolinfo.json rename to PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/testdata/timelinevol.json From 0705f3733054484aaba72fbf27abe59dbb9ad9bb Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 18:54:34 -0400 Subject: [PATCH 101/109] fix(monitor/frontend): dshield panel (topports record has date/limit siblings of port entries) --- .../frontend/src/api/types/threat.types.ts | 67 +++++++++++++++++++ .../src/pages/panels/DShieldPanel.tsx | 46 +++++-------- 2 files changed, 82 insertions(+), 31 deletions(-) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts new file mode 100644 index 00000000..ece0d22f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts @@ -0,0 +1,67 @@ +// ©AngelaMos | 2026 +// threat.types.ts + +import { z } from 'zod' + +export const ransomwareVictimSchema = z.object({ + post_title: z.string(), + group_name: z.string(), + discovered: z.string(), + country: z.string().optional(), + activity: z.string().optional(), + website: z.string().optional(), + description: z.string().optional(), +}) + +export type RansomwareVictim = z.infer + +export const dshieldPortSchema = z.object({ + rank: z.number(), + targetport: z.number(), + records: z.number(), + targets: z.number(), + sources: z.number(), +}) + +export type DShieldPort = z.infer + +export const dshieldSourceSchema = z.object({ + rank: z.number(), + source: z.string(), + reports: z.number(), + targets: z.number(), +}) + +export type DShieldSource = z.infer + +export const dshieldDailySummarySchema = z.object({ + date: z.string(), + records: z.number(), + sources: z.number(), + targets: z.number(), +}) + +export type DShieldDailySummary = z.infer + +export const dshieldDataSchema = z.object({ + topports: z + .union([z.record(z.string(), z.unknown()), z.array(z.unknown())]) + .optional(), + topips: z.array(dshieldSourceSchema).optional(), + dailysummary: z.array(dshieldDailySummarySchema).optional(), + ts: z.string().optional(), +}) + +export type DShieldData = z.infer + +export const isValidRansomwareVictim = ( + data: unknown +): data is RansomwareVictim => { + if (data === null || data === undefined || typeof data !== 'object') return false + return ransomwareVictimSchema.safeParse(data).success +} + +export const isValidDShieldData = (data: unknown): data is DShieldData => { + if (data === null || data === undefined || typeof data !== 'object') return false + return dshieldDataSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx index 30964cec..233ca464 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx @@ -1,7 +1,8 @@ // ©AngelaMos | 2026 // DShieldPanel.tsx -import { useSnapshot } from '@/api/snapshot' +import { type DShieldDailySummary, type DShieldPort } from '@/api/types' +import { useDShieldData } from '@/api/hooks' import styles from './DShieldPanel.module.scss' import { Panel } from './Panel' @@ -10,39 +11,11 @@ const SOURCE_ROW_LIMIT = 8 const THOUSAND = 1_000 const MILLION = 1_000_000 -interface DShieldPort { - rank: number - targetport: number - records: number - targets: number - sources: number -} - -interface DShieldSource { - rank: number - source: string - reports: number - targets: number -} - -interface DShieldDailySummary { - date: string - records: number - sources: number - targets: number -} - -interface DShieldData { - topports?: Record | DShieldPort[] - topips?: DShieldSource[] - dailysummary?: DShieldDailySummary[] -} - export function DShieldPanel(): React.ReactElement { - const { data } = useSnapshot() - const ds = (data?.scan_firehose as DShieldData | undefined) ?? {} + const ds = useDShieldData() const ports = toArray(ds.topports) + .filter(isPort) .slice() .sort((a, b) => a.rank - b.rank) .slice(0, PORT_ROW_LIMIT) @@ -119,6 +92,17 @@ function toArray(v: Record | T[] | undefined): T[] { return Object.values(v) } +function isPort(v: unknown): v is DShieldPort { + if (!v || typeof v !== 'object') return false + const p = v as Record + return ( + typeof p.targetport === 'number' && + typeof p.records === 'number' && + typeof p.sources === 'number' && + typeof p.rank === 'number' + ) +} + function pickLatestSummary( list: DShieldDailySummary[] | undefined ): DShieldDailySummary | undefined { From c25fd57eaf3860fe65d90789165c166fd4e0ce1f Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 3 May 2026 19:05:45 -0400 Subject: [PATCH 102/109] =?UTF-8?q?feat(monitor/frontend):=20dashboard=20l?= =?UTF-8?q?ayout=20=E2=80=94=20CVE=20Velocity=20to=20left=20under=20ISS,?= =?UTF-8?q?=20BGP=20HIJACKS=20to=20right=20top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/pages/dashboard/Dashboard.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx index e93295f0..6d77b49a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/dashboard/Dashboard.tsx @@ -2,6 +2,7 @@ // Dashboard.tsx import { Globe } from '@/pages/globe/Globe' +import { BGPHijackPanel } from '@/pages/panels/BGPHijackPanel' import { BTCPanel } from '@/pages/panels/BTCPanel' import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel' import { DShieldPanel } from '@/pages/panels/DShieldPanel' @@ -38,12 +39,13 @@ export function Dashboard(): React.ReactElement { +
@@ -46,6 +49,7 @@ export function Dashboard(): React.ReactElement {

sq@j4#83!c~m&ZwUgy-1jCS+~sK~ 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 literal 0 HcmV?d00001 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/public/assets/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..338e90d0fd359ba5da2ff517a1a4f32f0f40e78c GIT binary patch literal 2032 zcmV^_n??QOm%DfG4k{3jlarIEM=vEMC1hr1Qe0d-#B4Aa`0}6s7#jc3jtsz@Q01Z1 z=rRv;`}S>WYHCPLP36Lc3!FG{g7Wfm4*%|TLW+)?Q(=}iDu5|VW)PenJKJqF{bJ$KJ`nO=*ck#zwP7%n?TgAPFC;`#U8~CGyskr>BR;MTw{q=A-%YKis@|lfJ$_*_dxiDz?R^sCi;%WTL3d0w^pjBtJi& zBZm$V6dq1Qm?QTt9YLefFfcHHpPwJz-rjU~cVp4fj$QU2xvmYQKVmXx13V~>)Y2BM zC9u7f?iD2Wgk_Np0S?N(zi%YPe-Lv z(H0)bc`tvizO{+geR)jiXk$`!C2j-#bPmFpz{#f8#mrLyv{YB|@v4Bb@&p(dA#undcVKjfCqfZ!?q*`B1V|-AM)HQic z09-wLhTS`JSdsN19*&Om#b~g#wKWw~Cc2jR zl?B{WYtU@XGLbhd|#|K9N_{NPJJnyW+^AGRIyaR>-EG;eN{?jTG07j<9#zqEDe#L`9aC38`T^Osq zc9qNTq;ustZ>*mWrd0hC)Z5;dSE|fdgVzV^@p&^1=hs)#vwRf~vOmH4Mio*NHR^q` z9T7l9$!XLW7$dgv-Pc|vXWA5Q&z*(iUv}Wx(u^cHrM-n#0kEKVkalqt`_G@nr|~}e zsF_U9+=7LL1uelL`1<-jX?JKW=@}Xf@AO^zMmD0hw7~DH&zbtd3pnlEORtOTa5hu| z;6$;*7y99x@H*-Ack1;#eE1L>;X8Ieo%6##fnF%sy6*tRSaDRWE>?Jx{%Aff$@8gS zn9S{vu*X155+D~^TM#c^D?tgaEay^`7U$Smgl)(aGb5L$?!e~Fn~7PHOq#-uW74X` zHoMGvk*c+C!<`yzs;^NsK2Ut1krfBl)zJLerz0~wqL=`XBHaG{`%#EvrfhCv<5&e( zUQ8hVz^5pLBL*D(jFu^(IBncQg8=pdi zFMvgV-6MHqx)GVN~4a$4=|xhI^i&DM@KOC`4oPsucM zl?i~(&Q4k`e#4qF9T&&g(XuXs>gsAa;DjZoppJV9i+>l;aTl_wPFG^eV;8}H+C-VNJC4Ff z`WGZIZq;w;?d_Gsp8A>qu%8@E)vs6L>FGHnkei!Jc6K&v)~q2fFAtqg$DTcVsHmvG zH!u(hWLfz|W@E>4X{Ycms~XCOt>@Wh|V^dV(WbW8yFil`;6*hj>6Bd@)^Jplm$ z?AWn`y1F_hPMkhlgnoXAT@UN|sJe_h1E8$0L!{g|I5-$pjMyqhqzFQ1 z?k@DN{G6UsCvXrMkOWTJPD!z}`s?CCQiyz^DI$sw9o$J&I#OeRHmfldiex4xCPrv6 zU(5n9)ZL-ySU&osd5pJK5)l!>ty{NbWaMR&)t*B!i)93 zUBwIi5{Y3N9zkFi!Y}R;xw&5s~iuy?ggES7b`Va|P+zjk2;J z@7@CzFJ3%c)rftwJ75(Wj^oZ;q*eq1T3cJm%F05mRzIfD)zy`*t}dp!xzZ$#<-1$A z%DLiWOd|};0x-%9igdg=X&$Q5(=O~@9!^>367n~Zj}epF_kACRiick0{{U3{~W2|`2YX_21!IgR09B6Vn9z_E+-5C O0000f+2z|CH8pj$YHGT=YIHSK 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 C +