13 Commits

Author SHA1 Message Date
dd08ecaf27 Merge branch 'codex/production-intelligence-terminal' into codex/modrinth-app-redesign
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 57s
2026-05-17 19:03:03 +00:00
MrSphay
bc354e7bc5 feat(ui): redesign dashboard in app-style shell
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 59s
2026-05-17 20:57:54 +02:00
1c2b48f588 Merge pull request 'fix: infer source fetch metrics' (#35) from codex/issue-22-source-fetch-instrumentation into codex/production-intelligence-terminal
All checks were successful
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 5s
Build / test-and-image (push) Successful in 27s
2026-05-17 18:53:45 +00:00
MrSphay
a590bf62c2 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-22-source-fetch-instrumentation
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 57s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:52:27 +02:00
6a9918bc98 Merge pull request 'fix: keep sse streams alive behind proxies' (#34) from codex/issue-17-sse-heartbeat into codex/production-intelligence-terminal
All checks were successful
Build / test-and-image (push) Successful in 25s
Release Dry Run / release-dry-run (push) Successful in 13s
Codex Template Compliance / template-compliance (push) Successful in 5s
2026-05-17 18:51:04 +00:00
MrSphay
4448f5931b Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-17-sse-heartbeat
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 4s
Build / test-and-image (pull_request) Successful in 57s
# Conflicts:
#	.env.example
#	README.md
#	crucix.config.mjs
#	test/fetch-utils.test.mjs
2026-05-17 20:49:14 +02:00
9b15913049 Merge pull request 'feat: extend memory prediction loop' (#32) from codex/issue-4-memory-prediction-loop into codex/production-intelligence-terminal
All checks were successful
Build / test-and-image (push) Successful in 24s
Release Dry Run / release-dry-run (push) Successful in 14s
Codex Template Compliance / template-compliance (push) Successful in 5s
2026-05-17 18:47:39 +00:00
MrSphay
e4834cd3cd Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-22-source-fetch-instrumentation
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 57s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:40:44 +02:00
MrSphay
0fbd8640ca Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-17-sse-heartbeat
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 6s
Build / test-and-image (pull_request) Successful in 1m0s
# Conflicts:
#	test/fetch-utils.test.mjs
2026-05-17 20:40:10 +02:00
MrSphay
c102017b16 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-22-source-fetch-instrumentation
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 55s
# Conflicts:
#	README.md
#	test/fetch-utils.test.mjs
2026-05-17 20:37:21 +02:00
MrSphay
eefc1a4c77 Merge remote-tracking branch 'origin/codex/production-intelligence-terminal' into codex/issue-17-sse-heartbeat
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 53s
# Conflicts:
#	README.md
#	test/fetch-utils.test.mjs
2026-05-17 20:36:31 +02:00
MrSphay
2025ae09db fix: infer source fetch metrics
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 4s
Build / test-and-image (pull_request) Successful in 53s
2026-05-17 14:44:21 +02:00
MrSphay
446076cb84 fix: keep sse streams alive behind proxies
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 52s
2026-05-17 14:41:55 +02:00
9 changed files with 430 additions and 58 deletions

View File

@@ -10,6 +10,7 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL=
TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
SSE_HEARTBEAT_INTERVAL_MS=25000
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard

View File

@@ -2,7 +2,7 @@
# Intelligence Terminal
**Self-hosted intelligence dashboard. 27 open sources. Docker-first. No telemetry.**
**Modrinth-app-inspired operator dashboard. 27 open sources. Docker-first. No telemetry.**
[![Gitea Repository](https://img.shields.io/badge/source-Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white)](https://git.wilkensxl.de/MrSphay/intelligence-terminal)
[![Docker Image](https://img.shields.io/badge/image-Gitea%20Registry-0b1220?style=for-the-badge&logo=docker&logoColor=white)](https://git.wilkensxl.de/MrSphay/-/packages/container/intelligence-terminal/latest)
@@ -34,8 +34,10 @@
> Runtime data stays in your configured `runs/` volume and API keys are operator-owned.
> **Source:** [git.wilkensxl.de/MrSphay/intelligence-terminal](https://git.wilkensxl.de/MrSphay/intelligence-terminal)
> Pull the image or clone the repository to run Intelligence Terminal on your own infrastructure.
>
> **Design transparency:** the dashboard is inspired by app-style marketplace UX patterns, especially dark desktop app shells with a strong left navigation. It does not use Modrinth branding, logos, or assets and is not affiliated with Modrinth.
Intelligence Terminal pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds in parallel, every 15 minutes, and renders everything on a single self-contained dashboard.
Intelligence Terminal pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds in parallel, every 15 minutes, and renders everything in a dark, app-style operator workspace.
Hook it up to an LLM and it becomes a **two-way intelligence assistant**: pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating trade ideas grounded in real cross-domain data.
@@ -134,6 +136,7 @@ STALE_ALERT_COOLDOWN_MINUTES=60
DASHBOARD_URL=https://intelligence.example.internal
TERMINAL_ACTIONS_ENABLED=true
SWEEP_TOKEN=
SSE_HEARTBEAT_INTERVAL_MS=25000
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
@@ -234,6 +237,22 @@ Retention, backup, and privacy expectations:
- Do not commit `runs/` or `.env`. API credentials stay in `.env`; memory stores derived observations, not secrets.
- If you expose the dashboard through a reverse proxy, protect Terminal Actions and memory queries behind your normal authentication boundary.
#### Reverse Proxy SSE
The dashboard receives live sweep updates from `GET /events` using Server-Sent Events. The server sends `retry: 10000` reconnect guidance and lightweight heartbeat comments every `SSE_HEARTBEAT_INTERVAL_MS` milliseconds so reverse proxies do not close an otherwise idle stream between 15-minute sweeps.
Recommended proxy settings:
| Proxy | Setting |
| --- | --- |
| Pangolin / Traefik-style frontends | Keep response streaming enabled and set idle timeouts above `SSE_HEARTBEAT_INTERVAL_MS`. |
| Nginx | Disable proxy buffering for `/events`, keep `proxy_read_timeout` above the heartbeat interval, and preserve `Connection: keep-alive`. |
| Cloudflare-style proxies | Keep the heartbeat below common idle cutoffs; the default 25s is intentionally conservative. |
If you raise the heartbeat interval, keep it shorter than the lowest idle timeout in the proxy chain.
`/api/metrics` includes network health grouped by host and source/provider. Source modules should use `safeFetch(url, { source: 'SourceName' })`; when omitted, the shared helper infers a stable provider bucket from the URL host instead of grouping normal source traffic under `unknown`. Raw fetch exceptions are documented in [Source Fetch Instrumentation](docs/source-fetch-instrumentation.md).
#### Scenario Watchlist
Intelligence Terminal can track operator hypotheses across sweeps with a runtime scenario file at `runs/scenarios.json`. On first run, the server creates three disabled starter examples:
@@ -284,26 +303,21 @@ Gitea Actions publishes the same image automatically when the repository secret
## What You Get
### Live Dashboard
A self-contained Jarvis-style HUD with:
- **3D WebGL globe** (Globe.gl) with atmosphere glow, star field, and smooth rotation — plus a classic flat map toggle
- **9 marker types** across both views: fire detections, air traffic, radiation sites, maritime chokepoints, SDR receivers, OSINT events, health alerts, geolocated news, conflict events
- **Animated 3D flight corridor arcs** between air traffic hotspots and global hubs
- **Region filters** (World, Americas, Europe, Middle East, Asia Pacific, Africa) — rotates the globe or zooms the flat map
- **Live market data** — indexes, crypto, energy, commodities via Yahoo Finance (no API key needed)
- **Risk gauges** — VIX, high-yield spread, supply chain pressure index
- **OSINT feed** — English-language posts from 17 Telegram intelligence channels (expandable)
- **News ticker** — merged RSS + GDELT headlines + Telegram posts, auto-scrolling
- **Sweep delta** — live panel showing what changed since last sweep (new signals, escalations, de-escalations with severity)
- **Cross-source signals** — correlated intelligence across satellite, economic, conflict, and social domains
- **Nuclear watch** — real-time radiation readings from Safecast + EPA RadNet
- **Space watch** — CelesTrak satellite tracking: recent launches, ISS, military constellations, Starlink/OneWeb counts
- **Leverageable ideas** — AI-generated trade ideas (with LLM) or signal-correlated ideas (without)
A self-contained app-style operator dashboard with dark mode by default, a large left navigation rail, rounded content surfaces, and view-specific panels:
- **Home** - sweep status, alert posture, latest feed, source count, macro summary, and high-level signal state
- **Worldview** - Globe.gl 3D globe or flat map, regional focus controls, 9 marker types, flight corridors, and layer focus/hide controls
- **Sources** - sensor grid, source health, API-key degradation signals, and transparent partial-data states
- **Signals** - cross-source signals, sweep delta, scenario watchlist, OSINT feed, and escalation/de-escalation context
- **Markets** - live indexes, crypto, energy, commodities, VIX, high-yield spread, supply-chain pressure, and LLM-assisted ideas
- **Ops** - browser-triggered terminal actions, performance mode, Telegram/Discord operator workflows, and system status
The UI keeps the existing operational features: `/api/data`, SSE live refresh, globe/flat map mode, layer focus/hide, terminal actions, low-performance mode, LLM output, Telegram and Discord alerting, and scenario watchlist data.
### Performance Modes
The `VISUALS FULL` / `VISUALS LITE` button in the top bar only changes rendering behavior - it does **not** remove data sources or reduce sweep coverage.
When you switch to **VISUALS LITE**, the dashboard:
- Disables decorative background effects such as the radial/grid overlays and scanlines
- Disables decorative background effects such as radial and grid overlays
- Removes expensive blur/backdrop-filter effects on panels and overlays
- Stops non-essential animations like the logo ring blink, conflict rings, and corridor flow effects
- Disables globe auto-rotation and turns off animated flight-arc dashes
@@ -491,7 +505,7 @@ intelligence-terminal/
├── dashboard/
│ ├── inject.mjs # Data synthesis + standalone HTML injection
│ └── public/
│ └── jarvis.html # Self-contained Jarvis HUD
│ └── jarvis.html # Self-contained app-style operator dashboard
├── lib/
│ ├── llm/ # LLM abstraction (8 providers, raw fetch, no SDKs)
@@ -630,7 +644,7 @@ When running `npm run dev`:
| Endpoint | Description |
|----------|-------------|
| `GET /` | Jarvis HUD dashboard |
| `GET /` | App-style operator dashboard |
| `GET /api/data` | Current synthesized intelligence data (JSON) |
| `GET /api/health` | Server status, uptime, source count, LLM status |
| `GET /events` | SSE stream for live push updates |
@@ -709,16 +723,16 @@ Check these in order:
## Screenshots
The `docs/` folder contains dashboard screenshots referenced by this README:
The `docs/` folder contains dashboard screenshots referenced by this README. The hero screenshot has been refreshed for the app-style shell; regenerate the supporting map/globe images from a running instance when those views materially change.
| File | Description |
|------|-------------|
| `docs/dashboard.png` | Full dashboard hero image at the top of this README |
| `docs/boot.png` | Cinematic boot sequence animation |
| `docs/map.png` | D3 world map with marker types and flight arcs |
| `docs/dashboard.png` | Full operator dashboard - hero image at the top of this README |
| `docs/boot.png` | Boot sequence animation |
| `docs/map.png` | Worldview map with marker types and flight arcs |
| `docs/globe.png` | 3D WebGL globe view with atmosphere glow and markers |
To update them: run the dashboard, wait for a sweep to complete, then use your browser's DevTools (`F12` `Ctrl+Shift+P` "Capture full size screenshot") or a tool like [LICEcap](https://www.cockos.com/licecap/) for GIFs.
For a fresh capture, run the dashboard, wait for a sweep to complete, then use your browser's DevTools (`F12` -> `Ctrl+Shift+P` -> "Capture full size screenshot") or a tool like [LICEcap](https://www.cockos.com/licecap/) for GIFs.
---

View File

@@ -10,6 +10,44 @@ const fetchMetrics = {
recent: [],
};
const SOURCE_BY_HOST = [
[/api\.bls\.gov$/i, 'BLS'],
[/api\.fred\.stlouisfed\.org$/i, 'FRED'],
[/api\.eia\.gov$/i, 'EIA'],
[/api\.gdeltproject\.org$/i, 'GDELT'],
[/api\.weather\.gov$/i, 'NOAA'],
[/api\.open-notify\.org$/i, 'OpenNotify'],
[/opensky-network\.org$/i, 'OpenSky'],
[/firms\.modaps\.eosdis\.nasa\.gov$/i, 'FIRMS'],
[/api\.acleddata\.com$/i, 'ACLED'],
[/api\.reliefweb\.int$/i, 'ReliefWeb'],
[/receiverbook\.de$/i, 'KiwiSDR'],
[/safecast\.org$/i, 'Safecast'],
[/api\.patentsview\.org$/i, 'PatentsView'],
[/api\.trade\.gov$/i, 'Comtrade'],
[/api\.usaspending\.gov$/i, 'USASpending'],
[/api\.telegram\.org$/i, 'Telegram'],
[/oauth\.reddit\.com$/i, 'Reddit'],
[/reddit\.com$/i, 'Reddit'],
[/api\.bsky\.app$/i, 'Bluesky'],
[/api\.yahoo\.com$/i, 'YahooFinance'],
[/query\d?\.finance\.yahoo\.com$/i, 'YahooFinance'],
[/api\.cloudflare\.com$/i, 'CloudflareRadar'],
[/api\.opensanctions\.org$/i, 'OpenSanctions'],
[/home\.treasury\.gov$/i, 'Treasury'],
[/fiscaldata\.treasury\.gov$/i, 'Treasury'],
[/who\.int$/i, 'WHO'],
];
export function inferFetchSource(url) {
let host = 'unknown';
try { host = new URL(url).host.toLowerCase(); } catch { return 'unknown'; }
for (const [pattern, source] of SOURCE_BY_HOST) {
if (pattern.test(host)) return source;
}
return host;
}
function metricBucket(map, key) {
if (!map[key]) map[key] = { requests: 0, ok: 0, failed: 0, bytes: 0, lastStatus: null, lastError: null, lastMs: 0 };
return map[key];
@@ -38,7 +76,7 @@ export function getFetchMetrics() {
}
export async function safeFetch(url, opts = {}) {
const { timeout = 15000, retries = 1, headers = {}, source = undefined } = opts;
const { timeout = 15000, retries = 1, headers = {}, source = inferFetchSource(url) } = opts;
let lastError;
for (let i = 0; i <= retries; i++) {
const started = Date.now();
@@ -79,11 +117,11 @@ export async function safeFetch(url, opts = {}) {
if (i < retries) await new Promise(r => setTimeout(r, 2000 * (i + 1)));
}
}
return { error: lastError?.message || 'Unknown error', source: url };
return { error: lastError?.message || 'Unknown error', source };
}
export async function safeFetchText(url, opts = {}) {
const { timeout = 15000, retries = 1, headers = {}, source = undefined } = opts;
const { timeout = 15000, retries = 1, headers = {}, source = inferFetchSource(url) } = opts;
let lastError;
for (let i = 0; i <= retries; i++) {
const started = Date.now();

View File

@@ -29,6 +29,7 @@ export default {
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'),
terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000),
terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10),
sseHeartbeatIntervalMs: intEnv('SSE_HEARTBEAT_INTERVAL_MS', 25000),
llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok

View File

@@ -3,9 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CRUCIX — Intelligence Terminal</title>
<title>Intelligence Terminal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://d3js.org/topojson.v3.min.js"></script>
@@ -17,7 +17,7 @@
--bg:#020408;--panel:rgba(6,14,22,0.82);--glass:rgba(10,20,32,0.55);
--border:rgba(100,240,200,0.12);--border-bright:rgba(100,240,200,0.3);
--text:#e8f4f0;--dim:#6a8a82;--accent:#64f0c8;--accent2:#44ccff;
--warn:#ffb84c;--danger:#ff5f63;--mono:'IBM Plex Mono',monospace;--sans:'Space Grotesk',sans-serif;
--warn:#ffb84c;--danger:#ff5f63;--mono:'IBM Plex Mono',monospace;--sans:'Inter',system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);overflow-x:hidden}
.bg-grid{position:fixed;inset:0;pointer-events:none;opacity:0;
@@ -25,10 +25,6 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
background-size:60px 60px;mask-image:radial-gradient(ellipse at 50% 30%,black 20%,transparent 70%)}
.bg-radial{position:fixed;inset:0;pointer-events:none;opacity:0;
background:radial-gradient(ellipse at 50% 0%,rgba(40,120,100,0.15),transparent 50%),radial-gradient(ellipse at 80% 20%,rgba(40,100,180,0.08),transparent 40%)}
.scanline{position:fixed;inset:0;pointer-events:none;overflow:hidden;opacity:0}
.scanline::after{content:'';position:absolute;left:0;width:100%;height:2px;background:linear-gradient(90deg,transparent,rgba(100,240,200,0.12),transparent);animation:scanMove 4s linear infinite}
@keyframes scanMove{0%{top:-2px}100%{top:100%}}
/* BOOT */
#boot{position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;align-items:center;justify-content:center;background:var(--bg)}
.logo-ring{width:120px;height:120px;border:2px solid var(--border);border-radius:50%;display:flex;align-items:center;justify-content:center;position:relative;opacity:0}
@@ -274,7 +270,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.ideas-src.static{color:var(--dim);border-color:rgba(255,255,255,0.1);background:rgba(255,255,255,0.03)}
/* LOW PERFORMANCE MODE */
body.low-perf .bg-grid,body.low-perf .bg-radial,body.low-perf .scanline{display:none!important}
body.low-perf .bg-grid,body.low-perf .bg-radial{display:none!important}
body.low-perf .topbar,body.low-perf .g-panel,body.low-perf .map-popup,body.low-perf .map-loading{backdrop-filter:none!important}
body.low-perf .logo-ring::before,body.low-perf .logo-ring::after,body.low-perf .regime-chip .blink,body.low-perf .conflict-ring,body.low-perf .corridor-flow{animation:none!important}
body.low-perf .ticker-wrap{overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(100,240,200,0.2) transparent}
@@ -335,20 +331,202 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
/* IDEA HORIZON BADGE */
.idea-horizon{font-family:var(--mono);font-size:8px;letter-spacing:0.08em;text-transform:uppercase;padding:1px 5px;border:1px solid rgba(100,240,200,0.15);color:var(--dim);margin-left:6px}
/* APP-STYLE REDESIGN */
:root{
--app-bg:#17191f;
--app-surface:#22252d;
--app-surface-2:#2b2f38;
--app-card:#2a2d35;
--app-card-soft:#242830;
--app-border:#3a3f49;
--app-border-soft:#343942;
--app-text:#f3f5f7;
--app-muted:#aeb6c2;
--app-dim:#77818f;
--app-green:#1bd96a;
--app-green-soft:rgba(27,217,106,0.17);
--app-blue:#5fb4ff;
--app-red:#ff5f6f;
--app-yellow:#ffcc66;
}
body[data-theme="dark"]{
--bg:var(--app-bg);
--panel:var(--app-surface);
--glass:var(--app-card);
--border:var(--app-border);
--border-bright:rgba(27,217,106,0.55);
--text:var(--app-text);
--dim:var(--app-muted);
--accent:var(--app-green);
--accent2:var(--app-blue);
background:var(--app-bg);
}
body[data-theme="light"]{
--bg:#eef1f4;
--panel:#ffffff;
--glass:#ffffff;
--border:#d6dde5;
--border-bright:rgba(18,177,88,0.55);
--text:#161a21;
--dim:#5e6875;
--accent:#11b858;
--accent2:#2677c9;
--app-bg:#eef1f4;
--app-surface:#ffffff;
--app-surface-2:#f5f7fa;
--app-card:#ffffff;
--app-card-soft:#f3f6f9;
--app-border:#d6dde5;
--app-border-soft:#e1e7ee;
--app-text:#161a21;
--app-muted:#5e6875;
--app-dim:#788391;
color:#161a21;
}
body{font-family:var(--sans);letter-spacing:0;background:var(--app-bg)}
.bg-grid,.bg-radial{display:none}
#main.app-root{display:grid;grid-template-columns:112px minmax(0,1fr);gap:18px;min-height:100vh;padding:0;background:var(--app-bg)}
.app-sidebar{position:sticky;top:0;height:100vh;padding:26px 18px 24px;border-right:1px solid rgba(255,255,255,0.05);background:#20232b;display:flex;flex-direction:column;align-items:center;gap:28px}
body[data-theme="light"] .app-sidebar{background:#f8fafc;border-right-color:#dde3ea}
.app-brand-mark{width:54px;height:54px;border:3px solid var(--app-green);border-radius:18px;display:grid;place-items:center;color:var(--app-green);font-weight:800;font-size:19px;letter-spacing:0;background:rgba(27,217,106,0.06)}
.app-brand-mark span{line-height:1}
.app-nav{display:flex;flex-direction:column;gap:12px;width:100%;align-items:center}
.nav-item{width:74px;height:74px;border:0;border-radius:999px;background:transparent;color:var(--app-muted);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;cursor:pointer;transition:background .18s ease,color .18s ease,transform .18s ease}
.nav-item span{width:28px;height:28px;display:grid;place-items:center;font-weight:800;font-size:17px;border:2px solid currentColor;border-radius:10px;line-height:1}
.nav-item small{font-size:9px;font-weight:700;letter-spacing:0;text-transform:none}
.nav-item:hover{background:rgba(255,255,255,0.06);color:var(--app-text);transform:translateY(-1px)}
.nav-item.active{background:rgba(27,217,106,0.22);color:var(--app-green)}
.sidebar-status{margin-top:auto;width:74px;min-height:42px;border-top:1px solid var(--app-border);padding-top:14px;display:flex;align-items:center;justify-content:center;gap:7px;color:var(--app-muted);font-size:11px;font-weight:700}
.sidebar-status-dot{width:8px;height:8px;border-radius:50%;background:var(--app-green);box-shadow:0 0 16px rgba(27,217,106,.5)}
.app-shell{min-width:0;margin:24px 24px 24px 0;border:1px solid var(--app-border);border-radius:30px 0 0 30px;background:#181b21;overflow:hidden;box-shadow:0 24px 70px rgba(0,0,0,.26)}
body[data-theme="light"] .app-shell{background:#eef1f4;box-shadow:0 20px 50px rgba(33,43,54,.12)}
.topbar{border:0;border-bottom:1px solid var(--app-border);border-radius:0;background:var(--app-surface);padding:26px 36px;backdrop-filter:none;display:grid;grid-template-columns:minmax(220px,340px) minmax(240px,1fr);gap:18px;align-items:center}
.top-left,.top-center,.top-right{width:auto}
.top-left{align-items:flex-start;flex-direction:column;gap:6px}
.brand{font-family:var(--sans);font-size:34px;font-weight:800;letter-spacing:0;text-transform:none;color:var(--app-text);line-height:1}
.view-subtitle{font-size:14px;line-height:1.4;color:var(--app-muted);font-weight:600}
.regime-chip{border:0;border-radius:999px;background:var(--app-green-soft);color:var(--app-green);font-family:var(--sans);font-size:12px;letter-spacing:0;text-transform:none;font-weight:800;padding:7px 12px}
.regime-chip .blink{background:var(--app-green);box-shadow:0 0 10px rgba(27,217,106,.6)}
.app-search{height:46px;border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft);color:var(--app-dim);display:flex;align-items:center;padding:0 18px;font-size:14px;font-weight:600;min-width:0}
.app-search span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.top-right{grid-column:1/-1;justify-content:flex-start;flex-wrap:wrap}
@media(min-width:1240px){.topbar{grid-template-columns:minmax(260px,1fr) minmax(260px,420px) auto}.top-right{grid-column:auto;justify-content:flex-end}}
.meta-pill,.guide-btn,.alert-badge,.perf-pill{border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft);color:var(--app-muted);font-family:var(--sans);font-size:12px;letter-spacing:0;text-transform:none;font-weight:700;padding:8px 12px}
.meta-pill .v{color:var(--app-text)}
.guide-btn{color:var(--app-blue)}
.alert-badge{border-color:rgba(255,95,111,.35);background:rgba(255,95,111,.1);color:#ff9da8}
.theme-switch{display:flex;align-items:center;gap:4px;padding:4px;border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft)}
.theme-btn{border:0;border-radius:999px;background:transparent;color:var(--app-muted);font:700 11px var(--sans);padding:7px 10px;cursor:pointer}
.theme-btn.active{background:var(--app-green);color:#06120b}
.grid{display:grid;margin:0;padding:24px;grid-template-columns:300px minmax(0,1fr);gap:16px;min-height:calc(100vh - 122px);background:#181b21}
body[data-theme="light"] .grid{background:#eef1f4}
.col{gap:16px;min-width:0}
#leftRail,#centerCol,#rightRail{order:0}
#rightRail{grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
@media(min-width:1320px){.grid{grid-template-columns:280px minmax(0,1fr) 360px}#rightRail{grid-column:auto;display:flex;flex-direction:column}}
.g-panel,.map-region-bar,.map-container,.glossary-panel{border:1px solid var(--app-border);border-radius:18px;background:var(--app-card);box-shadow:none;backdrop-filter:none}
.g-panel{padding:18px;overflow:hidden}
.g-panel::before{display:none}
.sec-head{margin-bottom:14px;gap:10px}
.sec-head h3{font-family:var(--sans);font-size:17px;font-weight:800;letter-spacing:0;text-transform:none;color:var(--app-text)}
.badge{border:1px solid var(--app-border);border-radius:999px;background:var(--app-card-soft);font-family:var(--sans);font-size:11px;font-weight:800;color:var(--app-green);padding:4px 9px}
.layer-item,.site-row,.econ-row,.src-item,.mc,.signal-row,.sm,.idea-card,.ic,.tk-card{border-color:var(--app-border-soft);border-radius:12px;background:var(--app-card-soft)}
.layer-item{margin-bottom:8px;padding:11px}
.layer-item.focused{border-color:var(--app-green);background:var(--app-green-soft)}
.layer-name,.idea-title{font-weight:800;color:var(--app-text)}
.layer-sub,.layer-mode,.idea-text,.ic .ic-t,.tk-head{color:var(--app-muted)}
.layer-count,.site-val,.eval,.sm .smv{color:var(--app-green)}
.mini-btn,.action-btn,.region-btn,.map-ctrl-btn,.proj-toggle,.glossary-close{border:1px solid var(--app-border);border-radius:10px;background:var(--app-card-soft);font-family:var(--sans);letter-spacing:0;color:var(--app-muted)}
.action-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.action-btn{font-size:12px;font-weight:800;padding:10px 8px}
.action-btn:hover,.mini-btn:hover,.region-btn:hover{border-color:var(--app-green);color:var(--app-green);background:var(--app-green-soft)}
.terminal-output{border:1px solid var(--app-border);border-radius:14px;background:#151820;color:var(--app-muted)}
body[data-theme="light"] .terminal-output{background:#f6f8fb}
.map-region-bar{padding:12px;gap:8px;margin-bottom:0}
.region-btn{font-weight:800;text-transform:none;font-size:12px;padding:8px 12px}
.region-btn.active{background:var(--app-green);color:#06120b;border-color:var(--app-green)}
.map-container{min-height:590px;background:#101319}
body[data-theme="light"] .map-container{background:#e7ecf3}
.map-legend{border:1px solid var(--app-border);border-radius:14px;background:rgba(24,27,33,.86);padding:9px 11px;backdrop-filter:blur(8px)}
.map-hint,.map-hint-id{top:14px;right:18px;color:var(--app-muted);letter-spacing:0;font-family:var(--sans);font-weight:700}
.lower{gap:16px;margin-top:16px}
.lower .lp-ticker{flex:1.2 1 300px;max-width:460px}
.lower .lp-macro{flex:2.4 1 520px}
.lower .lp-ideas{flex:1.4 1 340px}
.lower .source-health{flex:1.5 1 420px}
.metrics-row{gap:8px}
.src-grid{grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}
.feed{max-height:none}
body[data-view="worldview"] .grid{grid-template-columns:300px minmax(0,1fr)}
body[data-view="worldview"] #rightRail,body[data-view="worldview"] .lower{display:none}
body[data-view="worldview"] .map-container{min-height:calc(100vh - 250px)}
body[data-view="sources"] .grid{grid-template-columns:360px minmax(0,1fr)}
body[data-view="sources"] #rightRail,body[data-view="sources"] #mapContainer,body[data-view="sources"] #mapRegionBar{display:none!important}
body[data-view="sources"] .lower .g-panel:not(.source-health){display:none}
body[data-view="sources"] .lower{margin-top:0}
body[data-view="signals"] .grid,body[data-view="ops"] .grid{display:block}
body[data-view="signals"] #leftRail,body[data-view="signals"] #centerCol{display:none}
body[data-view="signals"] #rightRail{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
body[data-view="signals"] #rightRail .right-actions{display:none}
body[data-view="ops"] #leftRail,body[data-view="ops"] #centerCol{display:none}
body[data-view="ops"] #rightRail{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
body[data-view="ops"] #rightRail .right-signals,body[data-view="ops"] #rightRail .right-scenarios,body[data-view="ops"] #rightRail .right-delta,body[data-view="ops"] #rightRail .right-osint{display:none}
body[data-view="markets"] .grid{display:block}
body[data-view="markets"] #leftRail,body[data-view="markets"] #rightRail,body[data-view="markets"] #mapContainer,body[data-view="markets"] #mapRegionBar{display:none!important}
body[data-view="markets"] .lower{margin-top:0}
body[data-view="markets"] .lower .g-panel:not(.lp-macro):not(.lp-ideas){display:none}
body[data-view="markets"] .lp-macro,body[data-view="markets"] .lp-ideas{max-width:none}
@media(max-width:760px){
#main.app-root{display:block;padding:0 0 86px}
.app-sidebar{position:fixed;z-index:30;left:0;right:0;bottom:0;top:auto;height:76px;flex-direction:row;padding:8px 12px;border-right:0;border-top:1px solid var(--app-border);gap:12px}
.app-brand-mark,.sidebar-status{display:none}
.app-nav{flex-direction:row;justify-content:space-between;gap:6px;width:100%}
.nav-item{width:54px;height:54px;border-radius:18px}
.nav-item span{width:23px;height:23px;font-size:13px;border-radius:8px}
.nav-item small{display:none}
.app-shell{margin:10px;border-radius:24px;min-height:calc(100vh - 96px)}
.topbar{grid-template-columns:1fr;padding:22px;gap:12px}
.brand{font-size:30px}
.top-center{display:flex;width:100%;overflow:auto}
.top-right{justify-content:flex-start}
.grid{padding:14px;display:flex;flex-direction:column}
body[data-view="worldview"] .grid,body[data-view="sources"] .grid{display:flex}
body[data-view="signals"] #rightRail,body[data-view="ops"] #rightRail{display:flex;flex-direction:column}
.map-container{min-height:420px}
.map-legend{left:8px;right:8px;bottom:8px}
}
</style>
</head>
<body>
<body data-theme="dark" data-view="home">
<div id="boot">
<div class="logo-ring"><span class="logo-text">CRUCIX</span></div>
<div class="logo-ring"><span class="logo-text">IT</span></div>
<div id="bootLines"></div>
<div id="bootFinal">TERMINAL ACTIVE</div>
<div id="bootFinal">APP READY</div>
</div>
<div class="bg-radial" id="bgRadial"></div>
<div class="bg-grid" id="bgGrid"></div>
<div class="scanline" id="scanline"></div>
<div id="main">
<div class="topbar" id="topbar"></div>
<div class="grid">
<div id="main" class="app-root">
<aside class="app-sidebar" aria-label="Primary views">
<div class="app-brand-mark"><span>IT</span></div>
<nav class="app-nav" id="appNav">
<button class="nav-item active" data-view-target="home" onclick="setAppView('home')" title="Home"><span>H</span><small>Home</small></button>
<button class="nav-item" data-view-target="worldview" onclick="setAppView('worldview')" title="Worldview"><span>W</span><small>World</small></button>
<button class="nav-item" data-view-target="sources" onclick="setAppView('sources')" title="Sources"><span>S</span><small>Sources</small></button>
<button class="nav-item" data-view-target="signals" onclick="setAppView('signals')" title="Signals"><span>I</span><small>Signals</small></button>
<button class="nav-item" data-view-target="markets" onclick="setAppView('markets')" title="Markets"><span>M</span><small>Markets</small></button>
<button class="nav-item" data-view-target="ops" onclick="setAppView('ops')" title="Ops"><span>O</span><small>Ops</small></button>
</nav>
<div class="sidebar-status">
<span class="sidebar-status-dot"></span>
<span id="sidebarStatus">Live</span>
</div>
</aside>
<main class="app-shell">
<div class="topbar" id="topbar"></div>
<div class="grid">
<div class="col" id="leftRail"></div>
<div class="col" id="centerCol">
<div class="map-region-bar" id="mapRegionBar"></div>
@@ -369,7 +547,8 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
<div class="lower" id="lowerGrid"></div>
</div>
<div class="col" id="rightRail"></div>
</div>
</div>
</main>
</div>
<div class="glossary-overlay" id="glossaryOverlay" onclick="if(event.target===this) closeGlossary()">
<div class="glossary-panel">
@@ -434,6 +613,56 @@ let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
const terminalActionTokenKey = 'crucix_sweep_token';
const appViews = {
home: { title: 'Home', subtitle: 'Sweep summary, alert posture, source status, and the latest operator feed.' },
worldview: { title: 'Worldview', subtitle: 'Globe or flat map with regional focus and layer controls.' },
sources: { title: 'Sources', subtitle: 'Sensor grid, source health, API-key degradation, and data coverage.' },
signals: { title: 'Signals', subtitle: 'Cross-source signals, sweep delta, scenario watchlist, and OSINT context.' },
markets: { title: 'Markets', subtitle: 'Macro indicators, live markets, volatility gauges, and AI-assisted ideas.' },
ops: { title: 'Ops', subtitle: 'Terminal actions, integration state, and low-performance controls.' }
};
let currentView = localStorage.getItem('intelligence_terminal_view') || 'home';
if(!appViews[currentView]) currentView = 'home';
let themePreference = localStorage.getItem('intelligence_terminal_theme') || 'dark';
function resolveTheme(pref){
if(pref === 'auto'){
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return pref === 'light' ? 'light' : 'dark';
}
function applyTheme(pref = themePreference){
themePreference = pref;
document.body.dataset.theme = resolveTheme(pref);
document.querySelectorAll('.theme-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.themeChoice === pref));
}
function setTheme(pref){
themePreference = pref;
localStorage.setItem('intelligence_terminal_theme', pref);
applyTheme(pref);
renderTopbar();
}
function renderAppNav(){
document.body.dataset.view = currentView;
document.querySelectorAll('.nav-item[data-view-target]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.viewTarget === currentView);
});
const status = document.getElementById('sidebarStatus');
if(status) status.textContent = currentView === 'home' ? 'Live' : appViews[currentView].title;
}
function setAppView(view){
if(!appViews[view]) return;
currentView = view;
localStorage.setItem('intelligence_terminal_view', view);
renderAppNav();
renderTopbar();
refreshMapViewport(true);
}
const layerTypeMap = {
air: ['air'],
thermal: ['thermal'],
@@ -634,23 +863,35 @@ function renderTopbar(){
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
const hasActionToken = !!getTerminalActionToken();
const view = appViews[currentView] || appViews.home;
const direction = D.delta?.summary?.direction;
const deltaLabel = direction === 'risk-off' ? '&#x25B2; '+t('dashboard.riskOff','RISK-OFF') : direction === 'risk-on' ? '&#x25BC; '+t('dashboard.riskOn','RISK-ON') : '&#x25C6; '+t('dashboard.mixed','MIXED');
document.getElementById('topbar').innerHTML=`
<div class="top-left">
<span class="brand">CRUCIX MONITOR</span>
<span class="regime-chip"><span class="blink"></span>WARTIME STAGFLATION RISK</span>
<span class="brand">${view.title}</span>
<span class="view-subtitle">${view.subtitle}</span>
<span class="regime-chip"><span class="blink"></span>Operator dashboard</span>
</div>
<div class="app-search"><span>Search sources, signals, regions, markets...</span></div>
${mobile ? `<div class="top-center">${getRegionControlsMarkup()}</div>` : ''}
<div class="top-right">
<button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.visuals','VISUALS')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.visualsLite','LITE'):t('dashboard.visualsFull','FULL')}</span></button>
<div class="theme-switch" aria-label="Theme selector">
<button class="theme-btn" data-theme-choice="dark" onclick="setTheme('dark')">Dark</button>
<button class="theme-btn" data-theme-choice="auto" onclick="setTheme('auto')">Auto</button>
<button class="theme-btn" data-theme-choice="light" onclick="setTheme('light')">Light</button>
</div>
<button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.visuals','Visuals')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.visualsLite','Lite'):t('dashboard.visualsFull','Full')}</span></button>
<span class="meta-pill">${t('dashboard.sweep','SWEEP')} <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span>
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
<span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${D.delta.summary.direction==='risk-off'?'&#x25B2; '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'&#x25BC; '+t('dashboard.riskOn','RISK-ON'):'&#x25C6; '+t('dashboard.mixed','MIXED')}</span></span>` : ''}
${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${deltaLabel}</span></span>` : ''}
<button class="guide-btn" onclick="configureTerminalActionToken()" title="Configure SWEEP_TOKEN for protected terminal actions">${hasActionToken?'TOKEN SET':'SET TOKEN'}</button>
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
</div>`;
renderRegionControls();
renderAppNav();
applyTheme(themePreference);
}
function getTerminalActionToken(){
@@ -734,16 +975,16 @@ function renderLeftRail(){
const claims=D.fred.find(f=>f.id==='ICSA');
document.getElementById('leftRail').innerHTML=`
<div class="g-panel">
<div class="g-panel source-layers">
<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 ${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 class="g-panel">
<div class="g-panel nuclear-panel">
<div class="sec-head"><h3>${t('panels.nuclearWatch','Nuclear Watch')}</h3><span class="badge">${t('badges.radiation','RADIATION')}</span></div>
<div class="nuke-ok">${allNormal?'&#9679; '+t('nuclear.allSitesNormal','ALL SITES NORMAL'):'&#9888; '+t('nuclear.anomalyDetected','ANOMALY DETECTED')}</div>
${nukeHtml}
</div>
<div class="g-panel">
<div class="g-panel risk-panel">
<div class="sec-head"><h3>${t('panels.riskGauges','Risk Gauges')}</h3><span class="badge">${t('badges.stress','STRESS')}</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.vix','VIX')} (Fear)</span><span class="eval" style="color:${vix?.value>20?'var(--warn)':'var(--accent)'}">${vix?.value||'--'}</span></div>
<div class="econ-row"><span class="elabel">${t('metrics.hySpread','HY Spread')}</span><span class="eval">${hy?.value||'--'}</span></div>
@@ -753,7 +994,7 @@ function renderLeftRail(){
<div class="econ-row"><span class="elabel">${t('metrics.m2Supply','M2 Supply')}</span><span class="eval">$${(m2?.value/1000)?.toFixed(1)||'--'}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 class="g-panel">
<div class="g-panel space-panel">
<div class="sec-head"><h3>${t('panels.spaceWatch','Space Watch')}</h3><button class="mini-btn" onclick="toggleSpaceDisplay()">${spaceDisplayMode.toUpperCase()}</button></div>
${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>
@@ -1603,7 +1844,12 @@ function renderLower(){
${ideasHtml}
<div class="disclosure">FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.</div>
</div>`;
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}`;
const sourcePanel = `<div class="g-panel source-health">
<div class="sec-head"><h3>Source Health</h3><span class="badge">${D.meta.sourcesOk}/${D.meta.sourcesQueried} online</span></div>
<div class="src-grid">${srcHtml || '<div class="src-item"><div class="sd err"></div><span>No source snapshot</span></div>'}</div>
<div class="disclosure">Sources that require API keys degrade visibly here while the rest of the sweep continues. The dashboard stays useful with partial data instead of hiding failures.</div>
</div>`;
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}${sourcePanel}`;
}
async function runTerminalAction(action){
@@ -1756,7 +2002,7 @@ function safeExternalUrl(raw){try{const u=new URL(raw,location.href);return u.pr
function runBoot(){
const acledStatus = D.acled?.totalEvents > 0 ? `<span class="ok">${D.acled.totalEvents} EVENTS</span>` : '<span style="color:var(--warn)">DEGRADED</span>';
const lines=[
{text:t('boot.initializing','INITIALIZING CRUCIX ENGINE v2.1.0'),delay:0},
{text:t('boot.initializing','INITIALIZING INTELLIGENCE TERMINAL'),delay:0},
{text:t('boot.connecting','CONNECTING {count} OSINT SOURCES...').replace('{count}',D.meta.sourcesQueried),delay:400},
{text:'&#9500;&#9472; '+t('boot.sourceGroup1','OPENSKY · FIRMS · KIWISDR · MARITIME'),delay:700},
{text:'&#9500;&#9472; '+t('boot.sourceGroup2','FRED · BLS · EIA · TREASURY · GSCPI'),delay:900},
@@ -1768,7 +2014,7 @@ function runBoot(){
{text:t('boot.intelligenceSynthesis','INTELLIGENCE SYNTHESIS')+': <span class="ok">'+t('boot.active','ACTIVE')+'</span>',delay:2400},
];
const container=document.getElementById('bootLines');
document.getElementById('bootFinal').textContent=t('dashboard.terminalActive','TERMINAL ACTIVE');
document.getElementById('bootFinal').textContent=t('dashboard.terminalActive','APP READY');
const tl=gsap.timeline();
tl.to('.logo-ring',{opacity:1,duration:0.6,ease:'power2.out'},0);
tl.to(container,{opacity:1,duration:0.3},0.3);
@@ -1783,7 +2029,6 @@ function runBoot(){
tl.set('#boot',{display:'none'},4.2);
tl.to('#bgRadial',{opacity:1,duration:1},3.8);
tl.to('#bgGrid',{opacity:1,duration:1.2},4.0);
tl.to('#scanline',{opacity:1,duration:0.8},4.3);
tl.to('#main',{opacity:1,duration:0.6},3.9);
tl.call(()=>{
gsap.from('.g-panel,.topbar,.map-container',{opacity:0,y:20,scale:0.97,duration:0.5,stagger:0.06,ease:'power2.out'});
@@ -1798,7 +2043,7 @@ function runBoot(){
},[],4.0);
}
function isMobileLayout(){ return window.innerWidth <= 1100; }
function isMobileLayout(){ return window.innerWidth <= 760; }
function buildOsintPanel(panelClass='', maxHeight=260){
const allPosts=[...D.tg.urgent,...D.tg.topPosts].sort((a,b)=>new Date(b.date||0)-new Date(a.date||0));
@@ -1935,6 +2180,8 @@ function connectSSE(){
// === INIT ===
let booted = false;
function init(){
applyTheme(themePreference);
renderAppNav();
renderTopbar();renderLeftRail();renderLower();renderRight();
renderGlossary();
initMap();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,21 @@
# Source Fetch Instrumentation
`safeFetch()` and `safeFetchText()` attribute requests to `/api/metrics.fetch.bySource`.
Rules:
- Prefer passing an explicit `source` option from source modules when the call has a clear Crucix source name.
- If `source` is omitted, the shared helper infers a stable provider name from the request host.
- Unknown hosts fall back to the lowercase host instead of the old `unknown` bucket.
- Raw `fetch()` calls should be limited to cases where the shared helper cannot represent the protocol cleanly.
Current raw-fetch exceptions:
| Area | Reason |
| --- | --- |
| OAuth/session handshakes | Token exchange calls often need custom form bodies, credential headers, or status-specific diagnostics. |
| Bot and alert delivery | Telegram/Discord alert calls are outbound operator notifications, not intelligence source health. |
| LLM providers | Provider clients already track model/provider status separately from source fetch health. |
| Dashboard browser calls | Browser-side `/api/*` and asset fetches are UI behavior, not source provider health. |
When adding a new intelligence source, use `safeFetch(url, { source: 'SourceName' })` unless there is a documented exception.

View File

@@ -370,10 +370,24 @@ app.get('/events', (req, res) => {
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
});
res.write('retry: 10000\n');
res.write('data: {"type":"connected"}\n\n');
const heartbeatMs = Math.max(5000, config.sseHeartbeatIntervalMs || 25000);
const heartbeat = setInterval(() => {
try {
res.write(`: heartbeat ${new Date().toISOString()}\n\n`);
} catch {
clearInterval(heartbeat);
sseClients.delete(res);
}
}, heartbeatMs);
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
req.on('close', () => {
clearInterval(heartbeat);
sseClients.delete(res);
});
});
function broadcast(data) {

View File

@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
import { safeFetch, safeFetchText, getFetchMetrics, inferFetchSource } from '../apis/utils/fetch.mjs';
import { formatStaleAlert, shouldSendStaleAlert } from '../lib/stale-alerts.mjs';
test('safeFetch reports HTML as degraded JSON response', async () => {
@@ -101,6 +101,42 @@ test('safeFetchText returns text and byte count', async () => {
}
});
test('safeFetch attributes unlabelled requests to a stable provider source', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: true,
status: 200,
headers: { get: () => 'application/json' },
text: async () => '{"observations":[]}',
});
try {
const data = await safeFetch('https://api.fred.stlouisfed.org/fred/series/observations?series_id=VIXCLS', { retries: 0 });
assert.deepEqual(data, { observations: [] });
const bucket = getFetchMetrics().bySource.FRED;
assert.ok(bucket.requests >= 1);
assert.equal(bucket.lastStatus, 200);
} finally {
globalThis.fetch = originalFetch;
}
});
test('inferFetchSource returns provider names and host fallback', () => {
assert.equal(inferFetchSource('https://api.bls.gov/publicAPI/v2/timeseries/data/CPI'), 'BLS');
assert.equal(inferFetchSource('https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC'), 'YahooFinance');
assert.equal(inferFetchSource('https://unknown.example.test/path'), 'unknown.example.test');
});
test('SSE endpoint sends reconnect guidance and clears heartbeat timer', () => {
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
const config = readFileSync(new URL('../crucix.config.mjs', import.meta.url), 'utf8');
assert.match(config, /sseHeartbeatIntervalMs/);
assert.match(server, /retry: 10000\\n/);
assert.match(server, /setInterval\(\(\) =>/);
assert.match(server, /: heartbeat/);
assert.match(server, /clearInterval\(heartbeat\)/);
assert.match(server, /X-Accel-Buffering/);
});
test('intelligence store defines durable memory and prediction lifecycle tables', () => {
const store = readFileSync(new URL('../lib/intelligence-store.mjs', import.meta.url), 'utf8');
assert.match(store, /CREATE TABLE IF NOT EXISTS events/);