diff --git a/package-lock.json b/package-lock.json index 1a1800454..c1baf3d75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "world-monitor", - "version": "2.3.4", + "version": "2.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "world-monitor", - "version": "2.3.4", + "version": "2.3.5", "dependencies": { "@deck.gl/aggregation-layers": "^9.2.6", "@deck.gl/core": "^9.2.6", diff --git a/package.json b/package.json index 707e6b6d5..88ad8eb48 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "world-monitor", "private": true, - "version": "2.3.4", + "version": "2.3.5", "type": "module", "scripts": { "lint:md": "markdownlint-cli2 '**/*.md'", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b025b4c99..286694bd6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "world-monitor" -version = "2.2.5" +version = "2.3.5" description = "World Monitor desktop application" authors = ["World Monitor"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f9e1189e5..d434a53b2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "World Monitor", "mainBinaryName": "world-monitor", - "version": "2.3.4", + "version": "2.3.5", "identifier": "app.worldmonitor.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.ts b/src/App.ts index d793f6776..f20152cbc 100644 --- a/src/App.ts +++ b/src/App.ts @@ -2943,48 +2943,54 @@ export class App { private async loadMarkets(): Promise { try { - // Stocks - const stocks = await fetchMultipleStocks(MARKET_SYMBOLS, { + const stocksResult = await fetchMultipleStocks(MARKET_SYMBOLS, { onBatch: (partialStocks) => { this.latestMarkets = partialStocks; (this.panels['markets'] as MarketPanel).renderMarkets(partialStocks); }, }); - this.latestMarkets = stocks; - (this.panels['markets'] as MarketPanel).renderMarkets(stocks); - this.statusPanel?.updateApi('Finnhub', { status: 'ok' }); - // Sectors - const sectors = await fetchMultipleStocks( - SECTORS.map((s) => ({ ...s, display: s.name })), - { - onBatch: (partialSectors) => { - (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( - partialSectors.map((s) => ({ name: s.name, change: s.change })) + if (stocksResult.skipped) { + const msg = 'FINNHUB_API_KEY not configured — add in Settings'; + this.panels['markets']?.showConfigError(msg); + this.panels['heatmap']?.showConfigError(msg); + this.panels['commodities']?.showConfigError(msg); + this.statusPanel?.updateApi('Finnhub', { status: 'error' }); + } else { + this.latestMarkets = stocksResult.data; + (this.panels['markets'] as MarketPanel).renderMarkets(stocksResult.data); + this.statusPanel?.updateApi('Finnhub', { status: 'ok' }); + + const sectorsResult = await fetchMultipleStocks( + SECTORS.map((s) => ({ ...s, display: s.name })), + { + onBatch: (partialSectors) => { + (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( + partialSectors.map((s) => ({ name: s.name, change: s.change })) + ); + }, + } + ); + (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( + sectorsResult.data.map((s) => ({ name: s.name, change: s.change })) + ); + + const commoditiesResult = await fetchMultipleStocks(COMMODITIES, { + onBatch: (partialCommodities) => { + (this.panels['commodities'] as CommoditiesPanel).renderCommodities( + partialCommodities.map((c) => ({ + display: c.display, + price: c.price, + change: c.change, + sparkline: c.sparkline, + })) ); }, - } - ); - (this.panels['heatmap'] as HeatmapPanel).renderHeatmap( - sectors.map((s) => ({ name: s.name, change: s.change })) - ); - - // Commodities - const commodities = await fetchMultipleStocks(COMMODITIES, { - onBatch: (partialCommodities) => { - (this.panels['commodities'] as CommoditiesPanel).renderCommodities( - partialCommodities.map((c) => ({ - display: c.display, - price: c.price, - change: c.change, - sparkline: c.sparkline, - })) - ); - }, - }); - (this.panels['commodities'] as CommoditiesPanel).renderCommodities( - commodities.map((c) => ({ display: c.display, price: c.price, change: c.change, sparkline: c.sparkline })) - ); + }); + (this.panels['commodities'] as CommoditiesPanel).renderCommodities( + commoditiesResult.data.map((c) => ({ display: c.display, price: c.price, change: c.change, sparkline: c.sparkline })) + ); + } } catch { this.statusPanel?.updateApi('Finnhub', { status: 'error' }); } @@ -3811,7 +3817,13 @@ export class App { private async loadFirmsData(): Promise { try { - const { regions, totalCount } = await fetchAllFires(1); + const fireResult = await fetchAllFires(1); + if (fireResult.skipped) { + this.panels['satellite-fires']?.showConfigError('NASA_FIRMS_API_KEY not configured — add in Settings'); + this.statusPanel?.updateApi('FIRMS', { status: 'error' }); + return; + } + const { regions, totalCount } = fireResult; if (totalCount > 0) { const flat = flattenFires(regions); const stats = computeRegionStats(regions); diff --git a/src/components/ETFFlowsPanel.ts b/src/components/ETFFlowsPanel.ts index ce1a9ee0a..11bf88e81 100644 --- a/src/components/ETFFlowsPanel.ts +++ b/src/components/ETFFlowsPanel.ts @@ -79,6 +79,10 @@ export class ETFFlowsPanel extends Panel { } } + private isUpstreamUnavailable(): boolean { + return this.data?.unavailable === true; + } + private renderPanel(): void { if (this.loading) { this.showLoading('Loading ETF data...'); @@ -90,6 +94,11 @@ export class ETFFlowsPanel extends Panel { return; } + if (this.isUpstreamUnavailable()) { + this.showError('Upstream API unavailable — will retry automatically'); + return; + } + const d = this.data; if (!d.etfs.length) { this.setContent('
ETF data temporarily unavailable
'); diff --git a/src/components/MacroSignalsPanel.ts b/src/components/MacroSignalsPanel.ts index 56da27353..e1be04dc2 100644 --- a/src/components/MacroSignalsPanel.ts +++ b/src/components/MacroSignalsPanel.ts @@ -16,6 +16,7 @@ interface MacroSignalData { fearGreed: { status: string; value: number | null; history: Array<{ value: number; date: string }> }; }; meta: { qqqSparkline: number[] }; + unavailable?: boolean; } function sparklineSvg(data: number[], width = 80, height = 24, color = '#4fc3f7'): string { @@ -105,6 +106,11 @@ export class MacroSignalsPanel extends Panel { return; } + if (this.data.unavailable) { + this.showError('Upstream API unavailable — will retry automatically'); + return; + } + const d = this.data; const s = d.signals; diff --git a/src/components/Panel.ts b/src/components/Panel.ts index bc2457225..13ebfa9c9 100644 --- a/src/components/Panel.ts +++ b/src/components/Panel.ts @@ -1,4 +1,6 @@ import { escapeHtml } from '../utils/sanitize'; +import { isDesktopRuntime } from '../services/runtime'; +import { invokeTauri } from '../services/tauri-bridge'; export interface PanelOptions { id: string; @@ -294,6 +296,18 @@ export class Panel { this.content.innerHTML = `
${escapeHtml(message)}
`; } + public showConfigError(message: string): void { + const settingsBtn = isDesktopRuntime() + ? '' + : ''; + this.content.innerHTML = `
${escapeHtml(message)}${settingsBtn}
`; + if (isDesktopRuntime()) { + this.content.querySelector('.config-error-settings-btn')?.addEventListener('click', () => { + void invokeTauri('open_settings_window_command').catch(() => {}); + }); + } + } + public setCount(count: number): void { if (this.countEl) { this.countEl.textContent = count.toString(); diff --git a/src/components/RuntimeConfigPanel.ts b/src/components/RuntimeConfigPanel.ts index b9d830e82..58cc65c71 100644 --- a/src/components/RuntimeConfigPanel.ts +++ b/src/components/RuntimeConfigPanel.ts @@ -162,11 +162,18 @@ export class RuntimeConfigPanel extends Panel { const availableFeatures = RUNTIME_FEATURES.filter((feature) => isFeatureAvailable(feature.id)).length; const missingFeatures = Math.max(0, totalFeatures - availableFeatures); const configuredCount = Object.keys(snapshot.secrets).length; + + if (missingFeatures === 0 && configuredCount >= totalFeatures) { + this.hide(); + return; + } + const alertTitle = configuredCount > 0 ? (missingFeatures > 0 ? 'Some features need API keys' : 'Desktop settings configured') : 'Configure API keys to unlock features'; const alertClass = missingFeatures > 0 ? 'warn' : 'ok'; + this.show(); this.content.innerHTML = `

${alertTitle}

diff --git a/src/components/StablecoinPanel.ts b/src/components/StablecoinPanel.ts index 30c4a29da..dcea19dd6 100644 --- a/src/components/StablecoinPanel.ts +++ b/src/components/StablecoinPanel.ts @@ -80,6 +80,10 @@ export class StablecoinPanel extends Panel { } } + private isUpstreamUnavailable(): boolean { + return this.data?.unavailable === true; + } + private renderPanel(): void { if (this.loading) { this.showLoading('Loading stablecoins...'); @@ -91,6 +95,11 @@ export class StablecoinPanel extends Panel { return; } + if (this.isUpstreamUnavailable()) { + this.showError('Upstream API unavailable — will retry automatically'); + return; + } + const d = this.data; if (!d.stablecoins.length) { this.setContent('
Stablecoin data temporarily unavailable
'); diff --git a/src/services/firms-satellite.ts b/src/services/firms-satellite.ts index 226f08f83..1ebc70c03 100644 --- a/src/services/firms-satellite.ts +++ b/src/services/firms-satellite.ts @@ -27,11 +27,14 @@ export interface FireRegionStats { const FIRMS_API = '/api/firms-fires'; -// Fetch fires for all monitored regions -export async function fetchAllFires(days: number = 1): Promise<{ +export interface FiresFetchResult { regions: Record; totalCount: number; -}> { + skipped?: boolean; + reason?: string; +} + +export async function fetchAllFires(days: number = 1): Promise { try { const res = await fetch(`${FIRMS_API}?days=${days}`); if (!res.ok) { @@ -39,6 +42,9 @@ export async function fetchAllFires(days: number = 1): Promise<{ return { regions: {}, totalCount: 0 }; } const data = await res.json(); + if (data.skipped) { + return { regions: {}, totalCount: 0, skipped: true, reason: data.reason || 'NASA_FIRMS_API_KEY not configured' }; + } return { regions: data.regions || {}, totalCount: data.totalCount || 0 }; } catch (e) { console.warn('[FIRMS] Fetch failed:', e); diff --git a/src/services/markets.ts b/src/services/markets.ts index 00a51552e..f060698e9 100644 --- a/src/services/markets.ts +++ b/src/services/markets.ts @@ -18,6 +18,14 @@ interface FinnhubQuote { interface FinnhubResponse { quotes: FinnhubQuote[]; error?: string; + skipped?: boolean; + reason?: string; +} + +export interface MarketFetchResult { + data: MarketData[]; + skipped?: boolean; + reason?: string; } interface YahooFinanceResponse { @@ -59,7 +67,7 @@ let lastSuccessfulResults: MarketData[] = []; async function fetchFromFinnhub( symbols: Array<{ symbol: string; name: string; display: string }> -): Promise { +): Promise { const symbolList = symbols.map(s => s.symbol); const url = API_URLS.finnhub(symbolList); @@ -68,19 +76,23 @@ async function fetchFromFinnhub( if (!response.ok) { console.warn(`[Markets] Finnhub returned ${response.status}`); - return []; + return { data: [] }; } const data: FinnhubResponse = await response.json(); + if (data.skipped) { + return { data: [], skipped: true, reason: data.reason || 'FINNHUB_API_KEY not configured' }; + } + if (data.error) { console.warn(`[Markets] Finnhub error: ${data.error}`); - return []; + return { data: [] }; } const symbolMap = new Map(symbols.map(s => [s.symbol, s])); - return data.quotes + const results = data.quotes .filter(q => !q.error && q.price > 0) .map(q => { const info = symbolMap.get(q.symbol); @@ -92,9 +104,10 @@ async function fetchFromFinnhub( change: q.changePercent, }; }); + return { data: results }; } catch (error) { console.error('[Markets] Finnhub fetch failed:', error); - return []; + return { data: [] }; } } @@ -132,21 +145,24 @@ export async function fetchMultipleStocks( options: { onBatch?: (results: MarketData[]) => void; } = {} -): Promise { - // Split symbols into Finnhub-compatible and Yahoo-only +): Promise { const finnhubSymbols = symbols.filter(s => !YAHOO_ONLY_SYMBOLS.has(s.symbol)); const yahooSymbols = symbols.filter(s => YAHOO_ONLY_SYMBOLS.has(s.symbol)); const results: MarketData[] = []; + let skipped = false; + let reason: string | undefined; - // Fetch from Finnhub (batch request) if (finnhubSymbols.length > 0) { - const finnhubResults = await fetchFromFinnhub(finnhubSymbols); - results.push(...finnhubResults); + const finnhubResult = await fetchFromFinnhub(finnhubSymbols); + if (finnhubResult.skipped) { + skipped = true; + reason = finnhubResult.reason; + } + results.push(...finnhubResult.data); options.onBatch?.(results); } - // Fetch indices/commodities from Yahoo (parallel) if (yahooSymbols.length > 0) { const yahooResults = await Promise.all( yahooSymbols.map(s => fetchFromYahoo(s.symbol, s.name, s.display)) @@ -159,10 +175,10 @@ export async function fetchMultipleStocks( lastSuccessfulResults = results; } - return results.length > 0 ? results : lastSuccessfulResults; + const data = results.length > 0 ? results : lastSuccessfulResults; + return { data, skipped, reason }; } -// Legacy single-symbol function (still used by some components) export async function fetchStockQuote( symbol: string, name: string, @@ -173,8 +189,8 @@ export async function fetchStockQuote( return result || { symbol, name, display, price: null, change: null }; } - const results = await fetchFromFinnhub([{ symbol, name, display }]); - return results[0] || { symbol, name, display, price: null, change: null }; + const result = await fetchFromFinnhub([{ symbol, name, display }]); + return result.data[0] || { symbol, name, display, price: null, change: null }; } export async function fetchCrypto(): Promise { diff --git a/src/styles/main.css b/src/styles/main.css index ee50150b7..bd6cef4bd 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -3618,6 +3618,31 @@ a.prediction-link:hover { text-align: center; } +.config-error-message { + color: #ffd27c; + font-size: 10px; + padding: 8px; + text-align: center; + line-height: 1.6; +} + +.config-error-settings-btn { + display: inline-block; + margin-top: 6px; + padding: 2px 10px; + font-size: 9px; + color: #ffd27c; + background: rgba(255, 210, 124, 0.1); + border: 1px solid rgba(255, 210, 124, 0.3); + border-radius: 3px; + cursor: pointer; + font-family: inherit; +} + +.config-error-settings-btn:hover { + background: rgba(255, 210, 124, 0.2); +} + /* Modal */ .modal-overlay { position: fixed;