docs: add registry dockge and dashboard operations
All checks were successful
Build / test-and-image (push) Successful in 2m39s

This commit is contained in:
2026-05-16 21:18:51 +02:00
parent 85f97bb2a6
commit 42b7fc2024
10 changed files with 372 additions and 87 deletions

View File

@@ -1,49 +1,49 @@
# ============================================ # Intelligence Terminal / Crucix configuration
# Crucix Intelligence Engine — Configuration # Copy to .env. Keep comments on separate lines; Docker env_file treats inline comments as values.
# ============================================
# Copy this file to .env and fill in your keys.
# Keys are optional — sources without keys degrade gracefully.
#
# IMPORTANT: Do NOT put comments on the same line as values.
# Docker env_file treats inline comments as part of the value.
# === OSINT Source API Keys === # Server
# Federal Reserve Economic Data (free: fred.stlouisfed.org/docs/api)
FRED_API_KEY=
# NASA FIRMS fire data (free: firms.modaps.eosdis.nasa.gov/api)
FIRMS_MAP_KEY=
# Energy Information Administration (free: api.eia.gov/register)
EIA_API_KEY=
# Maritime AIS data (aisstream.io)
AISSTREAM_API_KEY=
# Armed Conflict Location & Event Data (acleddata.com/user/register)
ACLED_EMAIL=
# OAuth2 password grant (API keys deprecated Sept 2025)
ACLED_PASSWORD=
# Cloudflare Radar internet outages & traffic anomalies (free: dash.cloudflare.com/profile/api-tokens, Account Analytics Read)
CLOUDFLARE_API_TOKEN=
# === Server Configuration ===
# Dashboard server port
PORT=3117 PORT=3117
# Auto-refresh interval (minutes)
REFRESH_INTERVAL_MINUTES=15 REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
SWEEP_TOKEN=
BRIEF_VERBOSITY=standard
# === LLM Layer (optional) === # LLM layer
# Enables AI-enhanced trade ideas and breaking news Telegram alerts. # Providers: openrouter | openai-compatible | lmstudio | ollama | openai | anthropic | gemini | mistral | minimax | grok | codex
# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok LLM_PROVIDER=openrouter
LLM_PROVIDER= LLM_BASE_URL=https://openrouter.ai/api/v1
# Not needed for codex (uses ~/.codex/auth.json) or ollama (local)
LLM_API_KEY= LLM_API_KEY=
# Optional override. Each provider has a sensible default: LLM_MODEL=openrouter/free
# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5 | ollama: llama3.1:8b | grok: grok-4-latest LLM_TEMPERATURE=0.2
LLM_MODEL= LLM_MAX_TOKENS=2000
# Ollama base URL (only needed if not using default http://localhost:11434) LLM_TIMEOUT_MS=90000
OLLAMA_BASE_URL= OPENROUTER_SITE_URL=https://git.wilkensxl.de/MrSphay/intelligence-terminal
OPENROUTER_APP_NAME=Intelligence Terminal
# === Telegram Alerts (optional, requires LLM) === # Local OpenAI-compatible examples
# Create a bot via @BotFather, get chat ID via @userinfobot # LM Studio: LLM_PROVIDER=lmstudio, LLM_BASE_URL=http://host.docker.internal:1234/v1, LLM_MODEL=local-model
# Ollama: LLM_PROVIDER=ollama, LLM_BASE_URL=http://host.docker.internal:11434, LLM_MODEL=llama3.1:8b
# Generic: LLM_PROVIDER=openai-compatible, LLM_BASE_URL=http://host.docker.internal:8000/v1, LLM_MODEL=your-model
# Core OSINT / market source keys
FRED_API_KEY=
FIRMS_MAP_KEY=
EIA_API_KEY=
AISSTREAM_API_KEY=
ACLED_EMAIL=
ACLED_PASSWORD=
CLOUDFLARE_API_TOKEN=
BLS_API_KEY=
# Telegram bot and alerts
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= TELEGRAM_CHAT_ID=
TELEGRAM_POLL_INTERVAL=5000
TELEGRAM_CHANNELS=
# Discord bot/webhook
DISCORD_BOT_TOKEN=
DISCORD_CHANNEL_ID=
DISCORD_GUILD_ID=
DISCORD_WEBHOOK_URL=

View File

@@ -0,0 +1,42 @@
name: Build
on:
push:
branches:
- master
- main
- codex/production-intelligence-terminal
pull_request:
jobs:
test-and-image:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Unit tests
run: npm run test:unit
- name: Compose config
run: docker compose config
- name: Build Docker image
run: docker build -t git.wilkensxl.de/mrsphay/intelligence-terminal:${{ github.sha }} .
- name: Publish Docker image
if: ${{ secrets.REGISTRY_TOKEN != '' }}
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.wilkensxl.de -u "${{ github.repository_owner }}" --password-stdin
docker tag git.wilkensxl.de/mrsphay/intelligence-terminal:${{ github.sha }} git.wilkensxl.de/mrsphay/intelligence-terminal:latest
docker push git.wilkensxl.de/mrsphay/intelligence-terminal:${{ github.sha }}
docker push git.wilkensxl.de/mrsphay/intelligence-terminal:latest

120
README.md
View File

@@ -97,13 +97,105 @@ The dashboard opens automatically at `http://localhost:3117` and immediately beg
### Docker ### Docker
```bash ```bash
git clone https://github.com/calesthio/Crucix.git docker pull git.wilkensxl.de/mrsphay/intelligence-terminal:latest
cd Crucix
cp .env.example .env # add your API keys
docker compose up -d
``` ```
Dashboard at `http://localhost:3117`. Sweep data persists in `./runs/` via volume mount. Includes a health check endpoint. Dashboard at `http://localhost:3117`. Sweep data persists in `./runs/` via volume mount. The container disables browser auto-open by default, exposes `/api/health` and `/api/metrics`, and is suitable for Dockge/Pangolin.
#### Dockge / Pangolin Compose
```yaml
services:
intelligence-terminal:
image: git.wilkensxl.de/mrsphay/intelligence-terminal:latest
container_name: intelligence-terminal
env_file:
- path: .env
required: false
environment:
PORT: ${PORT:-3117}
AUTO_OPEN_BROWSER: "false"
ports:
- "${PORT:-3117}:${PORT:-3117}"
volumes:
- ./runs:/app/runs
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3117)+'/api/health').then(r=>{if(![200,503].includes(r.status))process.exit(1);return r.json()}).then(j=>{if(j.status==='error')process.exit(1)}).catch(()=>process.exit(1))"]
interval: 60s
timeout: 10s
start_period: 45s
retries: 3
```
#### Required `.env` Example
```env
PORT=3117
REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
SWEEP_TOKEN=
BRIEF_VERBOSITY=standard
LLM_PROVIDER=openrouter
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=
LLM_MODEL=openrouter/free
LLM_TEMPERATURE=0.2
LLM_MAX_TOKENS=2000
LLM_TIMEOUT_MS=90000
OPENROUTER_SITE_URL=https://git.wilkensxl.de/MrSphay/intelligence-terminal
OPENROUTER_APP_NAME=Intelligence Terminal
FRED_API_KEY=
FIRMS_MAP_KEY=
EIA_API_KEY=
AISSTREAM_API_KEY=
ACLED_EMAIL=
ACLED_PASSWORD=
CLOUDFLARE_API_TOKEN=
BLS_API_KEY=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
DISCORD_BOT_TOKEN=
DISCORD_CHANNEL_ID=
DISCORD_GUILD_ID=
DISCORD_WEBHOOK_URL=
```
Local LLM examples:
```env
# LM Studio
LLM_PROVIDER=lmstudio
LLM_BASE_URL=http://host.docker.internal:1234/v1
LLM_MODEL=local-model
# Ollama
LLM_PROVIDER=ollama
LLM_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=llama3.1:8b
# Generic OpenAI-compatible endpoint
LLM_PROVIDER=openai-compatible
LLM_BASE_URL=http://host.docker.internal:8000/v1
LLM_API_KEY=
LLM_MODEL=your-model
```
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
#### Build And Publish Your Gitea Image
```bash
docker login git.wilkensxl.de
docker build -t git.wilkensxl.de/mrsphay/intelligence-terminal:latest .
docker tag git.wilkensxl.de/mrsphay/intelligence-terminal:latest git.wilkensxl.de/mrsphay/intelligence-terminal:20260516
docker push git.wilkensxl.de/mrsphay/intelligence-terminal:latest
docker push git.wilkensxl.de/mrsphay/intelligence-terminal:20260516
```
--- ---
@@ -186,12 +278,26 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye
**Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode. **Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode.
### Optional LLM Layer ### Optional LLM Layer
Connect any of 8 LLM providers for enhanced analysis: Connect cloud or local OpenAI-compatible LLM providers for enhanced analysis:
- **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data - **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data
- **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring - **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax, Mistral, Grok - Providers: OpenRouter, OpenAI-compatible APIs, LM Studio, Ollama, OpenAI, Anthropic Claude, Google Gemini, OpenAI Codex, MiniMax, Mistral, Grok
- Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle. - Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle.
Primary env keys:
```env
LLM_PROVIDER=openrouter
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=
LLM_MODEL=openrouter/free
LLM_TEMPERATURE=0.2
LLM_MAX_TOKENS=2000
LLM_TIMEOUT_MS=90000
```
OpenRouter also supports model ids ending in `:free`; use the model id shown in your OpenRouter account when you want a specific free model.
--- ---
## API Keys Setup ## API Keys Setup

View File

@@ -75,6 +75,14 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
/* LEFT RAIL */ /* LEFT RAIL */
.layer-item{display:flex;align-items:center;justify-content:space-between;padding:8px;border:1px solid rgba(255,255,255,0.04);background:rgba(255,255,255,0.02);margin-bottom:4px} .layer-item{display:flex;align-items:center;justify-content:space-between;padding:8px;border:1px solid rgba(255,255,255,0.04);background:rgba(255,255,255,0.02);margin-bottom:4px}
.layer-item{cursor:pointer;transition:border-color .15s ease,background .15s ease,opacity .15s ease}
.layer-item:hover{border-color:rgba(100,240,200,0.24);background:rgba(100,240,200,0.04)}
.layer-item.focused{border-color:rgba(100,240,200,0.6);background:rgba(100,240,200,0.08)}
.layer-item.hidden-layer{opacity:.45;border-color:rgba(255,95,99,0.18)}
.layer-mode{font-family:var(--mono);font-size:8px;color:var(--dim);margin-top:2px;text-transform:uppercase}
.sensor-actions{display:flex;gap:6px;align-items:center}
.mini-btn{border:1px solid rgba(100,240,200,0.18);background:rgba(100,240,200,0.04);color:var(--dim);font-family:var(--mono);font-size:9px;padding:3px 6px;cursor:pointer}
.mini-btn:hover{color:var(--accent);border-color:rgba(100,240,200,0.4)}
.layer-left{display:flex;align-items:center;gap:8px} .layer-left{display:flex;align-items:center;gap:8px}
.ldot{width:10px;height:10px;border-radius:50%;flex-shrink:0} .ldot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.ldot.air{background:var(--accent);box-shadow:0 0 6px rgba(100,240,200,0.4)} .ldot.air{background:var(--accent);box-shadow:0 0 6px rgba(100,240,200,0.4)}
@@ -394,8 +402,23 @@ let globeInitialized = false;
let flightsVisible = true; let flightsVisible = true;
let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true'; let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true';
let isFlat = shouldStartFlat(); let isFlat = shouldStartFlat();
let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
let currentRegion = 'world'; let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
const layerTypeMap = {
air: ['air'],
thermal: ['thermal'],
sdr: ['sdr'],
maritime: ['maritime'],
nuke: ['nuke', 'radiation'],
conflict: ['conflict'],
health: ['health'],
news: ['news', 'gdelt'],
osint: ['osint'],
space: ['space'],
};
const signalGuideItems = [ const signalGuideItems = [
{ {
term:'No Callsign', term:'No Callsign',
@@ -602,6 +625,44 @@ function renderTopbar(){
} }
// === LEFT RAIL === // === LEFT RAIL ===
function layerMode(key){ return layerModes[key] || 'normal'; }
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
function setLayerMode(key, mode){
if(mode === 'normal') delete layerModes[key]; else layerModes[key] = mode;
localStorage.setItem('crucix_layer_modes', JSON.stringify(layerModes));
renderLeftRail();
if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); }
else plotMarkers();
}
function cycleLayerMode(key, event){
const current = layerMode(key);
const next = event && (event.shiftKey || event.ctrlKey || event.metaKey)
? (current === 'hidden' ? 'normal' : 'hidden')
: (current === 'focus' ? 'normal' : 'focus');
setLayerMode(key, next);
}
function resetLayerModes(){
layerModes = {};
localStorage.removeItem('crucix_layer_modes');
renderLeftRail();
if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); }
else plotMarkers();
}
function toggleSpaceDisplay(){
spaceDisplayMode = spaceDisplayMode === 'icons' ? 'orbits' : 'icons';
localStorage.setItem('crucix_space_display', spaceDisplayMode);
renderLeftRail();
if(isFlat && flatG){ flatG.selectAll('*').remove(); drawFlatMap(); }
else plotMarkers();
}
function shouldShowType(type){
const focused = Object.entries(layerModes).filter(([,mode]) => mode === 'focus').flatMap(([key]) => layerTypeMap[key] || []);
const hidden = Object.entries(layerModes).filter(([,mode]) => mode === 'hidden').flatMap(([key]) => layerTypeMap[key] || []);
if(hidden.includes(type)) return false;
if(focused.length > 0) return focused.includes(type);
return true;
}
function renderLeftRail(){ function renderLeftRail(){
const totalAir=D.air.reduce((s,a)=>s+a.total,0); const totalAir=D.air.reduce((s,a)=>s+a.total,0);
const totalThermal=D.thermal.reduce((s,t)=>s+t.det,0); const totalThermal=D.thermal.reduce((s,t)=>s+t.det,0);
@@ -610,16 +671,16 @@ function renderLeftRail(){
const conflictEvents = D.acled?.totalEvents || 0; const conflictEvents = D.acled?.totalEvents || 0;
const conflictFatal = D.acled?.totalFatalities || 0; const conflictFatal = D.acled?.totalFatalities || 0;
const layers=[ const layers=[
{name:t('layers.airActivity','Air Activity'),count:totalAir,dot:'air',sub:`${D.air.length} ${t('layers.theaters','theaters')}`}, {key:'air',name:t('layers.airActivity','Air Activity'),count:totalAir,dot:'air',sub:`${D.air.length} ${t('layers.theaters','theaters')}`},
{name:t('layers.thermalSpikes','Thermal Spikes'),count:totalThermal.toLocaleString(),dot:'thermal',sub:`${totalNight.toLocaleString()} ${t('layers.nightDet','night det.')}`}, {key:'thermal',name:t('layers.thermalSpikes','Thermal Spikes'),count:totalThermal.toLocaleString(),dot:'thermal',sub:`${totalNight.toLocaleString()} ${t('layers.nightDet','night det.')}`},
{name:t('layers.sdrCoverage','SDR Coverage'),count:D.sdr.total,dot:'sdr',sub:`${D.sdr.online} ${t('layers.online','online')}`}, {key:'sdr',name:t('layers.sdrCoverage','SDR Coverage'),count:D.sdr.total,dot:'sdr',sub:`${D.sdr.online} ${t('layers.online','online')}`},
{name:t('layers.maritimeWatch','Maritime Watch'),count:D.chokepoints.length,dot:'maritime',sub:t('layers.chokepoints','chokepoints')}, {key:'maritime',name:t('layers.maritimeWatch','Maritime Watch'),count:D.chokepoints.length,dot:'maritime',sub:t('layers.chokepoints','chokepoints')},
{name:t('layers.nuclearSites','Nuclear Sites'),count:D.nuke.length,dot:'nuke',sub:t('layers.monitors','monitors')}, {key:'nuke',name:t('layers.nuclearSites','Nuclear Sites'),count:D.nuke.length,dot:'nuke',sub:t('layers.monitors','monitors')},
{name:t('layers.conflictEvents','Conflict Events'),count:conflictEvents,dot:'thermal',sub:`${conflictFatal.toLocaleString()} ${t('layers.fatalities','fatalities')}`}, {key:'conflict',name:t('layers.conflictEvents','Conflict Events'),count:conflictEvents,dot:'thermal',sub:`${conflictFatal.toLocaleString()} ${t('layers.fatalities','fatalities')}`},
{name:t('layers.healthWatch','Health Watch'),count:D.who.length,dot:'health',sub:t('layers.whoAlerts','WHO alerts')}, {key:'health',name:t('layers.healthWatch','Health Watch'),count:D.who.length,dot:'health',sub:t('layers.whoAlerts','WHO alerts')},
{name:t('layers.worldNews','World News'),count:newsCount,dot:'news',sub:t('layers.rssGeolocated','RSS geolocated')}, {key:'news',name:t('layers.worldNews','World News'),count:newsCount,dot:'news',sub:t('layers.rssGeolocated','RSS geolocated')},
{name:t('layers.osintFeed','OSINT Feed'),count:D.tg.posts,dot:'incident',sub:`${D.tg.urgent.length} ${t('badges.urgent','urgent').toLowerCase()}`}, {key:'osint',name:t('layers.osintFeed','OSINT Feed'),count:D.tg.posts,dot:'incident',sub:`${D.tg.urgent.length} ${t('badges.urgent','urgent').toLowerCase()}`},
{name:t('layers.spaceActivity','Satellites'),count:D.space?.militarySats||0,dot:'space',sub:`${D.space?.totalNewObjects||0} ${t('space.newLast30d','new (30d)')}`} {key:'space',name:t('layers.spaceActivity','Satellites'),count:D.space?.militarySats||0,dot:'space',sub:`${D.space?.totalNewObjects||0} ${t('space.newLast30d','new (30d)')}`}
]; ];
const allNormal=D.nuke.every(s=>!s.anom); const allNormal=D.nuke.every(s=>!s.anom);
const nukeHtml=D.nuke.map(s=>`<div class="site-row"><span>${s.site}</span><span class="site-val">${s.n>0?(s.cpm?.toFixed(1)||'--')+' CPM':'No data'}</span></div>`).join(''); const nukeHtml=D.nuke.map(s=>`<div class="site-row"><span>${s.site}</span><span class="site-val">${s.n>0?(s.cpm?.toFixed(1)||'--')+' CPM':'No data'}</span></div>`).join('');
@@ -632,8 +693,8 @@ function renderLeftRail(){
document.getElementById('leftRail').innerHTML=` document.getElementById('leftRail').innerHTML=`
<div class="g-panel"> <div class="g-panel">
<div class="sec-head"><h3>${t('panels.sensorGrid','Sensor Grid')}</h3><span class="badge">${t('badges.live','LIVE')}</span></div> <div class="sec-head"><h3>${t('panels.sensorGrid','Sensor Grid')}</h3><div class="sensor-actions"><button class="mini-btn" onclick="resetLayerModes();event.stopPropagation()">RESET</button><span class="badge">${t('badges.live','LIVE')}</span></div></div>
${layers.map(l=>`<div class="layer-item"><div class="layer-left"><div class="ldot ${l.dot}"></div><div><div class="layer-name">${l.name}</div><div class="layer-sub">${l.sub}</div></div></div><div class="layer-count">${l.count}</div></div>`).join('')} ${layers.map(l=>`<div class="layer-item ${layerMode(l.key)==='focus'?'focused':''} ${layerMode(l.key)==='hidden'?'hidden-layer':''}" onclick="cycleLayerMode('${l.key}',event)" title="Click to focus. Shift/Ctrl-click to hide."><div class="layer-left"><div class="ldot ${l.dot}"></div><div><div class="layer-name">${l.name}</div><div class="layer-sub">${l.sub}</div><div class="layer-mode">${layerModeLabel(l.key)}</div></div></div><div class="layer-count">${l.count}</div></div>`).join('')}
</div> </div>
<div class="g-panel"> <div class="g-panel">
<div class="sec-head"><h3>${t('panels.nuclearWatch','Nuclear Watch')}</h3><span class="badge">${t('badges.radiation','RADIATION')}</span></div> <div class="sec-head"><h3>${t('panels.nuclearWatch','Nuclear Watch')}</h3><span class="badge">${t('badges.radiation','RADIATION')}</span></div>
@@ -651,7 +712,7 @@ function renderLeftRail(){
<div class="econ-row"><span class="elabel">${t('metrics.natDebt','Nat. Debt')}</span><span class="eval">$${(parseFloat(D.treasury.totalDebt)/1e12).toFixed(2)}T</span></div> <div class="econ-row"><span class="elabel">${t('metrics.natDebt','Nat. Debt')}</span><span class="eval">$${(parseFloat(D.treasury.totalDebt)/1e12).toFixed(2)}T</span></div>
</div> </div>
<div class="g-panel"> <div class="g-panel">
<div class="sec-head"><h3>${t('panels.spaceWatch','Space Watch')}</h3><span class="badge">${t('badges.orbital','CELESTRAK')}</span></div> <div class="sec-head"><h3>${t('panels.spaceWatch','Space Watch')}</h3><button class="mini-btn" onclick="toggleSpaceDisplay()">${spaceDisplayMode.toUpperCase()}</button></div>
${D.space ? ` ${D.space ? `
<div class="econ-row"><span class="elabel">New Objects (30d)</span><span class="eval" style="color:var(--accent2)">${D.space.totalNewObjects||0}</span></div> <div class="econ-row"><span class="elabel">New Objects (30d)</span><span class="eval" style="color:var(--accent2)">${D.space.totalNewObjects||0}</span></div>
<div class="econ-row"><span class="elabel">Military Sats</span><span class="eval">${D.space.militarySats||0}</span></div> <div class="econ-row"><span class="elabel">Military Sats</span><span class="eval">${D.space.militarySats||0}</span></div>
@@ -953,7 +1014,7 @@ function plotMarkers(){
}); });
// === ISS + Space Stations (bright white, pulsing) === // === ISS + Space Stations (bright white, pulsing) ===
(D.space?.stationPositions||[]).forEach(s=>{ if(spaceDisplayMode === 'icons') (D.space?.stationPositions||[]).forEach(s=>{
points.push({ points.push({
lat:s.lat, lng:s.lon, size:0.4, alt:0.04, lat:s.lat, lng:s.lon, size:0.4, alt:0.04,
color:'rgba(255,255,255,0.95)', type:'space', priority:1, color:'rgba(255,255,255,0.95)', type:'space', priority:1,
@@ -973,12 +1034,15 @@ function plotMarkers(){
}); });
}); });
// Set points on globe
globe.pointsData(points);
globe.labelsData(labels);
// === ACLED CONFLICT EVENTS (pulsing rings) === // === ACLED CONFLICT EVENTS (pulsing rings) ===
const conflictRings = (D.acled?.deadliestEvents || []).filter(e => e.lat && e.lon).map(e => { const visiblePoints = points.filter(p => shouldShowType(p.type));
const visibleLabels = labels.filter(l => !l.type || shouldShowType(l.type));
// Set points on globe
globe.pointsData(visiblePoints);
globe.labelsData(visibleLabels);
const conflictRings = shouldShowType('conflict') ? (D.acled?.deadliestEvents || []).filter(e => e.lat && e.lon).map(e => {
const logFatal = Math.log2(Math.max(e.fatalities, 1)); const logFatal = Math.log2(Math.max(e.fatalities, 1));
return { return {
lat: e.lat, lng: e.lon, lat: e.lat, lng: e.lon,
@@ -988,12 +1052,12 @@ function plotMarkers(){
popHead: e.type || 'CONFLICT', popMeta: 'ACLED Conflict Data', popHead: e.type || 'CONFLICT', popMeta: 'ACLED Conflict Data',
popText: `${e.fatalities} fatalities<br>${e.location}, ${e.country}<br>Date: ${e.date}` popText: `${e.fatalities} fatalities<br>${e.location}, ${e.country}<br>Date: ${e.date}`
}; };
}); }) : [];
globe.ringsData(conflictRings); globe.ringsData(conflictRings);
// === FLIGHT CORRIDORS (3D arcs) === // === FLIGHT CORRIDORS (3D arcs) ===
const arcs = []; const arcs = [];
if(flightsVisible){ if(flightsVisible && shouldShowType('air')){
const airCoordsFlight = [ const airCoordsFlight = [
{region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120}, {region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120},
{region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24}, {region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24},
@@ -1040,6 +1104,17 @@ function plotMarkers(){
}); });
}); });
} }
if(spaceDisplayMode === 'orbits' && shouldShowType('space')){
(D.space?.stationPositions||[]).forEach(s=>{
arcs.push({
startLat: Math.max(-65, s.lat - 18), startLng: s.lon - 80,
endLat: Math.min(65, s.lat + 18), endLng: s.lon + 80,
color: ['rgba(224,176,255,0.55)','rgba(224,176,255,0.12)'],
stroke: 0.7,
label: `${s.name} approximate orbital track`
});
});
}
globe.arcsData(arcs); globe.arcsData(arcs);
// Zoom-aware marker sizing: scale markers and labels with camera altitude // Zoom-aware marker sizing: scale markers and labels with camera altitude
@@ -1055,11 +1130,11 @@ function plotMarkers(){
globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000); globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000);
// Priority-based point visibility: hide low-priority markers when zoomed out // Priority-based point visibility: hide low-priority markers when zoomed out
if(alt > 2.0){ if(alt > 2.0){
globe.pointsData(points.filter(p => (p.priority||3) <= 1)); globe.pointsData(visiblePoints.filter(p => (p.priority||3) <= 1));
} else if(alt > 1.2){ } else if(alt > 1.2){
globe.pointsData(points.filter(p => (p.priority||3) <= 2)); globe.pointsData(visiblePoints.filter(p => (p.priority||3) <= 2));
} else { } else {
globe.pointsData(points); globe.pointsData(visiblePoints);
} }
}; };
if(typeof globe.onZoom==='function') globe.onZoom(onGlobeZoom); if(typeof globe.onZoom==='function') globe.onZoom(onGlobeZoom);
@@ -1178,7 +1253,7 @@ function initFlatMap(){
flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)}); flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)});
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px') flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px')
.style('display',k>=2.5?'block':'none'); .style('display',k>=2.5?'block':'none');
}); }).on('end',()=>{ flatG.selectAll('.marker-label').style('display','block'); });
flatSvg.call(flatZoom); flatSvg.call(flatZoom);
drawFlatMap(); drawFlatMap();
} }
@@ -1197,7 +1272,8 @@ function drawFlatMap(){
function plotFlatMarkers(){ function plotFlatMarkers(){
const mg=flatG.append('g').attr('class','markers'); const mg=flatG.append('g').attr('class','markers');
const proj=flatProjection; const proj=flatProjection;
const addPt=(lat,lon,r,fill,stroke,onClick,priority)=>{ const addPt=(lat,lon,r,fill,stroke,onClick,priority,type)=>{
if(type && !shouldShowType(type)) return null;
const[x,y]=proj([lon,lat]);if(!x||!y)return null; const[x,y]=proj([lon,lat]);if(!x||!y)return null;
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',priority||3); const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',priority||3);
if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)}); if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)});
@@ -1215,12 +1291,12 @@ function plotFlatMarkers(){
}); });
} }
// Thermal // Thermal
D.thermal.forEach(t=>t.fires.forEach(f=>{ if(shouldShowType('thermal')) D.thermal.forEach(t=>t.fires.forEach(f=>{
addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)', addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)',
ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'),3); ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'),3);
})); }));
// Chokepoints // Chokepoints
D.chokepoints.forEach(cp=>{ if(shouldShowType('maritime')) D.chokepoints.forEach(cp=>{
const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return; const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return;
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1) const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
.on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')}); .on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')});
@@ -1229,30 +1305,30 @@ function plotFlatMarkers(){
}); });
// Nuclear // Nuclear
const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}]; const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}];
D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'),2)}); if(shouldShowType('nuke')) D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'),2)});
// SDR // SDR
D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'),3)})); if(shouldShowType('sdr')) D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'),3)}));
// OSINT // OSINT
const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}]; const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}];
osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`),2)}); if(shouldShowType('osint')) osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`),2)});
// WHO // WHO
const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}]; const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}];
D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'),2)}); if(shouldShowType('health')) D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'),2)});
// News // News
(D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region),3)}); if(shouldShowType('news')) (D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region),3)});
// NOAA weather // NOAA weather
(D.noaa?.alerts||[]).forEach(a=>{addPt(a.lat,a.lon,4,'rgba(255,152,0,0.7)','rgba(255,152,0,0.3)',ev=>showPopup(ev,a.event,a.headline||'','NOAA/NWS'),2)}); if(shouldShowType('weather')) (D.noaa?.alerts||[]).forEach(a=>{addPt(a.lat,a.lon,4,'rgba(255,152,0,0.7)','rgba(255,152,0,0.3)',ev=>showPopup(ev,a.event,a.headline||'','NOAA/NWS'),2)});
// EPA RadNet // EPA RadNet
(D.epa?.stations||[]).forEach(s=>{addPt(s.lat,s.lon,3,'rgba(205,220,57,0.6)','rgba(205,220,57,0.2)',ev=>showPopup(ev,'RadNet: '+s.location,`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}`,'EPA'),3)}); if(shouldShowType('environment')) (D.epa?.stations||[]).forEach(s=>{addPt(s.lat,s.lon,3,'rgba(205,220,57,0.6)','rgba(205,220,57,0.2)',ev=>showPopup(ev,'RadNet: '+s.location,`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}`,'EPA'),3)});
// Space stations // Space stations
(D.space?.stationPositions||[]).forEach(s=>{ if(spaceDisplayMode === 'icons' && shouldShowType('space')) (D.space?.stationPositions||[]).forEach(s=>{
const g=addPt(s.lat,s.lon,5,'rgba(255,255,255,0.9)','rgba(255,255,255,0.4)',ev=>showPopup(ev,s.name,'Orbital position estimate','Space Station'),1); const g=addPt(s.lat,s.lon,5,'rgba(255,255,255,0.9)','rgba(255,255,255,0.4)',ev=>showPopup(ev,s.name,'Orbital position estimate','Space Station'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','rgba(255,255,255,0.7)').attr('font-size','8px').attr('font-family','var(--mono)').text(s.name.split('(')[0].trim()); if(g) g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','rgba(255,255,255,0.7)').attr('font-size','8px').attr('font-family','var(--mono)').text(s.name.split('(')[0].trim());
}); });
// GDELT geo events // GDELT geo events
(D.gdelt?.geoPoints||[]).forEach(g=>{addPt(g.lat,g.lon,2.5,'rgba(100,149,237,0.5)','rgba(100,149,237,0.2)',ev=>showPopup(ev,'GDELT Event',g.name||'','GDELT · '+g.count+' reports'),3)}); if(shouldShowType('gdelt')) (D.gdelt?.geoPoints||[]).forEach(g=>{addPt(g.lat,g.lon,2.5,'rgba(100,149,237,0.5)','rgba(100,149,237,0.2)',ev=>showPopup(ev,'GDELT Event',g.name||'','GDELT · '+g.count+' reports'),3)});
// ACLED // ACLED
(D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{ if(shouldShowType('conflict')) (D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{
const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return; const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return;
const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5)); const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5));
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1) const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
@@ -1261,7 +1337,7 @@ function plotFlatMarkers(){
g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)'); g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)');
}); });
// Flight corridors // Flight corridors
if(flightsVisible){ if(flightsVisible && shouldShowType('air')){
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}]; const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}];
const cG=flatG.append('g').attr('class','corridors-layer'); const cG=flatG.append('g').attr('class','corridors-layer');

18
docs/sources/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Source Operations
Each source should fail visibly and safely. A missing key or remote outage must produce `degraded` source health instead of silent demo data.
Check runtime state:
```bash
curl http://localhost:3117/api/health
curl http://localhost:3117/api/metrics
```
Source docs:
- [OpenSky](opensky.md)
- [ACLED](acled.md)
- [Telegram](telegram.md)
- [FIRMS](firms.md)
- [Maritime](maritime.md)

9
docs/sources/acled.md Normal file
View File

@@ -0,0 +1,9 @@
# ACLED
Provides conflict events, fatalities, event types, and locations.
- Auth: `ACLED_EMAIL` and `ACLED_PASSWORD`.
- Flow: OAuth password grant is tried first, then cookie session fallback.
- Failure modes: missing credentials, terms/profile not completed, expired token, account missing API access.
- Behavior: missing or rejected credentials produce degraded source health with the ACLED error text.
- Test: set credentials, run `node apis/sources/acled.mjs`, then check `/api/health`.

8
docs/sources/firms.md Normal file
View File

@@ -0,0 +1,8 @@
# FIRMS
Provides NASA satellite fire and thermal detections.
- Auth: `FIRMS_MAP_KEY` recommended.
- Failure modes: missing key, timeout, regional API throttle.
- Behavior: source health reports degraded or failed; dashboard panels stay visible and show available sources.
- Test: set `FIRMS_MAP_KEY`, run a sweep, inspect thermal markers and `/api/metrics`.

8
docs/sources/maritime.md Normal file
View File

@@ -0,0 +1,8 @@
# Maritime
Provides chokepoint context and optional AIS-related maritime data.
- Auth: `AISSTREAM_API_KEY` enables richer AIS behavior where supported.
- Failure modes: missing key, remote stream unavailable, no vessels in a monitored region.
- Behavior: static chokepoint context remains available, live source health reports degraded when remote data is unavailable.
- Test: run a sweep and inspect maritime markers, source health, and network metrics.

9
docs/sources/opensky.md Normal file
View File

@@ -0,0 +1,9 @@
# OpenSky
Provides public aircraft state data for regional air-activity hotspots.
- Auth: public queries work without credentials.
- Failure modes: timeouts, `HTTP 429`, and empty regions.
- Behavior: source health is marked degraded on API errors. The dashboard may use the most recent non-empty air snapshot from `runs/` and marks it in `airMeta.fallback`.
- Test: start a sweep and inspect `/api/health` plus `airMeta` from `/api/data`.
- Operator note: Crucix does not bypass OpenSky throttles. Increase `REFRESH_INTERVAL_MINUTES` if the source degrades repeatedly.

9
docs/sources/telegram.md Normal file
View File

@@ -0,0 +1,9 @@
# Telegram
Provides OSINT posts and bot commands/alerts.
- Source collection uses configured public/channel inputs where available.
- Bot alerts require `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`.
- Polling failures are non-fatal and use backoff logging to avoid log spam.
- `/brief` includes source integrity, evidence links, event id, why-it-matters, and next-step context.
- Test: configure bot variables, start the container, send `/status` and `/brief`.