Compare commits

..

54 Commits

Author SHA1 Message Date
anoracleofra-code 34db99deaf v0.8.0: POTUS fleet tracking, full aircraft color-coding, carrier fidelity, UI overhaul
New features:
- POTUS fleet (AF1, AF2, Marine One) with hot-pink icons + gold halo ring
- 9-color aircraft system: military, medical, police, VIP, privacy, dictators
- Sentinel-2 fullscreen overlay with download/copy/open buttons (green themed)
- Carrier homeport deconfliction — distinct pier positions instead of stacking
- Toggle all data layers button (cyan when active, excludes MODIS Terra)
- Version badge + update checker + Discussions shortcut in UI
- Overhauled MapLegend with POTUS fleet, wildfires, infrastructure sections
- Data center map layer with ~700 global DCs from curated dataset

Fixes:
- All Air Force Two ICAO hex codes now correctly identified
- POTUS icon priority over grounded state
- Sentinel-2 no longer overlaps bottom coordinate bar
- Region dossier Nominatim 429 rate-limit retry/backoff
- Docker ENV legacy format warnings resolved
- UI buttons cyan in dark mode, grey in light mode
- Circuit breaker for flaky upstream APIs

Community: @suranyami — parallel multi-arch Docker builds + runtime BACKEND_URL fix (PR #35, #44)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 7c523df70a2d26f675603166e3513d29230592cd
2026-03-12 09:31:37 -06:00
Shadowbroker a0d0a449eb Merge pull request #44 from suranyami/fix-backend-url-regression-speed-up-docker-builds
ci: speed up multi-arch Docker builds + fix BACKEND_URL baked in at build time
Former-commit-id: 54ca8d59aede7e47df315ac526bde35f4e4d0622
2026-03-11 19:34:57 -06:00
David Parry 26a72f4f95 chore: untrack local config files (.claude, .mise.local.toml)
These are already covered by the .gitignore added in this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Former-commit-id: dcfdd7bb329ef7e63ee5755ccbe403bf951903f6
2026-03-12 12:11:09 +11:00
David Parry 3eff24c6ed Merge branch 'main' of github.com:suranyami/Shadowbroker
Former-commit-id: 8e9607c7adaf4f1b4b5013fab10429787671ec03
2026-03-12 12:08:19 +11:00
anoracleofra-code bb345ed665 feat: add TopRightControls component
Former-commit-id: e75da4288a
2026-03-11 18:39:26 -06:00
anoracleofra-code dec5b0da9c chore: bump version to 0.7.0
Former-commit-id: 8ee47f52ab
2026-03-11 18:30:49 -06:00
David Parry 68cacc0fed Merge pull request #6 from suranyami/fix-regression-BACKEND_URL
Fix regression, BACKEND_URL now only processed at request-time

Former-commit-id: 4131a0cadb3f17398ccaf7d14704e4399e9fa7b8
2026-03-12 11:22:03 +11:00
David Parry 40e89ac30b Fix regression, BACKEND_URL now only processed at request-time
Former-commit-id: da14f44e910786e9e21b5968b77e97a94f2876ab
2026-03-12 11:18:23 +11:00
David Parry 350ec11725 Merge pull request #5 from suranyami/speed-up-docker-builds
Ensure lower case image name

Former-commit-id: dc43a87ef0
2026-03-12 10:59:41 +11:00
David Parry 5d4dd0560d Ensure lower case image name
Former-commit-id: f98cafd987
2026-03-12 10:34:33 +11:00
David Parry 345f3c7451 Merge pull request #4 from suranyami/speed-up-docker-builds
Add optimizations for separate arm64/x86_64 builds

Former-commit-id: 50d265fcf0
2026-03-12 10:30:01 +11:00
David Parry dde527821c Merge branch 'BigBodyCobain:main' into main
Former-commit-id: 5c49568921
2026-03-12 10:29:30 +11:00
David Parry 5bee764614 Add optimizations for separate arm64/x86_64 builds
Former-commit-id: aff71e6cd7
2026-03-12 10:25:33 +11:00
anoracleofra-code c986de9e35 fix: legend - earthquake icon yellow, outage zone grey
Former-commit-id: 85478250c3
2026-03-11 14:57:51 -06:00
anoracleofra-code d2fa45c6a6 Merge branch 'main' of https://github.com/BigBodyCobain/Shadowbroker
Former-commit-id: cbc506242d
2026-03-11 14:30:25 -06:00
anoracleofra-code d78bf61256 fix: aircraft categorization, fullscreen satellite imagery, region dossier rate-limit, updated map legend
- Fixed 288+ miscategorized aircraft in plane_alert_db.json (gov/police/medical)
- data_fetcher.py: tracked_names enrichment now assigns blue/lime colors for gov/law/medical operators
- region_dossier.py: fixed Nominatim 429 rate-limiting with retry/backoff
- MaplibreViewer.tsx: Sentinel-2 popup replaced with fullscreen overlay + download/copy buttons
- MapLegend.tsx: updated to show all 9 tracked aircraft color categories + POTUS fleet + wildfires + infrastructure


Former-commit-id: d109434616
2026-03-11 14:29:18 -06:00
Shadowbroker b10d6e6e00 Update README.md
Former-commit-id: b1cb267da3
2026-03-11 14:09:50 -06:00
Shadowbroker afdc626bdb Update README.md
Former-commit-id: a3a0f5e990
2026-03-11 14:07:46 -06:00
anoracleofra-code 5ab02e821f feat: POTUS Fleet tracker, Docker secrets, route fix, SQLite->JSON migration
- Add Docker Swarm secrets _FILE support (AIS_API_KEY_FILE, etc.)
- Fix flight route lookup: pass lat/lng to adsb.lol routeset API, return airport names
- Replace SQLite plane_alert DB with JSON file + O(1) category color mapping
- Add POTUS Fleet (AF1, AF2, Marine One) with hardcoded ICAO overrides
- Add tracked_names enrichment from Excel data with POTUS protection
- Add oversized gold-ringed POTUS SVG icons on map
- Add POTUS Fleet tracker panel in WorldviewLeftPanel with fly-to
- Overhaul tracked flight labels: zoom-gated, PIA hidden, color-mapped
- Add orange color to trackedIconMap, soften white icon strokes
- Fix NewsFeed Wikipedia links to use alert_wiki slug


Former-commit-id: 6f952104c1
2026-03-11 12:28:04 -06:00
anoracleofra-code ac62e4763f chore: update ChangelogModal for v0.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: a771fe8cfb
2026-03-11 06:37:15 -06:00
anoracleofra-code cf68f1978d v0.7.0: performance hardening — parallel fetches, deferred icons, AIS stability
Optimizations:
- Parallelized yfinance stock/oil fetches via ThreadPoolExecutor (~2s vs ~8s)
- AIS backoff reset after 200 successes; removed hot-loop pruning (lock contention)
- Single-pass ETag serialization (was double-serializing JSON)
- Deferred ~50 non-critical map icons via setTimeout(0)
- News feed animation capped at 15 items (was 100+ simultaneous)
- heapq.nlargest() for FIRMS fires (60K→5K) and internet outages
- Removed satellite duplication from fast endpoint
- Geopolitics interval 5min → 30min
- Ship counts single-pass memoized; color maps module-level constants
- Improved GDELT URL-to-headline extraction (skip gibberish slugs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 4a14a2f078
2026-03-11 06:25:31 -06:00
David Parry beadce5dae Merge pull request #3 from suranyami/feat/multi-arch-docker-and-backend-proxy
fix: resolve proxy gzip decoding and BACKEND_URL Docker override issues
Former-commit-id: 7af4af1507
2026-03-11 15:58:05 +11:00
Shadowbroker 10f376d4d7 Merge pull request #35 from suranyami/feat/multi-arch-docker-and-backend-proxy
fix: resolve proxy gzip decoding and BACKEND_URL Docker override issues
Former-commit-id: c539a05d20
2026-03-10 22:45:11 -06:00
David Parry ff168150c9 Merge branch 'main' into feat/multi-arch-docker-and-backend-proxy
Former-commit-id: 7ead58d453
2026-03-11 15:05:55 +11:00
David Parry 782225ff99 fix: resolve proxy gzip decoding and BACKEND_URL Docker override issues
Two bugs introduced by the Next.js proxy Route Handler:

1. ERR_CONTENT_DECODING_FAILED — Node.js fetch() automatically
   decompresses gzip/br responses from the backend, but the proxy was
   still forwarding Content-Encoding and Content-Length headers to the
   browser. The browser would then try to decompress already-decompressed
   data and fail. Fixed by stripping Content-Encoding and Content-Length
   from upstream response headers.

2. BACKEND_URL shell env leak into Docker Compose — docker-compose.yml
   used ${BACKEND_URL:-http://backend:8000}, which was being overridden
   by BACKEND_URL=http://localhost:8000 set in .mise.local.toml for local
   dev. Inside the frontend container, localhost:8000 does not exist,
   causing all proxied requests to return 502. Fixed by hardcoding
   http://backend:8000 in docker-compose.yml so the shell environment
   cannot override it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Former-commit-id: 036c62d2c0
2026-03-11 15:00:50 +11:00
David Parry f99cc669f5 Merge pull request #2 from suranyami/feat/multi-arch-docker-and-backend-proxy
feat: proxy backend API through Next.js using runtime BACKEND_URL
Former-commit-id: d930001673
2026-03-11 14:22:58 +11:00
David Parry 25262323f5 feat: proxy backend API through Next.js using runtime BACKEND_URL
Previously, NEXT_PUBLIC_API_URL was a build-time Next.js variable, making
it impossible to configure the backend URL in docker-compose `environment`
without rebuilding the image.

This change introduces a proper server-side proxy:
- next.config.ts: adds a rewrite rule that forwards all /api/* requests
  to BACKEND_URL (read at server startup, not baked at build time).
  Defaults to http://localhost:8000 so local dev works without config.
- api.ts: API_BASE is now an empty string — all fetch calls use relative
  /api/... paths, which the Next.js server proxies to the backend.
- docker-compose.yml: replaces NEXT_PUBLIC_API_URL build arg with a
  runtime BACKEND_URL env var defaulting to http://backend:8000, using
  Docker's internal networking. Port 8000 no longer needs to be exposed.
- README: updates Docker setup docs, standalone compose example, and
  environment variable reference to reflect BACKEND_URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Former-commit-id: a3b18e23c1
2026-03-11 14:18:30 +11:00
Shadowbroker bad50b8924 Merge pull request #33 from suranyami/feat/multi-arch-docker-and-backend-proxy
Feat/multi arch docker and backend URL as env var

Former-commit-id: 4c92fbe990
2026-03-10 21:02:33 -06:00
David Parry 82715c79a6 Merge pull request #1 from suranyami/feat/multi-arch-docker-and-backend-proxy
Feat/multi arch docker and backend proxy

Former-commit-id: 82e0033239
2026-03-11 13:56:22 +11:00
David Parry e2a9ef9bbf feat: proxy backend API through Next.js using runtime BACKEND_URL
Previously, NEXT_PUBLIC_API_URL was a build-time Next.js variable, making
it impossible to configure the backend URL in docker-compose `environment`
without rebuilding the image.

This change introduces a proper server-side proxy:
- next.config.ts: adds a rewrite rule that forwards all /api/* requests
  to BACKEND_URL (read at server startup, not baked at build time).
  Defaults to http://localhost:8000 so local dev works without config.
- api.ts: API_BASE is now an empty string — all fetch calls use relative
  /api/... paths, which the Next.js server proxies to the backend.
- docker-compose.yml: replaces NEXT_PUBLIC_API_URL build arg with a
  runtime BACKEND_URL env var defaulting to http://backend:8000, using
  Docker's internal networking. Port 8000 no longer needs to be exposed.
- README: updates Docker setup docs, standalone compose example, and
  environment variable reference to reflect BACKEND_URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Former-commit-id: b4c9e78cdd
2026-03-11 13:49:00 +11:00
David Parry 3c16071fcd ci: build and publish multi-arch Docker images (amd64 + arm64)
Add `platforms: linux/amd64,linux/arm64` to both the frontend and
backend build-and-push steps. The existing setup-buildx-action already
enables QEMU-based cross-compilation, so no additional steps are needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Former-commit-id: e3e0db6f3d
2026-03-11 13:48:24 +11:00
anoracleofra-code 2ae104fca2 v0.6.0: custom news feeds, data center map layer, performance hardening
New features:
- Custom RSS Feed Manager: add/remove/prioritize up to 20 news sources
  from the Settings panel with weight levels 1-5. Persists across restarts.
- Global Data Center Map Layer: 2,000+ DCs plotted worldwide with clustering,
  server-rack icons, and automatic internet outage cross-referencing.
- Imperative map rendering: high-volume layers bypass React reconciliation
  via direct setData() calls with debounced updates on dense layers.
- Enhanced /api/health with per-source freshness timestamps and counts.

Fixes:
- Data center coordinates fixed for 187 Southern Hemisphere entries
- Docker CORS_ORIGINS passthrough in docker-compose.yml
- Start scripts warn on Python 3.13+ compatibility
- Settings panel redesigned with tabbed UI (API Keys / News Feeds)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 950c308f04
2026-03-10 15:27:20 -06:00
anoracleofra-code 12857a4b83 v0.5.0: FIRMS fire hotspots, space weather, internet outages
New intelligence layers:
- NASA FIRMS VIIRS fire hotspots (5K+ global thermal anomalies, flame icons)
- NOAA space weather badge (Kp index in status bar)
- IODA regional internet outage monitoring (grey markers, BGP/ping only)

Key improvements:
- Fire clusters use flame-shaped icons (not circles) for clear differentiation
- Internet outages are region-level with reliable datasources only
- Removed radiation layer (no viable free real-time API)
- All outage markers grey to avoid color confusion with other layers
- Filtered out merit-nt telescope data that produced misleading percentages

Updated changelog modal, README, and package.json for v0.5.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 195c6b64b9
2026-03-10 10:23:38 -06:00
anoracleofra-code c343084def feat: add FIRMS thermal, space weather, radiation, and internet outage layers
Add 4 new intelligence layers for v0.5:
- NASA FIRMS VIIRS thermal anomaly tiles (frontend-only WMTS)
- NOAA Space Weather Kp index badge in bottom bar
- Safecast radiation monitoring with clustered markers
- IODA internet outage alerts at country centroids

All use free keyless APIs. All layers default to off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 7cb926e227
2026-03-10 09:01:35 -06:00
anoracleofra-code c085475110 fix: remove defunct FLIR/NVG/CRT style presets, keep only DEFAULT and SATELLITE
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: c4de39bb02
2026-03-10 04:53:17 -06:00
anoracleofra-code e0257d2419 chore: remove debug/sample files from tracking, update .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: e7f3378b5a
2026-03-10 04:31:21 -06:00
anoracleofra-code 5d221c3dc7 fix: install backend Node.js deps (ws) in start scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 41a7811360
2026-03-10 04:25:53 -06:00
anoracleofra-code dd8485d1b6 fix: filter out TWR (tower/platform) ADS-B transponders from flight data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 791ec971d9
2026-03-09 21:41:57 -06:00
anoracleofra-code f6aa5ccbc1 chore: bump frontend version to 0.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: d05bef8de5
2026-03-09 21:02:03 -06:00
anoracleofra-code 97208a01a2 fix: tag Docker images as latest + semver instead of branch name
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: c84cba927a
2026-03-09 20:55:06 -06:00
Shadowbroker d4c725de6e Update README.md
Former-commit-id: ac040a307b
2026-03-09 19:38:55 -06:00
Shadowbroker d756dd5bd3 Update README.md
Former-commit-id: b0f91c4baf
2026-03-09 19:36:59 -06:00
Shadowbroker d96e8f5c21 Update README.md
Former-commit-id: 5a8f3813c8
2026-03-09 19:35:52 -06:00
Shadowbroker 8afcbca667 Update README.md
Former-commit-id: 35f6b5900e
2026-03-09 19:34:42 -06:00
Shadowbroker b68de6a594 Delete assets directory
Former-commit-id: c002d2fa1b
2026-03-09 19:33:37 -06:00
Shadowbroker 36dec1088d Update README.md
Former-commit-id: 65d1c2b715
2026-03-09 19:29:13 -06:00
Shadowbroker a38f4cbaea Update README.md
Former-commit-id: ab178747cc
2026-03-09 19:25:20 -06:00
Shadowbroker 8e7ef8e95e Update README.md
Former-commit-id: 3713b214d5
2026-03-09 19:11:25 -06:00
Shadowbroker e597147a16 Update README.md
Former-commit-id: b1827b5fa6
2026-03-09 19:07:36 -06:00
Shadowbroker 71c085cdd5 Add files via upload
Former-commit-id: c4e48e2579
2026-03-09 19:03:13 -06:00
Shadowbroker c9cec26309 Create placeholder
Former-commit-id: 1f3036e106
2026-03-09 18:26:38 -06:00
Shadowbroker 03aae3216b Delete assets
Former-commit-id: a0531362a9
2026-03-09 18:24:20 -06:00
Shadowbroker 31755b294e Create assets
Former-commit-id: 23e1ad1b0d
2026-03-09 18:23:02 -06:00
Shadowbroker 9c831e37ff Update README.md
Former-commit-id: 83a7488740
2026-03-09 18:03:56 -06:00
51 changed files with 3813 additions and 12203 deletions
+16
View File
@@ -0,0 +1,16 @@
# ShadowBroker — Docker Compose Environment Variables
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# ── Required for backend container ─────────────────────────────
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
AIS_API_KEY=
# ── Optional ───────────────────────────────────────────────────
# LTA (Singapore traffic cameras) — leave blank to skip
# LTA_ACCOUNT_KEY=
# Override the backend URL the frontend uses (leave blank for auto-detect)
# NEXT_PUBLIC_API_URL=http://192.168.1.50:8000
+163 -13
View File
@@ -13,17 +13,29 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build-and-push-frontend: build-frontend:
runs-on: ubuntu-latest runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write id-token: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.0.0
@@ -41,28 +53,103 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
- name: Build and push Docker image - name: Build and push Docker image by digest
id: build-and-push id: build
uses: docker/build-push-action@v5.0.0 uses: docker/build-push-action@v5.0.0
with: with:
context: ./frontend context: ./frontend
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha,scope=frontend-${{ matrix.platform }}
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,scope=frontend-${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
build-and-push-backend: - name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests/frontend
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/frontend/${digest#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-frontend-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/frontend/*
if-no-files-found: error
retention-days: 1
merge-frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
needs: build-frontend
permissions:
contents: read
packages: write
steps:
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests/frontend
pattern: digests-frontend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Create and push manifest
working-directory: /tmp/digests/frontend
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend@sha256:%s ' *)
build-backend:
runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write id-token: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.0.0
@@ -80,13 +167,76 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
- name: Build and push Docker image - name: Build and push Docker image by digest
id: build-and-push id: build
uses: docker/build-push-action@v5.0.0 uses: docker/build-push-action@v5.0.0
with: with:
context: ./backend context: ./backend
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha,scope=backend-${{ matrix.platform }}
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,scope=backend-${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
- name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests/backend
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/backend/${digest#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/backend/*
if-no-files-found: error
retention-days: 1
merge-backend:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
needs: build-backend
permissions:
contents: read
packages: write
steps:
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests/backend
pattern: digests-backend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Create and push manifest
working-directory: /tmp/digests/backend
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend@sha256:%s ' *)
+9
View File
@@ -68,6 +68,12 @@ TheAirTraffic Database.xlsx
# Debug dumps & release artifacts # Debug dumps & release artifacts
backend/dump.json backend/dump.json
backend/debug_fast.json backend/debug_fast.json
backend/nyc_sample.json
backend/nyc_full.json
backend/liveua_test.html
backend/out_liveua.json
frontend/server_logs*.txt
frontend/cctv.db
*.zip *.zip
.git_backup/ .git_backup/
@@ -88,3 +94,6 @@ clean_zip.py
zip_repo.py zip_repo.py
refactor_cesium.py refactor_cesium.py
jobs.json jobs.json
.claude
.mise.local.toml
+97 -27
View File
@@ -7,7 +7,16 @@
</p> </p>
--- ---
![Shadowbroker1](https://github.com/user-attachments/assets/000b94eb-bf33-4e8b-8c60-15ca4a723c68)
https://github.com/user-attachments/assets/248208ec-62f7-49d1-831d-4bd0a1fa6852
**ShadowBroker** is a real-time, multi-domain OSINT dashboard that aggregates live data from dozens of open-source intelligence feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time. **ShadowBroker** is a real-time, multi-domain OSINT dashboard that aggregates live data from dozens of open-source intelligence feeds and renders them on a unified dark-ops map interface. It tracks aircraft, ships, satellites, earthquakes, conflict zones, CCTV networks, GPS jamming, and breaking geopolitical events — all updating in real time.
Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's designed for analysts, researchers, and enthusiasts who want a single-pane-of-glass view of global activity. Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's designed for analysts, researchers, and enthusiasts who want a single-pane-of-glass view of global activity.
@@ -16,8 +25,9 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
## Interesting Use Cases ## Interesting Use Cases
* Track private jets of billionaires * Track everything from Air Force One to the private jets of billionaires, dictators, and corporations
* Monitor satellites passing overhead * Monitor satellites passing overhead and see high-resolution satellite imagery
* Nose around local emergency scanners
* Watch naval traffic worldwide * Watch naval traffic worldwide
* Detect GPS jamming zones * Detect GPS jamming zones
* Follow earthquakes and disasters in real time * Follow earthquakes and disasters in real time
@@ -73,7 +83,7 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
* **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events) * **Global Incidents** — GDELT-powered conflict event aggregation (last 8 hours, ~1,000 events)
* **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map * **Ukraine Frontline** — Live warfront GeoJSON from DeepState Map
* **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources * **SIGINT/RISINT News Feed** — Real-time RSS aggregation from multiple intelligence-focused sources with user-customizable feeds (up to 20 sources, configurable priority weights 1-5)
* **Region Dossier** — Right-click anywhere on the map for: * **Region Dossier** — Right-click anywhere on the map for:
* Country profile (population, capital, languages, currencies, area) * Country profile (population, capital, languages, currencies, area)
* Head of state & government type (Wikidata SPARQL) * Head of state & government type (Wikidata SPARQL)
@@ -110,6 +120,13 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
* Red overlay squares with "GPS JAM XX%" severity labels * Red overlay squares with "GPS JAM XX%" severity labels
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications * **Radio Intercept Panel** — Scanner-style UI for monitoring communications
### 🔥 Environmental & Infrastructure Monitoring
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1G5). Data from SWPC planetary K-index 1-minute feed.
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
* **Data Center Mapping** — 2,000+ global data centers plotted from a curated dataset. Clustered purple markers with server-rack icons. Click for operator, location, and automatic internet outage cross-referencing by country.
### 🌐 Additional Layers ### 🌐 Additional Layers
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers * **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
@@ -118,6 +135,8 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
* **Measurement Tool** — Point-to-point distance & bearing measurement on the map * **Measurement Tool** — Point-to-point distance & bearing measurement on the map
* **LOCATE Bar** — Search by coordinates (31.8, 34.8) or place name (Tehran, Strait of Hormuz) to fly directly to any location — geocoded via OpenStreetMap Nominatim * **LOCATE Bar** — Search by coordinates (31.8, 34.8) or place name (Tehran, Strait of Hormuz) to fly directly to any location — geocoded via OpenStreetMap Nominatim
![Gaza](https://github.com/user-attachments/assets/f2c953b2-3528-4360-af5a-7ea34ff28489)
--- ---
## 🏗️ Architecture ## 🏗️ Architecture
@@ -148,6 +167,9 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │ │ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │ │ │ │ DeepState│ RSS │ Region │ GPS │ │ │
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │ │ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │ │ │ └──────────┴──────────┴──────────┴───────────┘ │ │
│ └──────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────┘
@@ -178,6 +200,10 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No | | [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No | | [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No |
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No | | [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
| [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov) | NOAA-20 VIIRS fire/thermal hotspots | ~120s | No |
| [NOAA SWPC](https://services.swpc.noaa.gov) | Space weather Kp index & solar events | ~120s | No |
| [IODA (Georgia Tech)](https://ioda.inetintel.cc.gatech.edu) | Regional internet outage alerts | ~120s | No |
| [DC Map (GitHub)](https://github.com/Ringmast4r/Data-Center-Map---Global) | Global data center locations | Static (cached 7d) | No |
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No | | [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
--- ---
@@ -197,36 +223,76 @@ cd Shadowbroker
Open `http://localhost:3000` to view the dashboard. Open `http://localhost:3000` to view the dashboard.
> **Deploying publicly or on a LAN?** The frontend **auto-detects** the > **Deploying publicly or on a LAN?** No configuration needed for most setups.
> backend — it uses your browser's hostname with port `8000` > The frontend proxies all API calls through the Next.js server to `BACKEND_URL`,
> (e.g. if you visit `http://192.168.1.50:3000`, API calls go to > which defaults to `http://backend:8000` (Docker internal networking).
> `http://192.168.1.50:8000`). **No configuration needed** for most setups. > Port 8000 does not need to be exposed externally.
> >
> If your backend runs on a **different port or host** (reverse proxy, > If your backend runs on a **different host or port**, set `BACKEND_URL` at runtime — no rebuild required:
> custom Docker port mapping, separate server), set `NEXT_PUBLIC_API_URL`:
> >
> ```bash > ```bash
> # Linux / macOS > # Linux / macOS
> NEXT_PUBLIC_API_URL=http://myserver.com:9096 docker-compose up -d --build > BACKEND_URL=http://myserver.com:9096 docker-compose up -d
> >
> # Podman (via compose.sh wrapper) > # Podman (via compose.sh wrapper)
> NEXT_PUBLIC_API_URL=http://192.168.1.50:9096 ./compose.sh up -d --build > BACKEND_URL=http://192.168.1.50:9096 ./compose.sh up -d
> >
> # Windows (PowerShell) > # Windows (PowerShell)
> $env:NEXT_PUBLIC_API_URL="http://myserver.com:9096"; docker-compose up -d --build > $env:BACKEND_URL="http://myserver.com:9096"; docker-compose up -d
> >
> # Or add to a .env file next to docker-compose.yml: > # Or add to a .env file next to docker-compose.yml:
> # NEXT_PUBLIC_API_URL=http://myserver.com:9096 > # BACKEND_URL=http://myserver.com:9096
> ``` > ```
>
> This is a **build-time** variable (Next.js limitation) — it gets baked into
> the frontend during `npm run build`. Changing it requires a rebuild.
If you prefer to call the container engine directly, Podman users can run `podman compose up -d`, or force the wrapper to use Podman with `./compose.sh --engine podman up -d`. If you prefer to call the container engine directly, Podman users can run `podman compose up -d`, or force the wrapper to use Podman with `./compose.sh --engine podman up -d`.
Depending on your local Podman configuration, `podman compose` may still delegate to an external compose provider while talking to the Podman socket. Depending on your local Podman configuration, `podman compose` may still delegate to an external compose provider while talking to the Podman socket.
--- ---
### 🐋 Standalone Deploy (Portainer, Uncloud, NAS, etc.)
No need to clone the repo. Use the pre-built images published to the GitHub Container Registry.
Create a `docker-compose.yml` with the following content and deploy it directly — paste it into Portainer's stack editor, `uncloud deploy`, or any Docker host:
```yaml
services:
backend:
image: ghcr.io/bigbodycobain/shadowbroker-backend:latest
container_name: shadowbroker-backend
ports:
- "8000:8000"
environment:
- AIS_API_KEY=your_aisstream_key # Required — get one free at aisstream.io
- OPENSKY_CLIENT_ID= # Optional — higher flight data rate limits
- OPENSKY_CLIENT_SECRET= # Optional — paired with Client ID above
- LTA_ACCOUNT_KEY= # Optional — Singapore CCTV cameras
- CORS_ORIGINS= # Optional — comma-separated allowed origins
volumes:
- backend_data:/app/data
restart: unless-stopped
frontend:
image: ghcr.io/bigbodycobain/shadowbroker-frontend:latest
container_name: shadowbroker-frontend
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8000 # Docker internal networking — no rebuild needed
depends_on:
- backend
restart: unless-stopped
volumes:
backend_data:
```
> **How it works:** The frontend container proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. The browser only ever talks to port 3000 — port 8000 does not need to be exposed externally.
>
> `BACKEND_URL` is a plain runtime environment variable (not a build-time `NEXT_PUBLIC_*`), so you can change it in Portainer, Uncloud, or any compose editor without rebuilding the image. Set it to the address where your backend is reachable from inside the Docker network (e.g. `http://backend:8000`, `http://192.168.1.50:8000`).
---
### 📦 Quick Start (No Code Required) ### 📦 Quick Start (No Code Required)
If you just want to run the dashboard without dealing with terminal commands: If you just want to run the dashboard without dealing with terminal commands:
@@ -312,6 +378,9 @@ All layers are independently toggleable from the left panel:
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery | | MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery | | High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers | | KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
| Data Centers | ❌ OFF | Global data center locations (2,000+) |
| Day / Night Cycle | ✅ ON | Solar terminator overlay | | Day / Night Cycle | ✅ ON | Solar terminator overlay |
--- ---
@@ -323,8 +392,9 @@ The platform is optimized for handling massive real-time datasets:
* **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB) * **Gzip Compression** — API payloads compressed ~92% (11.6 MB → 915 KB)
* **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing * **ETag Caching** — `304 Not Modified` responses skip redundant JSON parsing
* **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered * **Viewport Culling** — Only features within the visible map bounds (+20% buffer) are rendered
* **Clustered Rendering** — Ships, CCTV, and earthquakes use MapLibre clustering to reduce feature count * **Imperative Map Updates** — High-volume layers (flights, satellites, fires) bypass React reconciliation via direct `setData()` calls
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom * **Clustered Rendering** — Ships, CCTV, earthquakes, and data centers use MapLibre clustering to reduce feature count
* **Debounced Viewport Updates** — 300ms debounce prevents GeoJSON rebuild thrash during pan/zoom; 2s debounce on dense layers (satellites, fires)
* **Position Interpolation** — Smooth 10s tick animation between data refreshes * **Position Interpolation** — Smooth 10s tick animation between data refreshes
* **React.memo** — Heavy components wrapped to prevent unnecessary re-renders * **React.memo** — Heavy components wrapped to prevent unnecessary re-renders
* **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size * **Coordinate Precision** — Lat/lng rounded to 5 decimals (~1m) to reduce JSON size
@@ -339,6 +409,8 @@ live-risk-dashboard/
│ ├── main.py # FastAPI app, middleware, API routes │ ├── main.py # FastAPI app, middleware, API routes
│ ├── carrier_cache.json # Persisted carrier OSINT positions │ ├── carrier_cache.json # Persisted carrier OSINT positions
│ ├── cctv.db # SQLite CCTV camera database │ ├── cctv.db # SQLite CCTV camera database
│ ├── config/
│ │ └── news_feeds.json # User-customizable RSS feed list (persists across restarts)
│ └── services/ │ └── services/
│ ├── data_fetcher.py # Core scheduler — fetches all data sources │ ├── data_fetcher.py # Core scheduler — fetches all data sources
│ ├── ais_stream.py # AIS WebSocket client (25K+ vessels) │ ├── ais_stream.py # AIS WebSocket client (25K+ vessels)
@@ -350,7 +422,8 @@ live-risk-dashboard/
│ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper │ ├── kiwisdr_fetcher.py # KiwiSDR receiver scraper
│ ├── sentinel_search.py # Sentinel-2 STAC imagery search │ ├── sentinel_search.py # Sentinel-2 STAC imagery search
│ ├── network_utils.py # HTTP client with curl fallback │ ├── network_utils.py # HTTP client with curl fallback
── api_settings.py # API key management ── api_settings.py # API key management
│ └── news_feed_config.py # RSS feed config manager (add/remove/weight feeds)
├── frontend/ ├── frontend/
│ ├── src/ │ ├── src/
@@ -368,7 +441,7 @@ live-risk-dashboard/
│ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel │ │ ├── RadioInterceptPanel.tsx # Scanner-style radio panel
│ │ ├── FindLocateBar.tsx # Search/locate bar │ │ ├── FindLocateBar.tsx # Search/locate bar
│ │ ├── ChangelogModal.tsx # Version changelog popup │ │ ├── ChangelogModal.tsx # Version changelog popup
│ │ ├── SettingsPanel.tsx # App settings │ │ ├── SettingsPanel.tsx # App settings (API Keys + News Feed manager)
│ │ ├── ScaleBar.tsx # Map scale indicator │ │ ├── ScaleBar.tsx # Map scale indicator
│ │ ├── WikiImage.tsx # Wikipedia image fetcher │ │ ├── WikiImage.tsx # Wikipedia image fetcher
│ │ └── ErrorBoundary.tsx # Crash recovery wrapper │ │ └── ErrorBoundary.tsx # Crash recovery wrapper
@@ -391,16 +464,13 @@ OPENSKY_CLIENT_SECRET=your_opensky_secret # OAuth2 — paired with Client ID
LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras LTA_ACCOUNT_KEY=your_lta_key # Singapore CCTV cameras
``` ```
### Frontend (optional) ### Frontend
| Variable | Where to set | Purpose | | Variable | Where to set | Purpose |
|---|---|---| |---|---|---|
| `NEXT_PUBLIC_API_URL` | `.env` next to `docker-compose.yml`, or shell env | Override backend URL when deploying publicly or behind a reverse proxy. Leave unset for auto-detection. | | `BACKEND_URL` | `environment` in `docker-compose.yml`, or shell env | URL the Next.js server uses to proxy API calls to the backend. Defaults to `http://backend:8000`. **Runtime variable — no rebuild needed.** |
**How auto-detection works:** When `NEXT_PUBLIC_API_URL` is not set, the frontend **How it works:** The frontend proxies all `/api/*` requests through the Next.js server to `BACKEND_URL` using Docker's internal networking. Browsers only talk to port 3000; port 8000 never needs to be exposed externally. For local dev without Docker, `BACKEND_URL` defaults to `http://localhost:8000`.
reads `window.location.hostname` in the browser and calls `{protocol}//{hostname}:8000`.
This means the dashboard works on `localhost`, LAN IPs, and public domains without
any configuration — as long as the backend is reachable on port 8000 of the same host.
--- ---
+15
View File
@@ -0,0 +1,15 @@
# ShadowBroker Backend — Environment Variables
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# ── Required Keys ──────────────────────────────────────────────
# Without these, the corresponding data layers will be empty.
OPENSKY_CLIENT_ID= # https://opensky-network.org/ — free account, OAuth2 client ID
OPENSKY_CLIENT_SECRET= # OAuth2 client secret from your OpenSky dashboard
AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# ── Optional ───────────────────────────────────────────────────
# Override allowed CORS origins (comma-separated). Defaults to localhost + LAN auto-detect.
# CORS_ORIGINS=http://192.168.1.50:3000,https://my-domain.com
+6 -4
View File
@@ -9,16 +9,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install dependencies # Install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
# Copy manifests first so this layer is cached unless deps change
COPY package*.json ./
RUN npm install --omit=dev
# Copy source code # Copy source code
COPY . . COPY . .
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
RUN npm install --omit=dev
# Create a non-root user for security # Create a non-root user for security
RUN adduser --system --uid 1001 backenduser \ RUN adduser --system --uid 1001 backenduser \
&& chown -R backenduser /app && chown -R backenduser /app
+44
View File
@@ -0,0 +1,44 @@
{
"feeds": [
{
"name": "NPR",
"url": "https://feeds.npr.org/1004/rss.xml",
"weight": 4
},
{
"name": "BBC",
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
"weight": 3
},
{
"name": "AlJazeera",
"url": "https://www.aljazeera.com/xml/rss/all.xml",
"weight": 2
},
{
"name": "NYT",
"url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
"weight": 1
},
{
"name": "GDACS",
"url": "https://www.gdacs.org/xml/rss.xml",
"weight": 5
},
{
"name": "NHK",
"url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
"weight": 3
},
{
"name": "CNA",
"url": "https://www.channelnewsasia.com/rssfeed/8395986",
"weight": 3
},
{
"name": "Mercopress",
"url": "https://en.mercopress.com/rss/",
"weight": 3
}
]
}
@@ -0,0 +1 @@
430ac93c4f7c4fb5a3e596ec38e3b7794c731cc1
@@ -0,0 +1 @@
476b691be156eb4fe6a6ad80f882c1dbaded8c33
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{}
@@ -0,0 +1 @@
38a18cbbf1acbec5eb9266b809c28d31e2941c53
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
5c3b1c768973ca54e9a1befee8dc075f38e8cc56
-1
View File
@@ -1 +0,0 @@
2b64633521ffb6f06da36e19f5c8eb86979e2187
+166
View File
@@ -0,0 +1,166 @@
"""
Geocode data center street addresses via Nominatim (OpenStreetMap).
Rate limit: 1 request/second (Nominatim policy).
Resumable: caches results in geocode_cache.json so interrupted runs can continue.
"""
import json
import time
import urllib.request
import urllib.parse
import os
import sys
# Fix Windows console encoding + force unbuffered output
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
# Force line-buffered stdout for detached processes
class Unbuffered:
def __init__(self, stream):
self.stream = stream
def write(self, data):
self.stream.write(data)
self.stream.flush()
def writelines(self, datas):
self.stream.writelines(datas)
self.stream.flush()
def __getattr__(self, attr):
return getattr(self.stream, attr)
sys.stdout = Unbuffered(sys.stdout)
DATA_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters.json")
CACHE_FILE = os.path.join(os.path.dirname(__file__), "data", "geocode_cache.json")
OUTPUT_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters_geocoded.json")
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
USER_AGENT = "ShadowBroker-DataCenterGeocoder/1.0"
def geocode_address(address: str, retries: int = 3) -> tuple[float, float] | None:
"""Geocode a single address via Nominatim. Returns (lat, lng) or None."""
params = urllib.parse.urlencode({"q": address, "format": "json", "limit": 1})
url = f"{NOMINATIM_URL}?{params}"
for attempt in range(retries):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
resp = urllib.request.urlopen(req, timeout=15)
data = json.loads(resp.read())
if data:
return float(data[0]["lat"]), float(data[0]["lon"])
return None # Valid response but no results
except Exception as e:
if attempt < retries - 1:
wait = 2 ** (attempt + 1)
print(f" RETRY ({attempt+1}/{retries}): {e} — waiting {wait}s")
time.sleep(wait)
else:
print(f" ERROR (gave up after {retries} attempts): {e}")
return None
def main():
with open(DATA_FILE, "r", encoding="utf-8") as f:
dcs = json.load(f)
# Load cache
cache = {}
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r", encoding="utf-8") as f:
cache = json.load(f)
print(f"Loaded {len(cache)} cached geocode results")
# Filter to DCs with real street addresses
to_geocode = []
skipped = 0
for i, dc in enumerate(dcs):
street = (dc.get("street") or "").strip()
if not street or len(street) <= 3 or street.lower() in ("tbc", "n/a", "na", "-"):
skipped += 1
continue
to_geocode.append((i, dc))
print(f"Total DCs: {len(dcs)}")
print(f"Skipped (no real address): {skipped}")
print(f"To geocode: {len(to_geocode)}")
# Count how many already cached
already_cached = sum(1 for _, dc in to_geocode if dc.get("address", "") in cache)
need_api = len(to_geocode) - already_cached
print(f"Already cached: {already_cached}")
print(f"Need API calls: {need_api}")
if need_api > 0:
print(f"Estimated time: {need_api // 60}m {need_api % 60}s")
print()
geocoded = 0
failed = 0
api_calls = 0
save_interval = 50 # Save cache every 50 API calls
for idx, (i, dc) in enumerate(to_geocode):
address = dc.get("address", "").strip()
if not address:
# Build address from parts
parts = [dc.get("street", ""), dc.get("zip", ""), dc.get("city", ""), dc.get("country", "")]
address = " ".join(p.strip() for p in parts if p and p.strip())
if not address:
failed += 1
continue
# Check cache first
if address in cache:
result = cache[address]
if result:
dcs[i]["lat"] = result[0]
dcs[i]["lng"] = result[1]
dcs[i]["geocode_source"] = "nominatim"
geocoded += 1
else:
failed += 1
continue
# API call — Nominatim requires 1 req/s, use 1.5s to avoid 429s after heavy use
time.sleep(1.5)
coords = geocode_address(address)
api_calls += 1
if coords:
cache[address] = coords
dcs[i]["lat"] = coords[0]
dcs[i]["lng"] = coords[1]
dcs[i]["geocode_source"] = "nominatim"
geocoded += 1
print(f"[{api_calls}/{need_api}] OK: {dc.get('name', '?')} -> ({coords[0]:.4f}, {coords[1]:.4f})")
else:
cache[address] = None
failed += 1
print(f"[{api_calls}/{need_api}] FAIL: {dc.get('name', '?')} | {address}")
# Periodic cache save
if api_calls % save_interval == 0:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f)
print(f" -- Cache saved ({len(cache)} entries) --")
# Final save
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f)
# Write output - only DCs with real coordinates
output = [dc for dc in dcs if dc.get("lat") is not None and dc.get("lng") is not None]
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2)
print(f"\nDone!")
print(f"Geocoded: {geocoded}")
print(f"Failed: {failed}")
print(f"API calls made: {api_calls}")
print(f"Output: {len(output)} DCs with coordinates -> {OUTPUT_FILE}")
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
+127 -34
View File
@@ -1,15 +1,76 @@
import os
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Docker Swarm Secrets support
# For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY),
# the file is read and its trimmed content is placed into VAR.
# This MUST run before service imports — modules read os.environ at import time.
# ---------------------------------------------------------------------------
_SECRET_VARS = [
"AIS_API_KEY",
"OPENSKY_CLIENT_ID",
"OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY",
"CORS_ORIGINS",
]
for _var in _SECRET_VARS:
_file_var = f"{_var}_FILE"
_file_path = os.environ.get(_file_var)
if _file_path:
try:
with open(_file_path, "r") as _f:
_value = _f.read().strip()
if _value:
os.environ[_var] = _value
logger.info(f"Loaded secret {_var} from {_file_path}")
else:
logger.warning(f"Secret file {_file_path} for {_var} is empty")
except FileNotFoundError:
logger.error(f"Secret file {_file_path} for {_var} not found")
except Exception as _e:
logger.error(f"Failed to read secret file {_file_path} for {_var}: {_e}")
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps
from services.ais_stream import start_ais_stream, stop_ais_stream from services.ais_stream import start_ais_stream, stop_ais_stream
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
import uvicorn import uvicorn
import logging
import hashlib import hashlib
import json as json_mod import json as json_mod
import socket
logging.basicConfig(level=logging.INFO)
def _build_cors_origins():
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
Falls back to wildcard only if auto-detection fails entirely."""
origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:8000",
"http://127.0.0.1:8000",
]
# Add this machine's LAN IPs (covers common home/office setups)
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
ip = info[4][0]
if ip not in ("127.0.0.1", "0.0.0.0"):
origins.append(f"http://{ip}:3000")
origins.append(f"http://{ip}:8000")
except Exception:
pass
# Allow user override via CORS_ORIGINS env var (comma-separated)
extra = os.environ.get("CORS_ORIGINS", "")
if extra:
origins.extend([o.strip() for o in extra.split(",") if o.strip()])
return list(set(origins)) # deduplicate
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -29,7 +90,7 @@ from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # Must be permissive — users access from localhost, LAN IPs, Docker, custom ports allow_origins=_build_cors_origins(),
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -49,6 +110,15 @@ async def force_refresh():
async def live_data(): async def live_data():
return get_latest_data() return get_latest_data()
def _etag_response(request: Request, payload: dict, prefix: str = "", default=None):
"""Serialize once, hash the bytes for ETag, return 304 or full response."""
content = json_mod.dumps(payload, default=default)
etag = hashlib.md5(f"{prefix}{content[:256]}".encode()).hexdigest()[:16]
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
return Response(content=content, media_type="application/json",
headers={"ETag": etag, "Cache-Control": "no-cache"})
@app.get("/api/live-data/fast") @app.get("/api/live-data/fast")
async def live_data_fast(request: Request): async def live_data_fast(request: Request):
d = get_latest_data() d = get_latest_data()
@@ -59,24 +129,15 @@ async def live_data_fast(request: Request):
"private_jets": d.get("private_jets", []), "private_jets": d.get("private_jets", []),
"tracked_flights": d.get("tracked_flights", []), "tracked_flights": d.get("tracked_flights", []),
"ships": d.get("ships", []), "ships": d.get("ships", []),
"satellites": d.get("satellites", []),
"cctv": d.get("cctv", []), "cctv": d.get("cctv", []),
"uavs": d.get("uavs", []), "uavs": d.get("uavs", []),
"liveuamap": d.get("liveuamap", []), "liveuamap": d.get("liveuamap", []),
"gps_jamming": d.get("gps_jamming", []), "gps_jamming": d.get("gps_jamming", []),
"satellites": d.get("satellites", []),
"satellite_source": d.get("satellite_source", "none"),
"freshness": dict(source_timestamps),
} }
# ETag includes last_updated timestamp so it changes on every data refresh, return _etag_response(request, payload, prefix="fast|")
# not just when item counts change (old bug: positions went stale)
last_updated = d.get("last_updated", "")
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
return Response(
content=json_mod.dumps(payload),
media_type="application/json",
headers={"ETag": etag, "Cache-Control": "no-cache"}
)
@app.get("/api/live-data/slow") @app.get("/api/live-data/slow")
async def live_data_slow(request: Request): async def live_data_slow(request: Request):
@@ -93,19 +154,14 @@ async def live_data_slow(request: Request):
"gdelt": d.get("gdelt", []), "gdelt": d.get("gdelt", []),
"airports": d.get("airports", []), "airports": d.get("airports", []),
"satellites": d.get("satellites", []), "satellites": d.get("satellites", []),
"kiwisdr": d.get("kiwisdr", []) "kiwisdr": d.get("kiwisdr", []),
"space_weather": d.get("space_weather"),
"internet_outages": d.get("internet_outages", []),
"firms_fires": d.get("firms_fires", []),
"datacenters": d.get("datacenters", []),
"freshness": dict(source_timestamps),
} }
# ETag based on last_updated + item counts return _etag_response(request, payload, prefix="slow|", default=str)
last_updated = d.get("last_updated", "")
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items())
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
return Response(
content=json_mod.dumps(payload, default=str),
media_type="application/json",
headers={"ETag": etag, "Cache-Control": "no-cache"}
)
@app.get("/api/debug-latest") @app.get("/api/debug-latest")
async def debug_latest_data(): async def debug_latest_data():
@@ -128,7 +184,12 @@ async def health_check():
"earthquakes": len(d.get("earthquakes", [])), "earthquakes": len(d.get("earthquakes", [])),
"cctv": len(d.get("cctv", [])), "cctv": len(d.get("cctv", [])),
"news": len(d.get("news", [])), "news": len(d.get("news", [])),
"uavs": len(d.get("uavs", [])),
"firms_fires": len(d.get("firms_fires", [])),
"liveuamap": len(d.get("liveuamap", [])),
"gdelt": len(d.get("gdelt", [])),
}, },
"freshness": dict(source_timestamps),
"uptime_seconds": round(time.time() - _start_time), "uptime_seconds": round(time.time() - _start_time),
} }
@@ -161,9 +222,9 @@ async def api_get_nearest_radios_list(lat: float, lng: float, limit: int = 5):
from services.network_utils import fetch_with_curl from services.network_utils import fetch_with_curl
@app.get("/api/route/{callsign}") @app.get("/api/route/{callsign}")
async def get_flight_route(callsign: str): async def get_flight_route(callsign: str, lat: float = 0.0, lng: float = 0.0):
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign}]}, timeout=10) r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign, "lat": lat, "lng": lng}]}, timeout=10)
if r.status_code == 200: if r and r.status_code == 200:
data = r.json() data = r.json()
route_list = [] route_list = []
if isinstance(data, dict): if isinstance(data, dict):
@@ -175,9 +236,13 @@ async def get_flight_route(callsign: str):
route = route_list[0] route = route_list[0]
airports = route.get("_airports", []) airports = route.get("_airports", [])
if len(airports) >= 2: if len(airports) >= 2:
orig = airports[0]
dest = airports[-1]
return { return {
"orig_loc": [airports[0].get("lon", 0), airports[0].get("lat", 0)], "orig_loc": [orig.get("lon", 0), orig.get("lat", 0)],
"dest_loc": [airports[-1].get("lon", 0), airports[-1].get("lat", 0)] "dest_loc": [dest.get("lon", 0), dest.get("lat", 0)],
"origin_name": f"{orig.get('iata', '') or orig.get('icao', '')}: {orig.get('name', 'Unknown')}",
"dest_name": f"{dest.get('iata', '') or dest.get('icao', '')}: {dest.get('name', 'Unknown')}",
} }
return {} return {}
@@ -216,6 +281,34 @@ async def api_update_key(body: ApiKeyUpdate):
return {"status": "updated", "env_key": body.env_key} return {"status": "updated", "env_key": body.env_key}
return {"status": "error", "message": "Failed to update .env file"} return {"status": "error", "message": "Failed to update .env file"}
# ---------------------------------------------------------------------------
# News Feed Configuration
# ---------------------------------------------------------------------------
from services.news_feed_config import get_feeds, save_feeds, reset_feeds
@app.get("/api/settings/news-feeds")
async def api_get_news_feeds():
return get_feeds()
@app.put("/api/settings/news-feeds")
async def api_save_news_feeds(request: Request):
body = await request.json()
ok = save_feeds(body)
if ok:
return {"status": "updated", "count": len(body)}
return Response(
content=json_mod.dumps({"status": "error", "message": "Validation failed (max 20 feeds, each needs name/url/weight 1-5)"}),
status_code=400,
media_type="application/json",
)
@app.post("/api/settings/news-feeds/reset")
async def api_reset_news_feeds():
ok = reset_feeds()
if ok:
return {"status": "reset", "feeds": get_feeds()}
return {"status": "error", "message": "Failed to reset feeds"}
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -24
View File
@@ -238,49 +238,51 @@ def _ais_stream_loop():
logger.info("AIS Stream proxy started — receiving vessel data") logger.info("AIS Stream proxy started — receiving vessel data")
msg_count = 0 msg_count = 0
ok_streak = 0 # Track consecutive successful messages for backoff reset
last_log_time = time.time()
for raw_msg in iter(process.stdout.readline, ''): for raw_msg in iter(process.stdout.readline, ''):
if not _ws_running: if not _ws_running:
process.terminate() process.terminate()
break break
raw_msg = raw_msg.strip() raw_msg = raw_msg.strip()
if not raw_msg: if not raw_msg:
continue continue
try: try:
data = json.loads(raw_msg) data = json.loads(raw_msg)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if "error" in data: if "error" in data:
logger.error(f"AIS Stream error: {data['error']}") logger.error(f"AIS Stream error: {data['error']}")
continue continue
msg_type = data.get("MessageType", "") msg_type = data.get("MessageType", "")
metadata = data.get("MetaData", {}) metadata = data.get("MetaData", {})
message = data.get("Message", {}) message = data.get("Message", {})
mmsi = metadata.get("MMSI", 0) mmsi = metadata.get("MMSI", 0)
if not mmsi: if not mmsi:
continue continue
with _vessels_lock: with _vessels_lock:
if mmsi not in _vessels: if mmsi not in _vessels:
_vessels[mmsi] = {"_updated": time.time()} _vessels[mmsi] = {"_updated": time.time()}
vessel = _vessels[mmsi] vessel = _vessels[mmsi]
# Update position from PositionReport or StandardClassBPositionReport # Update position from PositionReport or StandardClassBPositionReport
if msg_type in ("PositionReport", "StandardClassBPositionReport"): if msg_type in ("PositionReport", "StandardClassBPositionReport"):
report = message.get(msg_type, {}) report = message.get(msg_type, {})
lat = report.get("Latitude", metadata.get("latitude", 0)) lat = report.get("Latitude", metadata.get("latitude", 0))
lng = report.get("Longitude", metadata.get("longitude", 0)) lng = report.get("Longitude", metadata.get("longitude", 0))
# Skip invalid positions # Skip invalid positions
if lat == 0 and lng == 0: if lat == 0 and lng == 0:
continue continue
if abs(lat) > 90 or abs(lng) > 180: if abs(lat) > 90 or abs(lng) > 180:
continue continue
with _vessels_lock: with _vessels_lock:
vessel["lat"] = lat vessel["lat"] = lat
vessel["lng"] = lng vessel["lng"] = lng
@@ -292,12 +294,12 @@ def _ais_stream_loop():
# Use metadata name if we don't have one yet # Use metadata name if we don't have one yet
if not vessel.get("name") or vessel["name"] == "UNKNOWN": if not vessel.get("name") or vessel["name"] == "UNKNOWN":
vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN" vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
# Update static data from ShipStaticData # Update static data from ShipStaticData
elif msg_type == "ShipStaticData": elif msg_type == "ShipStaticData":
static = message.get("ShipStaticData", {}) static = message.get("ShipStaticData", {})
ais_type = static.get("Type", 0) ais_type = static.get("Type", 0)
with _vessels_lock: with _vessels_lock:
vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN" vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN"
vessel["callsign"] = (static.get("CallSign", "") or "").strip() vessel["callsign"] = (static.get("CallSign", "") or "").strip()
@@ -306,21 +308,24 @@ def _ais_stream_loop():
vessel["ais_type_code"] = ais_type vessel["ais_type_code"] = ais_type
vessel["type"] = classify_vessel(ais_type, mmsi) vessel["type"] = classify_vessel(ais_type, mmsi)
vessel["_updated"] = time.time() vessel["_updated"] = time.time()
msg_count += 1 msg_count += 1
if msg_count % 5000 == 0: ok_streak += 1
# Reset backoff after 200 consecutive successful messages
if ok_streak >= 200 and backoff > 1:
backoff = 1
ok_streak = 0
# Periodic logging + cache save (time-based instead of count-based to avoid lock in hot loop)
now = time.time()
if now - last_log_time >= 60:
with _vessels_lock: with _vessels_lock:
# Inline pruning: remove vessels not updated in 15 minutes
prune_cutoff = time.time() - 900
stale = [k for k, v in _vessels.items() if v.get("_updated", 0) < prune_cutoff]
for k in stale:
del _vessels[k]
count = len(_vessels) count = len(_vessels)
if stale:
logger.info(f"AIS pruned {len(stale)} stale vessels")
logger.info(f"AIS Stream: processed {msg_count} messages, tracking {count} vessels") logger.info(f"AIS Stream: processed {msg_count} messages, tracking {count} vessels")
_save_cache() # Auto-save every 5000 messages (~60 seconds) _save_cache()
last_log_time = now
except Exception as e: except Exception as e:
logger.error(f"AIS proxy connection error: {e}") logger.error(f"AIS proxy connection error: {e}")
if _ws_running: if _ws_running:
@@ -328,8 +333,6 @@ def _ais_stream_loop():
time.sleep(backoff) time.sleep(backoff)
backoff = min(backoff * 2, 60) # Double up to 60s max backoff = min(backoff * 2, 60) # Double up to 60s max
continue continue
# Reset backoff on successful connection (got at least some messages)
backoff = 1
def _run_ais_loop(): def _run_ais_loop():
+150 -86
View File
@@ -26,104 +26,116 @@ logger = logging.getLogger(__name__)
# Carrier registry: hull number → metadata + fallback position # Carrier registry: hull number → metadata + fallback position
# ----------------------------------------------------------------- # -----------------------------------------------------------------
CARRIER_REGISTRY: Dict[str, dict] = { CARRIER_REGISTRY: Dict[str, dict] = {
# Fallback positions sourced from USNI News Fleet & Marine Tracker (Mar 9, 2026)
# https://news.usni.org/2026/03/09/usni-news-fleet-and-marine-tracker-march-9-2026
# --- Bremerton, WA (Naval Base Kitsap) ---
# Distinct pier positions along Sinclair Inlet so carriers don't stack
"CVN-68": { "CVN-68": {
"name": "USS Nimitz (CVN-68)", "name": "USS Nimitz (CVN-68)",
"wiki": "https://en.wikipedia.org/wiki/USS_Nimitz", "wiki": "https://en.wikipedia.org/wiki/USS_Nimitz",
"homeport": "Bremerton, WA", "homeport": "Bremerton, WA",
"homeport_lat": 47.56, "homeport_lng": -122.63, "homeport_lat": 47.5535, "homeport_lng": -122.6400,
"fallback_lat": 21.35, "fallback_lng": -157.95, "fallback_lat": 47.5535, "fallback_lng": -122.6400,
"fallback_heading": 270,
"fallback_desc": "Pacific Fleet / Pearl Harbor"
},
"CVN-69": {
"name": "USS Dwight D. Eisenhower (CVN-69)",
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 18.0, "fallback_lng": 39.5,
"fallback_heading": 120,
"fallback_desc": "Red Sea / CENTCOM AOR"
},
"CVN-78": {
"name": "USS Gerald R. Ford (CVN-78)",
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 34.0, "fallback_lng": 25.0,
"fallback_heading": 90, "fallback_heading": 90,
"fallback_desc": "Eastern Mediterranean deterrence" "fallback_desc": "Bremerton, WA (Maintenance)"
},
"CVN-70": {
"name": "USS Carl Vinson (CVN-70)",
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 15.0, "fallback_lng": 115.0,
"fallback_heading": 45,
"fallback_desc": "South China Sea patrol"
},
"CVN-71": {
"name": "USS Theodore Roosevelt (CVN-71)",
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 22.0, "fallback_lng": 122.0,
"fallback_heading": 300,
"fallback_desc": "Philippine Sea / Taiwan Strait"
},
"CVN-72": {
"name": "USS Abraham Lincoln (CVN-72)",
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 21.0, "fallback_lng": -158.0,
"fallback_heading": 270,
"fallback_desc": "Pacific deployment"
},
"CVN-73": {
"name": "USS George Washington (CVN-73)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
"homeport": "Yokosuka, Japan",
"homeport_lat": 35.28, "homeport_lng": 139.67,
"fallback_lat": 35.0, "fallback_lng": 139.0,
"fallback_heading": 0,
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
},
"CVN-74": {
"name": "USS John C. Stennis (CVN-74)",
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 36.95, "fallback_lng": -76.33,
"fallback_heading": 0,
"fallback_desc": "RCOH / Norfolk (maintenance)"
},
"CVN-75": {
"name": "USS Harry S. Truman (CVN-75)",
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 36.0, "fallback_lng": 15.0,
"fallback_heading": 90,
"fallback_desc": "Mediterranean deployment"
}, },
"CVN-76": { "CVN-76": {
"name": "USS Ronald Reagan (CVN-76)", "name": "USS Ronald Reagan (CVN-76)",
"wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan", "wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan",
"homeport": "Bremerton, WA", "homeport": "Bremerton, WA",
"homeport_lat": 47.56, "homeport_lng": -122.63, "homeport_lat": 47.5580, "homeport_lng": -122.6360,
"fallback_lat": 47.56, "fallback_lng": -122.63, "fallback_lat": 47.5580, "fallback_lng": -122.6360,
"fallback_heading": 90,
"fallback_desc": "Bremerton, WA (Decommissioning)"
},
# --- Norfolk, VA (Naval Station Norfolk) ---
# Piers run N-S along Willoughby Bay; each carrier gets a distinct berth
"CVN-69": {
"name": "USS Dwight D. Eisenhower (CVN-69)",
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9465, "homeport_lng": -76.3265,
"fallback_lat": 36.9465, "fallback_lng": -76.3265,
"fallback_heading": 0, "fallback_heading": 0,
"fallback_desc": "Bremerton, WA (Homeport)" "fallback_desc": "Norfolk, VA (Post-deployment maintenance)"
},
"CVN-78": {
"name": "USS Gerald R. Ford (CVN-78)",
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9505, "homeport_lng": -76.3250,
"fallback_lat": 18.0, "fallback_lng": 39.5,
"fallback_heading": 0,
"fallback_desc": "Red Sea — Operation Epic Fury (USNI Mar 9)"
},
"CVN-74": {
"name": "USS John C. Stennis (CVN-74)",
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9540, "homeport_lng": -76.3235,
"fallback_lat": 36.98, "fallback_lng": -76.43,
"fallback_heading": 0,
"fallback_desc": "Newport News, VA (RCOH refueling overhaul)"
},
"CVN-75": {
"name": "USS Harry S. Truman (CVN-75)",
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9580, "homeport_lng": -76.3220,
"fallback_lat": 36.0, "fallback_lng": 15.0,
"fallback_heading": 0,
"fallback_desc": "Mediterranean Sea deployment (USNI Mar 9)"
}, },
"CVN-77": { "CVN-77": {
"name": "USS George H.W. Bush (CVN-77)", "name": "USS George H.W. Bush (CVN-77)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush", "wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush",
"homeport": "Norfolk, VA", "homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33, "homeport_lat": 36.9620, "homeport_lng": -76.3210,
"fallback_lat": 36.95, "fallback_lng": -76.33, "fallback_lat": 36.5, "fallback_lng": -74.0,
"fallback_heading": 0, "fallback_heading": 0,
"fallback_desc": "Norfolk, VA (Homeport)" "fallback_desc": "Atlantic — Pre-deployment workups (USNI Mar 9)"
},
# --- San Diego, CA (Naval Base San Diego) ---
# Carrier piers along the east shore of San Diego Bay, spread N-S
"CVN-70": {
"name": "USS Carl Vinson (CVN-70)",
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
"homeport": "San Diego, CA",
"homeport_lat": 32.6840, "homeport_lng": -117.1290,
"fallback_lat": 32.6840, "fallback_lng": -117.1290,
"fallback_heading": 180,
"fallback_desc": "San Diego, CA (Homeport)"
},
"CVN-71": {
"name": "USS Theodore Roosevelt (CVN-71)",
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
"homeport": "San Diego, CA",
"homeport_lat": 32.6885, "homeport_lng": -117.1280,
"fallback_lat": 32.6885, "fallback_lng": -117.1280,
"fallback_heading": 180,
"fallback_desc": "San Diego, CA (Maintenance)"
},
"CVN-72": {
"name": "USS Abraham Lincoln (CVN-72)",
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
"homeport": "San Diego, CA",
"homeport_lat": 32.6925, "homeport_lng": -117.1275,
"fallback_lat": 20.0, "fallback_lng": 64.0,
"fallback_heading": 0,
"fallback_desc": "Arabian Sea — Operation Epic Fury (USNI Mar 9)"
},
# --- Yokosuka, Japan (CFAY) ---
"CVN-73": {
"name": "USS George Washington (CVN-73)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
"homeport": "Yokosuka, Japan",
"homeport_lat": 35.2830, "homeport_lng": 139.6700,
"fallback_lat": 35.2830, "fallback_lng": 139.6700,
"fallback_heading": 180,
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
}, },
} }
@@ -302,7 +314,8 @@ def _parse_carrier_positions_from_news(articles: List[dict]) -> Dict[str, dict]:
"lat": coords[0], "lat": coords[0],
"lng": coords[1], "lng": coords[1],
"desc": title[:100], "desc": title[:100],
"source": "GDELT OSINT", "source": "GDELT News API",
"source_url": article.get("url", "https://api.gdeltproject.org"),
"updated": datetime.now(timezone.utc).isoformat() "updated": datetime.now(timezone.utc).isoformat()
} }
logger.info(f"Carrier update: {CARRIER_REGISTRY[hull]['name']}{coords} (from: {title[:80]})") logger.info(f"Carrier update: {CARRIER_REGISTRY[hull]['name']}{coords} (from: {title[:80]})")
@@ -316,7 +329,7 @@ def update_carrier_positions():
logger.info("Carrier tracker: updating positions from OSINT sources...") logger.info("Carrier tracker: updating positions from OSINT sources...")
# Start with fallback positions # Start with fallback positions (sourced from USNI News Fleet Tracker)
positions: Dict[str, dict] = {} positions: Dict[str, dict] = {}
for hull, info in CARRIER_REGISTRY.items(): for hull, info in CARRIER_REGISTRY.items():
positions[hull] = { positions[hull] = {
@@ -326,7 +339,8 @@ def update_carrier_positions():
"heading": info["fallback_heading"], "heading": info["fallback_heading"],
"desc": info["fallback_desc"], "desc": info["fallback_desc"],
"wiki": info["wiki"], "wiki": info["wiki"],
"source": "Static OSINT estimate", "source": "USNI News Fleet & Marine Tracker",
"source_url": "https://news.usni.org/category/fleet-tracker",
"updated": datetime.now(timezone.utc).isoformat() "updated": datetime.now(timezone.utc).isoformat()
} }
@@ -370,6 +384,55 @@ def update_carrier_positions():
logger.info(f"Carrier tracker: {len(positions)} carriers updated. Sources: {sources}") logger.info(f"Carrier tracker: {len(positions)} carriers updated. Sources: {sources}")
def _deconflict_positions(result: List[dict]) -> List[dict]:
"""Offset carriers that share identical coordinates so they don't stack.
At port: offset along the pier axis (~500m / 0.004° apart).
At sea: offset perpendicular to each other (~0.08° / ~9km apart)
so they're visibly separate but clearly operating together.
"""
# Group by rounded lat/lng (within ~0.01° ≈ 1km = same spot)
from collections import defaultdict
groups: dict[str, list[int]] = defaultdict(list)
for i, c in enumerate(result):
key = f"{round(c['lat'], 2)},{round(c['lng'], 2)}"
groups[key].append(i)
for indices in groups.values():
if len(indices) < 2:
continue
n = len(indices)
# Determine if this is a port (near a homeport) or at sea
sample = result[indices[0]]
at_port = any(
abs(sample["lat"] - info.get("homeport_lat", 0)) < 0.05
and abs(sample["lng"] - info.get("homeport_lng", 0)) < 0.05
for info in CARRIER_REGISTRY.values()
)
if at_port:
# Use each carrier's distinct homeport pier coordinates
for idx in indices:
carrier = result[idx]
hull = None
for h, info in CARRIER_REGISTRY.items():
if info["name"] == carrier["name"]:
hull = h
break
if hull:
info = CARRIER_REGISTRY[hull]
carrier["lat"] = info["homeport_lat"]
carrier["lng"] = info["homeport_lng"]
else:
# At sea: spread in a line perpendicular to travel (~0.08° apart)
spacing = 0.08 # ~9km — close enough to see they're together
start_offset = -(n - 1) * spacing / 2
for j, idx in enumerate(indices):
result[idx]["lng"] += start_offset + j * spacing
return result
def get_carrier_positions() -> List[dict]: def get_carrier_positions() -> List[dict]:
"""Return current carrier positions for the data pipeline.""" """Return current carrier positions for the data pipeline."""
with _positions_lock: with _positions_lock:
@@ -381,7 +444,7 @@ def get_carrier_positions() -> List[dict]:
"type": "carrier", "type": "carrier",
"lat": pos["lat"], "lat": pos["lat"],
"lng": pos["lng"], "lng": pos["lng"],
"heading": pos.get("heading", 0), "heading": None, # Heading unknown for carriers — OSINT cannot determine true heading
"sog": 0, "sog": 0,
"cog": 0, "cog": 0,
"country": "United States", "country": "United States",
@@ -389,9 +452,10 @@ def get_carrier_positions() -> List[dict]:
"wiki": pos.get("wiki", info.get("wiki", "")), "wiki": pos.get("wiki", info.get("wiki", "")),
"estimated": True, "estimated": True,
"source": pos.get("source", "OSINT estimated position"), "source": pos.get("source", "OSINT estimated position"),
"source_url": pos.get("source_url", "https://news.usni.org/category/fleet-tracker"),
"last_osint_update": pos.get("updated", "") "last_osint_update": pos.get("updated", "")
}) })
return result return _deconflict_positions(result)
# ----------------------------------------------------------------- # -----------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+153 -27
View File
@@ -86,8 +86,10 @@ def _extract_domain(url):
def _url_to_headline(url): def _url_to_headline(url):
"""Extract a human-readable headline from a URL path. """Extract a human-readable headline from a URL path.
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites (nytimes.com)' e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites'
Falls back to domain name if the URL slug is gibberish (hex IDs, UUIDs, etc.).
""" """
import re
try: try:
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
parsed = urlparse(url) parsed = urlparse(url)
@@ -100,43 +102,151 @@ def _url_to_headline(url):
if not path: if not path:
return domain return domain
# Take the last path segment (usually the slug) # Try the last path segment first, then walk backwards
slug = path.split('/')[-1] segments = [s for s in path.split('/') if s]
# Remove file extensions slug = ''
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']: for seg in reversed(segments):
if slug.lower().endswith(ext): # Remove file extensions
slug = slug[:-len(ext)] for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
# If slug is purely numeric or a short ID, try the second-to-last segment if seg.lower().endswith(ext):
import re seg = seg[:-len(ext)]
if re.match(r'^[a-z]?\d{5,}$', slug, re.IGNORECASE): # Skip segments that are clearly not headlines
segments = path.split('/') if _is_gibberish(seg):
if len(segments) >= 2: continue
slug = segments[-2] slug = seg
for ext in ['.html', '.htm', '.php']: break
if slug.lower().endswith(ext):
slug = slug[:-len(ext)] if not slug:
return domain
# Remove common ID patterns at start/end # Remove common ID patterns at start/end
slug = re.sub(r'^[\d]+-', '', slug) # leading numbers like "13847569-" slug = re.sub(r'^[\d]+-', '', slug) # leading "13847569-"
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431" slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234 slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
# Convert slug separators to spaces # Convert slug separators to spaces
slug = slug.replace('-', ' ').replace('_', ' ') slug = slug.replace('-', ' ').replace('_', ' ')
# Clean up multiple spaces
slug = re.sub(r'\s+', ' ', slug).strip() slug = re.sub(r'\s+', ' ', slug).strip()
# If slug is still just a number or too short, fall back to domain # Final gibberish check after cleanup
if len(slug) < 5 or re.match(r'^\d+$', slug): if len(slug) < 8 or _is_gibberish(slug.replace(' ', '-')):
return domain return domain
# Title case and truncate # Title case and truncate
headline = slug.title() headline = slug.title()
if len(headline) > 80: if len(headline) > 90:
headline = headline[:77] + '...' headline = headline[:87] + '...'
return f"{headline} ({domain})" return headline
except Exception: except Exception:
return url[:60] return url[:60]
def _is_gibberish(text):
"""Detect if a URL segment is gibberish (hex IDs, UUIDs, numeric IDs, etc.)
rather than a real human-readable slug like 'us-strikes-iran'."""
import re
t = text.strip()
if not t:
return True
# Pure numbers
if re.match(r'^\d+$', t):
return True
# UUID pattern (with or without dashes)
if re.match(r'^[0-9a-f]{8}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{12}$', t, re.I):
return True
# Hex-heavy string: more than 40% hex digits among alphanumeric chars
alnum = re.sub(r'[^a-zA-Z0-9]', '', t)
if alnum:
hex_chars = sum(1 for c in alnum if c in '0123456789abcdefABCDEF')
if hex_chars / len(alnum) > 0.4 and len(alnum) > 6:
return True
# Mostly digits with a few alpha (like "article8efa6c53")
digits = sum(1 for c in alnum if c.isdigit())
if alnum and digits / len(alnum) > 0.5:
return True
# Too short to be a headline slug
if len(t) < 5:
return True
# Query-param style segments
if '=' in t:
return True
return False
# Persistent cache for article titles — survives across GDELT cache refreshes
_article_title_cache = {}
def _fetch_article_title(url):
"""Fetch the real headline from an article's HTML <title> or og:title tag.
Returns the title string, or None if it can't be fetched.
Uses a persistent cache to avoid refetching."""
if url in _article_title_cache:
return _article_title_cache[url]
import re
try:
# Only read the first 32KB — the <title> is always in <head>
resp = requests.get(url, timeout=4, headers={
'User-Agent': 'Mozilla/5.0 (compatible; OSINT Dashboard/1.0)'
}, stream=True)
if resp.status_code != 200:
_article_title_cache[url] = None
return None
chunk = resp.raw.read(32768).decode('utf-8', errors='replace')
resp.close()
title = None
# Try og:title first (usually the cleanest)
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\'>]+)["\']', chunk, re.I)
if not og_match:
og_match = re.search(r'<meta[^>]+content=["\']([^"\'>]+)["\'][^>]+property=["\']og:title["\']', chunk, re.I)
if og_match:
title = og_match.group(1).strip()
# Fall back to <title> tag
if not title:
title_match = re.search(r'<title[^>]*>([^<]+)</title>', chunk, re.I)
if title_match:
title = title_match.group(1).strip()
if title:
# Clean up HTML entities
import html as html_mod
title = html_mod.unescape(title)
# Remove site name suffixes like " | CNN" or " - BBC News"
title = re.sub(r'\s*[|\-–—]\s*[^|\-–—]{2,30}$', '', title).strip()
# Truncate very long titles
if len(title) > 120:
title = title[:117] + '...'
if len(title) > 10:
_article_title_cache[url] = title
return title
_article_title_cache[url] = None
return None
except Exception:
_article_title_cache[url] = None
return None
def _batch_fetch_titles(urls):
"""Fetch real article titles for a list of URLs in parallel.
Returns a dict of url -> title (or None if fetch failed)."""
from concurrent.futures import ThreadPoolExecutor
results = {}
with ThreadPoolExecutor(max_workers=16) as executor:
futures = {executor.submit(_fetch_article_title, u): u for u in urls}
for future in futures:
url = futures[future]
try:
results[url] = future.result()
except Exception:
results[url] = None
return results
def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_index): def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_index):
"""Parse a single GDELT export ZIP and append conflict features. """Parse a single GDELT export ZIP and append conflict features.
loc_index maps loc_key -> index in features list for fast duplicate merging. loc_index maps loc_key -> index in features list for fast duplicate merging.
@@ -278,11 +388,27 @@ def fetch_global_military_incidents():
if zip_bytes: if zip_bytes:
_parse_gdelt_export_zip(zip_bytes, CONFLICT_CODES, seen_locs, features, loc_index) _parse_gdelt_export_zip(zip_bytes, CONFLICT_CODES, seen_locs, features, loc_index)
# Collect all unique article URLs for batch title fetching
all_article_urls = set()
for f in features:
for u in f["properties"].get("_urls", []):
if u:
all_article_urls.add(u)
logger.info(f"Fetching real article titles for {len(all_article_urls)} unique URLs...")
fetched_titles = _batch_fetch_titles(all_article_urls)
fetched_count = sum(1 for v in fetched_titles.values() if v)
logger.info(f"Resolved {fetched_count}/{len(all_article_urls)} article titles from HTML")
# Build URL + headline arrays for frontend rendering # Build URL + headline arrays for frontend rendering
for f in features: for f in features:
urls = f["properties"].pop("_urls", []) urls = f["properties"].pop("_urls", [])
f["properties"].pop("_domains", None) f["properties"].pop("_domains", None)
headlines = [_url_to_headline(u) for u in urls] headlines = []
for u in urls:
# Try the real fetched title first, then fall back to URL slug parsing
real_title = fetched_titles.get(u)
headlines.append(real_title if real_title else _url_to_headline(u))
f["properties"]["_urls_list"] = urls f["properties"]["_urls_list"] = urls
f["properties"]["_headlines_list"] = headlines f["properties"]["_headlines_list"] = headlines
import html import html
+15 -1
View File
@@ -24,6 +24,11 @@ _BASH_PATH = shutil.which("bash") or "bash"
_domain_fail_cache: dict[str, float] = {} _domain_fail_cache: dict[str, float] = {}
_DOMAIN_FAIL_TTL = 300 # 5 minutes _DOMAIN_FAIL_TTL = 300 # 5 minutes
# Circuit breaker: track domains where BOTH requests AND curl fail
# If a domain failed completely within the last 2 minutes, skip it entirely
_circuit_breaker: dict[str, float] = {}
_CIRCUIT_BREAKER_TTL = 120 # 2 minutes
class _DummyResponse: class _DummyResponse:
"""Minimal response object matching requests.Response interface.""" """Minimal response object matching requests.Response interface."""
def __init__(self, status_code, text): def __init__(self, status_code, text):
@@ -54,6 +59,10 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
domain = urlparse(url).netloc domain = urlparse(url).netloc
# Circuit breaker: if domain failed completely <2min ago, fail fast
if domain in _circuit_breaker and (time.time() - _circuit_breaker[domain]) < _CIRCUIT_BREAKER_TTL:
raise Exception(f"Circuit breaker open for {domain} (failed <{_CIRCUIT_BREAKER_TTL}s ago)")
# Check if this domain recently failed with requests — skip straight to curl # Check if this domain recently failed with requests — skip straight to curl
if domain in _domain_fail_cache and (time.time() - _domain_fail_cache[domain]) < _DOMAIN_FAIL_TTL: if domain in _domain_fail_cache and (time.time() - _domain_fail_cache[domain]) < _DOMAIN_FAIL_TTL:
pass # Fall through to curl below pass # Fall through to curl below
@@ -64,8 +73,9 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
else: else:
res = _session.get(url, timeout=timeout, headers=default_headers) res = _session.get(url, timeout=timeout, headers=default_headers)
res.raise_for_status() res.raise_for_status()
# Clear failure cache on success # Clear failure caches on success
_domain_fail_cache.pop(domain, None) _domain_fail_cache.pop(domain, None)
_circuit_breaker.pop(domain, None)
return res return res
except Exception as e: except Exception as e:
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...") logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
@@ -92,10 +102,14 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
lines = res.stdout.rstrip().rsplit("\n", 1) lines = res.stdout.rstrip().rsplit("\n", 1)
body = lines[0] if len(lines) > 1 else res.stdout body = lines[0] if len(lines) > 1 else res.stdout
http_code = int(lines[-1]) if len(lines) > 1 and lines[-1].strip().isdigit() else 200 http_code = int(lines[-1]) if len(lines) > 1 and lines[-1].strip().isdigit() else 200
if http_code < 400:
_circuit_breaker.pop(domain, None) # Clear circuit breaker on success
return _DummyResponse(http_code, body) return _DummyResponse(http_code, body)
else: else:
logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}") logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "") return _DummyResponse(500, "")
except Exception as curl_e: except Exception as curl_e:
logger.error(f"bash curl fallback exception: {curl_e}") logger.error(f"bash curl fallback exception: {curl_e}")
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "") return _DummyResponse(500, "")
+74
View File
@@ -0,0 +1,74 @@
"""
News feed configuration — manages the user-customisable RSS feed list.
Feeds are stored in backend/config/news_feeds.json and persist across restarts.
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
CONFIG_PATH = Path(__file__).parent.parent / "config" / "news_feeds.json"
MAX_FEEDS = 20
DEFAULT_FEEDS = [
{"name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4},
{"name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3},
{"name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2},
{"name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1},
{"name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5},
{"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3},
{"name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3},
{"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3},
]
def get_feeds() -> list[dict]:
"""Load feeds from config file, falling back to defaults."""
try:
if CONFIG_PATH.exists():
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
feeds = data.get("feeds", []) if isinstance(data, dict) else data
if isinstance(feeds, list) and len(feeds) > 0:
return feeds
except Exception as e:
logger.warning(f"Failed to read news feed config: {e}")
return list(DEFAULT_FEEDS)
def save_feeds(feeds: list[dict]) -> bool:
"""Validate and save feeds to config file. Returns True on success."""
if not isinstance(feeds, list):
return False
if len(feeds) > MAX_FEEDS:
return False
# Validate each feed entry
for f in feeds:
if not isinstance(f, dict):
return False
name = f.get("name", "").strip()
url = f.get("url", "").strip()
weight = f.get("weight", 3)
if not name or not url:
return False
if not isinstance(weight, (int, float)) or weight < 1 or weight > 5:
return False
# Normalise
f["name"] = name
f["url"] = url
f["weight"] = int(weight)
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
json.dumps({"feeds": feeds}, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return True
except Exception as e:
logger.error(f"Failed to write news feed config: {e}")
return False
def reset_feeds() -> bool:
"""Reset feeds to defaults."""
return save_feeds(list(DEFAULT_FEEDS))
+36 -14
View File
@@ -1,6 +1,8 @@
import logging import logging
import time
import concurrent.futures import concurrent.futures
from urllib.parse import quote from urllib.parse import quote
import requests as _requests
from cachetools import TTLCache from cachetools import TTLCache
from services.network_utils import fetch_with_curl from services.network_utils import fetch_with_curl
@@ -10,26 +12,46 @@ logger = logging.getLogger(__name__)
# Key: rounded lat/lng grid (0.1 degree ≈ 11km) # Key: rounded lat/lng grid (0.1 degree ≈ 11km)
dossier_cache = TTLCache(maxsize=500, ttl=86400) dossier_cache = TTLCache(maxsize=500, ttl=86400)
# Nominatim requires max 1 req/sec — track last call time
_nominatim_last_call = 0.0
def _reverse_geocode(lat: float, lng: float) -> dict: def _reverse_geocode(lat: float, lng: float) -> dict:
global _nominatim_last_call
url = ( url = (
f"https://nominatim.openstreetmap.org/reverse?" f"https://nominatim.openstreetmap.org/reverse?"
f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en" f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en"
) )
try: headers = {"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"}
res = fetch_with_curl(url, timeout=10)
if res.status_code == 200: for attempt in range(2):
data = res.json() # Enforce Nominatim's 1 req/sec policy
addr = data.get("address", {}) elapsed = time.time() - _nominatim_last_call
return { if elapsed < 1.1:
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "", time.sleep(1.1 - elapsed)
"state": addr.get("state") or addr.get("region") or "", _nominatim_last_call = time.time()
"country": addr.get("country") or "",
"country_code": (addr.get("country_code") or "").upper(), try:
"display_name": data.get("display_name", ""), # Use requests directly — fetch_with_curl raises on non-200 which breaks 429 handling
} res = _requests.get(url, timeout=10, headers=headers)
except Exception as e: if res.status_code == 200:
logger.warning(f"Reverse geocode failed: {e}") data = res.json()
addr = data.get("address", {})
return {
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
"state": addr.get("state") or addr.get("region") or "",
"country": addr.get("country") or "",
"country_code": (addr.get("country_code") or "").upper(),
"display_name": data.get("display_name", ""),
}
elif res.status_code == 429:
logger.warning(f"Nominatim 429 rate-limited, retrying after 2s (attempt {attempt+1})")
time.sleep(2)
continue
else:
logger.warning(f"Nominatim returned {res.status_code}")
except Exception as e:
logger.warning(f"Reverse geocode failed: {e}")
return {} return {}
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -12,6 +12,8 @@ services:
- OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID} - OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}
- OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET} - OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}
- LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY} - LTA_ACCOUNT_KEY=${LTA_ACCOUNT_KEY}
# Override allowed CORS origins (comma-separated). Auto-detects LAN IPs if empty.
- CORS_ORIGINS=${CORS_ORIGINS:-}
volumes: volumes:
- backend_data:/app/data - backend_data:/app/data
restart: unless-stopped restart: unless-stopped
@@ -19,13 +21,13 @@ services:
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
args:
# Optional: set this to your backend's external URL if using custom ports
# e.g. http://192.168.1.50:9096 — leave empty to auto-detect from browser
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
container_name: shadowbroker-frontend container_name: shadowbroker-frontend
ports: ports:
- "3000:3000" - "3000:3000"
environment:
# Points the Next.js server-side proxy at the backend container via Docker networking.
# Change this if your backend runs on a different host or port.
- BACKEND_URL=http://backend:8000
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
+5 -5
View File
@@ -10,7 +10,7 @@ FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_* vars must exist at build time for Next.js to inline them. # NEXT_PUBLIC_* vars must exist at build time for Next.js to inline them.
# Default empty = auto-detect from browser hostname at runtime. # Default empty = auto-detect from browser hostname at runtime.
ARG NEXT_PUBLIC_API_URL="" ARG NEXT_PUBLIC_API_URL=""
@@ -19,8 +19,8 @@ RUN npm run build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@@ -36,7 +36,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT=3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "server.js"]
BIN
View File
Binary file not shown.
+5
View File
@@ -1,5 +1,10 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
// /api/* requests are proxied to the backend by the catch-all route handler at
// src/app/api/[...path]/route.ts, which reads BACKEND_URL at request time.
// Do NOT add rewrites for /api/* here — next.config is evaluated at build time,
// so any URL baked in here ignores the runtime BACKEND_URL env var.
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'], transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
output: "standalone", output: "standalone",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.3.0", "version": "0.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"", "dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+54 -17
View File
@@ -10,6 +10,7 @@ import NewsFeed from "@/components/NewsFeed";
import MarketsPanel from "@/components/MarketsPanel"; import MarketsPanel from "@/components/MarketsPanel";
import FilterPanel from "@/components/FilterPanel"; import FilterPanel from "@/components/FilterPanel";
import FindLocateBar from "@/components/FindLocateBar"; import FindLocateBar from "@/components/FindLocateBar";
import TopRightControls from "@/components/TopRightControls";
import RadioInterceptPanel from "@/components/RadioInterceptPanel"; import RadioInterceptPanel from "@/components/RadioInterceptPanel";
import SettingsPanel from "@/components/SettingsPanel"; import SettingsPanel from "@/components/SettingsPanel";
import MapLegend from "@/components/MapLegend"; import MapLegend from "@/components/MapLegend";
@@ -28,7 +29,7 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]); const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>(); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
@@ -50,7 +51,7 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
return; return;
} }
// Geocode with Nominatim (debounced) // Geocode with Nominatim (debounced)
clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
if (q.trim().length < 2) { setResults([]); return; } if (q.trim().length < 2) { setResults([]); return; }
timerRef.current = setTimeout(async () => { timerRef.current = setTimeout(async () => {
setLoading(true); setLoading(true);
@@ -146,6 +147,9 @@ export default function Dashboard() {
gibs_imagery: false, gibs_imagery: false,
highres_satellite: false, highres_satellite: false,
kiwisdr: false, kiwisdr: false,
firms: false,
internet_outages: false,
datacenters: false,
}); });
// NASA GIBS satellite imagery state // NASA GIBS satellite imagery state
@@ -161,7 +165,7 @@ export default function Dashboard() {
}); });
const [activeStyle, setActiveStyle] = useState('DEFAULT'); const [activeStyle, setActiveStyle] = useState('DEFAULT');
const stylesList = ['DEFAULT', 'SATELLITE', 'FLIR', 'NVG', 'CRT']; const stylesList = ['DEFAULT', 'SATELLITE'];
const cycleStyle = () => { const cycleStyle = () => {
setActiveStyle((prev) => { setActiveStyle((prev) => {
@@ -295,23 +299,32 @@ export default function Dashboard() {
const slowEtag = useRef<string | null>(null); const slowEtag = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
// Track whether we've received substantial data yet (backend may still be starting up)
let hasData = false;
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
const fetchFastData = async () => { const fetchFastData = async () => {
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current; if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers }); const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
if (res.status === 304) { setBackendStatus('connected'); return; } if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
if (res.ok) { if (res.ok) {
setBackendStatus('connected'); setBackendStatus('connected');
fastEtag.current = res.headers.get('etag') || null; fastEtag.current = res.headers.get('etag') || null;
const json = await res.json(); const json = await res.json();
dataRef.current = { ...dataRef.current, ...json }; dataRef.current = { ...dataRef.current, ...json };
setDataVersion(v => v + 1); setDataVersion(v => v + 1);
// Check if we got real data (backend finished loading)
const flights = json.commercial_flights?.length || 0;
if (flights > 100) hasData = true;
} }
} catch (e) { } catch (e) {
console.error("Failed fetching fast live data", e); console.error("Failed fetching fast live data", e);
setBackendStatus('disconnected'); setBackendStatus('disconnected');
} }
scheduleNext('fast');
}; };
const fetchSlowData = async () => { const fetchSlowData = async () => {
@@ -319,7 +332,7 @@ export default function Dashboard() {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current; if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers }); const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
if (res.status === 304) return; if (res.status === 304) { scheduleNext('slow'); return; }
if (res.ok) { if (res.ok) {
slowEtag.current = res.headers.get('etag') || null; slowEtag.current = res.headers.get('etag') || null;
const json = await res.json(); const json = await res.json();
@@ -329,19 +342,26 @@ export default function Dashboard() {
} catch (e) { } catch (e) {
console.error("Failed fetching slow live data", e); console.error("Failed fetching slow live data", e);
} }
scheduleNext('slow');
};
// Adaptive polling: retry every 3s during startup, back off to normal cadence once data arrives
const scheduleNext = (tier: 'fast' | 'slow') => {
if (tier === 'fast') {
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
fastTimerId = setTimeout(fetchFastData, delay);
} else {
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
slowTimerId = setTimeout(fetchSlowData, delay);
}
}; };
fetchFastData(); fetchFastData();
fetchSlowData(); fetchSlowData();
// Fast polling: 60s (matches backend update cadence — was 15s, wasting 75% on 304s)
// Slow polling: 120s (backend updates every 30min)
const fastInterval = setInterval(fetchFastData, 60000);
const slowInterval = setInterval(fetchSlowData, 120000);
return () => { return () => {
clearInterval(fastInterval); if (fastTimerId) clearTimeout(fastTimerId);
clearInterval(slowInterval); if (slowTimerId) clearTimeout(slowTimerId);
}; };
}, []); }, []);
@@ -415,7 +435,7 @@ export default function Dashboard() {
{/* LEFT HUD CONTAINER */} {/* LEFT HUD CONTAINER */}
<div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none"> <div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none">
{/* LEFT PANEL - DATA LAYERS */} {/* LEFT PANEL - DATA LAYERS */}
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} /> <WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
{/* LEFT BOTTOM - DISPLAY CONFIG */} {/* LEFT BOTTOM - DISPLAY CONFIG */}
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} /> <WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
@@ -423,6 +443,8 @@ export default function Dashboard() {
{/* RIGHT HUD CONTAINER */} {/* RIGHT HUD CONTAINER */}
<div className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2"> <div className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2">
<TopRightControls />
{/* FIND / LOCATE */} {/* FIND / LOCATE */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<FindLocateBar <FindLocateBar
@@ -470,8 +492,8 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* BOTTOM CENTER COORDINATE / LOCATION BAR */} {/* BOTTOM CENTER COORDINATE / LOCATION BAR — hidden when Sentinel-2 imagery overlay is open */}
<motion.div {!(selectedEntity?.type === 'region_dossier' && regionDossier?.sentinel2) && <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 1 }} transition={{ delay: 1, duration: 1 }}
@@ -511,8 +533,23 @@ export default function Dashboard() {
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div> <div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div> <div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
</div> </div>
{/* Divider */}
<div className="w-px h-8 bg-[var(--border-primary)]" />
{/* Space Weather */}
<div className="flex flex-col items-center" title={`Kp Index: ${data?.space_weather?.kp_index ?? 'N/A'}`}>
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">SOLAR</div>
<div className={`text-[11px] font-mono font-bold ${
(data?.space_weather?.kp_index ?? 0) >= 5 ? 'text-red-400' :
(data?.space_weather?.kp_index ?? 0) >= 4 ? 'text-yellow-400' :
'text-green-400'
}`}>
{data?.space_weather?.kp_text || 'N/A'}
</div>
</div>
</div> </div>
</motion.div> </motion.div>}
</> </>
)} )}
@@ -574,7 +611,7 @@ export default function Dashboard() {
{backendStatus === 'disconnected' && ( {backendStatus === 'disconnected' && (
<div className="absolute top-0 left-0 right-0 z-[9000] flex items-center justify-center py-2 bg-red-950/90 border-b border-red-500/40 backdrop-blur-sm"> <div className="absolute top-0 left-0 right-0 z-[9000] flex items-center justify-center py-2 bg-red-950/90 border-b border-red-500/40 backdrop-blur-sm">
<span className="text-[10px] font-mono tracking-widest text-red-400"> <span className="text-[10px] font-mono tracking-widest text-red-400">
BACKEND OFFLINE Cannot reach {API_BASE}. Start the backend server or check your connection. BACKEND OFFLINE Cannot reach backend server. Check that the backend container is running and BACKEND_URL is correct.
</span> </span>
</div> </div>
)} )}
+2 -4
View File
@@ -656,13 +656,11 @@ export default function CesiumViewer({ data, activeLayers, activeFilters, effect
} }
if (filters.tracked_owner?.length) { if (filters.tracked_owner?.length) {
const op = (f.alert_operator || '').toLowerCase(); const op = (f.alert_operator || '').toLowerCase();
const t1 = (f.alert_tag1 || '').toLowerCase(); const tags = (f.alert_tags || '').toLowerCase();
const t2 = (f.alert_tag2 || '').toLowerCase();
const t3 = (f.alert_tag3 || '').toLowerCase();
const cs = (f.callsign || '').toLowerCase(); const cs = (f.callsign || '').toLowerCase();
if (!filters.tracked_owner.some(sv => { if (!filters.tracked_owner.some(sv => {
const q = sv.toLowerCase(); const q = sv.toLowerCase();
return op.includes(q) || t1.includes(q) || t2.includes(q) || t3.includes(q) || cs.includes(q); return op.includes(q) || tags.includes(q) || cs.includes(q);
})) return false; })) return false;
} }
return true; return true;
+55 -29
View File
@@ -2,54 +2,60 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { X, Satellite, Radio, MapPin, Image, Layers, Bug } from "lucide-react"; import { X, Zap, Shield, Satellite, MapPin, Palette, ToggleRight, Bug, Heart } from "lucide-react";
const CURRENT_VERSION = "0.4"; const CURRENT_VERSION = "0.8";
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`; const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const NEW_FEATURES = [ const NEW_FEATURES = [
{ {
icon: <Satellite size={14} className="text-cyan-400" />, icon: <Shield size={14} className="text-pink-400" />,
title: "NASA GIBS Satellite Imagery", title: "POTUS Fleet Tracking",
desc: "Daily MODIS Terra true-color imagery with 30-day time slider, play/pause animation, and opacity control.", desc: "Air Force One, Air Force Two, and Marine One aircraft now display with oversized hot-pink icons and a gold dashed halo ring — instantly recognizable on the map.",
color: "cyan", color: "pink",
}, },
{ {
icon: <Layers size={14} className="text-green-400" />, icon: <Palette size={14} className="text-yellow-400" />,
title: "High-Res Satellite (Esri)", title: "Full Aircraft Color-Coding",
desc: "Sub-meter resolution imagery — zoom into buildings and terrain. Toggle in Data Layers or cycle to SATELLITE style.", desc: "9-color system: military (yellow), medical/rescue (lime), police/government (blue), privacy (black), VIPs (hot pink), dictators/oligarchs (red), and more — all enriched from plane_alert_db.",
color: "yellow",
},
{
icon: <Satellite size={14} className="text-green-400" />,
title: "Sentinel-2 Satellite Overhaul",
desc: "Replaced the tiny satellite popup with a fullscreen image overlay. Added Download, Copy to Clipboard, and Open Full Res buttons. Green dossier-themed UI.",
color: "green", color: "green",
}, },
{ {
icon: <Radio size={14} className="text-amber-400" />, icon: <MapPin size={14} className="text-blue-400" />,
title: "KiwiSDR Radio Receivers", title: "Region Dossier & Carrier Fidelity",
desc: "500+ public SDR receivers plotted worldwide. Click any node to open a live radio tuner directly in the SIGINT panel.", desc: "Fixed Nominatim 429 rate-limit errors with retry/backoff. Carriers at shared homeports now dock at distinct pier positions instead of stacking.",
color: "amber",
},
{
icon: <Image size={14} className="text-blue-400" />,
title: "Sentinel-2 Intel Card",
desc: "Right-click anywhere — a floating intel card shows the latest Sentinel-2 satellite photo with capture date and cloud cover. Click to open full resolution.",
color: "blue", color: "blue",
}, },
{ {
icon: <MapPin size={14} className="text-purple-400" />, icon: <Zap size={14} className="text-cyan-400" />,
title: "LOCATE Bar", title: "Overhauled Map Legend & Controls",
desc: "New search bar above coordinates — enter coordinates (31.8, 34.8) or place names (Tehran, Strait of Hormuz) to fly directly there.", desc: "Full 9-color aircraft legend with POTUS fleet, wildfires, and infrastructure sections. New version badge, update checker, and Discussions shortcut in the UI.",
color: "purple", color: "cyan",
}, },
{ {
icon: <Layers size={14} className="text-cyan-400" />, icon: <ToggleRight size={14} className="text-purple-400" />,
title: "SATELLITE Style Preset", title: "Toggle All Data Layers",
desc: "STYLE button now cycles: DEFAULT → SATELLITE → FLIR → NVG → CRT. SATELLITE auto-enables high-res imagery.", desc: "One-click button to enable/disable all data layers at once. Turns cyan when active. MODIS Terra excluded from bulk toggle to prevent accidental imagery load.",
color: "cyan", color: "purple",
}, },
]; ];
const BUG_FIXES = [ const BUG_FIXES = [
"Satellite imagery renders below all data icons — flights, ships, markers always visible on top", "POTUS fleet ICAO codes expanded — all Air Force Two (C-32A/B) airframes now correctly identified with gold halo",
"Sentinel-2 click now opens the actual high-res PNG image directly in browser", "POTUS icon priority fixed — presidential aircraft always show the POTUS icon even when grounded",
"Light/dark theme fixed — UI stays dark, only the map basemap switches", "Sentinel-2 imagery no longer overlaps the bottom coordinate bar",
"Docker ENV format warnings resolved (legacy syntax → key=value)",
"Settings/Key/Version buttons now cyan in dark mode, grey only in light mode",
];
const CONTRIBUTORS = [
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
]; ];
export function useChangelog() { export function useChangelog() {
@@ -154,6 +160,26 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog
))} ))}
</div> </div>
</div> </div>
{/* Contributors */}
<div>
<div className="text-[9px] font-mono tracking-[0.2em] text-pink-400 font-bold mb-3 flex items-center gap-2">
<Heart size={10} className="text-pink-400" />
COMMUNITY CONTRIBUTORS
</div>
<div className="space-y-1.5">
{CONTRIBUTORS.map((c, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded-lg border border-pink-500/20 bg-pink-500/5">
<span className="text-pink-400 text-[10px] mt-0.5 flex-shrink-0">&hearts;</span>
<div>
<span className="text-[10px] font-mono text-pink-300 font-bold">{c.name}</span>
<span className="text-[9px] font-mono text-[var(--text-muted)]"> {c.desc}</span>
<span className="text-[8px] font-mono text-[var(--text-muted)]"> (PR {c.pr})</span>
</div>
</div>
))}
</div>
</div>
</div> </div>
{/* Footer */} {/* Footer */}
+1 -2
View File
@@ -106,8 +106,7 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
const ops = new Set<string>(trackedOperators); const ops = new Set<string>(trackedOperators);
for (const f of data?.tracked_flights || []) { for (const f of data?.tracked_flights || []) {
if (f.alert_operator) ops.add(f.alert_operator); if (f.alert_operator) ops.add(f.alert_operator);
if (f.alert_tag1) ops.add(f.alert_tag1); if (f.alert_tags) ops.add(f.alert_tags);
if (f.alert_tag2) ops.add(f.alert_tag2);
} }
return Array.from(ops).sort(); return Array.from(ops).sort();
}, [data?.tracked_flights]); }, [data?.tracked_flights]);
+6 -4
View File
@@ -89,12 +89,13 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
}); });
} }
// Tracked flights // Tracked flights — include tags/owner/name for broad search (first name, last name, etc.)
for (const f of data?.tracked_flights || []) { for (const f of data?.tracked_flights || []) {
const uid = f.icao24 || f.registration || f.callsign || ''; const uid = f.icao24 || f.registration || f.callsign || '';
const operator = f.alert_operator || 'Unknown Operator'; const operator = f.alert_operator || 'Unknown Operator';
const category = f.alert_category || 'Tracked'; const category = f.alert_category || 'Tracked';
const type = f.alert_type || f.model || 'Unknown'; const type = f.alert_type || f.model || 'Unknown';
const extras = [f.alert_tags, f.owner, f.name, f.callsign].filter(Boolean).join(' ');
results.push({ results.push({
id: `tracked-${uid}`, id: `tracked-${uid}`,
label: operator, label: operator,
@@ -104,7 +105,8 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
lat: f.lat, lat: f.lat,
lng: f.lng, lng: f.lng,
entityType: "tracked_flight", entityType: "tracked_flight",
}); _extra: extras,
} as any);
} }
// Ships // Ships
@@ -144,7 +146,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
const q = query.toLowerCase(); const q = query.toLowerCase();
return allEntities return allEntities
.filter(e => { .filter(e => {
const searchable = `${e.label} ${e.sublabel} ${e.id}`.toLowerCase(); const searchable = `${e.label} ${e.sublabel} ${e.id} ${(e as any)._extra || ''}`.toLowerCase();
return searchable.includes(q); return searchable.includes(q);
}) })
.slice(0, 12); .slice(0, 12);
@@ -177,7 +179,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
ref={inputRef} ref={inputRef}
type="text" type="text"
value={query} value={query}
placeholder="Find aircraft or vessel..." placeholder="Find aircraft, person or vessel..."
className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]" className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
+35 -7
View File
@@ -94,18 +94,30 @@ const LEGEND: LegendCategory[] = [
{ svg: airliner("yellow"), label: "Military — Standard" }, { svg: airliner("yellow"), label: "Military — Standard" },
{ svg: plane("yellow"), label: "Fighter / Interceptor" }, { svg: plane("yellow"), label: "Fighter / Interceptor" },
{ svg: heli("yellow"), label: "Military — Helicopter" }, { svg: heli("yellow"), label: "Military — Helicopter" },
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone" }, { svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`, label: "UAV / Drone (live ADS-B)" },
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="orange" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.6"/><circle cx="12" cy="12" r="2" fill="orange"/></svg>`, label: "UAV Operational Range (dashed circle)" },
], ],
}, },
{ {
name: "TRACKED AIRCRAFT (ALERT)", name: "TRACKED AIRCRAFT (ALERT)",
color: "text-pink-400 border-pink-500/30", color: "text-pink-400 border-pink-500/30",
items: [ items: [
{ svg: airliner("#FF1493"), label: "Alert — Low Priority (pink)" }, { svg: airliner("#FF1493"), label: "VIP / Celebrity / Bizjet (hot pink)" },
{ svg: airliner("#FF2020"), label: "Alert — High Priority (red)" }, { svg: airliner("#FF2020"), label: "Dictator / Oligarch (red)" },
{ svg: airliner("#1A3A8A"), label: "Alert — Government (navy)" }, { svg: airliner("#3b82f6"), label: "Government / Police / Customs (blue)" },
{ svg: airliner("white"), label: "Alert — General (white)" }, { svg: heli("#32CD32"), label: "Medical / Fire / Rescue (lime)" },
{ svg: airliner("yellow"), label: "Military / Intelligence (yellow)" },
{ svg: airliner("#222"), label: "PIA — Privacy / Stealth (black)" },
{ svg: airliner("#FF8C00"), label: "Private Flights / Joe Cool (orange)" },
{ svg: airliner("white"), label: "Climate Crisis (white)" },
{ svg: airliner("#9B59B6"), label: "Private Jets / Historic / Other (purple)" },
],
},
{
name: "POTUS FLEET",
color: "text-yellow-400 border-yellow-500/30",
items: [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,6)"><path d="M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Air Force One / Two (gold ring)" },
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(8,6)"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Marine One (gold ring + heli)" },
], ],
}, },
{ {
@@ -139,7 +151,15 @@ const LEGEND: LegendCategory[] = [
name: "GEOPHYSICAL", name: "GEOPHYSICAL",
color: "text-orange-400 border-orange-500/30", color: "text-orange-400 border-orange-500/30",
items: [ items: [
{ svg: circle("#ff6600"), label: "Earthquake (size = magnitude)" }, { svg: circle("#ffcc00"), label: "Earthquake (yellow blob, size = magnitude)" },
],
},
{
name: "WILDFIRES",
color: "text-red-400 border-red-500/30",
items: [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 1C8 7 5 10 5 14a7 7 0 0 0 14 0c0-4-3-7-7-13z" fill="#ff6600" stroke="#ffcc00" stroke-width="1"/></svg>`, label: "Active wildfire / hotspot" },
{ svg: clusterCircle("#cc0000", "#ff3300"), label: "Fire cluster (grouped hotspots)" },
], ],
}, },
{ {
@@ -167,6 +187,14 @@ const LEGEND: LegendCategory[] = [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" }, { svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" },
], ],
}, },
{
name: "INFRASTRUCTURE",
color: "text-purple-400 border-purple-500/30",
items: [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/></svg>`, label: "Data Center" },
{ svg: circle("#888"), label: "Internet Outage Zone (grey)" },
],
},
{ {
name: "SURVEILLANCE / CCTV", name: "SURVEILLANCE / CCTV",
color: "text-green-400 border-green-500/30", color: "text-green-400 border-green-500/30",
File diff suppressed because it is too large Load Diff
+81 -49
View File
@@ -260,28 +260,40 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
if (flight) { if (flight) {
const callsign = flight.callsign || "UNKNOWN"; const callsign = flight.callsign || "UNKNOWN";
const alertColorMap: Record<string, string> = { const alertColorMap: Record<string, string> = {
'pink': 'text-pink-400', 'red': 'text-red-400', '#ff1493': 'text-[#ff1493]', pink: 'text-[#ff1493]', red: 'text-red-400', yellow: 'text-yellow-400',
'darkblue': 'text-blue-400', 'white': 'text-white' blue: 'text-blue-400', orange: 'text-orange-400', '#32cd32': 'text-[#32cd32]', purple: 'text-purple-400',
black: 'text-gray-400', white: 'text-white'
}; };
const alertBorderMap: Record<string, string> = { const alertBorderMap: Record<string, string> = {
'pink': 'border-pink-500/30', 'red': 'border-red-500/30', '#ff1493': 'border-[#ff1493]/30', pink: 'border-[#ff1493]/30', red: 'border-red-500/30', yellow: 'border-yellow-500/30',
'darkblue': 'border-blue-500/30', 'white': 'border-[var(--border-primary)]/30' blue: 'border-blue-500/30', orange: 'border-orange-500/30', '#32cd32': 'border-[#32cd32]/30', purple: 'border-purple-500/30',
black: 'border-gray-500/30', white: 'border-[var(--border-primary)]/30'
}; };
const alertBgMap: Record<string, string> = { const alertBgMap: Record<string, string> = {
'pink': 'bg-pink-950/40', 'red': 'bg-red-950/40', '#ff1493': 'bg-[#ff1493]/10', pink: 'bg-[#ff1493]/10', red: 'bg-red-950/40', yellow: 'bg-yellow-950/40',
'darkblue': 'bg-blue-950/40', 'white': 'bg-[var(--bg-panel)]' blue: 'bg-blue-950/40', orange: 'bg-orange-950/40', '#32cd32': 'bg-lime-950/40', purple: 'bg-purple-950/40',
black: 'bg-gray-900/40', white: 'bg-[var(--bg-panel)]'
}; };
const ac = flight.alert_color || 'white'; const ac = flight.alert_color || 'white';
const headerColor = alertColorMap[ac] || 'text-white'; const headerColor = alertColorMap[ac] || 'text-white';
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30'; const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]'; const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
const shadowColor = (ac === 'pink' || ac === '#ff1493') ? 'rgba(255,20,147,0.4)'
: ac === 'red' ? 'rgba(255,32,32,0.2)'
: ac === 'yellow' ? 'rgba(255,255,0,0.2)'
: ac === 'blue' ? 'rgba(59,130,246,0.2)'
: ac === 'orange' ? 'rgba(255,140,0,0.3)'
: ac === '#32cd32' ? 'rgba(50,205,50,0.2)'
: ac === 'purple' ? 'rgba(155,89,182,0.2)'
: 'rgba(255,255,255,0.1)';
return ( return (
<motion.div <motion.div
initial={{ y: 50, opacity: 0 }} initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`} className={`w-full bg-black/60 backdrop-blur-md border ${(ac === 'pink' || ac === '#ff1493') ? 'border-[#ff1493]' : ac === 'red' ? 'border-red-800' : ac === 'yellow' ? 'border-yellow-800' : ac === 'blue' ? 'border-blue-800' : ac === 'orange' ? 'border-orange-800' : ac === '#32cd32' ? 'border-lime-800' : ac === 'purple' ? 'border-purple-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden flex-shrink-0`}
> >
<div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}> <div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}>
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}> <h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
@@ -293,31 +305,39 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<div className="p-4 flex flex-col gap-3"> <div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2"> <div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span> <span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? ( {flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (() => {
<a const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`} const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
target="_blank" return (
rel="noreferrer" <a
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`} href={wikiHref}
title={`Search Wikipedia for ${flight.alert_operator}`} target="_blank"
> rel="noreferrer"
{flight.alert_operator} className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
</a> title={`Search Wikipedia for ${flight.alert_operator}`}
) : ( >
{flight.alert_operator}
</a>
);
})() : (
<span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span> <span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span>
)} )}
</div> </div>
{/* Owner/Operator Wikipedia photo */} {/* Owner/Operator Wikipedia photo */}
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && ( {flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (() => {
<div className="border-b border-[var(--border-primary)] pb-2"> const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
<WikiImage const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`} return (
label={flight.alert_operator} <div className="border-b border-[var(--border-primary)] pb-2">
maxH="max-h-36" <WikiImage
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'} wikiUrl={wikiHref}
/> label={flight.alert_operator}
</div> maxH="max-h-36"
)} accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
/>
</div>
);
})()}
{/* Aircraft model Wikipedia photo */} {/* Aircraft model Wikipedia photo */}
{aircraftImgUrl && ( {aircraftImgUrl && (
<div className="border-b border-[var(--border-primary)] pb-2"> <div className="border-b border-[var(--border-primary)] pb-2">
@@ -348,22 +368,10 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span> <span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span> <span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
</div> </div>
{flight.alert_tag1 && ( {flight.alert_tags && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2"> <div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAG</span> <span className="text-[var(--text-muted)] text-[10px]">INTEL TAGS</span>
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span> <span className={`text-xs font-bold text-right max-w-[200px] ${headerColor}`}>{flight.alert_tags}</span>
</div>
)}
{flight.alert_tag2 && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SECONDARY</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_tag2}</span>
</div>
)}
{flight.alert_tag3 && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DETAIL</span>
<span className="text-[var(--text-secondary)] text-xs">{flight.alert_tag3}</span>
</div> </div>
)} )}
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2"> <div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
@@ -667,10 +675,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
</div> </div>
<div className="flex flex-col gap-2 mt-2"> <div className="flex flex-col gap-2 mt-2">
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span> <span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
<div <div className="flex flex-col gap-1 max-h-[250px] overflow-y-auto styled-scrollbar">
className="text-[var(--text-primary)] text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2" {(() => {
dangerouslySetInnerHTML={{ __html: props.html || 'No articles available.' }} const urls: string[] = props._urls_list || [];
/> const headlines: string[] = props._headlines_list || [];
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
return urls.map((url: string, idx: number) => {
const headline = headlines[idx] || '';
let domain = '';
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
return (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block py-1.5 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
>
<span className="text-orange-400 text-[11px] font-bold leading-tight group-hover:text-orange-300 block">
{headline || domain || 'View Article'}
</span>
{headline && domain && (
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
)}
</a>
);
});
})()}
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -966,9 +998,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<motion.div <motion.div
key={idx} key={idx}
ref={(el) => { itemRefs.current[idx] = el; }} ref={(el) => { itemRefs.current[idx] = el; }}
initial={{ opacity: 0, x: -10 }} initial={idx < 15 ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + (idx * 0.05) }} transition={idx < 15 ? { delay: 0.1 + (idx * 0.05) } : { duration: 0 }}
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`} className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
> >
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest"> <div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
+335 -166
View File
@@ -3,7 +3,7 @@
import { API_BASE } from "@/lib/api"; import { API_BASE } from "@/lib/api";
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react"; import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp, Rss, Plus, Trash2, RotateCcw } from "lucide-react";
interface ApiEntry { interface ApiEntry {
id: string; id: string;
@@ -18,6 +18,22 @@ interface ApiEntry {
is_set: boolean; is_set: boolean;
} }
interface FeedEntry {
name: string;
url: string;
weight: number;
}
const WEIGHT_LABELS: Record<number, string> = { 1: "LOW", 2: "MED", 3: "STD", 4: "HIGH", 5: "CRIT" };
const WEIGHT_COLORS: Record<number, string> = {
1: "text-gray-400 border-gray-600",
2: "text-blue-400 border-blue-600",
3: "text-cyan-400 border-cyan-600",
4: "text-orange-400 border-orange-600",
5: "text-red-400 border-red-600",
};
const MAX_FEEDS = 20;
// Category colors for the tactical UI // Category colors for the tactical UI
const CATEGORY_COLORS: Record<string, string> = { const CATEGORY_COLORS: Record<string, string> = {
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20", Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
@@ -31,33 +47,54 @@ const CATEGORY_COLORS: Record<string, string> = {
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20", SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
}; };
type Tab = "api-keys" | "news-feeds";
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [activeTab, setActiveTab] = useState<Tab>("api-keys");
// --- API Keys state ---
const [apis, setApis] = useState<ApiEntry[]>([]); const [apis, setApis] = useState<ApiEntry[]>([]);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"])); const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
// --- News Feeds state ---
const [feeds, setFeeds] = useState<FeedEntry[]>([]);
const [feedsDirty, setFeedsDirty] = useState(false);
const [feedSaving, setFeedSaving] = useState(false);
const [feedMsg, setFeedMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const fetchKeys = useCallback(async () => { const fetchKeys = useCallback(async () => {
try { try {
const res = await fetch(`${API_BASE}/api/settings/api-keys`); const res = await fetch(`${API_BASE}/api/settings/api-keys`);
if (res.ok) { if (res.ok) setApis(await res.json());
const data = await res.json();
setApis(data);
}
} catch (e) { } catch (e) {
console.error("Failed to fetch API keys", e); console.error("Failed to fetch API keys", e);
} }
}, []); }, []);
useEffect(() => { const fetchFeeds = useCallback(async () => {
if (isOpen) fetchKeys(); try {
}, [isOpen, fetchKeys]); const res = await fetch(`${API_BASE}/api/settings/news-feeds`);
if (res.ok) {
setFeeds(await res.json());
setFeedsDirty(false);
}
} catch (e) {
console.error("Failed to fetch news feeds", e);
}
}, []);
const startEditing = (api: ApiEntry) => { useEffect(() => {
setEditingId(api.id); if (isOpen) {
setEditValue(""); fetchKeys();
}; fetchFeeds();
}
}, [isOpen, fetchKeys, fetchFeeds]);
// API Keys handlers
const startEditing = (api: ApiEntry) => { setEditingId(api.id); setEditValue(""); };
const saveKey = async (api: ApiEntry) => { const saveKey = async (api: ApiEntry) => {
if (!api.env_key) return; if (!api.env_key) return;
@@ -68,33 +105,81 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env_key: api.env_key, value: editValue }), body: JSON.stringify({ env_key: api.env_key, value: editValue }),
}); });
if (res.ok) { if (res.ok) { setEditingId(null); fetchKeys(); }
setEditingId(null);
fetchKeys(); // Refresh to get new obfuscated value
}
} catch (e) { } catch (e) {
console.error("Failed to save API key", e); console.error("Failed to save API key", e);
} finally { } finally { setSaving(false); }
setSaving(false);
}
}; };
const toggleCategory = (cat: string) => { const toggleCategory = (cat: string) => {
setExpandedCategories(prev => { setExpandedCategories(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(cat)) next.delete(cat); if (next.has(cat)) next.delete(cat); else next.add(cat);
else next.add(cat);
return next; return next;
}); });
}; };
// Group APIs by category
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => { const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
if (!acc[api.category]) acc[api.category] = []; if (!acc[api.category]) acc[api.category] = [];
acc[api.category].push(api); acc[api.category].push(api);
return acc; return acc;
}, {}); }, {});
// News Feeds handlers
const updateFeed = (idx: number, field: keyof FeedEntry, value: string | number) => {
setFeeds(prev => prev.map((f, i) => i === idx ? { ...f, [field]: value } : f));
setFeedsDirty(true);
setFeedMsg(null);
};
const removeFeed = (idx: number) => {
setFeeds(prev => prev.filter((_, i) => i !== idx));
setFeedsDirty(true);
setFeedMsg(null);
};
const addFeed = () => {
if (feeds.length >= MAX_FEEDS) return;
setFeeds(prev => [...prev, { name: "", url: "", weight: 3 }]);
setFeedsDirty(true);
setFeedMsg(null);
};
const saveFeeds = async () => {
setFeedSaving(true);
setFeedMsg(null);
try {
const res = await fetch(`${API_BASE}/api/settings/news-feeds`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(feeds),
});
if (res.ok) {
setFeedsDirty(false);
setFeedMsg({ type: "ok", text: "Feeds saved. Changes take effect on next news refresh (~30min) or manual /api/refresh." });
} else {
const d = await res.json().catch(() => ({}));
setFeedMsg({ type: "err", text: d.message || "Save failed" });
}
} catch (e) {
setFeedMsg({ type: "err", text: "Network error" });
} finally { setFeedSaving(false); }
};
const resetFeeds = async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, { method: "POST" });
if (res.ok) {
const d = await res.json();
setFeeds(d.feeds || []);
setFeedsDirty(false);
setFeedMsg({ type: "ok", text: "Reset to defaults" });
}
} catch (e) {
setFeedMsg({ type: "err", text: "Reset failed" });
}
};
return ( return (
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
@@ -124,7 +209,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</div> </div>
<div> <div>
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2> <h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">API KEY REGISTRY</span> <span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">SETTINGS &amp; DATA SOURCES</span>
</div> </div>
</div> </div>
<button <button
@@ -135,153 +220,237 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</button> </button>
</div> </div>
{/* Info Banner */} {/* Tab Bar */}
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10"> <div className="flex border-b border-[var(--border-primary)]/60">
<div className="flex items-start gap-2"> <button
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" /> onClick={() => setActiveTab("api-keys")}
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed"> className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "api-keys" ? "text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key. >
</p> <Key size={10} />
</div> API KEYS
</button>
<button
onClick={() => setActiveTab("news-feeds")}
className={`flex-1 px-4 py-2.5 text-[10px] font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === "news-feeds" ? "text-orange-400 border-b-2 border-orange-500 bg-orange-950/10" : "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"}`}
>
<Rss size={10} />
NEWS FEEDS
{feedsDirty && <span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />}
</button>
</div> </div>
{/* API List */} {/* ==================== API KEYS TAB ==================== */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3"> {activeTab === "api-keys" && (
{Object.entries(grouped).map(([category, categoryApis]) => { <>
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20"; {/* Info Banner */}
const isExpanded = expandedCategories.has(category); <div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
<div className="flex items-start gap-2">
return ( <Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden"> <p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
{/* Category Header */} API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
<button </p>
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
{category.toUpperCase()}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
</span>
</div>
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
</button>
{/* APIs in Category */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{categoryApis.map((api) => (
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
{/* API Name + Status */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{api.required && <Key size={10} className="text-yellow-500" />}
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
</div>
<div className="flex items-center gap-1.5">
{api.has_key ? (
api.is_set ? (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
KEY SET
</span>
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
MISSING
</span>
)
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">
PUBLIC
</span>
)}
{api.url && (
<a
href={api.url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={10} />
</a>
)}
</div>
</div>
{/* Description */}
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">
{api.description}
</p>
{/* Key Field (only for APIs with keys) */}
{api.has_key && (
<div className="mt-2">
{editingId === api.id ? (
/* Edit Mode */
<div className="flex gap-2">
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
placeholder="Enter API key..."
autoFocus
/>
<button
onClick={() => saveKey(api)}
disabled={saving}
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
>
<Save size={10} />
{saving ? "..." : "SAVE"}
</button>
<button
onClick={() => setEditingId(null)}
className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono"
>
ESC
</button>
</div>
) : (
/* Display Mode */
<div className="flex items-center gap-1.5">
<div
className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none"
onClick={() => startEditing(api)}
>
<span className="text-[var(--text-muted)] tracking-wider">
{api.is_set ? api.value_obfuscated : "Click to set key..."}
</span>
</div>
</div>
)}
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); </div>
})}
</div>
{/* Footer */} {/* API List */}
<div className="p-4 border-t border-[var(--border-primary)]/80"> <div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono"> {Object.entries(grouped).map(([category, categoryApis]) => {
<span>{apis.length} REGISTERED APIs</span> const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span> const isExpanded = expandedCategories.has(category);
</div> return (
</div> <div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
<button
onClick={() => toggleCategory(category)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
{category.toUpperCase()}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono">
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
</span>
</div>
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{categoryApis.map((api) => (
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{api.required && <Key size={10} className="text-yellow-500" />}
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
</div>
<div className="flex items-center gap-1.5">
{api.has_key ? (
api.is_set ? (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">KEY SET</span>
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">MISSING</span>
)
) : (
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">PUBLIC</span>
)}
{api.url && (
<a href={api.url} target="_blank" rel="noopener noreferrer" className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors" onClick={(e) => e.stopPropagation()}>
<ExternalLink size={10} />
</a>
)}
</div>
</div>
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">{api.description}</p>
{api.has_key && (
<div className="mt-2">
{editingId === api.id ? (
<div className="flex gap-2">
<input type="text" value={editValue} onChange={(e) => setEditValue(e.target.value)} className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors" placeholder="Enter API key..." autoFocus />
<button onClick={() => saveKey(api)} disabled={saving} className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1">
<Save size={10} />{saving ? "..." : "SAVE"}
</button>
<button onClick={() => setEditingId(null)} className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono">ESC</button>
</div>
) : (
<div className="flex items-center gap-1.5">
<div className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none" onClick={() => startEditing(api)}>
<span className="text-[var(--text-muted)] tracking-wider">{api.is_set ? api.value_obfuscated : "Click to set key..."}</span>
</div>
</div>
)}
</div>
)}
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
<span>{apis.length} REGISTERED APIs</span>
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
</div>
</div>
</>
)}
{/* ==================== NEWS FEEDS TAB ==================== */}
{activeTab === "news-feeds" && (
<>
{/* Info Banner */}
<div className="mx-4 mt-4 p-3 rounded-lg border border-orange-900/30 bg-orange-950/10">
<div className="flex items-start gap-2">
<Rss size={12} className="text-orange-500 mt-0.5 flex-shrink-0" />
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored by keyword heuristics and weighted by the priority you set. Up to <span className="text-orange-400">{MAX_FEEDS}</span> sources.
</p>
</div>
</div>
{/* Feed List */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
{feeds.map((feed, idx) => (
<div key={idx} className="rounded-lg border border-[var(--border-primary)]/60 p-3 hover:border-[var(--border-secondary)]/60 transition-colors group">
{/* Row 1: Name + Weight + Delete */}
<div className="flex items-center gap-2 mb-2">
<input
type="text"
value={feed.name}
onChange={(e) => updateFeed(idx, "name", e.target.value)}
className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
placeholder="Source name..."
/>
{/* Weight selector */}
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map(w => (
<button
key={w}
onClick={() => updateFeed(idx, "weight", w)}
className={`w-5 h-5 rounded text-[8px] font-mono font-bold border transition-all ${feed.weight === w ? WEIGHT_COLORS[w] + " bg-black/40" : "border-[var(--border-primary)]/40 text-[var(--text-muted)]/50 hover:border-[var(--border-secondary)]"}`}
title={WEIGHT_LABELS[w]}
>
{w}
</button>
))}
<span className={`text-[8px] font-mono ml-1 w-7 ${WEIGHT_COLORS[feed.weight]?.split(" ")[0] || "text-gray-400"}`}>
{WEIGHT_LABELS[feed.weight] || "STD"}
</span>
</div>
<button
onClick={() => removeFeed(idx)}
className="w-6 h-6 rounded flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 hover:bg-red-950/20 transition-all opacity-0 group-hover:opacity-100"
title="Remove feed"
>
<Trash2 size={11} />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={feed.url}
onChange={(e) => updateFeed(idx, "url", e.target.value)}
className="w-full bg-black/30 border border-[var(--border-primary)]/40 rounded px-2 py-1 text-[10px] font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
placeholder="https://example.com/rss.xml"
/>
</div>
))}
{/* Add Feed Button */}
<button
onClick={addFeed}
disabled={feeds.length >= MAX_FEEDS}
className="w-full py-2.5 rounded-lg border border-dashed border-[var(--border-primary)]/60 text-[var(--text-muted)] hover:border-orange-500/50 hover:text-orange-400 hover:bg-orange-950/10 transition-all text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
>
<Plus size={10} />
ADD FEED ({feeds.length}/{MAX_FEEDS})
</button>
</div>
{/* Status message */}
{feedMsg && (
<div className={`mx-4 mb-2 px-3 py-2 rounded text-[10px] font-mono ${feedMsg.type === "ok" ? "text-green-400 bg-green-950/20 border border-green-900/30" : "text-red-400 bg-red-950/20 border border-red-900/30"}`}>
{feedMsg.text}
</div>
)}
{/* Footer */}
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center gap-2">
<button
onClick={saveFeeds}
disabled={!feedsDirty || feedSaving}
className="flex-1 px-4 py-2 rounded bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30 transition-colors text-[10px] font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
>
<Save size={10} />
{feedSaving ? "SAVING..." : "SAVE FEEDS"}
</button>
<button
onClick={resetFeeds}
className="px-3 py-2 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-all text-[10px] font-mono flex items-center gap-1.5"
title="Reset to defaults"
>
<RotateCcw size={10} />
RESET
</button>
</div>
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono mt-2">
<span>{feeds.length}/{MAX_FEEDS} SOURCES</span>
<span>WEIGHT: 1=LOW 5=CRITICAL</span>
</div>
</div>
</>
)}
</motion.div> </motion.div>
</> </>
)} )}
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { Github, MessageSquare, Download, AlertCircle, CheckCircle2 } from "lucide-react";
import packageJson from "../../package.json";
export default function TopRightControls() {
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "uptodate" | "error">("idle");
const [latestVersion, setLatestVersion] = useState<string>("");
const currentVersion = packageJson.version;
const checkForUpdates = async () => {
setUpdateStatus("checking");
try {
const res = await fetch("https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases/latest");
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
// Remove 'v' prefix if it exists to compare semver cleanly
const latest = data.tag_name?.replace('v', '') || data.name?.replace('v', '');
const current = currentVersion.replace('v', '');
if (latest && latest !== current) {
setLatestVersion(latest);
setUpdateStatus("available");
} else {
setUpdateStatus("uptodate");
setTimeout(() => setUpdateStatus("idle"), 3000);
}
} catch (err) {
console.error("Update check failed:", err);
setUpdateStatus("error");
setTimeout(() => setUpdateStatus("idle"), 3000);
}
};
return (
<div className="flex items-center gap-2 mb-1 justify-end">
<a
href="https://github.com/BigBodyCobain/Shadowbroker/discussions"
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--bg-primary)]/50 backdrop-blur-md border border-[var(--border-primary)] rounded-lg hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer"
>
<MessageSquare size={12} className="text-cyan-400 w-3 h-3" />
<span className="tracking-widest">DISCUSSIONS</span>
</a>
{updateStatus === "available" ? (
<a
href="https://github.com/BigBodyCobain/Shadowbroker/releases/latest"
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-green-500/10 backdrop-blur-md border border-green-500/50 rounded-lg hover:bg-green-500/20 transition-all text-[10px] text-green-400 font-mono cursor-pointer shadow-[0_0_15px_rgba(34,197,94,0.3)]"
>
<Download size={12} className="w-3 h-3" />
<span className="tracking-widest animate-pulse">v{latestVersion} UPDATE!</span>
</a>
) : (
<button
onClick={checkForUpdates}
disabled={updateStatus === "checking"}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--bg-primary)]/50 backdrop-blur-md border border-[var(--border-primary)] rounded-lg hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateStatus === "checking" && <Github size={12} className="w-3 h-3 animate-spin text-cyan-400" />}
{updateStatus === "idle" && <Github size={12} className="w-3 h-3 text-cyan-400" />}
{updateStatus === "uptodate" && <CheckCircle2 size={12} className="w-3 h-3 text-green-400" />}
{updateStatus === "error" && <AlertCircle size={12} className="w-3 h-3 text-red-400" />}
<span className="tracking-widest">
{updateStatus === "checking" ? "CHECKING..." :
updateStatus === "uptodate" ? "UP TO DATE" :
updateStatus === "error" ? "CHECK FAILED" :
"CHECK UPDATES"}
</span>
</button>
)}
</div>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@ const _cache: Record<string, { url: string | null; done: boolean }> = {};
* maxH: Max height class (default "max-h-32") * maxH: Max height class (default "max-h-32")
* accent: Border hover color class (default "hover:border-cyan-500/50") * accent: Border hover color class (default "hover:border-cyan-500/50")
*/ */
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent = 'hover:border-cyan-500/50' }: { export default function WikiImage({ wikiUrl, label, maxH = 'max-h-52', accent = 'hover:border-cyan-500/50' }: {
wikiUrl: string; wikiUrl: string;
label?: string; label?: string;
maxH?: string; maxH?: string;
@@ -56,7 +56,7 @@ export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent =
<img <img
src={imgUrl} src={imgUrl}
alt={label || title.replace(/_/g, ' ')} alt={label || title.replace(/_/g, ' ')}
className={`w-full h-auto ${maxH} object-cover rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`} className={`w-full h-auto ${maxH} object-contain rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
/> />
</a> </a>
)} )}
+202 -17
View File
@@ -1,14 +1,69 @@
"use client"; "use client";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe } from "lucide-react"; import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight } from "lucide-react";
import packageJson from "../../package.json";
import { useTheme } from "@/lib/ThemeContext"; import { useTheme } from "@/lib/ThemeContext";
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) { function relativeTime(iso: string | undefined): string {
if (!iso) return "";
const diff = Date.now() - new Date(iso + "Z").getTime();
if (diff < 0) return "now";
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
// Map layer IDs to freshness keys from the backend source_timestamps dict
const FRESHNESS_MAP: Record<string, string> = {
flights: "commercial_flights",
private: "private_flights",
jets: "private_jets",
military: "military_flights",
tracked: "military_flights",
earthquakes: "earthquakes",
satellites: "satellites",
ships_important: "ships",
ships_civilian: "ships",
ships_passenger: "ships",
ukraine_frontline: "frontlines",
global_incidents: "gdelt",
cctv: "cctv",
gps_jamming: "commercial_flights",
kiwisdr: "kiwisdr",
firms: "firms_fires",
internet_outages: "internet_outages",
datacenters: "datacenters",
};
// POTUS fleet ICAO hex codes for client-side filtering
const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
'ADFDF8': { label: 'Air Force One (82-8000)', type: 'AF1' },
'ADFDF9': { label: 'Air Force One (92-9000)', type: 'AF1' },
'ADFEB7': { label: 'Air Force Two (98-0001)', type: 'AF2' },
'ADFEB8': { label: 'Air Force Two (98-0002)', type: 'AF2' },
'ADFEB9': { label: 'Air Force Two (99-0003)', type: 'AF2' },
'ADFEBA': { label: 'Air Force Two (99-0004)', type: 'AF2' },
'AE4AE6': { label: 'Air Force Two (09-0015)', type: 'AF2' },
'AE4AE8': { label: 'Air Force Two (09-0016)', type: 'AF2' },
'AE4AEA': { label: 'Air Force Two (09-0017)', type: 'AF2' },
'AE4AEC': { label: 'Air Force Two (19-0018)', type: 'AF2' },
'AE0865': { label: 'Marine One (VH-3D)', type: 'M1' },
'AE5E76': { label: 'Marine One (VH-92A)', type: 'M1' },
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
};
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: { type: string; id: number; extra?: any }) => void; onFlyTo?: (lat: number, lng: number) => void }) {
const [isMinimized, setIsMinimized] = useState(false); const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false); const [gibsPlaying, setGibsPlaying] = useState(false);
const [potusEnabled, setPotusEnabled] = useState(true);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// GIBS time slider play/pause animation // GIBS time slider play/pause animation
@@ -35,10 +90,34 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); }; return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
}, [gibsPlaying, gibsDate, setGibsDate]); }, [gibsPlaying, gibsDate, setGibsDate]);
// Compute ship category counts // Compute ship category counts (memoized — ships array can be 1000+ items)
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0; const { importantShipCount, passengerShipCount, civilianShipCount } = useMemo(() => {
const passengerShipCount = data?.ships?.filter((s: any) => s.type === 'passenger')?.length || 0; const ships = data?.ships;
const civilianShipCount = data?.ships?.filter((s: any) => !['carrier', 'military_vessel', 'tanker', 'cargo', 'passenger'].includes(s.type))?.length || 0; if (!ships || !ships.length) return { importantShipCount: 0, passengerShipCount: 0, civilianShipCount: 0 };
let important = 0, passenger = 0, civilian = 0;
for (const s of ships) {
const t = s.type;
if (t === 'carrier' || t === 'military_vessel' || t === 'tanker' || t === 'cargo') important++;
else if (t === 'passenger') passenger++;
else civilian++;
}
return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian };
}, [data?.ships]);
// Find POTUS fleet planes currently airborne from tracked flights
const potusFlights = useMemo(() => {
const tracked = data?.tracked_flights;
if (!tracked) return [];
const results: { index: number; flight: any; meta: { label: string; type: string } }[] = [];
for (let i = 0; i < tracked.length; i++) {
const f = tracked[i];
const icao = (f.icao24 || '').toUpperCase();
if (POTUS_ICAOS[icao]) {
results.push({ index: i, flight: f, meta: POTUS_ICAOS[icao] });
}
}
return results;
}, [data?.tracked_flights]);
const layers = [ const layers = [
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane }, { id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
@@ -47,7 +126,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle }, { id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle },
{ id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye }, { id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye },
{ id: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity }, { id: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity },
{ id: "satellites", name: "Satellites", source: "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite }, { id: "satellites", name: "Satellites", source: data?.satellite_source === "celestrak" ? "CelesTrak SGP4" : data?.satellite_source === "tle_api" ? "TLE API · SGP4" : data?.satellite_source === "disk_cache" ? "Cached · SGP4 (est.)" : "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite },
{ id: "ships_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship }, { id: "ships_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship },
{ id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor }, { id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor },
{ id: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor }, { id: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor },
@@ -58,6 +137,9 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe }, { id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite }, { id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio }, { id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
{ id: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun }, { id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
]; ];
@@ -78,7 +160,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1> <h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]" className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'} title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
> >
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />} {theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
@@ -86,7 +168,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{onSettingsClick && ( {onSettingsClick && (
<button <button
onClick={onSettingsClick} onClick={onSettingsClick}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)] group" className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)] group`}
title="System Settings" title="System Settings"
> >
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" /> <Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
@@ -95,13 +177,16 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{onLegendClick && ( {onLegendClick && (
<button <button
onClick={onLegendClick} onClick={onLegendClick}
className="h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]" className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title="Map Legend / Icon Key" title="Map Legend / Icon Key"
> >
<BookOpen size={12} /> <BookOpen size={12} />
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span> <span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
</button> </button>
)} )}
<span className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] flex items-center justify-center text-[8px] ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} font-mono tracking-widest select-none`}>
v{packageJson.version}
</span>
</div> </div>
</div> </div>
@@ -111,12 +196,30 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{/* Header / Toggle */} {/* Header / Toggle */}
<div <div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50" className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
> >
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA LAYERS</span> <span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest" onClick={() => setIsMinimized(!isMinimized)}>DATA LAYERS</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"> <div className="flex items-center gap-2">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />} <button
</button> title={Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? "Disable all layers" : "Enable all layers"}
className={`${Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-400 transition-colors`}
onClick={(e) => {
e.stopPropagation();
const allOn = Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v);
setActiveLayers((prev: any) => {
const next: any = {};
for (const k of Object.keys(prev)) {
next[k] = k === 'gibs_imagery' ? false : !allOn;
}
return next;
});
}}
>
{Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
</button>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" onClick={() => setIsMinimized(!isMinimized)}>
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
</div> </div>
<AnimatePresence> <AnimatePresence>
@@ -128,6 +231,61 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
className="overflow-y-auto styled-scrollbar" className="overflow-y-auto styled-scrollbar"
> >
<div className="flex flex-col gap-6 p-4 pt-2 pb-6"> <div className="flex flex-col gap-6 p-4 pt-2 pb-6">
{/* POTUS Fleet — pinned to TOP when aircraft are active */}
{potusEnabled && potusFlights.length > 0 && (
<div className="bg-[#ff1493]/5 border border-[#ff1493]/30 rounded-lg p-3 -mt-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[#ff1493]" />
<span className="text-[10px] text-[#ff1493] font-mono tracking-widest font-bold">POTUS FLEET</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
{potusFlights.length} ACTIVE
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(false); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
title="Hide POTUS Fleet tracker"
>
HIDE
</button>
</div>
<div className="flex flex-col gap-2">
{potusFlights.map((pf) => {
const color = pf.meta.type === 'AF1' ? '#ff1493' : pf.meta.type === 'M1' ? '#ff1493' : '#3b82f6';
const alt = pf.flight.alt_baro || pf.flight.alt || 0;
const speed = pf.flight.gs || pf.flight.speed || 0;
return (
<div
key={pf.flight.icao24}
className="flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all hover:bg-[var(--bg-secondary)]/60"
style={{ borderColor: `${color}40`, background: `${color}10` }}
onClick={() => {
if (onFlyTo && pf.flight.lat != null && pf.flight.lng != null) {
onFlyTo(pf.flight.lat, pf.flight.lng);
}
if (onEntityClick) {
onEntityClick({ type: 'tracked_flight', id: pf.index });
}
}}
>
<div className="flex flex-col">
<span className="text-[10px] font-bold font-mono" style={{ color }}>{pf.meta.label}</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
{alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} · {speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: color }} />
<span className="text-[8px] font-mono" style={{ color }}>TRACK</span>
</div>
</div>
);
})}
</div>
</div>
)}
{layers.map((layer, idx) => { {layers.map((layer, idx) => {
const Icon = layer.icon; const Icon = layer.icon;
const active = activeLayers[layer.id as keyof typeof activeLayers] || false; const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
@@ -144,7 +302,12 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span> <span className={`text-sm font-medium ${active ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'} tracking-wide`}>{layer.name}</span>
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? 'LIVE' : 'OFF'}</span> <span className="text-[9px] text-[var(--text-muted)] font-mono tracking-wider mt-0.5">{layer.source} · {active ? (() => {
const fKey = FRESHNESS_MAP[layer.id];
const freshness = fKey && data?.freshness?.[fKey];
const rt = freshness ? relativeTime(freshness) : '';
return rt ? <span className="text-cyan-500/70">{rt}</span> : 'LIVE';
})() : 'OFF'}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -208,6 +371,28 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
</div> </div>
) )
})} })}
{/* POTUS Fleet — bottom section when inactive or hidden */}
{(potusFlights.length === 0 || !potusEnabled) && (
<div className="border-t border-[var(--border-primary)]/50 pt-4 mt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[var(--text-muted)]" />
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">POTUS FLEET</span>
</div>
{!potusEnabled ? (
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(true); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
>
SHOW
</button>
) : (
<span className="text-[8px] font-mono text-[var(--text-muted)]">NO ACTIVE AIRCRAFT</span>
)}
</div>
</div>
)}
</div> </div>
</motion.div> </motion.div>
)} )}
+8 -28
View File
@@ -1,28 +1,8 @@
// NEXT_PUBLIC_* vars are baked at build time in Next.js, so setting them // All API calls use relative paths (e.g. /api/flights).
// in docker-compose `environment` has no effect at runtime. Instead we // The catch-all route handler at src/app/api/[...path]/route.ts proxies them
// auto-detect: use the browser's current hostname with a configurable port // to BACKEND_URL at runtime (set in docker-compose or .env.local for dev).
// so the dashboard works on localhost, LAN IPs, and custom Docker port maps // This means:
// without any code changes. // - No build-time baking of the backend URL into the client bundle
// // - BACKEND_URL=http://backend:8000 works via Docker internal networking
// Override order: // - Only port 3000 needs to be exposed externally
// 1. Build-time NEXT_PUBLIC_API_URL (for advanced users who rebuild the image) export const API_BASE = "";
// 2. Runtime auto-detect from window.location.hostname + port 8000
function resolveApiBase(): string {
// Build-time override (works when image is rebuilt with the env var)
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL;
}
// Server-side rendering: fall back to localhost
if (typeof window === "undefined") {
return "http://localhost:8000";
}
// Client-side: use the same hostname the user is browsing on
const proto = window.location.protocol;
const host = window.location.hostname;
return `${proto}//${host}:8000`;
}
export const API_BASE = resolveApiBase();
+12 -2
View File
@@ -17,9 +17,16 @@ if %errorlevel% neq 0 (
exit /b 1 exit /b 1
) )
:: Check Python version :: Check Python version (warn if 3.13+)
for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYVER=%%v
echo [*] Found Python %PYVER% echo [*] Found Python %PYVER%
for /f "tokens=1,2 delims=." %%a in ("%PYVER%") do (
if %%b GEQ 13 (
echo [!] WARNING: Python %PYVER% detected. Some packages may fail to build.
echo [!] Recommended: Python 3.10, 3.11, or 3.12.
echo.
)
)
:: Check for Node.js :: Check for Node.js
where npm >nul 2>&1 where npm >nul 2>&1
@@ -47,7 +54,7 @@ if not exist "venv\" (
) )
call venv\Scripts\activate.bat call venv\Scripts\activate.bat
echo [*] Installing Python dependencies (this may take a minute)... echo [*] Installing Python dependencies (this may take a minute)...
pip install -r requirements.txt pip install -q -r requirements.txt
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo. echo.
echo [!] ERROR: pip install failed. See errors above. echo [!] ERROR: pip install failed. See errors above.
@@ -58,6 +65,9 @@ if %errorlevel% neq 0 (
exit /b 1 exit /b 1
) )
echo [*] Backend dependencies OK. echo [*] Backend dependencies OK.
echo [*] Installing backend Node.js dependencies...
call npm install --silent
echo [*] Backend Node.js dependencies OK.
cd .. cd ..
echo. echo.
+12 -2
View File
@@ -23,7 +23,14 @@ else
exit 1 exit 1
fi fi
echo "[*] Found $($PYTHON_CMD --version 2>&1)" PYVER=$($PYTHON_CMD --version 2>&1 | awk '{print $2}')
echo "[*] Found Python $PYVER"
PY_MINOR=$(echo "$PYVER" | cut -d. -f2)
if [ "$PY_MINOR" -ge 13 ] 2>/dev/null; then
echo "[!] WARNING: Python $PYVER detected. Some packages may fail to build."
echo "[!] Recommended: Python 3.10, 3.11, or 3.12."
echo ""
fi
# Get the directory where this script lives # Get the directory where this script lives
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -42,7 +49,7 @@ fi
source venv/bin/activate source venv/bin/activate
echo "[*] Installing Python dependencies (this may take a minute)..." echo "[*] Installing Python dependencies (this may take a minute)..."
pip install -r requirements.txt pip install -q -r requirements.txt
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "" echo ""
echo "[!] ERROR: pip install failed. See errors above." echo "[!] ERROR: pip install failed. See errors above."
@@ -52,6 +59,9 @@ if [ $? -ne 0 ]; then
fi fi
echo "[*] Backend dependencies OK." echo "[*] Backend dependencies OK."
deactivate deactivate
echo "[*] Installing backend Node.js dependencies..."
npm install --silent
echo "[*] Backend Node.js dependencies OK."
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"