diff --git a/README.md b/README.md index ff9a0e39..a8f1feed 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ All three variants run from a single codebase — switch between them with one c ### Localization & Regional Support -- **Multilingual UI** — Fully localized interface supporting **14 languages: English, French, Spanish, German, Italian, Polish, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese, Japanese, and Turkish**. Language bundles are lazy-loaded on demand — only the active language is fetched, keeping initial bundle size minimal. +- **Multilingual UI** — Fully localized interface supporting **16 languages: English, French, Spanish, German, Italian, Polish, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese, Japanese, Turkish, Thai, and Vietnamese**. Language bundles are lazy-loaded on demand — only the active language is fetched, keeping initial bundle size minimal. - **RTL Support** — Native right-to-left layout support for Arabic (`ar`) and Hebrew. -- **Localized News Feeds** — Region-specific RSS selection based on language preference (e.g., viewing the app in French loads Le Monde, Jeune Afrique, and France24). +- **Localized News Feeds** — Region-specific RSS selection based on language preference (e.g., viewing the app in French loads Le Monde, Jeune Afrique, and France24). Seven locales have dedicated native-language feed sets: French, Arabic, German, Spanish, Turkish (BBC Türkçe, Hurriyet, DW Turkish), Polish (TVN24, Polsat News, Rzeczpospolita), Russian (BBC Russian, Meduza, Novaya Gazeta Europe), Thai (Bangkok Post, Thai PBS), and Vietnamese (VnExpress, Tuoi Tre News). - **AI Translation** — Integrated LLM translation for news headlines and summaries, enabling cross-language intelligence gathering. - **Regional Intelligence** — Dedicated monitoring panels for Africa, Latin America, Middle East, and Asia with local sources. @@ -177,6 +177,7 @@ All three variants run from a single codebase — switch between them with one c - **19 live webcams** — real-time YouTube streams from geopolitical hotspots across 4 regions (Middle East, Europe, Americas, Asia-Pacific). Grid view shows 4 strategic feeds simultaneously; single-feed view available. Region filtering (ALL/MIDEAST/EUROPE/AMERICAS/ASIA), idle-aware playback that pauses after 5 minutes, and Intersection Observer-based lazy loading - **Custom keyword monitors** — user-defined keyword alerts with word-boundary matching (prevents "ai" from matching "train"), automatic color-coding from a 10-color palette, and multi-keyword support (comma-separated). Monitors search across both headline titles and descriptions and show real-time match counts - **Entity extraction** — Auto-links countries, leaders, organizations +- **Instant flat render** — news items appear immediately as a flat list the moment feed data arrives. ML-based clustering (topic grouping, entity extraction, sentiment analysis) runs asynchronously in the background and progressively upgrades the view when ready — eliminating the 1–3 second blank delay that would occur if clustering blocked initial render. Finance variant categories fetch with 5 concurrent requests (vs 3) for ~10–15 second faster cold starts - **Virtual scrolling** — news panels with 15+ items use a custom virtual list renderer that only creates DOM elements for visible items plus a 3-item overscan buffer. Viewport spacers simulate full-list height. Uses `requestAnimationFrame`-batched scroll handling and `ResizeObserver` for responsive adaptation. DOM elements are pooled and recycled rather than created/destroyed ### Signal Aggregation & Anomaly Detection @@ -229,7 +230,7 @@ All three variants run from a single codebase — switch between them with one c - **Cmd+K search** — fuzzy search across 20+ result types: news headlines, countries (with direct country brief navigation), hotspots, markets, military bases, cables, pipelines, datacenters, nuclear facilities, tech companies, and more - **Historical playback** — dashboard snapshots are stored in IndexedDB. A time slider allows rewinding to any saved state, with live updates paused during playback - **Mobile detection** — screens below 768px receive a warning modal since the dashboard is designed for multi-panel desktop use -- **UCDP conflict classification** — countries with active wars (1,000+ battle deaths/year) receive automatic CII floor scores, preventing optimistic drift +- **UCDP conflict classification** — countries with active wars (1,000+ battle deaths/year) receive automatic CII floor scores, preventing optimistic drift. The UCDP GED API integration uses automatic version discovery (probing multiple year-based API versions in parallel), negative caching (5-minute backoff after upstream failures), discovered-version caching (1-hour TTL), and stale-on-error fallback to ensure conflict data is always available even when the upstream API is intermittently down - **HAPI humanitarian data** — UN OCHA humanitarian access metrics and displacement flows feed into country-level instability scoring with dual-perspective (origins vs. hosts) panel - **Idle-aware resource management** — animations pause after 2 minutes of inactivity and when the tab is hidden, preventing battery drain. Video streams are destroyed from the DOM and recreated on return - **Country-specific stock indices** — country briefs display the primary stock market index with 1-week change (S&P 500 for US, Shanghai Composite for China, etc.) via the `/api/stock-index` endpoint @@ -346,12 +347,15 @@ The Ollama tier communicates via the OpenAI-compatible `/v1/chat/completions` en ### Threat Classification Pipeline -Every news item passes through a two-stage classification pipeline: +Every news item passes through a three-stage classification pipeline: -1. **Keyword classifier** (instant) — pattern-matches against ~120 threat keywords organized by severity tier (critical → high → medium → low → info) and category (conflict, terrorism, cyber, disaster, etc.). Returns immediately with a confidence score. -2. **LLM classifier** (async) — fires in the background via a Vercel Edge Function calling Groq's Llama 3.1 8B at temperature 0. Results are cached in Redis (24h TTL) keyed by headline hash. When the LLM result arrives, it overrides the keyword result only if its confidence is higher. +1. **Keyword classifier** (instant, `source: 'keyword'`) — pattern-matches against ~120 threat keywords organized by severity tier (critical → high → medium → low → info) and 14 event categories (conflict, protest, disaster, diplomatic, economic, terrorism, cyber, health, environmental, military, crime, infrastructure, tech, general). Keywords use word-boundary regex matching to prevent false positives (e.g., "war" won't match "award"). Each match returns a severity level, category, and confidence score. Variant-specific keyword sets ensure the tech variant doesn't flag "sanctions" in non-geopolitical contexts. -This hybrid approach means the UI is never blocked waiting for AI — users see keyword results instantly, with LLM refinements arriving within seconds and persisting for all subsequent visitors. +2. **Browser-side ML** (async, `source: 'ml'`) — Transformers.js runs NER, sentiment analysis, and topic classification directly in the browser with no server dependency. Provides a second classification opinion without any API call. + +3. **LLM classifier** (batched async, `source: 'llm'`) — headlines are collected into a batch queue and fired as parallel `classifyEvent` RPCs via the sebuf proto client. Each RPC calls the configured LLM provider (Groq Llama 3.1 8B at temperature 0, or Ollama for local inference). Results are cached in Redis (24h TTL) keyed by headline hash. When 500-series errors occur, the LLM classifier automatically pauses its queue to avoid wasting API quota, resuming after an exponential backoff delay. When the LLM result arrives, it overrides the keyword result only if its confidence is higher. + +This hybrid approach means the UI is never blocked waiting for AI — users see keyword results instantly, with ML and LLM refinements arriving within seconds and persisting for all subsequent visitors. Each classification carries its `source` tag (`keyword`, `ml`, or `llm`) so downstream consumers can weight confidence accordingly. ### Country Instability Index (CII) @@ -447,6 +451,15 @@ Beyond displaying static cable routes on the map, the system actively monitors c Advisories are classified by severity: `fault` (cable break, cut, or damage — potential traffic rerouting) or `degraded` (repair work in progress with partial capacity). Impact descriptions are generated dynamically, linking the advisory to the specific cable and the countries it serves — enabling questions like "which cables serving South Asia are currently under repair?" +**Health scoring algorithm** — Each cable receives a composite health score (0–100) computed from weighted signals with exponential time decay: + +``` +signal_weight = severity × (e^(-λ × age_hours)) where λ = ln(2) / 168 (7-day half-life) +health_score = max(0, 100 − Σ(signal_weights) × 100) +``` + +Signals are classified into two kinds: `operator_fault` (confirmed cable damage — severity 1.0) and `cable_advisory` (repair operations, navigational warnings — severity 0.6). Geographic matching uses cosine-latitude-corrected equirectangular approximation to find the nearest cataloged cable within 50km of each NGA warning's coordinates. Results are cached in Redis (6-hour TTL for complete results, 10 minutes for partial) with an in-memory fallback that serves stale data when Redis is unavailable — ensuring the cable health layer never shows blank data even during cache failures. + ### Infrastructure Cascade Modeling Beyond proximity correlation, the system models how disruptions propagate through interconnected infrastructure. A dependency graph connects undersea cables, pipelines, ports, chokepoints, and countries with weighted edges representing capacity dependencies: @@ -745,6 +758,24 @@ All real-time data sources feed into a central signal aggregator that builds a u 2. **Detects regional convergence** — identifies when multiple signal types spike in the same geographic corridor (e.g., military flights + protests + satellite fires in Eastern Mediterranean) 3. **Feeds downstream analysis** — the CII, hotspot escalation, focal point detection, and AI insights modules all consume the aggregated signal picture rather than raw data +### PizzINT Activity Monitor & GDELT Tension Index + +The dashboard integrates two complementary geopolitical pulse indicators: + +**PizzINT DEFCON scoring** — monitors foot traffic patterns at key military, intelligence, and government locations worldwide via the PizzINT API. Aggregate activity levels across monitored sites are converted into a 5-level DEFCON-style readout: + +| Adjusted Activity | DEFCON Level | Label | +| ----------------- | ------------ | ----------------- | +| ≥ 85% | 1 | Maximum Activity | +| 70% – 84% | 2 | High Activity | +| 50% – 69% | 3 | Elevated Activity | +| 25% – 49% | 4 | Above Normal | +| < 25% | 5 | Normal Activity | + +Activity spikes at individual locations boost the aggregate score (+10 per spike, capped at 100). Data freshness is tracked per-location — the system distinguishes between stale readings (location sensor lag) and genuine low activity. Per-location detail includes current popularity percentage, spike magnitude, and open/closed status. + +**GDELT bilateral tension pairs** — six strategic country pairs (USA↔Russia, Russia↔Ukraine, USA↔China, China↔Taiwan, USA↔Iran, USA↔Venezuela) are tracked via GDELT's GPR (Goldstein Political Relations) batch API. Each pair shows a current tension score, a percentage change from the previous data point, and a trend direction (rising/stable/falling, with ±5% thresholds). Rising bilateral tension scores that coincide with military signal spikes in the same region feed into the focal point detection algorithm. + ### Data Freshness & Intelligence Gaps A singleton tracker monitors 22 data sources (GDELT, RSS, AIS, military flights, earthquakes, weather, outages, ACLED, Polymarket, economic indicators, NASA FIRMS, cyber threat feeds, trending keywords, oil/energy, population exposure, and more) with status categorization: fresh (<15 min), stale (1h), very_stale (6h), no_data, error, disabled. It explicitly reports **intelligence gaps** — what analysts can't see — preventing false confidence when critical data sources are down or degraded. @@ -867,7 +898,7 @@ A single codebase produces three specialized dashboards, each with distinct feed | Principle | Implementation | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Speed over perfection** | Keyword classifier is instant; LLM refines asynchronously. Users never wait. | -| **Assume failure** | Per-feed circuit breakers with 5-minute cooldowns. AI fallback chain: Ollama (local) → Groq → OpenRouter → browser-side T5. Redis cache failures degrade gracefully. Every edge function returns stale cached data when upstream APIs are down. | +| **Assume failure** | Per-feed circuit breakers with 5-minute cooldowns. AI fallback chain: Ollama (local) → Groq → OpenRouter → browser-side T5. Redis cache failures degrade to in-memory fallback with stale-on-error. Negative caching prevents hammering downed upstream APIs. Every edge function returns stale cached data when upstream APIs are down. In-flight request deduplication prevents concurrent clients from stampeding the same upstream endpoint. | | **Show what you can't see** | Intelligence gap tracker explicitly reports data source outages rather than silently hiding them. | | **Browser-first compute** | Analysis (clustering, instability scoring, surge detection) runs client-side — no backend compute dependency for core intelligence. | | **Local-first geolocation** | Country detection uses browser-side ray-casting against GeoJSON polygons rather than network reverse-geocoding. Sub-millisecond response, zero API dependency, works offline. Network geocoding is a fallback, not the primary path. | @@ -973,9 +1004,9 @@ The Tauri desktop app wraps the dashboard in a native window (macOS, Windows, Li ▼ ┌─────────────────────────────────────────────────┐ │ Node.js Sidecar (port 46123) │ -│ 60+ API handlers · Gzip compression │ -│ Cloud fallback · Traffic logging │ -│ Verbose debug mode · Circuit breakers │ +│ 60+ API handlers · Local RSS proxy │ +│ Brotli/Gzip compression · Cloud fallback │ +│ Traffic logging · Verbose debug mode │ └─────────────────────┬───────────────────────────┘ │ fetch (on local failure) ▼ @@ -1005,6 +1036,10 @@ A unique 32-character hex token is generated per app launch using randomized has The `/api/service-status` health check endpoint is exempt from token validation to support monitoring tools. +### Local RSS Proxy + +The sidecar includes a built-in RSS proxy handler that fetches news feeds directly from source domains, bypassing the cloud RSS proxy entirely. This means the desktop app can load all 150+ RSS feeds without any cloud dependency — the same domain allowlist used by the Vercel edge proxy is enforced locally. Combined with the local API handlers, this enables the desktop app to operate as a fully self-contained intelligence aggregation platform. + ### Cloud Fallback When a local API handler is missing, throws an error, or returns a 5xx status, the sidecar transparently proxies the request to the cloud deployment. Endpoints that fail are marked as `cloudPreferred` — subsequent requests skip the local handler and go directly to the cloud until the sidecar is restarted. Origin and Referer headers are stripped before proxying to maintain server-to-server parity. @@ -1058,6 +1093,10 @@ Cloudflare will negotiate Brotli automatically for compatible clients when the o All relay server responses pass through `gzipSync` when the client accepts gzip and the payload exceeds 1KB. Sidecar API responses prefer Brotli and use gzip fallback with proper `Content-Encoding`/`Vary` headers for the same threshold. This applies to OpenSky aircraft JSON, RSS XML feeds, UCDP event data, AIS snapshots, and health checks — reducing wire size by approximately 50–80%. +### In-Flight Request Deduplication + +When multiple connected clients poll simultaneously (common with the relay's multi-tenant WebSocket architecture), identical upstream requests are deduplicated at the relay level. The first request for a given resource key (e.g., an RSS feed URL or OpenSky bounding box) creates a Promise stored in an in-flight Map. All concurrent requests for the same key await that single Promise rather than stampeding the upstream API. Subsequent requests are served from cache with an `X-Cache: DEDUP` header. This prevents scenarios like 53 concurrent RSS cache misses or 5 simultaneous OpenSky requests for the same geographic region — all resolved by a single upstream fetch. + ### Frontend Polling Intervals Panels refresh at staggered intervals to avoid synchronized API storms: @@ -1109,6 +1148,7 @@ The AI summarization pipeline adds content-based deduplication: headlines are ha | **Desktop sidecar auth** | The local API sidecar requires a per-session `Bearer` token generated at launch. The token is stored in Rust state and injected into the sidecar environment — only the Tauri frontend can retrieve it via IPC. Health check endpoints are exempt. | | **OS keychain storage** | Desktop API keys are stored in the operating system's credential manager (macOS Keychain, Windows Credential Manager), never in plaintext files or environment variables on disk. | | **Bot-aware social previews** | The `/api/story` endpoint detects social crawlers (10+ signatures: Twitter, Facebook, LinkedIn, Telegram, Discord, Reddit, WhatsApp, Google) and serves OG-tagged HTML with dynamic preview images. Regular browsers receive a 302 redirect to the SPA. | +| **Bot protection middleware** | Edge Middleware blocks crawlers and scrapers from all `/api/*` routes — bot user-agents and requests with short or missing `User-Agent` headers receive 403. Social preview bots are selectively allowed on `/api/story` and `/api/og-story` for Open Graph image generation. Reinforced by `robots.txt` Disallow rules on API paths. | | **No debug endpoints** | The `api/debug-env.js` endpoint returns 404 in production — it exists only as a disabled placeholder. | --- @@ -1248,7 +1288,7 @@ Set `WS_RELAY_URL` (server-side, HTTPS) and `VITE_WS_RELAY_URL` (client-side, WS | **Market APIs** | Yahoo Finance (equities, forex, crypto), CoinGecko (stablecoins), mempool.space (BTC hashrate), alternative.me (Fear & Greed) | | **Threat Intel APIs** | abuse.ch (Feodo Tracker, URLhaus), AlienVault OTX, AbuseIPDB, C2IntelFeeds | | **Economic APIs** | FRED (Federal Reserve), EIA (Energy), Finnhub (stock quotes) | -| **Localization** | i18next (14 languages: en, fr, de, es, it, pl, pt, nl, sv, ru, ar, zh, ja, tr), RTL support, lazy-loaded bundles | +| **Localization** | i18next (16 languages: en, fr, de, es, it, pl, pt, nl, sv, ru, ar, zh, ja, tr, th, vi), RTL support, lazy-loaded bundles, native-language feeds for 7 locales | | **API Contracts** | Protocol Buffers (92 proto files, 17 services), sebuf HTTP annotations, buf CLI (lint + breaking checks), auto-generated TypeScript clients/servers + OpenAPI 3.1.0 docs | | **Deployment** | Vercel Edge Functions (60+ endpoints) + Railway (WebSocket relay) + Tauri (macOS/Windows/Linux) + PWA (installable) | | **Finance Data** | 92 stock exchanges, 19 financial centers, 13 central banks, 10 commodity hubs, 64 Gulf FDI investments | @@ -1348,6 +1388,16 @@ Desktop release details, signing hooks, variant outputs, and clean-machine valid - [x] Dynamic Open Graph images for social sharing (SVG card generation with CII scores) - [x] Storage quota management (graceful degradation on exhausted localStorage/IndexedDB) - [x] Chunk reload guard (one-shot recovery from stale-asset 404s after deployments) +- [x] PizzINT activity monitor with DEFCON-style scoring and GDELT bilateral tension tracking +- [x] Bot protection middleware (edge-level crawler blocking with social preview exceptions) +- [x] In-flight request deduplication on relay (prevents upstream API stampede from concurrent clients) +- [x] Instant flat-render news panels (ML clustering runs async, items appear immediately) +- [x] Cable health scoring algorithm (time-decay weighted signals from NGA warnings with cos-lat distance matching) +- [x] Thai and Vietnamese localization (16 total languages, 1,361 keys per locale) +- [x] Native-language RSS feeds for Turkish, Polish, Russian, Thai, and Vietnamese locales +- [x] Desktop sidecar RSS proxy (local feed fetching without cloud fallback) +- [x] Negative caching and version discovery for UCDP upstream API resilience +- [x] XRP (Ripple) added to crypto market tracking - [ ] Mobile-optimized views - [ ] Push notifications for critical alerts - [ ] Self-hosted Docker image diff --git a/docs/NEWS_TRANSLATION_ANALYSIS.md b/docs/NEWS_TRANSLATION_ANALYSIS.md deleted file mode 100644 index 5444d863..00000000 --- a/docs/NEWS_TRANSLATION_ANALYSIS.md +++ /dev/null @@ -1,60 +0,0 @@ -# News Translation Analysis - -## Current Architecture - -The application fetches news via `src/services/rss.ts`. - -- **Mechanism**: Direct HTTP requests (via proxy) to RSS/Atom XML feeds. -- **Processing**: `DOMParser` parses XML client-side. -- **Storage**: Items are stored in-memory in `App.ts` (`allNews`, `newsByCategory`). - -## The Challenge - -Legacy RSS feeds are static XML files in their original language. There is no built-in "negotiation" for language. To display French news, we must either: - -1. Fetch French feeds. -2. Translate English feeds on the fly. - -## Proposed Solutions - -### Option 1: Localized Feed Discovery (Recommended for "Major" Support) - -Instead of forcing translation, we switch the *source* based on the selected language. - -- **Implementation**: - - In `src/config/feeds.ts`, change the simple URL string to an object: `url: { en: '...', fr: '...' }` or separate constant lists `FEEDS_EN`, `FEEDS_FR`. - - **Pros**: Zero latency, native content quality, no API costs. - - **Cons**: Hard to find equivalent feeds for niche topics (e.g., specific mil-tech blogs) in all languages. - - **Strategy**: Creating a curated list of international feeds for major categories (World, Politics, Finance) is the most robust & scalable approach. - -### Option 2: On-Demand Client-Side Translation - -Add a "Translate" button to each news card. - -- **Implementation**: - - Click triggers a call to a translation API (Google/DeepL/LLM). - - Store result in a local cache (Map). -- **Pros**: Low cost (only used when needed), preserves original context. -- **Cons**: User friction (click to read). - -### Option 3: Automatic Auto-Translation (Not Recommended) - -Translating 500+ headlines on every load. - -- **Cons**: - - **Cost**: Prohibitive for free/low-cost APIs. - - **Latency**: Massive slowdown on startup. - - **Quality**: Short headlines often translate poorly without context. - -## Recommendation - -**Hybrid Approach**: - -1. **Primary**: Source localized feeds where possible (e.g., Le Monde for FR, Spiegel for DE). This requires a community effort to curate `feeds.json` for each locale. -2. **Fallback**: Keep English feeds for niche tech/intel sources where no alternative exists. -3. **Feature**: Add a "Summarize & Translate" button using the existing LLM worker. The prompt to the LLM (currently used for summaries) can be adjusted to "Summarize this in [Current Language]". - -## Next Steps - -1. Audit `src/config/feeds.ts` to structure it for multi-language support. -2. Update `rss.ts` to select the correct URL based on `i18n.language`. diff --git a/server/_shared/constants.ts b/server/_shared/constants.ts index fd7d60a5..605f9f94 100644 --- a/server/_shared/constants.ts +++ b/server/_shared/constants.ts @@ -7,11 +7,15 @@ export const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleW */ let yahooLastRequest = 0; const YAHOO_MIN_GAP_MS = 600; +let yahooQueue: Promise = Promise.resolve(); -export async function yahooGate(): Promise { - const elapsed = Date.now() - yahooLastRequest; - if (elapsed < YAHOO_MIN_GAP_MS) { - await new Promise(r => setTimeout(r, YAHOO_MIN_GAP_MS - elapsed)); - } - yahooLastRequest = Date.now(); +export function yahooGate(): Promise { + yahooQueue = yahooQueue.then(async () => { + const elapsed = Date.now() - yahooLastRequest; + if (elapsed < YAHOO_MIN_GAP_MS) { + await new Promise(r => setTimeout(r, YAHOO_MIN_GAP_MS - elapsed)); + } + yahooLastRequest = Date.now(); + }); + return yahooQueue; } diff --git a/server/worldmonitor/aviation/v1/list-airport-delays.ts b/server/worldmonitor/aviation/v1/list-airport-delays.ts index 19e62c05..ad805a79 100644 --- a/server/worldmonitor/aviation/v1/list-airport-delays.ts +++ b/server/worldmonitor/aviation/v1/list-airport-delays.ts @@ -18,17 +18,26 @@ import { determineSeverity, generateSimulatedDelay, } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'aviation:delays:v1'; +const REDIS_CACHE_TTL = 1800; // 30 min — FAA updates infrequently export async function listAirportDelays( _ctx: ServerContext, _req: ListAirportDelaysRequest, ): Promise { try { + // Redis shared cache + const cached = (await getCachedJson(REDIS_CACHE_KEY)) as ListAirportDelaysResponse | null; + if (cached?.alerts?.length) return cached; + const alerts: AirportDelayAlert[] = []; // 1. Fetch and parse FAA XML const faaResponse = await fetch(FAA_URL, { - headers: { Accept: 'application/xml' }, + headers: { Accept: 'application/xml', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(15_000), }); @@ -76,7 +85,11 @@ export async function listAirportDelays( } } - return { alerts }; + const result: ListAirportDelaysResponse = { alerts }; + if (alerts.length > 0) { + setCachedJson(REDIS_CACHE_KEY, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { // Graceful empty response on ANY failure (established pattern from 2F-01) return { alerts: [] }; diff --git a/server/worldmonitor/climate/v1/list-climate-anomalies.ts b/server/worldmonitor/climate/v1/list-climate-anomalies.ts index 283ac8bd..4f6dc14f 100644 --- a/server/worldmonitor/climate/v1/list-climate-anomalies.ts +++ b/server/worldmonitor/climate/v1/list-climate-anomalies.ts @@ -17,6 +17,12 @@ import type { ClimateAnomaly, } from '../../../../src/generated/server/worldmonitor/climate/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'climate:anomalies:v1'; +const REDIS_CACHE_TTL = 1800; // 30 min — daily archive data, slow-moving + /** The 15 monitored zones matching the legacy api/climate-anomalies.js list. */ const ZONES: { name: string; lat: number; lon: number }[] = [ { name: 'Ukraine', lat: 48.4, lon: 31.2 }, @@ -85,7 +91,7 @@ async function fetchZone( ): Promise { const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${zone.lat}&longitude=${zone.lon}&start_date=${startDate}&end_date=${endDate}&daily=temperature_2m_mean,precipitation_sum&timezone=UTC`; - const response = await fetch(url, { signal: AbortSignal.timeout(20_000) }); + const response = await fetch(url, { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(20_000) }); if (!response.ok) { throw new Error(`Open-Meteo ${response.status} for ${zone.name}`); } @@ -133,6 +139,10 @@ export const listClimateAnomalies: ClimateServiceHandler['listClimateAnomalies'] _ctx: ServerContext, _req: ListClimateAnomaliesRequest, ): Promise => { + // Redis shared cache + const cached = (await getCachedJson(REDIS_CACHE_KEY)) as ListClimateAnomaliesResponse | null; + if (cached?.anomalies?.length) return cached; + // Compute 30-day date range const endDate = new Date().toISOString().slice(0, 10); const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) @@ -156,5 +166,9 @@ export const listClimateAnomalies: ClimateServiceHandler['listClimateAnomalies'] } } - return { anomalies, pagination: undefined }; + const result: ListClimateAnomaliesResponse = { anomalies, pagination: undefined }; + if (anomalies.length > 0) { + setCachedJson(REDIS_CACHE_KEY, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; }; diff --git a/server/worldmonitor/conflict/v1/get-humanitarian-summary.ts b/server/worldmonitor/conflict/v1/get-humanitarian-summary.ts index e886cc33..8bd02c34 100644 --- a/server/worldmonitor/conflict/v1/get-humanitarian-summary.ts +++ b/server/worldmonitor/conflict/v1/get-humanitarian-summary.ts @@ -13,6 +13,12 @@ import type { HumanitarianCountrySummary, } from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'conflict:humanitarian:v1'; +const REDIS_CACHE_TTL = 21600; // 6 hr — monthly humanitarian data + const ISO2_TO_ISO3: Record = { US: 'USA', RU: 'RUS', CN: 'CHN', UA: 'UKR', IR: 'IRN', IL: 'ISR', TW: 'TWN', KP: 'PRK', SA: 'SAU', TR: 'TUR', @@ -49,7 +55,7 @@ async function fetchHapiSummary(countryCode: string): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.countryCode || 'all'}`; + const cached = (await getCachedJson(cacheKey)) as GetHumanitarianSummaryResponse | null; + if (cached?.summary) return cached; + const summary = await fetchHapiSummary(req.countryCode); - return { summary }; + const result: GetHumanitarianSummaryResponse = { summary }; + if (summary) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { summary: undefined }; } diff --git a/server/worldmonitor/conflict/v1/list-acled-events.ts b/server/worldmonitor/conflict/v1/list-acled-events.ts index fc1bd5a0..80e2e056 100644 --- a/server/worldmonitor/conflict/v1/list-acled-events.ts +++ b/server/worldmonitor/conflict/v1/list-acled-events.ts @@ -15,6 +15,12 @@ import type { AcledConflictEvent, } from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'conflict:acled:v1'; +const REDIS_CACHE_TTL = 900; // 15 min — ACLED rate-limited + const ACLED_API_URL = 'https://acleddata.com/api/acled/read'; async function fetchAcledConflicts(req: ListAcledEventsRequest): Promise { @@ -44,6 +50,7 @@ async function fetchAcledConflicts(req: ListAcledEventsRequest): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.country || 'all'}:${req.timeRange?.start || 0}:${req.timeRange?.end || 0}`; + const cached = (await getCachedJson(cacheKey)) as ListAcledEventsResponse | null; + if (cached?.events?.length) return cached; + const events = await fetchAcledConflicts(req); - return { events, pagination: undefined }; + const result: ListAcledEventsResponse = { events, pagination: undefined }; + if (events.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { events: [], pagination: undefined }; } diff --git a/server/worldmonitor/conflict/v1/list-ucdp-events.ts b/server/worldmonitor/conflict/v1/list-ucdp-events.ts index b21f90cc..0473318c 100644 --- a/server/worldmonitor/conflict/v1/list-ucdp-events.ts +++ b/server/worldmonitor/conflict/v1/list-ucdp-events.ts @@ -14,6 +14,7 @@ import type { UcdpViolenceType, } from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; const UCDP_PAGE_SIZE = 1000; const MAX_PAGES = 12; @@ -60,7 +61,7 @@ function buildVersionCandidates(): string[] { // Negative cache: prevent hammering UCDP when it's down let lastFailureTimestamp = 0; -const NEGATIVE_CACHE_MS = 5 * 60 * 1000; // 5 minutes backoff after failure +const NEGATIVE_CACHE_MS = 60 * 1000; // 60 seconds backoff after failure // Discovered version cache: avoid re-probing every request let discoveredVersion: string | null = null; @@ -71,8 +72,8 @@ async function fetchGedPage(version: string, page: number): Promise { const response = await fetch( `https://ucdpapi.pcr.uu.se/api/gedevents/${version}?pagesize=${UCDP_PAGE_SIZE}&page=${page}`, { - headers: { Accept: 'application/json' }, - signal: AbortSignal.timeout(6000), + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(15000), }, ); if (!response.ok) { @@ -194,9 +195,12 @@ async function fetchUcdpGedEvents(req: ListUcdpEventsRequest): Promise {}); - fallbackCache = { data: mapped, timestamp: Date.now(), ttlMs: ttl * 1000 }; + if (mapped.length > 0) { + await setCachedJson(CACHE_KEY, mapped, ttl).catch(() => {}); + fallbackCache = { data: mapped, timestamp: Date.now(), ttlMs: ttl * 1000 }; + } return mapped; } catch { diff --git a/server/worldmonitor/cyber/v1/_shared.ts b/server/worldmonitor/cyber/v1/_shared.ts index 29987557..870245c3 100644 --- a/server/worldmonitor/cyber/v1/_shared.ts +++ b/server/worldmonitor/cyber/v1/_shared.ts @@ -24,6 +24,8 @@ import type { CriticalityLevel, } from '../../../../src/generated/server/worldmonitor/cyber/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; + // ======================================================================== // Constants // ======================================================================== @@ -276,6 +278,7 @@ async function fetchGeoIp( // Primary: ipinfo.io try { const resp = await fetch(`https://ipinfo.io/${encodeURIComponent(ip)}/json`, { + headers: { 'User-Agent': CHROME_UA }, signal: signal || AbortSignal.timeout(GEO_PER_IP_TIMEOUT_MS), }); if (resp.ok) { @@ -295,6 +298,7 @@ async function fetchGeoIp( // Fallback: freeipapi.com try { const resp = await fetch(`https://freeipapi.com/api/json/${encodeURIComponent(ip)}`, { + headers: { 'User-Agent': CHROME_UA }, signal: signal || AbortSignal.timeout(GEO_PER_IP_TIMEOUT_MS), }); if (!resp.ok) return null; @@ -434,7 +438,7 @@ function parseFeodoRecord(record: any, cutoffMs: number): RawThreat | null { export async function fetchFeodoSource(limit: number, cutoffMs: number): Promise { try { const response = await fetch(FEODO_URL, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (!response.ok) return { ok: false, threats: [] }; @@ -524,7 +528,7 @@ export async function fetchUrlhausSource(limit: number, cutoffMs: number): Promi try { const response = await fetch(URLHAUS_RECENT_URL(limit), { method: 'GET', - headers: { Accept: 'application/json', 'Auth-Key': authKey }, + headers: { Accept: 'application/json', 'Auth-Key': authKey, 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (!response.ok) return { ok: false, threats: [] }; @@ -591,7 +595,7 @@ function parseC2IntelCsvLine(line: string): RawThreat | null { export async function fetchC2IntelSource(limit: number): Promise { try { const response = await fetch(C2INTEL_URL, { - headers: { Accept: 'text/plain' }, + headers: { Accept: 'text/plain', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (!response.ok) return { ok: false, threats: [] }; @@ -621,7 +625,7 @@ export async function fetchOtxSource(limit: number, days: number): Promise try { const url = `${ABUSEIPDB_BLACKLIST_URL}?confidenceMinimum=90&limit=${Math.min(limit, 500)}`; const response = await fetch(url, { - headers: { Accept: 'application/json', Key: apiKey }, + headers: { Accept: 'application/json', Key: apiKey, 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (!response.ok) return { ok: false, threats: [] }; diff --git a/server/worldmonitor/cyber/v1/list-cyber-threats.ts b/server/worldmonitor/cyber/v1/list-cyber-threats.ts index b155d17b..c02243bb 100644 --- a/server/worldmonitor/cyber/v1/list-cyber-threats.ts +++ b/server/worldmonitor/cyber/v1/list-cyber-threats.ts @@ -6,6 +6,8 @@ import type { ListCyberThreatsResponse, } from '../../../../src/generated/server/worldmonitor/cyber/v1/service_server'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + import { DEFAULT_LIMIT, MAX_LIMIT, @@ -26,12 +28,20 @@ import { toProtoCyberThreat, } from './_shared'; +const REDIS_CACHE_KEY = 'cyber:threats:v1'; +const REDIS_CACHE_TTL = 900; // 15 min — threat feeds update infrequently + export async function listCyberThreats( _ctx: ServerContext, req: ListCyberThreatsRequest, ): Promise { try { const now = Date.now(); + + // Redis shared cache (keyed by page size + time range for most common calls) + const cacheKey = `${REDIS_CACHE_KEY}:${req.pagination?.pageSize || 0}:${req.timeRange?.start || 0}:${req.type || ''}:${req.source || ''}:${req.minSeverity || ''}`; + const cached = (await getCachedJson(cacheKey)) as ListCyberThreatsResponse | null; + if (cached?.threats?.length) return cached; const pageSize = clampInt(req.pagination?.pageSize, DEFAULT_LIMIT, 1, MAX_LIMIT); // Derive days from timeRange or use default @@ -97,10 +107,14 @@ export async function listCyberThreats( }) .slice(0, pageSize); - return { + const result: ListCyberThreatsResponse = { threats: results.map(toProtoCyberThreat), pagination: undefined, }; + if (result.threats.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { threats: [], pagination: undefined }; } diff --git a/server/worldmonitor/displacement/v1/get-displacement-summary.ts b/server/worldmonitor/displacement/v1/get-displacement-summary.ts index ea1ccf51..e625b621 100644 --- a/server/worldmonitor/displacement/v1/get-displacement-summary.ts +++ b/server/worldmonitor/displacement/v1/get-displacement-summary.ts @@ -12,6 +12,12 @@ import type { GeoCoordinates, } from '../../../../src/generated/server/worldmonitor/displacement/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'displacement:summary:v1'; +const REDIS_CACHE_TTL = 43200; // 12 hr — annual UNHCR data, very slow-moving + // ---------- Country centroids (ISO3 -> [lat, lon]) ---------- const COUNTRY_CENTROIDS: Record = { @@ -51,7 +57,7 @@ async function fetchUnhcrYearItems(year: number): Promise for (let page = 1; page <= maxPageGuard; page++) { const response = await fetch( `https://api.unhcr.org/population/v1/population/?year=${year}&limit=${limit}&page=${page}`, - { headers: { Accept: 'application/json' } }, + { headers: { Accept: 'application/json', 'User-Agent': CHROME_UA } }, ); if (!response.ok) return null; @@ -124,6 +130,12 @@ export async function getDisplacementSummary( req: GetDisplacementSummaryRequest, ): Promise { try { + // Redis shared cache (keyed by year) + const year = req.year > 0 ? req.year : new Date().getFullYear(); + const cacheKey = `${REDIS_CACHE_KEY}:${year}:${req.countryLimit || 0}:${req.flowLimit || 0}`; + const cached = (await getCachedJson(cacheKey)) as GetDisplacementSummaryResponse | null; + if (cached?.summary) return cached; + // 1. Determine year with fallback const currentYear = new Date().getFullYear(); const requestYear = req.year > 0 ? req.year : 0; @@ -295,7 +307,7 @@ export async function getDisplacementSummary( })); // 8. Return proto-shaped response - return { + const result: GetDisplacementSummaryResponse = { summary: { year: dataYearUsed, globalTotals: { @@ -309,6 +321,8 @@ export async function getDisplacementSummary( topFlows: protoFlows, }, }; + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + return result; } catch { // Graceful degradation: return empty summary on ANY failure return { diff --git a/server/worldmonitor/economic/v1/_shared.ts b/server/worldmonitor/economic/v1/_shared.ts index 49ad4a6b..6c81bf82 100644 --- a/server/worldmonitor/economic/v1/_shared.ts +++ b/server/worldmonitor/economic/v1/_shared.ts @@ -2,15 +2,18 @@ * Shared helpers for the economic domain RPCs. */ +import { CHROME_UA, yahooGate } from '../../../_shared/constants'; + /** * Fetch JSON from a URL with a configurable timeout. * Rejects on non-2xx status. */ export async function fetchJSON(url: string, timeout = 8000): Promise { + if (url.includes('yahoo.com')) await yahooGate(); const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); try { - const res = await fetch(url, { signal: controller.signal }); + const res = await fetch(url, { headers: { 'User-Agent': CHROME_UA }, signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } finally { diff --git a/server/worldmonitor/economic/v1/get-energy-prices.ts b/server/worldmonitor/economic/v1/get-energy-prices.ts index da5901cb..9070a464 100644 --- a/server/worldmonitor/economic/v1/get-energy-prices.ts +++ b/server/worldmonitor/economic/v1/get-energy-prices.ts @@ -12,6 +12,12 @@ import type { EnergyPrice, } from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'economic:energy:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — weekly EIA data + interface EiaSeriesConfig { commodity: string; name: string; @@ -53,7 +59,7 @@ async function fetchEiaSeries( }); const response = await fetch(`https://api.eia.gov${config.apiPath}?${params}`, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10000), }); @@ -104,8 +110,16 @@ export async function getEnergyPrices( req: GetEnergyPricesRequest, ): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${[...req.commodities].sort().join(',') || 'all'}`; + const cached = (await getCachedJson(cacheKey)) as GetEnergyPricesResponse | null; + if (cached?.prices?.length) return cached; + const prices = await fetchEnergyPrices(req.commodities); - return { prices }; + const result: GetEnergyPricesResponse = { prices }; + if (prices.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { prices: [] }; } diff --git a/server/worldmonitor/economic/v1/get-fred-series.ts b/server/worldmonitor/economic/v1/get-fred-series.ts index 1dab2c10..76c0823a 100644 --- a/server/worldmonitor/economic/v1/get-fred-series.ts +++ b/server/worldmonitor/economic/v1/get-fred-series.ts @@ -13,7 +13,11 @@ import type { FredObservation, } from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + const FRED_API_BASE = 'https://api.stlouisfed.org/fred'; +const REDIS_CACHE_KEY = 'economic:fred:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — FRED data updates infrequently async function fetchFredSeries(req: GetFredSeriesRequest): Promise { try { @@ -92,8 +96,16 @@ export async function getFredSeries( req: GetFredSeriesRequest, ): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.seriesId}:${req.limit || 0}`; + const cached = (await getCachedJson(cacheKey)) as GetFredSeriesResponse | null; + if (cached?.series) return cached; + const series = await fetchFredSeries(req); - return { series }; + const result: GetFredSeriesResponse = { series }; + if (series) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { series: undefined }; } diff --git a/server/worldmonitor/economic/v1/get-macro-signals.ts b/server/worldmonitor/economic/v1/get-macro-signals.ts index bf633501..2eeb52da 100644 --- a/server/worldmonitor/economic/v1/get-macro-signals.ts +++ b/server/worldmonitor/economic/v1/get-macro-signals.ts @@ -19,6 +19,10 @@ import { extractClosePrices, extractAlignedPriceVolume, } from './_shared'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'economic:macro-signals:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — matches in-memory TTL const MACRO_CACHE_TTL = 300; // 5 minutes in seconds let macroSignalsCached: GetMacroSignalsResponse | null = null; @@ -46,11 +50,14 @@ function buildFallbackResult(): GetMacroSignalsResponse { async function computeMacroSignals(): Promise { const yahooBase = 'https://query1.finance.yahoo.com/v8/finance/chart'; - const [jpyChart, btcChart, qqqChart, xlpChart, fearGreed, mempoolHash] = await Promise.allSettled([ - fetchJSON(`${yahooBase}/JPY=X?range=1y&interval=1d`), - fetchJSON(`${yahooBase}/BTC-USD?range=1y&interval=1d`), - fetchJSON(`${yahooBase}/QQQ?range=1y&interval=1d`), - fetchJSON(`${yahooBase}/XLP?range=1y&interval=1d`), + + // Yahoo calls go through global yahooGate() in fetchJSON + const jpyChart = await Promise.allSettled([fetchJSON(`${yahooBase}/JPY=X?range=1y&interval=1d`)]).then(r => r[0]!); + const btcChart = await Promise.allSettled([fetchJSON(`${yahooBase}/BTC-USD?range=1y&interval=1d`)]).then(r => r[0]!); + const qqqChart = await Promise.allSettled([fetchJSON(`${yahooBase}/QQQ?range=1y&interval=1d`)]).then(r => r[0]!); + const xlpChart = await Promise.allSettled([fetchJSON(`${yahooBase}/XLP?range=1y&interval=1d`)]).then(r => r[0]!); + // Non-Yahoo calls can go in parallel + const [fearGreed, mempoolHash] = await Promise.allSettled([ fetchJSON('https://api.alternative.me/fng/?limit=30&format=json'), fetchJSON('https://mempool.space/api/v1/mining/hashrate/1m'), ]); @@ -178,6 +185,11 @@ async function computeMacroSignals(): Promise { const verdict = totalCount === 0 ? 'UNKNOWN' : (bullishCount / totalCount >= 0.57 ? 'BUY' : 'CASH'); + // Stale-while-revalidate: if Yahoo rate-limited all calls, serve cached data + if (totalCount === 0 && macroSignalsCached && !macroSignalsCached.unavailable) { + return macroSignalsCached; + } + return { timestamp: new Date().toISOString(), verdict, @@ -233,10 +245,21 @@ export async function getMacroSignals( return macroSignalsCached; } + // Redis shared cache (cross-instance) + const redisCached = (await getCachedJson(REDIS_CACHE_KEY)) as GetMacroSignalsResponse | null; + if (redisCached && !redisCached.unavailable && redisCached.totalCount > 0) { + macroSignalsCached = redisCached; + macroSignalsCacheTimestamp = now; + return redisCached; + } + try { const result = await computeMacroSignals(); macroSignalsCached = result; macroSignalsCacheTimestamp = now; + if (!result.unavailable) { + setCachedJson(REDIS_CACHE_KEY, result, REDIS_CACHE_TTL).catch(() => {}); + } return result; } catch { const fallback = macroSignalsCached || buildFallbackResult(); diff --git a/server/worldmonitor/economic/v1/list-world-bank-indicators.ts b/server/worldmonitor/economic/v1/list-world-bank-indicators.ts index 787d2b11..6b886625 100644 --- a/server/worldmonitor/economic/v1/list-world-bank-indicators.ts +++ b/server/worldmonitor/economic/v1/list-world-bank-indicators.ts @@ -10,6 +10,12 @@ import type { WorldBankCountryData, } from '../../../../src/generated/server/worldmonitor/economic/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'economic:worldbank:v1'; +const REDIS_CACHE_TTL = 86400; // 24 hr — annual data + const TECH_COUNTRIES = [ 'USA', 'CHN', 'JPN', 'DEU', 'KOR', 'GBR', 'IND', 'ISR', 'SGP', 'TWN', 'FRA', 'CAN', 'SWE', 'NLD', 'CHE', 'FIN', 'IRL', 'AUS', 'BRA', 'IDN', @@ -37,7 +43,7 @@ async function fetchWorldBankIndicators( const response = await fetch(wbUrl, { headers: { Accept: 'application/json', - 'User-Agent': 'Mozilla/5.0 (compatible; WorldMonitor/1.0; +https://worldmonitor.app)', + 'User-Agent': CHROME_UA, }, signal: AbortSignal.timeout(15000), }); @@ -70,8 +76,16 @@ export async function listWorldBankIndicators( req: ListWorldBankIndicatorsRequest, ): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.indicatorCode}:${req.countryCode || 'all'}:${req.year || 0}`; + const cached = (await getCachedJson(cacheKey)) as ListWorldBankIndicatorsResponse | null; + if (cached?.data?.length) return cached; + const data = await fetchWorldBankIndicators(req); - return { data, pagination: undefined }; + const result: ListWorldBankIndicatorsResponse = { data, pagination: undefined }; + if (data.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { data: [], pagination: undefined }; } diff --git a/server/worldmonitor/infrastructure/v1/get-cable-health.ts b/server/worldmonitor/infrastructure/v1/get-cable-health.ts index 94cebcbd..e0b1143d 100644 --- a/server/worldmonitor/infrastructure/v1/get-cable-health.ts +++ b/server/worldmonitor/infrastructure/v1/get-cable-health.ts @@ -9,6 +9,7 @@ import type { import { getCachedJson, setCachedJson } from '../../../_shared/redis'; import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; // ======================================================================== // Constants @@ -121,7 +122,7 @@ async function fetchNgaWarnings(): Promise { try { const res = await fetch( 'https://msi.nga.mil/api/publications/broadcast-warn?output=json&status=A', - { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }, + { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }, ); if (!res.ok) return []; const data = await res.json(); diff --git a/server/worldmonitor/infrastructure/v1/list-internet-outages.ts b/server/worldmonitor/infrastructure/v1/list-internet-outages.ts index 0f8680d7..0f4ffc68 100644 --- a/server/worldmonitor/infrastructure/v1/list-internet-outages.ts +++ b/server/worldmonitor/infrastructure/v1/list-internet-outages.ts @@ -9,6 +9,11 @@ import type { } from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server'; import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'infra:outages:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — Cloudflare Radar rate-limited // ======================================================================== // Constants @@ -113,71 +118,81 @@ export async function listInternetOutages( req: ListInternetOutagesRequest, ): Promise { try { - const token = process.env.CLOUDFLARE_API_TOKEN; - if (!token) { - return { outages: [], pagination: undefined }; - } - - const response = await fetch( - `${CLOUDFLARE_RADAR_URL}?dateRange=7d&limit=50`, - { - headers: { Authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }, - ); - if (!response.ok) { - return { outages: [], pagination: undefined }; - } - - const data: CloudflareResponse = await response.json(); - if (data.configured === false || !data.success || data.errors?.length) { - return { outages: [], pagination: undefined }; - } - - const outages: InternetOutage[] = []; - - for (const raw of data.result?.annotations || []) { - if (!raw.locations?.length) continue; - const countryCode = raw.locations[0]; - if (!countryCode) continue; + // Redis shared cache (stores UNFILTERED outages — filters applied after) + const cached = (await getCachedJson(REDIS_CACHE_KEY)) as ListInternetOutagesResponse | null; + let outages: InternetOutage[]; + + if (cached?.outages?.length) { + outages = cached.outages; + } else { + const token = process.env.CLOUDFLARE_API_TOKEN; + if (!token) { + return { outages: [], pagination: undefined }; + } - const coords = COUNTRY_COORDS[countryCode]; - if (!coords) continue; + const response = await fetch( + `${CLOUDFLARE_RADAR_URL}?dateRange=7d&limit=50`, + { + headers: { Authorization: `Bearer ${token}`, 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }, + ); + if (!response.ok) { + return { outages: [], pagination: undefined }; + } - const countryName = raw.locationsDetails?.[0]?.name ?? countryCode; + const data: CloudflareResponse = await response.json(); + if (data.configured === false || !data.success || data.errors?.length) { + return { outages: [], pagination: undefined }; + } - const categories: string[] = ['Cloudflare Radar']; - if (raw.outage?.outageCause) categories.push(raw.outage.outageCause.replace(/_/g, ' ')); - if (raw.outage?.outageType) categories.push(raw.outage.outageType); - for (const asn of raw.asnsDetails?.slice(0, 2) || []) { - if (asn.name) categories.push(asn.name); + outages = []; + + for (const raw of data.result?.annotations || []) { + if (!raw.locations?.length) continue; + const countryCode = raw.locations[0]; + if (!countryCode) continue; + + const coords = COUNTRY_COORDS[countryCode]; + if (!coords) continue; + + const countryName = raw.locationsDetails?.[0]?.name ?? countryCode; + + const categories: string[] = ['Cloudflare Radar']; + if (raw.outage?.outageCause) categories.push(raw.outage.outageCause.replace(/_/g, ' ')); + if (raw.outage?.outageType) categories.push(raw.outage.outageType); + for (const asn of raw.asnsDetails?.slice(0, 2) || []) { + if (asn.name) categories.push(asn.name); + } + + outages.push({ + id: `cf-${raw.id}`, + title: raw.scope ? `${raw.scope} outage in ${countryName}` : `Internet disruption in ${countryName}`, + link: raw.linkedUrl || 'https://radar.cloudflare.com/outage-center', + description: raw.description, + detectedAt: toEpochMs(raw.startDate), + country: countryName, + region: '', + location: { latitude: coords[0], longitude: coords[1] }, + severity: mapOutageSeverity(raw.outage?.outageType), + categories, + cause: raw.outage?.outageCause || '', + outageType: raw.outage?.outageType || '', + endedAt: toEpochMs(raw.endDate), + }); } - outages.push({ - id: `cf-${raw.id}`, - title: raw.scope ? `${raw.scope} outage in ${countryName}` : `Internet disruption in ${countryName}`, - link: raw.linkedUrl || 'https://radar.cloudflare.com/outage-center', - description: raw.description, - detectedAt: toEpochMs(raw.startDate), - country: countryName, - region: '', - location: { latitude: coords[0], longitude: coords[1] }, - severity: mapOutageSeverity(raw.outage?.outageType), - categories, - cause: raw.outage?.outageCause || '', - outageType: raw.outage?.outageType || '', - endedAt: toEpochMs(raw.endDate), - }); + if (outages.length > 0) { + setCachedJson(REDIS_CACHE_KEY, { outages, pagination: undefined }, REDIS_CACHE_TTL).catch(() => {}); + } } - // Apply optional country filter + // Always apply filters (to both cached and fresh data) let filtered = outages; if (req.country) { const target = req.country.toLowerCase(); filtered = outages.filter((o) => o.country.toLowerCase().includes(target)); } - - // Apply optional time range filter if (req.timeRange?.start) { filtered = filtered.filter((o) => o.detectedAt >= req.timeRange!.start); } diff --git a/server/worldmonitor/infrastructure/v1/list-service-statuses.ts b/server/worldmonitor/infrastructure/v1/list-service-statuses.ts index 655fe1d1..3b27f1a6 100644 --- a/server/worldmonitor/infrastructure/v1/list-service-statuses.ts +++ b/server/worldmonitor/infrastructure/v1/list-service-statuses.ts @@ -8,6 +8,7 @@ import type { import { UPSTREAM_TIMEOUT_MS } from './_shared'; import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; // ======================================================================== // Service status page definitions and parsers @@ -110,7 +111,7 @@ async function checkServiceStatus(service: ServiceDef): Promise { 'Cache-Control': 'no-cache', }; if (service.customParser !== 'incidentio') { - headers['User-Agent'] = 'Mozilla/5.0 (compatible; WorldMonitor/1.0)'; + headers['User-Agent'] = CHROME_UA; } const start = Date.now(); diff --git a/server/worldmonitor/intelligence/v1/_shared.ts b/server/worldmonitor/intelligence/v1/_shared.ts index 0754571b..515eb234 100644 --- a/server/worldmonitor/intelligence/v1/_shared.ts +++ b/server/worldmonitor/intelligence/v1/_shared.ts @@ -6,7 +6,7 @@ // Constants // ======================================================================== -export const UPSTREAM_TIMEOUT_MS = 15_000; +export const UPSTREAM_TIMEOUT_MS = 30_000; export const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; export const GROQ_MODEL = 'llama-3.1-8b-instant'; diff --git a/server/worldmonitor/intelligence/v1/classify-event.ts b/server/worldmonitor/intelligence/v1/classify-event.ts index 52511238..63a01235 100644 --- a/server/worldmonitor/intelligence/v1/classify-event.ts +++ b/server/worldmonitor/intelligence/v1/classify-event.ts @@ -9,6 +9,7 @@ import type { import { getCachedJson, setCachedJson } from '../../../_shared/redis'; import { UPSTREAM_TIMEOUT_MS, GROQ_API_URL, GROQ_MODEL, hashString } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; // ======================================================================== // Constants @@ -75,7 +76,7 @@ Return: {"level":"...","category":"..."}`; const resp = await fetch(GROQ_API_URL, { method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': CHROME_UA }, body: JSON.stringify({ model: GROQ_MODEL, messages: [ diff --git a/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts b/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts index 56672304..0a956f9a 100644 --- a/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts +++ b/server/worldmonitor/intelligence/v1/get-country-intel-brief.ts @@ -8,6 +8,7 @@ import type { import { getCachedJson, setCachedJson } from '../../../_shared/redis'; import { UPSTREAM_TIMEOUT_MS, GROQ_API_URL, GROQ_MODEL, TIER1_COUNTRIES } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; // ======================================================================== // Constants @@ -59,7 +60,7 @@ Rules: try { const resp = await fetch(GROQ_API_URL, { method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'User-Agent': CHROME_UA }, body: JSON.stringify({ model: GROQ_MODEL, messages: [ diff --git a/server/worldmonitor/intelligence/v1/get-pizzint-status.ts b/server/worldmonitor/intelligence/v1/get-pizzint-status.ts index 9be5f823..b9e03ee5 100644 --- a/server/worldmonitor/intelligence/v1/get-pizzint-status.ts +++ b/server/worldmonitor/intelligence/v1/get-pizzint-status.ts @@ -10,6 +10,11 @@ import type { } from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'intel:pizzint:v1'; +const REDIS_CACHE_TTL = 600; // 10 min // ======================================================================== // Constants @@ -27,33 +32,38 @@ export async function getPizzintStatus( _ctx: ServerContext, req: GetPizzintStatusRequest, ): Promise { - // Fetch PizzINT dashboard data + // Redis shared cache + const cacheKey = `${REDIS_CACHE_KEY}:${req.includeGdelt ? 'gdelt' : 'base'}`; + const cached = (await getCachedJson(cacheKey)) as GetPizzintStatusResponse | null; + if (cached?.pizzint) return cached; + let pizzint: PizzintStatus | undefined; try { const resp = await fetch(PIZZINT_API, { - headers: { Accept: 'application/json', 'User-Agent': 'WorldMonitor/1.0' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); - if (resp.ok) { - const raw = (await resp.json()) as { - success?: boolean; - data?: Array<{ - place_id: string; - name: string; - address: string; - current_popularity: number; - percentage_of_usual: number | null; - is_spike: boolean; - spike_magnitude: number | null; - data_source: string; - recorded_at: string; - data_freshness: string; - is_closed_now?: boolean; - lat?: number; - lng?: number; - }>; - }; - if (raw.success && raw.data) { + if (!resp.ok) throw new Error(`PizzINT API returned ${resp.status}`); + + const raw = (await resp.json()) as { + success?: boolean; + data?: Array<{ + place_id: string; + name: string; + address: string; + current_popularity: number; + percentage_of_usual: number | null; + is_spike: boolean; + spike_magnitude: number | null; + data_source: string; + recorded_at: string; + data_freshness: string; + is_closed_now?: boolean; + lat?: number; + lng?: number; + }>; + }; + if (raw.success && raw.data) { const locations: PizzintLocation[] = raw.data.map((d) => ({ placeId: d.place_id, name: d.name, @@ -101,8 +111,7 @@ export async function getPizzintStatus( locations, }; } - } - } catch { /* pizzint unavailable */ } + } catch (_) { /* PizzINT unavailable — continue to GDELT */ } // Fetch GDELT tension pairs let tensionPairs: GdeltTensionPair[] = []; @@ -110,7 +119,7 @@ export async function getPizzintStatus( try { const url = `${GDELT_BATCH_API}?pairs=${encodeURIComponent(DEFAULT_GDELT_PAIRS)}&method=gpr`; const resp = await fetch(url, { - headers: { Accept: 'application/json', 'User-Agent': 'WorldMonitor/1.0' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (resp.ok) { @@ -140,5 +149,9 @@ export async function getPizzintStatus( } catch { /* gdelt unavailable */ } } - return { pizzint, tensionPairs }; + const result: GetPizzintStatusResponse = { pizzint, tensionPairs }; + if (pizzint) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } diff --git a/server/worldmonitor/intelligence/v1/get-risk-scores.ts b/server/worldmonitor/intelligence/v1/get-risk-scores.ts index 9304bf92..066d6cee 100644 --- a/server/worldmonitor/intelligence/v1/get-risk-scores.ts +++ b/server/worldmonitor/intelligence/v1/get-risk-scores.ts @@ -12,6 +12,7 @@ import type { import { getCachedJson, setCachedJson } from '../../../_shared/redis'; import { UPSTREAM_TIMEOUT_MS, TIER1_COUNTRIES } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; // ======================================================================== // Country risk baselines and multipliers @@ -73,7 +74,7 @@ async function fetchACLEDProtests(): Promise { const token = process.env.ACLED_ACCESS_TOKEN; const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - const headers: Record = { Accept: 'application/json' }; + const headers: Record = { Accept: 'application/json', 'User-Agent': CHROME_UA }; if (token) headers['Authorization'] = `Bearer ${token}`; const resp = await fetch( diff --git a/server/worldmonitor/intelligence/v1/search-gdelt-documents.ts b/server/worldmonitor/intelligence/v1/search-gdelt-documents.ts index 9e93fa5d..77fc2fdf 100644 --- a/server/worldmonitor/intelligence/v1/search-gdelt-documents.ts +++ b/server/worldmonitor/intelligence/v1/search-gdelt-documents.ts @@ -6,6 +6,11 @@ import type { } from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'intel:gdelt-docs:v1'; +const REDIS_CACHE_TTL = 600; // 10 min // ======================================================================== // Constants @@ -35,6 +40,10 @@ export async function searchGdeltDocuments( const timespan = req.timespan || '72h'; try { + const cacheKey = `${REDIS_CACHE_KEY}:${query}:${timespan}:${maxRecords}`; + const cached = (await getCachedJson(cacheKey)) as SearchGdeltDocumentsResponse | null; + if (cached?.articles?.length) return cached; + const gdeltUrl = new URL(GDELT_DOC_API); gdeltUrl.searchParams.set('query', query); gdeltUrl.searchParams.set('mode', 'artlist'); @@ -44,6 +53,7 @@ export async function searchGdeltDocuments( gdeltUrl.searchParams.set('timespan', timespan); const response = await fetch(gdeltUrl.toString(), { + headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); @@ -74,7 +84,11 @@ export async function searchGdeltDocuments( tone: typeof article.tone === 'number' ? article.tone : 0, })); - return { articles, query, error: '' }; + const result: SearchGdeltDocumentsResponse = { articles, query, error: '' }; + if (articles.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch (error) { return { articles: [], diff --git a/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts index 1ab33c21..9387c4e7 100644 --- a/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts +++ b/server/worldmonitor/maritime/v1/get-vessel-snapshot.ts @@ -11,6 +11,8 @@ import type { AisDisruptionSeverity, } from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; + // ======================================================================== // Helpers // ======================================================================== @@ -74,7 +76,7 @@ async function fetchVesselSnapshotFromRelay(): Promise { try { const response = await fetch(NGA_WARNINGS_URL, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(15000), }); @@ -75,8 +81,16 @@ export async function listNavigationalWarnings( req: ListNavigationalWarningsRequest, ): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.area || 'all'}`; + const cached = (await getCachedJson(cacheKey)) as ListNavigationalWarningsResponse | null; + if (cached?.warnings?.length) return cached; + const warnings = await fetchNgaWarnings(req.area); - return { warnings, pagination: undefined }; + const result: ListNavigationalWarningsResponse = { warnings, pagination: undefined }; + if (warnings.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { warnings: [], pagination: undefined }; } diff --git a/server/worldmonitor/market/v1/_shared.ts b/server/worldmonitor/market/v1/_shared.ts index 89099c5d..6ec65419 100644 --- a/server/worldmonitor/market/v1/_shared.ts +++ b/server/worldmonitor/market/v1/_shared.ts @@ -93,10 +93,33 @@ export async function fetchFinnhubQuote( // ======================================================================== // Yahoo Finance quote fetcher // ======================================================================== -// TODO: Yahoo v8 chart API aggressively rate-limits (429) at the IP level. -// When user has a WorldMonitor API key, fall back to cloud relay through -// worldmonitor.app instead of direct Yahoo calls. Also evaluate alternative -// free finance APIs (Alpha Vantage, Twelve Data, or Financial Modeling Prep). +// TODO: Add Financial Modeling Prep (FMP) as Yahoo Finance fallback. +// +// FMP API docs: https://site.financialmodelingprep.com/developer/docs +// Auth: API key required — env var FMP_API_KEY +// Free tier: 250 requests/day (paid tiers for higher volume) +// +// Endpoint mapping (Yahoo → FMP): +// Quote: /stable/quote?symbol=AAPL (batch: comma-separated) +// Indices: /stable/quote?symbol=^GSPC (^GSPC, ^DJI, ^IXIC supported) +// Commodities:/stable/quote?symbol=GCUSD (gold=GCUSD, oil=CLUSD, etc.) +// Forex: /stable/batch-forex-quotes (JPY/USD pairs) +// Crypto: /stable/batch-crypto-quotes (BTC, ETH, etc.) +// Sparkline: /stable/historical-price-eod/light?symbol=AAPL (daily close) +// Intraday: /stable/historical-chart/1min?symbol=AAPL +// +// Symbol mapping needed: +// ^GSPC → ^GSPC (same), ^VIX → ^VIX (same) +// GC=F → GCUSD, CL=F → CLUSD, NG=F → NGUSD, SI=F → SIUSD, HG=F → HGUSD +// JPY=X → JPYUSD (forex pair format differs) +// BTC-USD → BTCUSD +// +// Implementation plan: +// 1. Add FMP_API_KEY to SUPPORTED_SECRET_KEYS in main.rs + settings UI +// 2. Create fetchFMPQuote() here returning same shape as fetchYahooQuote() +// 3. fetchYahooQuote() tries Yahoo first → on 429/failure, tries FMP if key exists +// 4. economic/_shared.ts fetchJSON() same fallback for Yahoo chart URLs +// 5. get-macro-signals.ts needs chart data (1y range) — use /stable/historical-price-eod/light // ======================================================================== export async function fetchYahooQuote( diff --git a/server/worldmonitor/market/v1/get-country-stock-index.ts b/server/worldmonitor/market/v1/get-country-stock-index.ts index 058d7719..95a1b5e8 100644 --- a/server/worldmonitor/market/v1/get-country-stock-index.ts +++ b/server/worldmonitor/market/v1/get-country-stock-index.ts @@ -9,6 +9,8 @@ import type { GetCountryStockIndexResponse, } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; import { UPSTREAM_TIMEOUT_MS, type YahooChartResponse } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; // ======================================================================== // Country-to-index mapping @@ -66,8 +68,11 @@ const COUNTRY_INDEX: Record = { // Cache // ======================================================================== +const REDIS_CACHE_KEY = 'market:stock-index:v1'; +const REDIS_CACHE_TTL = 1800; // 30 min — weekly data, slow-moving + let stockIndexCache: Record = {}; -const STOCK_INDEX_CACHE_TTL = 3_600_000; // 1 hour +const STOCK_INDEX_CACHE_TTL = 3_600_000; // 1 hour (in-memory fallback) // ======================================================================== // Handler @@ -90,12 +95,20 @@ export async function getCountryStockIndex( const cached = stockIndexCache[code]; if (cached && Date.now() - cached.ts < STOCK_INDEX_CACHE_TTL) return cached.data; + // Layer 2: Redis shared cache (cross-instance) + const redisKey = `${REDIS_CACHE_KEY}:${code}`; + const redisCached = (await getCachedJson(redisKey)) as GetCountryStockIndexResponse | null; + if (redisCached?.available) { + stockIndexCache[code] = { data: redisCached, ts: Date.now() }; + return redisCached; + } + try { const encodedSymbol = encodeURIComponent(index.symbol); const yahooUrl = `https://query1.finance.yahoo.com/v8/finance/chart/${encodedSymbol}?range=1mo&interval=1d`; const res = await fetch(yahooUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' }, + headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); @@ -126,6 +139,7 @@ export async function getCountryStockIndex( }; stockIndexCache[code] = { data: payload, ts: Date.now() }; + setCachedJson(redisKey, payload, REDIS_CACHE_TTL).catch(() => {}); return payload; } catch { return notAvailable; diff --git a/server/worldmonitor/market/v1/get-sector-summary.ts b/server/worldmonitor/market/v1/get-sector-summary.ts index e6a4a97b..51b62eb5 100644 --- a/server/worldmonitor/market/v1/get-sector-summary.ts +++ b/server/worldmonitor/market/v1/get-sector-summary.ts @@ -12,12 +12,20 @@ import type { SectorPerformance, } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; import { fetchFinnhubQuote } from './_shared'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:sectors:v1'; +const REDIS_CACHE_TTL = 180; // 3 min — Finnhub rate-limited export async function getSectorSummary( _ctx: ServerContext, _req: GetSectorSummaryRequest, ): Promise { try { + // Redis shared cache (cross-instance) + const cached = (await getCachedJson(REDIS_CACHE_KEY)) as GetSectorSummaryResponse | null; + if (cached?.sectors?.length) return cached; + const apiKey = process.env.FINNHUB_API_KEY; if (!apiKey) return { sectors: [] }; @@ -38,7 +46,11 @@ export async function getSectorSummary( } } - return { sectors }; + const result: GetSectorSummaryResponse = { sectors }; + if (sectors.length > 0) { + setCachedJson(REDIS_CACHE_KEY, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { sectors: [] }; } diff --git a/server/worldmonitor/market/v1/list-commodity-quotes.ts b/server/worldmonitor/market/v1/list-commodity-quotes.ts index 0ae89965..3aab7f6a 100644 --- a/server/worldmonitor/market/v1/list-commodity-quotes.ts +++ b/server/worldmonitor/market/v1/list-commodity-quotes.ts @@ -10,6 +10,14 @@ import type { CommodityQuote, } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; import { fetchYahooQuote } from './_shared'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:commodities:v1'; +const REDIS_CACHE_TTL = 180; // 3 min — commodities move slower than indices + +function redisCacheKey(symbols: string[]): string { + return `${REDIS_CACHE_KEY}:${[...symbols].sort().join(',')}`; +} export async function listCommodityQuotes( _ctx: ServerContext, @@ -19,6 +27,11 @@ export async function listCommodityQuotes( const symbols = req.symbols; if (!symbols.length) return { quotes: [] }; + // Redis shared cache + const redisKey = redisCacheKey(symbols); + const cached = (await getCachedJson(redisKey)) as ListCommodityQuotesResponse | null; + if (cached?.quotes?.length) return cached; + const results = await Promise.all( symbols.map(async (s) => { const yahoo = await fetchYahooQuote(s); @@ -34,7 +47,11 @@ export async function listCommodityQuotes( }), ); - return { quotes: results.filter((r): r is CommodityQuote => r !== null) }; + const response: ListCommodityQuotesResponse = { quotes: results.filter((r): r is CommodityQuote => r !== null) }; + if (response.quotes.length > 0) { + setCachedJson(redisKey, response, REDIS_CACHE_TTL).catch(() => {}); + } + return response; } catch { return { quotes: [] }; } diff --git a/server/worldmonitor/market/v1/list-crypto-quotes.ts b/server/worldmonitor/market/v1/list-crypto-quotes.ts index 71a34016..0fb7f3bd 100644 --- a/server/worldmonitor/market/v1/list-crypto-quotes.ts +++ b/server/worldmonitor/market/v1/list-crypto-quotes.ts @@ -10,35 +10,52 @@ import type { CryptoQuote, } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; import { CRYPTO_META, fetchCoinGeckoMarkets } from './_shared'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:crypto:v1'; +const REDIS_CACHE_TTL = 180; // 3 min — CoinGecko rate-limited export async function listCryptoQuotes( _ctx: ServerContext, req: ListCryptoQuotesRequest, ): Promise { - try { - const ids = req.ids.length > 0 ? req.ids : Object.keys(CRYPTO_META); - const items = await fetchCoinGeckoMarkets(ids); - - const byId = new Map(items.map((c) => [c.id, c])); - const quotes: CryptoQuote[] = []; - - for (const id of ids) { - const coin = byId.get(id); - const meta = CRYPTO_META[id]; - const prices = coin?.sparkline_in_7d?.price; - const sparkline = prices && prices.length > 24 ? prices.slice(-48) : (prices || []); - - quotes.push({ - name: meta?.name || id, - symbol: meta?.symbol || id.toUpperCase(), - price: coin?.current_price ?? 0, - change: coin?.price_change_percentage_24h ?? 0, - sparkline, - }); - } - - return { quotes }; - } catch { - return { quotes: [] }; + const ids = req.ids.length > 0 ? req.ids : Object.keys(CRYPTO_META); + + // Redis shared cache + const cacheKey = `${REDIS_CACHE_KEY}:${[...ids].sort().join(',')}`; + const cached = (await getCachedJson(cacheKey)) as ListCryptoQuotesResponse | null; + if (cached?.quotes?.length) return cached; + + const items = await fetchCoinGeckoMarkets(ids); + + if (items.length === 0) { + throw new Error('CoinGecko returned no data'); } + + const byId = new Map(items.map((c) => [c.id, c])); + const quotes: CryptoQuote[] = []; + + for (const id of ids) { + const coin = byId.get(id); + if (!coin) continue; + const meta = CRYPTO_META[id]; + const prices = coin.sparkline_in_7d?.price; + const sparkline = prices && prices.length > 24 ? prices.slice(-48) : (prices || []); + + quotes.push({ + name: meta?.name || id, + symbol: meta?.symbol || id.toUpperCase(), + price: coin.current_price ?? 0, + change: coin.price_change_percentage_24h ?? 0, + sparkline, + }); + } + + if (quotes.every(q => q.price === 0)) { + throw new Error('CoinGecko returned all-zero prices'); + } + + const result: ListCryptoQuotesResponse = { quotes }; + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + return result; } diff --git a/server/worldmonitor/market/v1/list-etf-flows.ts b/server/worldmonitor/market/v1/list-etf-flows.ts index 394e09e9..799a63a1 100644 --- a/server/worldmonitor/market/v1/list-etf-flows.ts +++ b/server/worldmonitor/market/v1/list-etf-flows.ts @@ -10,11 +10,16 @@ import type { EtfFlow, } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; import { UPSTREAM_TIMEOUT_MS, type YahooChartResponse } from './_shared'; +import { CHROME_UA, yahooGate } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; // ======================================================================== // Constants and cache // ======================================================================== +const REDIS_CACHE_KEY = 'market:etf-flows:v1'; +const REDIS_CACHE_TTL = 600; // 10 min — daily volume data, slow-moving + const ETF_LIST = [ { ticker: 'IBIT', issuer: 'BlackRock' }, { ticker: 'FBTC', issuer: 'Fidelity' }, @@ -30,7 +35,7 @@ const ETF_LIST = [ let etfCache: ListEtfFlowsResponse | null = null; let etfCacheTimestamp = 0; -const ETF_CACHE_TTL = 900_000; // 15 minutes +const ETF_CACHE_TTL = 900_000; // 15 minutes (in-memory fallback) // ======================================================================== // Helpers @@ -38,10 +43,11 @@ const ETF_CACHE_TTL = 900_000; // 15 minutes async function fetchEtfChart(ticker: string): Promise { try { + await yahooGate(); const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?range=5d&interval=1d`; const resp = await fetch(url, { headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'User-Agent': CHROME_UA, }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); @@ -108,10 +114,19 @@ export async function listEtfFlows( return etfCache; } + // Redis shared cache (cross-instance) + const redisCached = (await getCachedJson(REDIS_CACHE_KEY)) as ListEtfFlowsResponse | null; + if (redisCached?.etfs?.length) { + etfCache = redisCached; + etfCacheTimestamp = now; + return redisCached; + } + try { - const charts = await Promise.allSettled( - ETF_LIST.map(etf => fetchEtfChart(etf.ticker)), - ); + const charts: PromiseSettledResult[] = []; + for (const etf of ETF_LIST) { + charts.push(await Promise.allSettled([fetchEtfChart(etf.ticker)]).then(r => r[0]!)); + } const etfs: EtfFlow[] = []; for (let i = 0; i < ETF_LIST.length; i++) { @@ -130,6 +145,11 @@ export async function listEtfFlows( etfs.sort((a, b) => b.volume - a.volume); + // Stale-while-revalidate: if Yahoo rate-limited all calls, serve cached data + if (etfs.length === 0 && etfCache) { + return etfCache; + } + const result: ListEtfFlowsResponse = { timestamp: new Date().toISOString(), summary: { @@ -143,8 +163,11 @@ export async function listEtfFlows( etfs, }; - etfCache = result; - etfCacheTimestamp = now; + if (etfs.length > 0) { + etfCache = result; + etfCacheTimestamp = now; + setCachedJson(REDIS_CACHE_KEY, result, REDIS_CACHE_TTL).catch(() => {}); + } return result; } catch { if (etfCache) return etfCache; diff --git a/server/worldmonitor/market/v1/list-market-quotes.ts b/server/worldmonitor/market/v1/list-market-quotes.ts index ec6ee14e..c17a68ce 100644 --- a/server/worldmonitor/market/v1/list-market-quotes.ts +++ b/server/worldmonitor/market/v1/list-market-quotes.ts @@ -12,23 +12,41 @@ import type { MarketQuote, } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; import { YAHOO_ONLY_SYMBOLS, fetchFinnhubQuote, fetchYahooQuotesBatch } from './_shared'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:quotes:v1'; +const REDIS_CACHE_TTL = 120; // 2 min — shared across all Vercel instances const quotesCache = new Map(); -const QUOTES_CACHE_TTL = 120_000; // 2 minutes +const QUOTES_CACHE_TTL = 120_000; // 2 minutes (in-memory fallback) function cacheKey(symbols: string[]): string { return [...symbols].sort().join(','); } +function redisCacheKey(symbols: string[]): string { + return `${REDIS_CACHE_KEY}:${[...symbols].sort().join(',')}`; +} + export async function listMarketQuotes( _ctx: ServerContext, req: ListMarketQuotesRequest, ): Promise { const now = Date.now(); const key = cacheKey(req.symbols); - const cached = quotesCache.get(key); - if (cached && now - cached.timestamp < QUOTES_CACHE_TTL) { - return cached.data; + + // Layer 1: in-memory cache (same instance) + const memCached = quotesCache.get(key); + if (memCached && now - memCached.timestamp < QUOTES_CACHE_TTL) { + return memCached.data; + } + + // Layer 2: Redis shared cache (cross-instance) + const redisKey = redisCacheKey(req.symbols); + const redisCached = (await getCachedJson(redisKey)) as ListMarketQuotesResponse | null; + if (redisCached?.quotes?.length) { + quotesCache.set(key, { data: redisCached, timestamp: now }); + return redisCached; } try { @@ -79,17 +97,18 @@ export async function listMarketQuotes( } // Stale-while-revalidate: if Yahoo rate-limited and no fresh data, serve cached - if (quotes.length === 0 && cached) { - return cached.data; + if (quotes.length === 0 && memCached) { + return memCached.data; } const result: ListMarketQuotesResponse = { quotes, finnhubSkipped: !apiKey, skipReason: !apiKey ? 'FINNHUB_API_KEY not configured' : '' }; if (quotes.length > 0) { quotesCache.set(key, { data: result, timestamp: now }); + setCachedJson(redisKey, result, REDIS_CACHE_TTL).catch(() => {}); } return result; } catch { - if (cached) return cached.data; + if (memCached) return memCached.data; return { quotes: [], finnhubSkipped: false, skipReason: '' }; } } diff --git a/server/worldmonitor/market/v1/list-stablecoin-markets.ts b/server/worldmonitor/market/v1/list-stablecoin-markets.ts index 995bcfb8..818625bb 100644 --- a/server/worldmonitor/market/v1/list-stablecoin-markets.ts +++ b/server/worldmonitor/market/v1/list-stablecoin-markets.ts @@ -10,6 +10,11 @@ import type { Stablecoin, } from '../../../../src/generated/server/worldmonitor/market/v1/service_server'; import { UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'market:stablecoins:v1'; +const REDIS_CACHE_TTL = 180; // 3 min — CoinGecko rate-limited // ======================================================================== // Constants and cache @@ -54,10 +59,19 @@ export async function listStablecoinMarkets( ? req.coins.filter(c => /^[a-z0-9-]+$/.test(c)).join(',') : DEFAULT_STABLECOIN_IDS; + // Redis shared cache (cross-instance) + const redisKey = `${REDIS_CACHE_KEY}:${coins}`; + const redisCached = (await getCachedJson(redisKey)) as ListStablecoinMarketsResponse | null; + if (redisCached?.stablecoins?.length) { + stablecoinCache = redisCached; + stablecoinCacheTimestamp = now; + return redisCached; + } + try { const url = `https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${coins}&order=market_cap_desc&sparkline=false&price_change_percentage=7d`; const resp = await fetch(url, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); @@ -107,6 +121,7 @@ export async function listStablecoinMarkets( stablecoinCache = result; stablecoinCacheTimestamp = now; + setCachedJson(redisKey, result, REDIS_CACHE_TTL).catch(() => {}); return result; } catch { if (stablecoinCache) return stablecoinCache; diff --git a/server/worldmonitor/military/v1/get-aircraft-details-batch.ts b/server/worldmonitor/military/v1/get-aircraft-details-batch.ts index df15c8eb..e2114802 100644 --- a/server/worldmonitor/military/v1/get-aircraft-details-batch.ts +++ b/server/worldmonitor/military/v1/get-aircraft-details-batch.ts @@ -8,6 +8,8 @@ import type { } from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; import { mapWingbitsDetails } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; export async function getAircraftDetailsBatch( _ctx: ServerContext, @@ -17,17 +19,33 @@ export async function getAircraftDetailsBatch( if (!apiKey) return { results: {}, fetched: 0, requested: 0, configured: false }; const limitedList = req.icao24s.slice(0, 20).map((id) => id.toLowerCase()); + + // Redis shared cache — check per-ICAO24 (reuse single-aircraft cache keys) + const SINGLE_KEY = 'military:aircraft:v1'; + const SINGLE_TTL = 300; const results: Record = {}; + const toFetch: string[] = []; + + for (const icao24 of limitedList) { + const cached = (await getCachedJson(`${SINGLE_KEY}:${icao24}`)) as { details?: AircraftDetails } | null; + if (cached?.details) { + results[icao24] = cached.details; + } else { + toFetch.push(icao24); + } + } - const fetches = limitedList.map(async (icao24) => { + const fetches = toFetch.map(async (icao24) => { try { const resp = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, { - headers: { 'x-api-key': apiKey, Accept: 'application/json' }, + headers: { 'x-api-key': apiKey, Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10_000), }); if (resp.ok) { const data = (await resp.json()) as Record; - return { icao24, details: mapWingbitsDetails(icao24, data) }; + const details = mapWingbitsDetails(icao24, data); + setCachedJson(`${SINGLE_KEY}:${icao24}`, { details, configured: true }, SINGLE_TTL).catch(() => {}); + return { icao24, details }; } } catch { /* skip failed lookups */ } return null; diff --git a/server/worldmonitor/military/v1/get-aircraft-details.ts b/server/worldmonitor/military/v1/get-aircraft-details.ts index 57926a14..7409c4e2 100644 --- a/server/worldmonitor/military/v1/get-aircraft-details.ts +++ b/server/worldmonitor/military/v1/get-aircraft-details.ts @@ -7,6 +7,11 @@ import type { } from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; import { mapWingbitsDetails } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'military:aircraft:v1'; +const REDIS_CACHE_TTL = 300; // 5 min — aircraft details rarely change export async function getAircraftDetails( _ctx: ServerContext, @@ -16,9 +21,15 @@ export async function getAircraftDetails( if (!apiKey) return { details: undefined, configured: false }; const icao24 = req.icao24.toLowerCase(); + + // Redis shared cache (cross-instance) + const cacheKey = `${REDIS_CACHE_KEY}:${icao24}`; + const cached = (await getCachedJson(cacheKey)) as GetAircraftDetailsResponse | null; + if (cached?.details) return cached; + try { const resp = await fetch(`https://customer-api.wingbits.com/v1/flights/details/${icao24}`, { - headers: { 'x-api-key': apiKey, Accept: 'application/json' }, + headers: { 'x-api-key': apiKey, Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10_000), }); @@ -27,10 +38,12 @@ export async function getAircraftDetails( } const data = (await resp.json()) as Record; - return { + const result: GetAircraftDetailsResponse = { details: mapWingbitsDetails(icao24, data), configured: true, }; + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + return result; } catch { return { details: undefined, configured: true }; } diff --git a/server/worldmonitor/military/v1/get-theater-posture.ts b/server/worldmonitor/military/v1/get-theater-posture.ts index d77320a5..890378bc 100644 --- a/server/worldmonitor/military/v1/get-theater-posture.ts +++ b/server/worldmonitor/military/v1/get-theater-posture.ts @@ -16,6 +16,7 @@ import { UPSTREAM_TIMEOUT_MS, type RawFlight, } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; const CACHE_KEY = 'theater-posture:sebuf:v1'; const STALE_CACHE_KEY = 'theater-posture:sebuf:stale:v1'; @@ -37,7 +38,7 @@ async function fetchMilitaryFlightsFromOpenSky(): Promise { if (!baseUrl) return []; const resp = await fetch(baseUrl, { - headers: { Accept: 'application/json', 'User-Agent': 'Mozilla/5.0 WorldMonitor/1.0' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (!resp.ok) throw new Error(`OpenSky API error: ${resp.status}`); @@ -83,7 +84,7 @@ async function fetchMilitaryFlightsFromWingbits(): Promise { try { const resp = await fetch('https://customer-api.wingbits.com/v1/flights', { method: 'POST', - headers: { 'x-api-key': apiKey, Accept: 'application/json', 'Content-Type': 'application/json' }, + headers: { 'x-api-key': apiKey, Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': CHROME_UA }, body: JSON.stringify(areas), signal: AbortSignal.timeout(15_000), }); diff --git a/server/worldmonitor/military/v1/get-usni-fleet-report.ts b/server/worldmonitor/military/v1/get-usni-fleet-report.ts index 95df8894..4c9b68a0 100644 --- a/server/worldmonitor/military/v1/get-usni-fleet-report.ts +++ b/server/worldmonitor/military/v1/get-usni-fleet-report.ts @@ -9,6 +9,7 @@ import type { } from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; const USNI_CACHE_KEY = 'usni-fleet:sebuf:v1'; const USNI_STALE_CACHE_KEY = 'usni-fleet:sebuf:stale:v1'; @@ -383,7 +384,7 @@ export async function getUSNIFleetReport( const response = await fetch( 'https://news.usni.org/wp-json/wp/v2/posts?categories=4137&per_page=1', { - headers: { Accept: 'application/json', 'User-Agent': 'WorldMonitor/2.0' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: controller.signal, }, ); diff --git a/server/worldmonitor/military/v1/list-military-flights.ts b/server/worldmonitor/military/v1/list-military-flights.ts index e78b800a..9758cce7 100644 --- a/server/worldmonitor/military/v1/list-military-flights.ts +++ b/server/worldmonitor/military/v1/list-military-flights.ts @@ -8,6 +8,11 @@ import type { } from '../../../../src/generated/server/worldmonitor/military/v1/service_server'; import { isMilitaryCallsign, isMilitaryHex, detectAircraftType, UPSTREAM_TIMEOUT_MS } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'military:flights:v1'; +const REDIS_CACHE_TTL = 120; // 2 min — real-time ADS-B data const AIRCRAFT_TYPE_MAP: Record = { tanker: 'MILITARY_AIRCRAFT_TYPE_TANKER', @@ -26,6 +31,17 @@ export async function listMilitaryFlights( const bb = req.boundingBox; if (!bb?.southWest || !bb?.northEast) return { flights: [], clusters: [], pagination: undefined }; + // Redis shared cache — use precise bbox + request qualifiers to avoid cross-request collisions. + const preciseBB = [ + bb.southWest.latitude, + bb.southWest.longitude, + bb.northEast.latitude, + bb.northEast.longitude, + ].map((v) => Number.isFinite(v) ? String(v) : 'NaN').join(':'); + const cacheKey = `${REDIS_CACHE_KEY}:${preciseBB}:${req.operator || ''}:${req.aircraftType || ''}:${req.pagination?.pageSize || 0}`; + const cached = (await getCachedJson(cacheKey)) as ListMilitaryFlightsResponse | null; + if (cached?.flights?.length) return cached; + const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar'); const baseUrl = isSidecar ? 'https://opensky-network.org/api/states/all' @@ -41,7 +57,7 @@ export async function listMilitaryFlights( const url = `${baseUrl}${params.toString() ? '?' + params.toString() : ''}`; const resp = await fetch(url, { - headers: { Accept: 'application/json', 'User-Agent': 'Mozilla/5.0 WorldMonitor/1.0' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); @@ -87,7 +103,11 @@ export async function listMilitaryFlights( }); } - return { flights, clusters: [], pagination: undefined }; + const result: ListMilitaryFlightsResponse = { flights, clusters: [], pagination: undefined }; + if (flights.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { flights: [], clusters: [], pagination: undefined }; } diff --git a/server/worldmonitor/news/v1/summarize-article.ts b/server/worldmonitor/news/v1/summarize-article.ts index c05f0fdc..69dce529 100644 --- a/server/worldmonitor/news/v1/summarize-article.ts +++ b/server/worldmonitor/news/v1/summarize-article.ts @@ -12,6 +12,7 @@ import { getProviderCredentials, getCacheKey, } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; // ====================================================================== // SummarizeArticle: Multi-provider LLM summarization with Redis caching @@ -107,7 +108,7 @@ export async function summarizeArticle( // LLM call const response = await fetch(apiUrl, { method: 'POST', - headers: providerHeaders, + headers: { ...providerHeaders, 'User-Agent': CHROME_UA }, body: JSON.stringify({ model, messages: [ diff --git a/server/worldmonitor/prediction/v1/list-prediction-markets.ts b/server/worldmonitor/prediction/v1/list-prediction-markets.ts index 0efdfa08..7e1897b0 100644 --- a/server/worldmonitor/prediction/v1/list-prediction-markets.ts +++ b/server/worldmonitor/prediction/v1/list-prediction-markets.ts @@ -15,6 +15,12 @@ import type { PredictionMarket, } from '../../../../src/generated/server/worldmonitor/prediction/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'prediction:markets:v1'; +const REDIS_CACHE_TTL = 300; // 5 min + const GAMMA_BASE = 'https://gamma-api.polymarket.com'; const FETCH_TIMEOUT = 8000; @@ -94,6 +100,11 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark req: ListPredictionMarketsRequest, ): Promise => { try { + // Redis shared cache (cross-instance) + const cacheKey = `${REDIS_CACHE_KEY}:${req.category || 'all'}:${req.query || ''}:${req.pagination?.pageSize || 50}`; + const cached = (await getCachedJson(cacheKey)) as ListPredictionMarketsResponse | null; + if (cached?.markets?.length) return cached; + // Determine endpoint: events (with tag_slug) or markets const useEvents = !!req.category; const endpoint = useEvents ? 'events' : 'markets'; @@ -120,7 +131,7 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark const response = await fetch( `${GAMMA_BASE}/${endpoint}?${params}`, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: controller.signal, }, ); @@ -153,7 +164,11 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark ); } - return { markets, pagination: undefined }; + const result: ListPredictionMarketsResponse = { markets, pagination: undefined }; + if (markets.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { // Catch-all: return empty on ANY failure return { markets: [], pagination: undefined }; diff --git a/server/worldmonitor/research/v1/list-arxiv-papers.ts b/server/worldmonitor/research/v1/list-arxiv-papers.ts index bf69940b..9eadd000 100644 --- a/server/worldmonitor/research/v1/list-arxiv-papers.ts +++ b/server/worldmonitor/research/v1/list-arxiv-papers.ts @@ -6,6 +6,11 @@ */ import { XMLParser } from 'fast-xml-parser'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:arxiv:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — daily arXiv updates import type { ServerContext, ListArxivPapersRequest, @@ -38,7 +43,7 @@ async function fetchArxivPapers(req: ListArxivPapersRequest): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.category || 'cs.AI'}:${req.query || ''}:${req.pagination?.pageSize || 50}`; + const cached = (await getCachedJson(cacheKey)) as ListArxivPapersResponse | null; + if (cached?.papers?.length) return cached; + const papers = await fetchArxivPapers(req); - return { papers, pagination: undefined }; + const result: ListArxivPapersResponse = { papers, pagination: undefined }; + if (papers.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { papers: [], pagination: undefined }; } diff --git a/server/worldmonitor/research/v1/list-hackernews-items.ts b/server/worldmonitor/research/v1/list-hackernews-items.ts index 1728dba3..fa7469d5 100644 --- a/server/worldmonitor/research/v1/list-hackernews-items.ts +++ b/server/worldmonitor/research/v1/list-hackernews-items.ts @@ -12,6 +12,12 @@ import type { HackernewsItem, } from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:hackernews:v1'; +const REDIS_CACHE_TTL = 600; // 10 min + // ---------- Constants ---------- const ALLOWED_HN_FEEDS = new Set(['top', 'new', 'best', 'ask', 'show', 'job']); @@ -26,6 +32,7 @@ async function fetchHackernewsItems(req: ListHackernewsItemsRequest): Promise { try { + const feedType = ALLOWED_HN_FEEDS.has(req.feedType) ? req.feedType : 'top'; + const cacheKey = `${REDIS_CACHE_KEY}:${feedType}:${req.pagination?.pageSize || 30}`; + const cached = (await getCachedJson(cacheKey)) as ListHackernewsItemsResponse | null; + if (cached?.items?.length) return cached; + const items = await fetchHackernewsItems(req); - return { items, pagination: undefined }; + const result: ListHackernewsItemsResponse = { items, pagination: undefined }; + if (items.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { items: [], pagination: undefined }; } diff --git a/server/worldmonitor/research/v1/list-tech-events.ts b/server/worldmonitor/research/v1/list-tech-events.ts index 0cb42361..4f644c52 100644 --- a/server/worldmonitor/research/v1/list-tech-events.ts +++ b/server/worldmonitor/research/v1/list-tech-events.ts @@ -19,6 +19,11 @@ import type { TechEventCoords, } from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; import { CITY_COORDS } from '../../../../api/data/city-coords'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:tech-events:v1'; +const REDIS_CACHE_TTL = 21600; // 6 hr — weekly event data // ---------- Constants ---------- @@ -255,10 +260,10 @@ async function fetchTechEvents(req: ListTechEventsRequest): Promise e.type === 'conference'); + const mappableCount = conferences.filter(e => e.coords && !e.coords.virtual).length; + return { ...res, events, count: events.length, conferenceCount: conferences.length, mappableCount }; +} + // ---------- Handler ---------- export async function listTechEvents( @@ -348,7 +360,23 @@ export async function listTechEvents( req: ListTechEventsRequest, ): Promise { try { - return await fetchTechEvents(req); + const cacheKey = `${REDIS_CACHE_KEY}:${req.type || 'all'}:${req.mappable ? 1 : 0}:${req.days || 0}`; + const cached = (await getCachedJson(cacheKey)) as ListTechEventsResponse | null; + if (cached?.events?.length) { + if (req.limit > 0 && cached.events.length > req.limit) { + return applyLimit(cached, req.limit); + } + return cached; + } + + const result = await fetchTechEvents({ ...req, limit: 0 }); + if (result.events.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + if (req.limit > 0 && result.events.length > req.limit) { + return applyLimit(result, req.limit); + } + return result; } catch (error) { return { success: false, diff --git a/server/worldmonitor/research/v1/list-trending-repos.ts b/server/worldmonitor/research/v1/list-trending-repos.ts index 87ccfe74..4279a211 100644 --- a/server/worldmonitor/research/v1/list-trending-repos.ts +++ b/server/worldmonitor/research/v1/list-trending-repos.ts @@ -12,6 +12,12 @@ import type { GithubRepo, } from '../../../../src/generated/server/worldmonitor/research/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'research:trending:v1'; +const REDIS_CACHE_TTL = 3600; // 1 hr — daily trending data + // ---------- Fetch ---------- async function fetchTrendingRepos(req: ListTrendingReposRequest): Promise { @@ -25,7 +31,7 @@ async function fetchTrendingRepos(req: ListTrendingReposRequest): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.language || 'python'}:${req.period || 'daily'}:${req.pagination?.pageSize || 50}`; + const cached = (await getCachedJson(cacheKey)) as ListTrendingReposResponse | null; + if (cached?.repos?.length) return cached; + const repos = await fetchTrendingRepos(req); - return { repos, pagination: undefined }; + const result: ListTrendingReposResponse = { repos, pagination: undefined }; + if (repos.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { repos: [], pagination: undefined }; } diff --git a/server/worldmonitor/seismology/v1/list-earthquakes.ts b/server/worldmonitor/seismology/v1/list-earthquakes.ts index 64c0a563..c7b2e9b5 100644 --- a/server/worldmonitor/seismology/v1/list-earthquakes.ts +++ b/server/worldmonitor/seismology/v1/list-earthquakes.ts @@ -13,6 +13,7 @@ import type { } from '../../../../src/generated/server/worldmonitor/seismology/v1/service_server'; import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { CHROME_UA } from '../../../_shared/constants'; const USGS_FEED_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson'; @@ -32,7 +33,7 @@ export const listEarthquakes: SeismologyServiceHandler['listEarthquakes'] = asyn } const response = await fetch(USGS_FEED_URL, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10_000), }); diff --git a/server/worldmonitor/unrest/v1/list-unrest-events.ts b/server/worldmonitor/unrest/v1/list-unrest-events.ts index 70266b88..6a1c6a0f 100644 --- a/server/worldmonitor/unrest/v1/list-unrest-events.ts +++ b/server/worldmonitor/unrest/v1/list-unrest-events.ts @@ -24,6 +24,11 @@ import { deduplicateEvents, sortBySeverityAndRecency, } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'unrest:events:v1'; +const REDIS_CACHE_TTL = 900; // 15 min — ACLED + GDELT merge // ---------- ACLED Fetch (ported from api/acled.js + src/services/protests.ts) ---------- @@ -54,6 +59,7 @@ async function fetchAcledProtests(req: ListUnrestEventsRequest): Promise { }); const response = await fetch(`${GDELT_GEO_URL}?${params}`, { - headers: { Accept: 'application/json' }, + headers: { Accept: 'application/json', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10000), }); @@ -187,13 +193,21 @@ export async function listUnrestEvents( req: ListUnrestEventsRequest, ): Promise { try { + const cacheKey = `${REDIS_CACHE_KEY}:${req.country || 'all'}:${req.timeRange?.start || 0}:${req.timeRange?.end || 0}`; + const cached = (await getCachedJson(cacheKey)) as ListUnrestEventsResponse | null; + if (cached?.events?.length) return cached; + const [acledEvents, gdeltEvents] = await Promise.all([ fetchAcledProtests(req), fetchGdeltEvents(), ]); const merged = deduplicateEvents([...acledEvents, ...gdeltEvents]); const sorted = sortBySeverityAndRecency(merged); - return { events: sorted, clusters: [], pagination: undefined }; + const result: ListUnrestEventsResponse = { events: sorted, clusters: [], pagination: undefined }; + if (sorted.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; } catch { return { events: [], clusters: [], pagination: undefined }; } diff --git a/server/worldmonitor/wildfire/v1/list-fire-detections.ts b/server/worldmonitor/wildfire/v1/list-fire-detections.ts index bc931886..9d7d6f25 100644 --- a/server/worldmonitor/wildfire/v1/list-fire-detections.ts +++ b/server/worldmonitor/wildfire/v1/list-fire-detections.ts @@ -17,6 +17,12 @@ import type { FireConfidence, } from '../../../../src/generated/server/worldmonitor/wildfire/v1/service_server'; +import { CHROME_UA } from '../../../_shared/constants'; +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const REDIS_CACHE_KEY = 'wildfire:fires:v1'; +const REDIS_CACHE_TTL = 1800; // 30 min — daily FIRMS data + const FIRMS_SOURCE = 'VIIRS_SNPP_NRT'; /** Bounding boxes as west,south,east,north */ @@ -91,13 +97,17 @@ export const listFireDetections: WildfireServiceHandler['listFireDetections'] = return { fireDetections: [], pagination: undefined }; } + // Redis shared cache (cross-instance) + const cached = (await getCachedJson(REDIS_CACHE_KEY)) as ListFireDetectionsResponse | null; + if (cached?.fireDetections?.length) return cached; + const entries = Object.entries(MONITORED_REGIONS); const results = await Promise.allSettled( entries.map(async ([regionName, bbox]) => { const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${apiKey}/${FIRMS_SOURCE}/${bbox}/1`; const res = await fetch(url, { - headers: { Accept: 'text/csv' }, + headers: { Accept: 'text/csv', 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(15_000), }); if (!res.ok) { @@ -139,5 +149,9 @@ export const listFireDetections: WildfireServiceHandler['listFireDetections'] = } } - return { fireDetections, pagination: undefined }; + const result: ListFireDetectionsResponse = { fireDetections, pagination: undefined }; + if (fireDetections.length > 0) { + setCachedJson(REDIS_CACHE_KEY, result, REDIS_CACHE_TTL).catch(() => {}); + } + return result; }; diff --git a/settings.html b/settings.html index fba24581..ba5c29ea 100644 --- a/settings.html +++ b/settings.html @@ -5,6 +5,7 @@ World Monitor Settings +
diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 7cc4e1ad..0beee672 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -796,11 +796,12 @@ async function dispatch(requestUrl, req, routes, context) { } return json({ verboseMode }); } - // Registration — call Convex directly (Vercel Attack Challenge Mode blocks server-side) + // Registration — call Convex directly (desktop frontend bypasses sidecar for this endpoint; + // this handler only runs when CONVEX_URL is available, e.g. self-hosted deployments) if (requestUrl.pathname === '/api/register-interest' && req.method === 'POST') { const convexUrl = process.env.CONVEX_URL; if (!convexUrl) { - return json({ error: 'Registration service not configured' }, 503); + return json({ error: 'Registration service not configured — use cloud endpoint directly' }, 503); } try { const body = await new Promise((resolve, reject) => { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a1c9adb4..8f47c86d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -61,6 +61,15 @@ struct SecretsCache { secrets: Mutex>, } +/// In-memory mirror of persistent-cache.json. The file can grow to 10+ MB, +/// so reading/parsing/writing it on every IPC call blocks the main thread. +/// Instead, load once into RAM and serialize writes to preserve ordering. +struct PersistentCache { + data: Mutex>, + dirty: Mutex, + write_lock: Mutex<()>, +} + impl SecretsCache { fn load_from_keychain() -> Self { // Try consolidated vault first — single keychain prompt @@ -116,6 +125,53 @@ impl SecretsCache { } } +impl PersistentCache { + fn load(path: &Path) -> Self { + let data = if path.exists() { + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() + } else { + Map::new() + }; + PersistentCache { + data: Mutex::new(data), + dirty: Mutex::new(false), + write_lock: Mutex::new(()), + } + } + + fn get(&self, key: &str) -> Option { + let data = self.data.lock().unwrap_or_else(|e| e.into_inner()); + data.get(key).cloned() + } + + /// Flush to disk only if dirty. Returns Ok(true) if written. + fn flush(&self, path: &Path) -> Result { + let _write_guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner()); + + let is_dirty = { + let dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty + }; + if !is_dirty { + return Ok(false); + } + + let data = self.data.lock().unwrap_or_else(|e| e.into_inner()); + let serialized = serde_json::to_string(&Value::Object(data.clone())) + .map_err(|e| format!("Failed to serialize cache: {e}"))?; + drop(data); + std::fs::write(path, serialized) + .map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?; + let mut dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty = false; + Ok(true) + } +} + #[derive(Serialize)] struct DesktopRuntimeInfo { os: String, @@ -254,46 +310,37 @@ fn cache_file_path(app: &AppHandle) -> Result { } #[tauri::command] -fn read_cache_entry(app: AppHandle, key: String) -> Result, String> { - let path = cache_file_path(&app)?; - if !path.exists() { - return Ok(None); - } - - let contents = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read cache store {}: {e}", path.display()))?; - let parsed: Value = - serde_json::from_str(&contents).unwrap_or_else(|_| Value::Object(Map::new())); - let Some(root) = parsed.as_object() else { - return Ok(None); - }; - - Ok(root.get(&key).cloned()) +fn read_cache_entry(cache: tauri::State<'_, PersistentCache>, key: String) -> Result, String> { + Ok(cache.get(&key)) } #[tauri::command] -fn write_cache_entry(app: AppHandle, key: String, value: String) -> Result<(), String> { - let path = cache_file_path(&app)?; - - let mut root: Map = if path.exists() { - let contents = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read cache store {}: {e}", path.display()))?; - serde_json::from_str::(&contents) - .ok() - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default() - } else { - Map::new() - }; - - let parsed_value: Value = - serde_json::from_str(&value).map_err(|e| format!("Invalid cache payload JSON: {e}"))?; - root.insert(key, parsed_value); +fn write_cache_entry(app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String, value: String) -> Result<(), String> { + let parsed_value: Value = serde_json::from_str(&value) + .map_err(|e| format!("Invalid cache payload JSON: {e}"))?; + let _write_guard = cache.write_lock.lock().unwrap_or_else(|e| e.into_inner()); + { + let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); + data.insert(key, parsed_value); + } + { + let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty = true; + } - let serialized = serde_json::to_string_pretty(&Value::Object(root)) - .map_err(|e| format!("Failed to serialize cache store: {e}"))?; - std::fs::write(&path, serialized) - .map_err(|e| format!("Failed to write cache store {}: {e}", path.display())) + // Flush synchronously under write lock so concurrent writes cannot reorder. + let path = cache_file_path(&app)?; + let data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); + let serialized = serde_json::to_string(&Value::Object(data.clone())) + .map_err(|e| format!("Failed to serialize cache: {e}"))?; + drop(data); + std::fs::write(&path, &serialized) + .map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?; + { + let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); + *dirty = false; + } + Ok(()) } fn logs_dir_path(app: &AppHandle) -> Result { @@ -455,14 +502,14 @@ fn open_settings_window(app: &AppHandle) -> Result<(), String> { return Ok(()); } - let _settings_window = - WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into())) - .title("World Monitor Settings") - .inner_size(980.0, 760.0) - .min_inner_size(820.0, 620.0) - .resizable(true) - .build() - .map_err(|e| format!("Failed to create settings window: {e}"))?; + let _settings_window = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into())) + .title("World Monitor Settings") + .inner_size(980.0, 760.0) + .min_inner_size(820.0, 620.0) + .resizable(true) + .background_color(tauri::webview::Color(26, 28, 30, 255)) + .build() + .map_err(|e| format!("Failed to create settings window: {e}"))?; // On Windows/Linux, menus are per-window. Remove the inherited app menu // from the settings window (macOS uses a shared app-wide menu bar instead). @@ -848,6 +895,10 @@ fn main() { fetch_polymarket ]) .setup(|app| { + // Load persistent cache into memory (avoids 14MB file I/O on every IPC call) + let cache_path = cache_file_path(&app.handle()).unwrap_or_default(); + app.manage(PersistentCache::load(&cache_path)); + if let Err(err) = start_local_api(&app.handle()) { append_desktop_log( &app.handle(), @@ -898,6 +949,12 @@ fn main() { } } RunEvent::ExitRequested { .. } | RunEvent::Exit => { + // Flush in-memory cache to disk before quitting + if let Ok(path) = cache_file_path(app) { + if let Some(cache) = app.try_state::() { + let _ = cache.flush(&path); + } + } stop_local_api(app); } _ => {} diff --git a/src/components/EconomicPanel.ts b/src/components/EconomicPanel.ts index f56d3b8f..0ab13f7f 100644 --- a/src/components/EconomicPanel.ts +++ b/src/components/EconomicPanel.ts @@ -5,6 +5,8 @@ import type { SpendingSummary } from '@/services/usa-spending'; import { getChangeClass, formatChange, formatOilValue, getTrendIndicator, getTrendColor } from '@/services/economic'; import { formatAwardAmount, getAwardTypeIcon } from '@/services/usa-spending'; import { escapeHtml } from '@/utils/sanitize'; +import { isFeatureAvailable } from '@/services/runtime-config'; +import { isDesktopRuntime } from '@/services/runtime'; type TabId = 'indicators' | 'oil' | 'spending'; @@ -114,6 +116,9 @@ export class EconomicPanel extends Panel { private renderIndicators(): string { if (this.fredData.length === 0) { + if (isDesktopRuntime() && !isFeatureAvailable('economicFred')) { + return `
${t('components.economic.fredKeyMissing')}
`; + } return `
${t('components.economic.noIndicatorData')}
`; } diff --git a/src/components/GdeltIntelPanel.ts b/src/components/GdeltIntelPanel.ts index e9ae796e..d13d8c21 100644 --- a/src/components/GdeltIntelPanel.ts +++ b/src/components/GdeltIntelPanel.ts @@ -67,14 +67,30 @@ export class GdeltIntelPanel extends Panel { private async loadActiveTopic(): Promise { this.showLoading(); - try { - const data = await fetchTopicIntelligence(this.activeTopic); - this.topicData.set(this.activeTopic.id, data); - this.renderArticles(data.articles); - this.setCount(data.articles.length); - } catch (error) { - console.error('[GdeltIntelPanel] Load error:', error); - this.showError(t('common.failedIntelFeed')); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const data = await fetchTopicIntelligence(this.activeTopic); + this.topicData.set(this.activeTopic.id, data); + + if (data.articles.length === 0 && attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 15_000)); + continue; + } + + this.renderArticles(data.articles); + this.setCount(data.articles.length); + return; + } catch (error) { + if (this.isAbortError(error)) return; + console.error(`[GdeltIntelPanel] Load error (attempt ${attempt + 1}):`, error); + if (attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 15_000)); + continue; + } + this.showError(t('common.failedIntelFeed')); + } } } diff --git a/src/components/MacroSignalsPanel.ts b/src/components/MacroSignalsPanel.ts index 534921f3..0761087d 100644 --- a/src/components/MacroSignalsPanel.ts +++ b/src/components/MacroSignalsPanel.ts @@ -22,7 +22,7 @@ interface MacroSignalData { unavailable?: boolean; } -const economicClient = new EconomicServiceClient('', { fetch: fetch.bind(globalThis) }); +const economicClient = new EconomicServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); /** Map proto response (optional fields = undefined) to MacroSignalData (null for absent values). */ function mapProtoToData(r: GetMacroSignalsResponse): MacroSignalData { @@ -139,17 +139,30 @@ export class MacroSignalsPanel extends Panel { } private async fetchData(): Promise { - try { - const res = await economicClient.getMacroSignals({}); - this.data = mapProtoToData(res); - this.error = null; - } catch (err) { - if (this.isAbortError(err)) return; - this.error = err instanceof Error ? err.message : 'Failed to fetch'; - } finally { - this.loading = false; - this.renderPanel(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await economicClient.getMacroSignals({}); + this.data = mapProtoToData(res); + this.error = null; + + if (this.data && this.data.unavailable && attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + break; + } catch (err) { + if (this.isAbortError(err)) return; + if (attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + this.error = err instanceof Error ? err.message : 'Failed to fetch'; + } } + this.loading = false; + this.renderPanel(); } private renderPanel(): void { diff --git a/src/components/Panel.ts b/src/components/Panel.ts index dc27fa92..1921c3ac 100644 --- a/src/components/Panel.ts +++ b/src/components/Panel.ts @@ -311,7 +311,7 @@ export class Panel { h('div', { className: 'panel-radar-sweep' }), h('div', { className: 'panel-radar-dot' }), ), - h('div', { className: 'panel-loading-text' }, message), + h('div', { className: 'panel-loading-text retrying' }, message), ), ); } diff --git a/src/components/PizzIntIndicator.ts b/src/components/PizzIntIndicator.ts index 25c8e664..8771fdc6 100644 --- a/src/components/PizzIntIndicator.ts +++ b/src/components/PizzIntIndicator.ts @@ -55,172 +55,6 @@ export class PizzIntIndicator { panel, ); - this.injectStyles(); - } - - private injectStyles(): void { - if (document.getElementById('pizzint-styles')) return; - const style = document.createElement('style'); - style.id = 'pizzint-styles'; - style.textContent = ` - .pizzint-indicator { - position: relative; - z-index: 1000; - font-family: 'JetBrains Mono', monospace; - } - .pizzint-toggle { - display: flex; - align-items: center; - gap: 6px; - background: transparent; - border: 1px solid var(--overlay-heavy); - border-radius: 4px; - padding: 4px 8px; - cursor: pointer; - transition: all 0.2s; - } - .pizzint-toggle:hover { - background: var(--overlay-medium); - border-color: var(--border-strong); - } - .pizzint-icon { font-size: 14px; } - .pizzint-defcon { - font-size: 10px; - font-weight: bold; - padding: 2px 5px; - border-radius: 3px; - background: var(--text-ghost); - color: var(--accent); - } - .pizzint-score { - font-size: 10px; - color: var(--text-dim); - } - .pizzint-panel { - position: absolute; - top: 100%; - left: 0; - margin-top: 8px; - width: 320px; - background: var(--bg); - border: 1px solid var(--overlay-heavy); - border-radius: 12px; - overflow: hidden; - box-shadow: 0 8px 32px var(--shadow-color); - } - .pizzint-panel.hidden { display: none; } - .pizzint-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - border-bottom: 1px solid var(--overlay-medium); - } - .pizzint-title { - font-size: 14px; - font-weight: bold; - color: var(--accent); - } - .pizzint-close { - background: none; - border: none; - color: var(--text-faint); - font-size: 20px; - cursor: pointer; - padding: 0; - line-height: 1; - } - .pizzint-close:hover { color: var(--accent); } - .pizzint-status-bar { - padding: 12px 16px; - background: var(--overlay-light); - } - .pizzint-defcon-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--text); - text-align: center; - } - .pizzint-locations { - padding: 8px 16px; - max-height: 180px; - overflow-y: auto; - } - .pizzint-location { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 0; - border-bottom: 1px solid var(--overlay-light); - font-size: 11px; - } - .pizzint-location:last-child { border-bottom: none; } - .pizzint-location-name { - color: var(--text); - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-right: 8px; - } - .pizzint-location-status { - padding: 2px 6px; - border-radius: 4px; - font-size: 10px; - font-weight: bold; - text-transform: uppercase; - } - .pizzint-location-status.spike { background: var(--defcon-1); color: var(--accent); } - .pizzint-location-status.high { background: var(--defcon-2); color: var(--accent); } - .pizzint-location-status.elevated { background: var(--defcon-3); color: var(--bg); } - .pizzint-location-status.nominal { background: var(--defcon-4); color: var(--accent); } - .pizzint-location-status.quiet { background: var(--status-live); color: var(--bg); } - .pizzint-location-status.closed { background: var(--text-ghost); color: var(--text-dim); } - .pizzint-tensions { - padding: 12px 16px; - border-top: 1px solid var(--overlay-medium); - } - .pizzint-tensions-title { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--text-faint); - margin-bottom: 8px; - } - .pizzint-tension-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 0; - font-size: 11px; - } - .pizzint-tension-label { color: var(--text); } - .pizzint-tension-score { - display: flex; - align-items: center; - gap: 6px; - } - .pizzint-tension-value { color: var(--accent); font-weight: bold; } - .pizzint-tension-trend { font-size: 10px; } - .pizzint-tension-trend.rising { color: var(--defcon-2); } - .pizzint-tension-trend.falling { color: var(--status-live); } - .pizzint-tension-trend.stable { color: var(--text-dim); } - .pizzint-footer { - display: flex; - justify-content: space-between; - padding: 8px 16px; - border-top: 1px solid var(--overlay-medium); - font-size: 10px; - color: var(--text-ghost); - } - .pizzint-footer a { - color: var(--text-faint); - text-decoration: none; - } - .pizzint-footer a:hover { color: var(--accent); } - `; - document.head.appendChild(style); } public updateStatus(status: PizzIntStatus): void { diff --git a/src/components/RuntimeConfigPanel.ts b/src/components/RuntimeConfigPanel.ts index 1bf6255b..aa07db2d 100644 --- a/src/components/RuntimeConfigPanel.ts +++ b/src/components/RuntimeConfigPanel.ts @@ -465,6 +465,19 @@ export class RuntimeConfigPanel extends Panel { } } this.updateFeatureCardStatus(key); + + // Update inline status text to reflect staged state + const statusEl = input.closest('.runtime-secret-row')?.querySelector('.runtime-secret-status'); + if (statusEl) { + statusEl.textContent = result.valid ? t('modals.runtimeConfig.status.staged') : t('modals.runtimeConfig.status.invalid'); + statusEl.className = `runtime-secret-status ${result.valid ? 'staged' : 'warn'}`; + } + + // When Ollama URL is staged, auto-fetch available models + if (key === 'OLLAMA_API_URL' && result.valid) { + const modelSelect = this.content.querySelector('select[data-model-select]'); + if (modelSelect) void this.fetchOllamaModels(modelSelect); + } } else { void setSecretValue(key, raw); input.value = ''; diff --git a/src/components/StablecoinPanel.ts b/src/components/StablecoinPanel.ts index 450a43e9..51176276 100644 --- a/src/components/StablecoinPanel.ts +++ b/src/components/StablecoinPanel.ts @@ -45,17 +45,30 @@ export class StablecoinPanel extends Panel { } private async fetchData(): Promise { - try { - const client = new MarketServiceClient('', { fetch: fetch.bind(globalThis) }); - this.data = await client.listStablecoinMarkets({ coins: [] }); - this.error = null; - } catch (err) { - if (this.isAbortError(err)) return; - this.error = err instanceof Error ? err.message : 'Failed to fetch'; - } finally { - this.loading = false; - this.renderPanel(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const client = new MarketServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); + this.data = await client.listStablecoinMarkets({ coins: [] }); + this.error = null; + + if (this.data && this.data.stablecoins.length === 0 && attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + break; + } catch (err) { + if (this.isAbortError(err)) return; + if (attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 20_000)); + continue; + } + this.error = err instanceof Error ? err.message : 'Failed to fetch'; + } } + this.loading = false; + this.renderPanel(); } private renderPanel(): void { diff --git a/src/components/TechEventsPanel.ts b/src/components/TechEventsPanel.ts index 7c724221..2f9670e4 100644 --- a/src/components/TechEventsPanel.ts +++ b/src/components/TechEventsPanel.ts @@ -7,7 +7,7 @@ import type { TechEvent } from '@/generated/client/worldmonitor/research/v1/serv type ViewMode = 'upcoming' | 'conferences' | 'earnings' | 'all'; -const researchClient = new ResearchServiceClient('', { fetch: fetch.bind(globalThis) }); +const researchClient = new ResearchServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); export class TechEventsPanel extends Panel { private viewMode: ViewMode = 'upcoming'; @@ -26,25 +26,39 @@ export class TechEventsPanel extends Panel { this.error = null; this.render(); - try { - const data = await researchClient.listTechEvents({ - type: '', - mappable: false, - days: 180, - limit: 100, - }); - if (!data.success) throw new Error(data.error || 'Unknown error'); - - this.events = data.events; - this.setCount(data.conferenceCount); - } catch (err) { - if (this.isAbortError(err)) return; - this.error = err instanceof Error ? err.message : 'Failed to fetch events'; - console.error('[TechEvents] Fetch error:', err); - } finally { - this.loading = false; - this.render(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const data = await researchClient.listTechEvents({ + type: '', + mappable: false, + days: 180, + limit: 100, + }); + if (!data.success) throw new Error(data.error || 'Unknown error'); + + this.events = data.events; + this.setCount(data.conferenceCount); + this.error = null; + + if (this.events.length === 0 && attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 15_000)); + continue; + } + break; + } catch (err) { + if (this.isAbortError(err)) return; + if (attempt < 2) { + this.showRetrying(); + await new Promise(r => setTimeout(r, 15_000)); + continue; + } + this.error = err instanceof Error ? err.message : 'Failed to fetch events'; + console.error('[TechEvents] Fetch error:', err); + } } + this.loading = false; + this.render(); } protected render(): void { diff --git a/src/components/WorldMonitorTab.ts b/src/components/WorldMonitorTab.ts index 46acb6a1..8336fc26 100644 --- a/src/components/WorldMonitorTab.ts +++ b/src/components/WorldMonitorTab.ts @@ -1,7 +1,9 @@ import { getSecretState, setSecretValue, type RuntimeSecretKey } from '@/services/runtime-config'; +import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime'; import { t } from '@/services/i18n'; const WM_KEY: RuntimeSecretKey = 'WORLDMONITOR_API_KEY'; +const REG_STORAGE_KEY = 'wm-waitlist-registered'; export class WorldMonitorTab { private el: HTMLElement; @@ -23,6 +25,7 @@ export class WorldMonitorTab { ? t('modals.settingsWindow.worldMonitor.apiKey.statusValid') : t('modals.settingsWindow.worldMonitor.apiKey.statusMissing'); const statusClass = state.present ? 'ok' : 'warn'; + const alreadyRegistered = localStorage.getItem(REG_STORAGE_KEY) === '1'; this.el.innerHTML = `
@@ -45,12 +48,16 @@ export class WorldMonitorTab {

${t('modals.settingsWindow.worldMonitor.register.title')}

${t('modals.settingsWindow.worldMonitor.register.description')}

+ ${alreadyRegistered ? ` +

${t('modals.settingsWindow.worldMonitor.register.alreadyRegistered')}

+ ` : `

+ `}

${t('modals.settingsWindow.worldMonitor.byokTitle')}

@@ -59,10 +66,17 @@ export class WorldMonitorTab { `; this.keyInput = this.el.querySelector('[data-wm-key-input]')!; - this.emailInput = this.el.querySelector('[data-wm-email]')!; - this.regStatus = this.el.querySelector('[data-wm-reg-status]')!; this.keyBadge = this.el.querySelector('[data-wm-badge]')!; + if (!alreadyRegistered) { + this.emailInput = this.el.querySelector('[data-wm-email]')!; + this.regStatus = this.el.querySelector('[data-wm-reg-status]')!; + + this.el.querySelector('[data-wm-register]')!.addEventListener('click', () => { + void this.submitRegistration(); + }); + } + this.keyInput.addEventListener('input', () => { this.pendingKeyValue = this.keyInput.value; }); @@ -70,16 +84,12 @@ export class WorldMonitorTab { this.el.querySelector('[data-wm-toggle]')!.addEventListener('click', () => { this.keyInput.type = this.keyInput.type === 'password' ? 'text' : 'password'; }); - - this.el.querySelector('[data-wm-register]')!.addEventListener('click', () => { - void this.submitRegistration(); - }); } private async submitRegistration(): Promise { const email = this.emailInput.value.trim(); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.error'); + this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.invalidEmail'); this.regStatus.className = 'wm-reg-status error'; return; } @@ -89,17 +99,18 @@ export class WorldMonitorTab { btn.textContent = t('modals.settingsWindow.worldMonitor.register.submitting'); try { - const res = await fetch('/api/register-interest', { + const base = isDesktopRuntime() ? getRemoteApiBaseUrl() : ''; + const res = await fetch(`${base}/api/register-interest`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, source: 'desktop-settings' }), }); const data = await res.json() as { status?: string; error?: string }; - if (data.status === 'already_registered') { - this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.alreadyRegistered'); - this.regStatus.className = 'wm-reg-status ok'; - } else if (data.status === 'registered') { - this.regStatus.textContent = t('modals.settingsWindow.worldMonitor.register.success'); + if (data.status === 'already_registered' || data.status === 'registered') { + localStorage.setItem(REG_STORAGE_KEY, '1'); + this.regStatus.textContent = data.status === 'already_registered' + ? t('modals.settingsWindow.worldMonitor.register.alreadyRegistered') + : t('modals.settingsWindow.worldMonitor.register.success'); this.regStatus.className = 'wm-reg-status ok'; } else { this.regStatus.textContent = data.error || t('modals.settingsWindow.worldMonitor.register.error'); @@ -129,6 +140,10 @@ export class WorldMonitorTab { this.keyBadge.className = `wm-badge ${state.present ? 'ok' : 'warn'}`; } + refresh(): void { + this.render(); + } + getElement(): HTMLElement { return this.el; } diff --git a/src/locales/ar.json b/src/locales/ar.json index 61e3a100..a6195633 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -1797,6 +1797,7 @@ "close": "إغلاق", "currentVariant": "(الحالي)", "retry": "Retry", + "retrying": "جاري إعادة المحاولة...", "refresh": "Refresh" } } diff --git a/src/locales/de.json b/src/locales/de.json index 577d66f4..3b9b9b51 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1797,6 +1797,7 @@ "close": "Schließen", "currentVariant": "(aktuell)", "retry": "Retry", + "retrying": "Wird wiederholt...", "refresh": "Refresh" } } diff --git a/src/locales/en.json b/src/locales/en.json index e540ec38..510678d5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -309,7 +309,8 @@ "submitting": "Submitting...", "success": "You're on the list! We'll notify you first.", "alreadyRegistered": "You're already on the waitlist.", - "error": "Registration failed. Please try again." + "error": "Registration failed. Please try again.", + "invalidEmail": "Please enter a valid email address." }, "byokTitle": "Or bring your own keys", "byokDescription": "Prefer full control? Head to the API Keys and LLMs tabs to configure each data source and AI provider individually." @@ -474,6 +475,7 @@ "noSpending": "No recent government awards", "awards": "awards", "noIndicatorData": "No indicator data yet - FRED may be loading", + "fredKeyMissing": "FRED API key required — add it in Settings to enable economic indicators", "noOilDataRetry": "Oil data temporarily unavailable - will retry", "vsPreviousWeek": "vs previous week", "in": "in" @@ -1800,6 +1802,7 @@ "close": "Close", "currentVariant": "(current)", "retry": "Retry", + "retrying": "Retrying...", "refresh": "Refresh" } } diff --git a/src/locales/es.json b/src/locales/es.json index 3bc4fe95..9b8b8a7c 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1797,6 +1797,7 @@ "close": "Cerrar", "currentVariant": "(actual)", "retry": "Retry", + "retrying": "Reintentando...", "refresh": "Refresh" } } diff --git a/src/locales/fr.json b/src/locales/fr.json index 642c8bb2..c5258ec5 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1797,6 +1797,7 @@ "close": "Fermer", "currentVariant": "(actuel)", "retry": "Retry", + "retrying": "Nouvelle tentative...", "refresh": "Refresh" } } diff --git a/src/locales/it.json b/src/locales/it.json index feccc9c5..8bc4d3c5 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1797,6 +1797,7 @@ "close": "Chiudi", "currentVariant": "(corrente)", "retry": "Retry", + "retrying": "Nuovo tentativo...", "refresh": "Refresh" } } diff --git a/src/locales/ja.json b/src/locales/ja.json index 64bdfec3..4c225b43 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1797,6 +1797,7 @@ "close": "閉じる", "currentVariant": "(現在)", "retry": "Retry", + "retrying": "再試行中...", "refresh": "Refresh" } } diff --git a/src/locales/nl.json b/src/locales/nl.json index 83bf9453..a94c73e3 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -1705,6 +1705,7 @@ "close": "Sluiten", "currentVariant": "(huidig)", "retry": "Retry", + "retrying": "Opnieuw proberen...", "refresh": "Refresh" }, "header": { diff --git a/src/locales/pl.json b/src/locales/pl.json index 4d10a20a..3777c52a 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1797,6 +1797,7 @@ "close": "Zamknij", "currentVariant": "(bieżący)", "retry": "Retry", + "retrying": "Ponawiam próbę...", "refresh": "Refresh" } } diff --git a/src/locales/pt.json b/src/locales/pt.json index 3ea1ef11..4e0b6dd0 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -1705,6 +1705,7 @@ "close": "Fechar", "currentVariant": "(atual)", "retry": "Retry", + "retrying": "Tentando novamente...", "refresh": "Refresh" }, "header": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 194de1ca..9fbdf925 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1797,6 +1797,7 @@ "close": "Закрыть", "currentVariant": "(текущий)", "retry": "Retry", + "retrying": "Повторная попытка...", "refresh": "Refresh" } } diff --git a/src/locales/sv.json b/src/locales/sv.json index a344bd7e..e679ccff 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -1705,6 +1705,7 @@ "close": "Stäng", "currentVariant": "(aktuell)", "retry": "Retry", + "retrying": "Försöker igen...", "refresh": "Refresh" }, "header": { diff --git a/src/locales/th.json b/src/locales/th.json index 399bf1b4..d392cde0 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -1774,6 +1774,7 @@ "close": "ปิด", "currentVariant": "(ปัจจุบัน)", "retry": "ลองใหม่", + "retrying": "กำลังลองใหม่...", "refresh": "รีเฟรช" } } diff --git a/src/locales/tr.json b/src/locales/tr.json index 555f1144..78372790 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -1797,6 +1797,7 @@ "close": "Kapat", "currentVariant": "(mevcut)", "retry": "Retry", + "retrying": "Tekrar deneniyor...", "refresh": "Refresh" } } diff --git a/src/locales/vi.json b/src/locales/vi.json index f71c1c13..0418b1c4 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1774,6 +1774,7 @@ "close": "Đóng", "currentVariant": "(hiện tại)", "retry": "Thử lại", + "retrying": "Đang thử lại...", "refresh": "Làm mới" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 9308626a..16a86f2a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1797,6 +1797,7 @@ "close": "关闭", "currentVariant": "(当前)", "retry": "Retry", + "retrying": "正在重试...", "refresh": "Refresh" } } diff --git a/src/main.ts b/src/main.ts index 6e22ea39..ceb6f8f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -71,13 +71,23 @@ Sentry.init({ /^fetchError: Network request failed$/, /window\.ethereum/, /^SyntaxError: Unexpected token/, + /^Operation timed out\.?$/, + /setting 'luma'/, + /ML request .* timed out/, + /^Element not found$/, + /^The operation was aborted\.?\s*$/, + /Unexpected end of script/, + /error loading dynamically imported module/, + /Style is not done loading/, + /Event `CustomEvent`.*captured as promise rejection/, + /getProgramInfoLog/, ], beforeSend(event) { const msg = event.exception?.values?.[0]?.value ?? ''; if (msg.length <= 3 && /^[a-zA-Z_$]+$/.test(msg)) return null; const frames = event.exception?.values?.[0]?.stacktrace?.frames ?? []; // Suppress maplibre internal null-access crashes (light, placement) only when stack is in map chunk - if (/this\.style\._layers|reading '_layers'|this\.light is null|can't access property "(id|type|setFilter)", \w+ is (null|undefined)|Cannot read properties of null \(reading '(id|type|setFilter|_layers)'\)|null is not an object \(evaluating '(E\.|this\.style)/.test(msg)) { + if (/this\.style\._layers|reading '_layers'|this\.light is null|can't access property "(id|type|setFilter)", \w+ is (null|undefined)|Cannot read properties of null \(reading '(id|type|setFilter|_layers)'\)|null is not an object \(evaluating '(E\.|this\.style)|^\w{1,2} is null$/.test(msg)) { if (frames.some(f => /\/map-[A-Za-z0-9]+\.js/.test(f.filename ?? ''))) return null; } // Suppress any TypeError that happens entirely within maplibre internals (no app code outside the map chunk) diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 32bfe603..a74d43cd 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -153,7 +153,7 @@ export async function initAnalytics(): Promise { api_host: POSTHOG_HOST, persistence: 'localStorage', autocapture: false, - capture_pageview: true, + capture_pageview: false, // Manual capture below — auto-capture silently fails with bootstrap + SPA capture_pageleave: true, disable_session_recording: true, bootstrap: { distinctID: getOrCreateInstallationId() }, @@ -192,6 +192,11 @@ export async function initAnalytics(): Promise { posthog.register(superProps); posthogInstance = posthog as unknown as PostHogInstance; + // Fire $pageview manually after full init — auto capture_pageview: true + // fires during init() before super props are registered, and silently + // fails with bootstrap + SPA setups (posthog-js #386). + posthog.capture('$pageview'); + // Flush any events queued while offline (desktop) flushOfflineQueue(); diff --git a/src/services/aviation/index.ts b/src/services/aviation/index.ts index 758b203e..5c1c59f8 100644 --- a/src/services/aviation/index.ts +++ b/src/services/aviation/index.ts @@ -89,7 +89,7 @@ function toDisplayAlert(proto: ProtoAlert): AirportDelayAlert { // --- Client + circuit breaker --- -const client = new AviationServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new AviationServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'FAA Flight Delays' }); // --- Main fetch (public API) --- diff --git a/src/services/cable-activity.ts b/src/services/cable-activity.ts index 30222ead..3cd5b456 100644 --- a/src/services/cable-activity.ts +++ b/src/services/cable-activity.ts @@ -2,7 +2,7 @@ import type { CableAdvisory, RepairShip, UnderseaCable } from '@/types'; import { UNDERSEA_CABLES } from '@/config'; import { MaritimeServiceClient, type NavigationalWarning } from '@/generated/client/worldmonitor/maritime/v1/service_client'; -const maritimeClient = new MaritimeServiceClient('', { fetch: fetch.bind(globalThis) }); +const maritimeClient = new MaritimeServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); interface CableActivity { advisories: CableAdvisory[]; diff --git a/src/services/cable-health.ts b/src/services/cable-health.ts index 825c3c56..1ec22531 100644 --- a/src/services/cable-health.ts +++ b/src/services/cable-health.ts @@ -6,7 +6,7 @@ import { import type { CableHealthRecord, CableHealthResponse, CableHealthStatus } from '@/types'; import { createCircuitBreaker } from '@/utils'; -const client = new InfrastructureServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new InfrastructureServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'Cable Health' }); const emptyFallback: GetCableHealthResponse = { generatedAt: 0, cables: {} }; diff --git a/src/services/cached-risk-scores.ts b/src/services/cached-risk-scores.ts index 4881bfc6..1b860b8d 100644 --- a/src/services/cached-risk-scores.ts +++ b/src/services/cached-risk-scores.ts @@ -16,7 +16,7 @@ import { // ---- Sebuf client ---- -const client = new IntelligenceServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new IntelligenceServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); // ---- Legacy types (preserved for consumer compatibility) ---- diff --git a/src/services/cached-theater-posture.ts b/src/services/cached-theater-posture.ts index 6a9c2a9d..552ae0a0 100644 --- a/src/services/cached-theater-posture.ts +++ b/src/services/cached-theater-posture.ts @@ -14,7 +14,7 @@ import { // ---- Sebuf client ---- -const client = new MilitaryServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new MilitaryServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); // ---- Legacy interface (preserved for consumer compatibility) ---- diff --git a/src/services/climate/index.ts b/src/services/climate/index.ts index b1831357..ad68a371 100644 --- a/src/services/climate/index.ts +++ b/src/services/climate/index.ts @@ -27,7 +27,7 @@ export interface ClimateFetchResult { anomalies: ClimateAnomaly[]; } -const client = new ClimateServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new ClimateServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'Climate Anomalies' }); const emptyClimateFallback: ListClimateAnomaliesResponse = { anomalies: [] }; diff --git a/src/services/cyber/index.ts b/src/services/cyber/index.ts index 0ee506e6..bb5f2430 100644 --- a/src/services/cyber/index.ts +++ b/src/services/cyber/index.ts @@ -14,7 +14,7 @@ import { createCircuitBreaker } from '@/utils'; // ---- Client + Circuit Breaker ---- -const client = new CyberServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new CyberServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'Cyber Threats' }); const emptyFallback: ListCyberThreatsResponse = { threats: [], pagination: undefined }; diff --git a/src/services/displacement/index.ts b/src/services/displacement/index.ts index e2e26ee1..ae282266 100644 --- a/src/services/displacement/index.ts +++ b/src/services/displacement/index.ts @@ -106,7 +106,7 @@ function toDisplayFlow(proto: ProtoFlow): DisplacementFlow { // ─── Client + circuit breaker ─── -const client = new DisplacementServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new DisplacementServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const emptyResult: UnhcrSummary = { year: new Date().getFullYear(), diff --git a/src/services/earthquakes.ts b/src/services/earthquakes.ts index 1328dcae..324d1965 100644 --- a/src/services/earthquakes.ts +++ b/src/services/earthquakes.ts @@ -8,7 +8,7 @@ import { createCircuitBreaker } from '@/utils'; // Re-export the proto Earthquake type as the domain's public type export type { Earthquake }; -const client = new SeismologyServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new SeismologyServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'Seismology' }); const emptyFallback: ListEarthquakesResponse = { earthquakes: [] }; diff --git a/src/services/economic/index.ts b/src/services/economic/index.ts index 98b2c489..730ebab1 100644 --- a/src/services/economic/index.ts +++ b/src/services/economic/index.ts @@ -22,7 +22,7 @@ import { dataFreshness } from '../data-freshness'; // ---- Client + Circuit Breakers ---- -const client = new EconomicServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new EconomicServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const fredBreaker = createCircuitBreaker({ name: 'FRED Economic' }); const wbBreaker = createCircuitBreaker({ name: 'World Bank' }); const eiaBreaker = createCircuitBreaker({ name: 'EIA Energy' }); diff --git a/src/services/gdelt-intel.ts b/src/services/gdelt-intel.ts index 376077f7..f059d4cc 100644 --- a/src/services/gdelt-intel.ts +++ b/src/services/gdelt-intel.ts @@ -86,7 +86,7 @@ export function getIntelTopics(): IntelTopic[] { // ---- Sebuf client ---- -const client = new IntelligenceServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new IntelligenceServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const gdeltBreaker = createCircuitBreaker({ name: 'GDELT Intelligence' }); const emptyGdeltFallback: SearchGdeltDocumentsResponse = { articles: [], query: '', error: '' }; diff --git a/src/services/infrastructure/index.ts b/src/services/infrastructure/index.ts index ea24c503..a4cd3af2 100644 --- a/src/services/infrastructure/index.ts +++ b/src/services/infrastructure/index.ts @@ -19,7 +19,7 @@ import { isFeatureAvailable } from '../runtime-config'; // ---- Client + Circuit Breakers ---- -const client = new InfrastructureServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new InfrastructureServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const outageBreaker = createCircuitBreaker({ name: 'Internet Outages' }); const statusBreaker = createCircuitBreaker({ name: 'Service Statuses' }); diff --git a/src/services/maritime/index.ts b/src/services/maritime/index.ts index b66b09f2..4883cb14 100644 --- a/src/services/maritime/index.ts +++ b/src/services/maritime/index.ts @@ -11,7 +11,7 @@ import { isFeatureAvailable } from '../runtime-config'; // ---- Proto fallback (desktop safety when relay URL is unavailable) ---- -const client = new MaritimeServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new MaritimeServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const snapshotBreaker = createCircuitBreaker({ name: 'Maritime Snapshot' }); const emptySnapshotFallback: GetVesselSnapshotResponse = { snapshot: undefined }; diff --git a/src/services/pizzint.ts b/src/services/pizzint.ts index 54491811..828b7598 100644 --- a/src/services/pizzint.ts +++ b/src/services/pizzint.ts @@ -11,7 +11,7 @@ import { // ---- Sebuf client ---- -const client = new IntelligenceServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new IntelligenceServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); // ---- Circuit breakers ---- diff --git a/src/services/population-exposure.ts b/src/services/population-exposure.ts index fabf9b46..4d6d0a4a 100644 --- a/src/services/population-exposure.ts +++ b/src/services/population-exposure.ts @@ -3,7 +3,7 @@ import type { CountryPopulation, PopulationExposure } from '@/types'; import { DisplacementServiceClient } from '@/generated/client/worldmonitor/displacement/v1/service_client'; import type { GetPopulationExposureResponse } from '@/generated/client/worldmonitor/displacement/v1/service_client'; -const client = new DisplacementServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new DisplacementServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const countriesBreaker = createCircuitBreaker({ name: 'WorldPop Countries' }); diff --git a/src/services/prediction/index.ts b/src/services/prediction/index.ts index 2a8c1ed7..0f7d7ab4 100644 --- a/src/services/prediction/index.ts +++ b/src/services/prediction/index.ts @@ -46,7 +46,7 @@ const RAILWAY_POLY_URL = wsRelayUrl const breaker = createCircuitBreaker({ name: 'Polymarket' }); // Sebuf client for strategy 4 -const client = new PredictionServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new PredictionServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); // Track whether direct browser->Polymarket fetch works // Cloudflare blocks server-side TLS but browsers pass JA3 fingerprint checks diff --git a/src/services/research/index.ts b/src/services/research/index.ts index 6124412a..907918ef 100644 --- a/src/services/research/index.ts +++ b/src/services/research/index.ts @@ -9,7 +9,7 @@ import { createCircuitBreaker } from '@/utils'; // Re-export proto types (no legacy mapping needed -- proto types are clean) export type { ArxivPaper, GithubRepo, HackernewsItem }; -const client = new ResearchServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new ResearchServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const arxivBreaker = createCircuitBreaker({ name: 'ArXiv Papers' }); const trendingBreaker = createCircuitBreaker({ name: 'GitHub Trending' }); diff --git a/src/services/runtime.ts b/src/services/runtime.ts index 1002ecf2..886f1b0c 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -150,6 +150,10 @@ function isLocalOnlyApiTarget(target: string): boolean { return target.startsWith('/api/local-'); } +function isKeyFreeApiTarget(target: string): boolean { + return target.startsWith('/api/register-interest'); +} + async function fetchLocalWithStartupRetry( nativeFetch: typeof window.fetch, localUrl: string, @@ -220,7 +224,7 @@ export function installRuntimeFetchPatch(): void { if (debug) console.log(`[fetch] intercept → ${target}`); let allowCloudFallback = !isLocalOnlyApiTarget(target); - if (allowCloudFallback) { + if (allowCloudFallback && !isKeyFreeApiTarget(target)) { try { const { getSecretState, secretsReady } = await import('@/services/runtime-config'); await Promise.race([secretsReady, new Promise(r => setTimeout(r, 2000))]); diff --git a/src/services/summarization.ts b/src/services/summarization.ts index ef4e812b..15a9b535 100644 --- a/src/services/summarization.ts +++ b/src/services/summarization.ts @@ -28,7 +28,7 @@ export type ProgressCallback = (step: number, total: number, message: string) => // ── Sebuf client (replaces direct fetch to /api/{provider}-summarize) ── -const newsClient = new NewsServiceClient('', { fetch: fetch.bind(globalThis) }); +const newsClient = new NewsServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const summaryBreaker = createCircuitBreaker({ name: 'News Summarization' }); const emptySummaryFallback: SummarizeArticleResponse = { summary: '', provider: '', model: '', cached: false, skipped: false, fallback: true, tokens: 0, reason: '', error: '', errorType: '' }; diff --git a/src/services/temporal-baseline.ts b/src/services/temporal-baseline.ts index 22c1baae..b93da424 100644 --- a/src/services/temporal-baseline.ts +++ b/src/services/temporal-baseline.ts @@ -22,7 +22,7 @@ export interface TemporalAnomaly { severity: 'medium' | 'high' | 'critical'; } -const client = new InfrastructureServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new InfrastructureServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const TYPE_LABELS: Record = { military_flights: 'Military flights', diff --git a/src/services/threat-classifier.ts b/src/services/threat-classifier.ts index 6b88a8be..8a52d120 100644 --- a/src/services/threat-classifier.ts +++ b/src/services/threat-classifier.ts @@ -296,7 +296,7 @@ import { type ClassifyEventResponse, } from '@/generated/client/worldmonitor/intelligence/v1/service_client'; -const classifyClient = new IntelligenceServiceClient('', { fetch: fetch.bind(globalThis) }); +const classifyClient = new IntelligenceServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const VALID_LEVELS: Record = { critical: 'critical', high: 'high', medium: 'medium', low: 'low', info: 'info', diff --git a/src/services/unrest/index.ts b/src/services/unrest/index.ts index acdbc087..75b94b82 100644 --- a/src/services/unrest/index.ts +++ b/src/services/unrest/index.ts @@ -8,7 +8,7 @@ import { createCircuitBreaker } from '@/utils'; // ---- Client + Circuit Breaker ---- -const client = new UnrestServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new UnrestServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const unrestBreaker = createCircuitBreaker({ name: 'Unrest Events', }); diff --git a/src/services/usni-fleet.ts b/src/services/usni-fleet.ts index 6f7fb0bb..0052ab5d 100644 --- a/src/services/usni-fleet.ts +++ b/src/services/usni-fleet.ts @@ -6,7 +6,7 @@ import { type GetUSNIFleetReportResponse, } from '@/generated/client/worldmonitor/military/v1/service_client'; -const client = new MilitaryServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new MilitaryServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'USNI Fleet Tracker', diff --git a/src/services/wildfires/index.ts b/src/services/wildfires/index.ts index 62b374ce..9d6978c0 100644 --- a/src/services/wildfires/index.ts +++ b/src/services/wildfires/index.ts @@ -38,7 +38,7 @@ export interface MapFire { // -- Client -- -const client = new WildfireServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new WildfireServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'Wildfires' }); const emptyFallback: ListFireDetectionsResponse = { fireDetections: [] }; diff --git a/src/services/wingbits.ts b/src/services/wingbits.ts index 98244dc3..add3de62 100644 --- a/src/services/wingbits.ts +++ b/src/services/wingbits.ts @@ -48,7 +48,7 @@ export interface EnrichedAircraftInfo { // ---- Sebuf client ---- -const client = new MilitaryServiceClient('', { fetch: fetch.bind(globalThis) }); +const client = new MilitaryServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); // Client-side cache for aircraft details const localCache = new Map(); diff --git a/src/settings-main.ts b/src/settings-main.ts index 3cb45c6e..dad9c0ed 100644 --- a/src/settings-main.ts +++ b/src/settings-main.ts @@ -81,13 +81,23 @@ async function initSettingsWindow(): Promise { requestAnimationFrame(() => { document.documentElement.classList.remove('no-transition'); }); - await loadDesktopSecrets(); const llmMount = document.getElementById('llmApp'); const apiMount = document.getElementById('apiKeysApp'); const wmMount = document.getElementById('worldmonitorApp'); if (!llmMount || !apiMount) return; + // Mount WorldMonitor tab immediately — it doesn't depend on secrets + const wmTab = new WorldMonitorTab(); + if (wmMount) { + wmMount.innerHTML = ''; + wmMount.appendChild(wmTab.getElement()); + } + + // Load secrets then refresh WorldMonitor tab to reflect actual key status + await loadDesktopSecrets(); + wmTab.refresh(); + const llmPanel = new RuntimeConfigPanel({ mode: 'full', buffered: true, featureFilter: LLM_FEATURES }); const apiPanel = new RuntimeConfigPanel({ mode: 'full', @@ -98,12 +108,6 @@ async function initSettingsWindow(): Promise { mountPanel(llmPanel, llmMount); mountPanel(apiPanel, apiMount); - const wmTab = new WorldMonitorTab(); - if (wmMount) { - wmMount.innerHTML = ''; - wmMount.appendChild(wmTab.getElement()); - } - const panels = [llmPanel, apiPanel]; window.addEventListener('beforeunload', () => { diff --git a/src/styles/main.css b/src/styles/main.css index 3fd24595..eeb41047 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -4498,6 +4498,10 @@ a.prediction-link:hover { letter-spacing: 0.5px; } +.panel-loading-text.retrying { + color: var(--yellow, #f0c040); +} + /* Error */ .error-message { color: var(--red); @@ -7772,6 +7776,167 @@ a.prediction-link:hover { transform: translateY(0); } +/* ========================================================================== + PizzINT DEFCON Indicator + ========================================================================== */ + +.pizzint-indicator { + position: relative; + z-index: 1000; + font-family: 'JetBrains Mono', monospace; +} +.pizzint-toggle { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: 1px solid var(--overlay-heavy); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + transition: all 0.2s; +} +.pizzint-toggle:hover { + background: var(--overlay-medium); + border-color: var(--border-strong); +} +.pizzint-icon { font-size: 14px; } +.pizzint-defcon { + font-size: 10px; + font-weight: bold; + padding: 2px 5px; + border-radius: 3px; + background: var(--text-ghost); + color: var(--accent); +} +.pizzint-score { + font-size: 10px; + color: var(--text-dim); +} +.pizzint-panel { + position: absolute; + top: 100%; + left: 0; + margin-top: 8px; + width: 320px; + background: var(--bg); + border: 1px solid var(--overlay-heavy); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 8px 32px var(--shadow-color); +} +.pizzint-panel.hidden { display: none; } +.pizzint-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--overlay-medium); +} +.pizzint-title { + font-size: 14px; + font-weight: bold; + color: var(--accent); +} +.pizzint-close { + background: none; + border: none; + color: var(--text-faint); + font-size: 20px; + cursor: pointer; + padding: 0; + line-height: 1; +} +.pizzint-close:hover { color: var(--accent); } +.pizzint-status-bar { + padding: 12px 16px; + background: var(--overlay-light); +} +.pizzint-defcon-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text); + text-align: center; +} +.pizzint-locations { + padding: 8px 16px; + max-height: 180px; + overflow-y: auto; +} +.pizzint-location { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--overlay-light); + font-size: 11px; +} +.pizzint-location:last-child { border-bottom: none; } +.pizzint-location-name { + color: var(--text); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 8px; +} +.pizzint-location-status { + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: bold; + text-transform: uppercase; +} +.pizzint-location-status.spike { background: var(--defcon-1); color: var(--accent); } +.pizzint-location-status.high { background: var(--defcon-2); color: var(--accent); } +.pizzint-location-status.elevated { background: var(--defcon-3); color: var(--bg); } +.pizzint-location-status.nominal { background: var(--defcon-4); color: var(--accent); } +.pizzint-location-status.quiet { background: var(--status-live); color: var(--bg); } +.pizzint-location-status.closed { background: var(--text-ghost); color: var(--text-dim); } +.pizzint-tensions { + padding: 12px 16px; + border-top: 1px solid var(--overlay-medium); +} +.pizzint-tensions-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-faint); + margin-bottom: 8px; +} +.pizzint-tension-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + font-size: 11px; +} +.pizzint-tension-label { color: var(--text); } +.pizzint-tension-score { + display: flex; + align-items: center; + gap: 6px; +} +.pizzint-tension-value { color: var(--accent); font-weight: bold; } +.pizzint-tension-trend { font-size: 10px; } +.pizzint-tension-trend.rising { color: var(--defcon-2); } +.pizzint-tension-trend.falling { color: var(--status-live); } +.pizzint-tension-trend.stable { color: var(--text-dim); } +.pizzint-footer { + display: flex; + justify-content: space-between; + padding: 8px 16px; + border-top: 1px solid var(--overlay-medium); + font-size: 10px; + color: var(--text-ghost); +} +.pizzint-footer a { + color: var(--text-faint); + text-decoration: none; +} +.pizzint-footer a:hover { color: var(--accent); } + /* ========================================================================== Mobile Touch Optimization ========================================================================== */