mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(cors): use ACAO: * for bootstrap to fix CF cache origin pinning CF ignores Vary: Origin and pins the first request's ACAO header on the cached response. Preview deployments from *.vercel.app got ACAO: worldmonitor.app from CF's cache, blocking CORS. Bootstrap data is fully public (world events, market prices, seismic data) so ACAO: * is safe and allows CF to cache one entry valid for all origins. isDisallowedOrigin() still gates non-cache paths. * chore: finish security triage * fix(aviation): update isArray callback signature for fast-xml-parser 5.5.x fast-xml-parser bumped from 5.4.2 to 5.5.7 changed the isArray callback's second parameter type from string to unknown. Guard with typeof check before calling .test() to satisfy the new type contract. * docs: fix MD032 blank lines around lists in tradingview-screener-integration * fix(security): address code review findings from PR #1903 - api/_json-response.js: add recursion depth limit (20) to sanitizeJsonValue and strip Error.cause chain alongside stack/stackTrace - scripts/ais-relay.cjs: extract WORLD_BANK_COUNTRY_ALLOWLIST to module level to eliminate duplicate; clamp years param to [1,30] to prevent unbounded World Bank date ranges - src-tauri/sidecar/local-api-server.mjs: use JSON.stringify for vq value in inline JS, consistent with safeVideoId/safeOrigin handling - src/services/story-share.ts: simplify sanitizeStoryType to use typed array instead of repeated as-casts * fix(desktop): use parent window origin for YouTube embed postMessage Sidecar youtube-embed route was targeting the iframe's own localhost origin for all window.parent.postMessage calls, so browsers dropped yt-ready/ yt-state/yt-error on Tauri builds where the parent is tauri://localhost or asset://localhost. LiveNewsPanel and LiveWebcamsPanel already pass parentOrigin=window.location.origin in the embed URL; the sidecar now reads, validates, and uses it as the postMessage target for all player event messages. The YT API playerVars origin/widget_referrer continue to use the sidecar's own localhost origin which YouTube requires. Also restore World Bank relay to a generic proxy: replace TECH_INDICATORS membership check with a format-only regex so any valid indicator code (NY.GDP.MKTP.CD etc.) is accepted, not just the 16 tech-sector codes.
377 lines
17 KiB
TypeScript
377 lines
17 KiB
TypeScript
import {
|
|
fetchAirportOpsSummary,
|
|
fetchAirportFlights,
|
|
fetchCarrierOps,
|
|
fetchAircraftPositions,
|
|
fetchFlightPrices,
|
|
fetchAviationNews,
|
|
isPriceExpired,
|
|
type AirportOpsSummary,
|
|
type FlightInstance,
|
|
type CarrierOps,
|
|
type PositionSample,
|
|
type PriceQuote,
|
|
type AviationNewsItem,
|
|
type FlightDelaySeverity,
|
|
} from '@/services/aviation';
|
|
import { aviationWatchlist } from '@/services/aviation/watchlist';
|
|
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
|
import { t } from '@/services/i18n';
|
|
import { Panel } from './Panel';
|
|
|
|
// ---- Helpers ----
|
|
|
|
const SEVERITY_COLOR: Record<FlightDelaySeverity, string> = {
|
|
normal: 'var(--color-success, #22c55e)',
|
|
minor: '#f59e0b',
|
|
moderate: '#f97316',
|
|
major: '#ef4444',
|
|
severe: '#dc2626',
|
|
};
|
|
|
|
const STATUS_BADGE: Record<string, string> = {
|
|
scheduled: '#6b7280', boarding: '#3b82f6', departed: '#8b5cf6',
|
|
airborne: '#22c55e', landed: '#14b8a6', arrived: '#0ea5e9',
|
|
cancelled: '#ef4444', diverted: '#f59e0b', unknown: '#6b7280',
|
|
};
|
|
|
|
function fmt(n: number | null | undefined): string { return n == null ? '—' : String(Math.round(n)); }
|
|
function fmtTime(dt: Date | null | undefined): string {
|
|
if (!dt) return '—';
|
|
return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
}
|
|
function fmtMin(m: number): string {
|
|
if (!m) return '—';
|
|
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
}
|
|
function expCountdown(exp: Date | null, now: number): string {
|
|
if (!exp) return '';
|
|
const ms = exp.getTime() - now;
|
|
if (ms <= 0) return '<span style="color:#ef4444;font-size:10px">EXPIRED</span>';
|
|
const h = Math.floor(ms / 3_600_000);
|
|
const m = Math.floor((ms % 3_600_000) / 60_000);
|
|
const color = h < 1 ? '#f97316' : '#6b7280';
|
|
return `<span style="font-size:10px;color:${color}">exp ${h > 0 ? `${h}h ` : ''}${m}m</span>`;
|
|
}
|
|
|
|
const TABS = ['ops', 'flights', 'airlines', 'tracking', 'news', 'prices'] as const;
|
|
type Tab = typeof TABS[number];
|
|
|
|
const TAB_LABELS: Record<Tab, string> = {
|
|
ops: '🛫 Ops', flights: '✈️ Flights', airlines: '🏢 Airlines',
|
|
tracking: '📡 Track', news: '📰 News', prices: '💸 Prices',
|
|
};
|
|
|
|
// ---- Panel class ----
|
|
|
|
export class AirlineIntelPanel extends Panel {
|
|
private activeTab: Tab = 'ops';
|
|
private airports: string[];
|
|
private opsData: AirportOpsSummary[] = [];
|
|
private flightsData: FlightInstance[] = [];
|
|
private carriersData: CarrierOps[] = [];
|
|
private trackingData: PositionSample[] = [];
|
|
private newsData: AviationNewsItem[] = [];
|
|
private pricesData: PriceQuote[] = [];
|
|
private pricesProvider = 'demo';
|
|
private pricesOrigin = 'IST';
|
|
private pricesDest = 'LHR';
|
|
private pricesDep = '';
|
|
private pricesCurrency = 'usd';
|
|
private loading = false;
|
|
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
private liveIndicator!: HTMLElement;
|
|
private tabBar!: HTMLElement;
|
|
|
|
constructor() {
|
|
super({ id: 'airline-intel', title: t('panels.airlineIntel'), trackActivity: true });
|
|
|
|
const wl = aviationWatchlist.get();
|
|
this.airports = wl.airports.slice(0, 8);
|
|
|
|
// Add refresh button to header
|
|
const refreshBtn = document.createElement('button');
|
|
refreshBtn.className = 'icon-btn';
|
|
refreshBtn.title = t('common.refresh');
|
|
refreshBtn.textContent = '↻';
|
|
refreshBtn.addEventListener('click', () => this.refresh());
|
|
this.header.appendChild(refreshBtn);
|
|
|
|
// Add LIVE indicator badge to the title
|
|
this.liveIndicator = document.createElement('span');
|
|
this.liveIndicator.className = 'live-badge';
|
|
this.liveIndicator.textContent = '\u25CF LIVE';
|
|
this.liveIndicator.style.cssText = 'display:none;color:#22c55e;font-size:10px;font-weight:700;margin-left:8px;letter-spacing:0.5px;';
|
|
this.header.querySelector('.panel-title')?.appendChild(this.liveIndicator);
|
|
|
|
// Insert tab bar between header and content
|
|
this.tabBar = document.createElement('div');
|
|
this.tabBar.className = 'panel-tabs';
|
|
TABS.forEach(tab => {
|
|
const btn = document.createElement('button');
|
|
btn.className = `panel-tab${tab === this.activeTab ? ' active' : ''}`;
|
|
btn.textContent = TAB_LABELS[tab];
|
|
btn.dataset.tab = tab;
|
|
btn.addEventListener('click', () => this.switchTab(tab as Tab));
|
|
this.tabBar.appendChild(btn);
|
|
});
|
|
this.element.insertBefore(this.tabBar, this.content);
|
|
|
|
// Add styling class to inherited content div
|
|
this.content.classList.add('airline-intel-content');
|
|
|
|
// Event delegation on stable content element (survives innerHTML replacements)
|
|
this.content.addEventListener('click', (e) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.id === 'priceSearchBtn' || target.closest('#priceSearchBtn')) {
|
|
this.pricesOrigin = ((this.content.querySelector('#priceFromInput') as HTMLInputElement)?.value || 'IST').toUpperCase();
|
|
this.pricesDest = ((this.content.querySelector('#priceToInput') as HTMLInputElement)?.value || 'LHR').toUpperCase();
|
|
this.pricesDep = (this.content.querySelector('#priceDepInput') as HTMLInputElement)?.value || '';
|
|
this.pricesCurrency = (this.content.querySelector('#priceCurrencySelect') as HTMLSelectElement)?.value || 'usd';
|
|
void this.loadTab('prices');
|
|
}
|
|
});
|
|
|
|
void this.refresh();
|
|
|
|
// Auto-refresh every 5 min — refresh() loads ops + active tab
|
|
this.refreshTimer = setInterval(() => void this.refresh(), 5 * 60_000);
|
|
}
|
|
|
|
toggle(visible: boolean): void {
|
|
this.element.style.display = visible ? '' : 'none';
|
|
}
|
|
|
|
destroy(): void {
|
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
super.destroy();
|
|
}
|
|
|
|
/** Called by the map when new aircraft positions arrive. */
|
|
updateLivePositions(positions: PositionSample[]): void {
|
|
this.trackingData = positions;
|
|
if (this.activeTab === 'tracking') this.renderTab();
|
|
}
|
|
|
|
/** Toggle the LIVE indicator badge. */
|
|
setLiveMode(active: boolean): void {
|
|
this.liveIndicator.style.display = active ? '' : 'none';
|
|
}
|
|
|
|
private switchTab(tab: Tab): void {
|
|
this.activeTab = tab;
|
|
this.tabBar.querySelectorAll('.panel-tab').forEach(b => {
|
|
b.classList.toggle('active', (b as HTMLElement).dataset.tab === tab);
|
|
});
|
|
this.renderTab();
|
|
if ((tab === 'ops' && !this.opsData.length) ||
|
|
(tab === 'flights' && !this.flightsData.length) ||
|
|
(tab === 'airlines' && !this.carriersData.length) ||
|
|
(tab === 'tracking' && !this.trackingData.length) ||
|
|
(tab === 'news' && !this.newsData.length) ||
|
|
(tab === 'prices' && !this.pricesData.length)) {
|
|
void this.loadTab(tab);
|
|
}
|
|
}
|
|
|
|
private async refresh(): Promise<void> {
|
|
void this.loadOps();
|
|
void this.loadTab(this.activeTab);
|
|
}
|
|
|
|
private async loadOps(): Promise<void> {
|
|
this.opsData = await fetchAirportOpsSummary(this.airports);
|
|
if (this.activeTab === 'ops') this.renderTab();
|
|
}
|
|
|
|
private async loadTab(tab: Tab): Promise<void> {
|
|
this.loading = true;
|
|
this.renderTab();
|
|
try {
|
|
switch (tab) {
|
|
case 'ops':
|
|
this.opsData = await fetchAirportOpsSummary(this.airports);
|
|
break;
|
|
case 'flights':
|
|
this.flightsData = await fetchAirportFlights(this.airports[0] ?? 'IST', 'both', 30);
|
|
break;
|
|
case 'airlines':
|
|
this.carriersData = await fetchCarrierOps(this.airports);
|
|
break;
|
|
case 'tracking':
|
|
this.trackingData = await fetchAircraftPositions({});
|
|
break;
|
|
case 'news': {
|
|
const entities = [...this.airports, ...aviationWatchlist.get().airlines];
|
|
this.newsData = await fetchAviationNews(entities, 24, 20);
|
|
break;
|
|
}
|
|
case 'prices': {
|
|
const dep = this.pricesDep || new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
|
const result = await fetchFlightPrices({
|
|
origin: this.pricesOrigin, destination: this.pricesDest,
|
|
departureDate: dep, currency: this.pricesCurrency,
|
|
});
|
|
this.pricesData = result.quotes;
|
|
this.pricesProvider = result.provider;
|
|
break;
|
|
}
|
|
}
|
|
} catch { /* silent */ }
|
|
this.loading = false;
|
|
this.renderTab();
|
|
}
|
|
|
|
private renderLoading(): void {
|
|
this.content.innerHTML = `<div class="panel-loading">${t('common.loading')}</div>`;
|
|
}
|
|
|
|
private renderTab(): void {
|
|
if (this.loading) { this.renderLoading(); return; }
|
|
switch (this.activeTab) {
|
|
case 'ops': this.renderOps(); break;
|
|
case 'flights': this.renderFlights(); break;
|
|
case 'airlines': this.renderAirlines(); break;
|
|
case 'tracking': this.renderTracking(); break;
|
|
case 'news': this.renderNews(); break;
|
|
case 'prices': this.renderPrices(); break;
|
|
}
|
|
}
|
|
|
|
// ---- Ops tab ----
|
|
private renderOps(): void {
|
|
if (!this.opsData.length) {
|
|
this.content.innerHTML = `<div class="no-data">${t('components.airlineIntel.noOpsData')}</div>`;
|
|
return;
|
|
}
|
|
const rows = this.opsData.map(s => `
|
|
<div class="ops-row">
|
|
<div class="ops-iata">${escapeHtml(s.iata)}</div>
|
|
<div class="ops-name">${escapeHtml(s.name || s.iata)}</div>
|
|
<div class="ops-severity" style="color:${SEVERITY_COLOR[s.severity] ?? '#aaa'}">${s.severity.toUpperCase()}</div>
|
|
<div class="ops-delay">${s.avgDelayMinutes > 0 ? `+${s.avgDelayMinutes}m` : '—'}</div>
|
|
<div class="ops-cancel">${s.cancellationRate > 0 ? `${s.cancellationRate.toFixed(1)}% cxl` : ''}</div>
|
|
${s.closureStatus ? '<div class="ops-closed">CLOSED</div>' : ''}
|
|
${s.notamFlags.length ? `<div class="ops-notam">⚠️ NOTAM</div>` : ''}
|
|
</div>`).join('');
|
|
this.content.innerHTML = `<div class="ops-grid">${rows}</div>`;
|
|
}
|
|
|
|
// ---- Flights tab ----
|
|
private renderFlights(): void {
|
|
if (!this.flightsData.length) {
|
|
this.content.innerHTML = `<div class="no-data">${t('components.airlineIntel.noFlights')}</div>`;
|
|
return;
|
|
}
|
|
const rows = this.flightsData.map(f => {
|
|
const color = STATUS_BADGE[f.status] ?? '#6b7280';
|
|
return `
|
|
<div class="flight-row">
|
|
<div class="flight-num">${escapeHtml(f.flightNumber)}</div>
|
|
<div class="flight-route">${escapeHtml(f.origin.iata)} → ${escapeHtml(f.destination.iata)}</div>
|
|
<div class="flight-time">${fmtTime(f.scheduledDeparture)}</div>
|
|
<div class="flight-delay" style="color:${f.delayMinutes > 0 ? '#f97316' : '#aaa'}">${f.delayMinutes > 0 ? `+${f.delayMinutes}m` : ''}</div>
|
|
<div class="flight-status" style="color:${color}">${f.status}</div>
|
|
</div>`;
|
|
}).join('');
|
|
this.content.innerHTML = `<div class="flights-list">${rows}</div>`;
|
|
}
|
|
|
|
// ---- Airlines tab ----
|
|
private renderAirlines(): void {
|
|
if (!this.carriersData.length) {
|
|
this.content.innerHTML = `<div class="no-data">${t('components.airlineIntel.noCarrierData')}</div>`;
|
|
return;
|
|
}
|
|
const rows = this.carriersData.slice(0, 15).map(c => `
|
|
<div class="carrier-row">
|
|
<div class="carrier-name">${escapeHtml(c.carrierName || c.carrierIata)}</div>
|
|
<div class="carrier-flights">${c.totalFlights} flt</div>
|
|
<div class="carrier-delay" style="color:${c.delayPct > 30 ? '#ef4444' : '#aaa'}">${c.delayPct.toFixed(1)}% delayed</div>
|
|
<div class="carrier-cancel">${c.cancellationRate.toFixed(1)}% cxl</div>
|
|
</div>`).join('');
|
|
this.content.innerHTML = `<div class="carriers-list">${rows}</div>`;
|
|
}
|
|
|
|
// ---- Tracking tab ----
|
|
private renderTracking(): void {
|
|
if (!this.trackingData.length) {
|
|
this.content.innerHTML = `<div class="no-data">${t('components.airlineIntel.noTrackingData')}</div>`;
|
|
return;
|
|
}
|
|
const rows = this.trackingData.slice(0, 20).map(p => `
|
|
<div class="track-row">
|
|
<div class="track-cs">${escapeHtml(p.callsign || p.icao24)}</div>
|
|
<div class="track-alt">${fmt(p.altitudeFt)} ft</div>
|
|
<div class="track-spd">${fmt(p.groundSpeedKts)} kts</div>
|
|
<div class="track-pos">${p.lat.toFixed(2)}, ${p.lon.toFixed(2)}</div>
|
|
</div>`).join('');
|
|
this.content.innerHTML = `<div class="tracking-list">${rows}</div>`;
|
|
}
|
|
|
|
// ---- News tab ----
|
|
private renderNews(): void {
|
|
if (!this.newsData.length) {
|
|
this.content.innerHTML = `<div class="no-data">${t('components.airlineIntel.noNews')}</div>`;
|
|
return;
|
|
}
|
|
const items = this.newsData.map(n => `
|
|
<div class="news-item" style="padding:8px 0;border-bottom:1px solid var(--border,#2a2a2a)">
|
|
<a href="${sanitizeUrl(n.url)}" target="_blank" rel="noopener" class="news-link">${escapeHtml(n.title)}</a>
|
|
<div class="news-meta" style="font-size:11px;color:var(--text-dim,#888);margin-top:2px">${escapeHtml(n.sourceName)} · ${n.publishedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
|
|
</div>`).join('');
|
|
this.content.innerHTML = `<div class="news-list" style="padding:0 4px">${items}</div>`;
|
|
}
|
|
|
|
// ---- Prices tab ----
|
|
private renderPrices(): void {
|
|
const provider = this.pricesProvider;
|
|
const providerBadge = provider === 'travelpayouts_data'
|
|
? `<span class="tp-badge">${escapeHtml(t('components.airlineIntel.cachedInsight'))} · Travelpayouts</span>`
|
|
: `<span class="demo-badge">${escapeHtml(t('components.airlineIntel.demoMode'))}</span>`;
|
|
|
|
const searchForm = `
|
|
<div class="price-controls" style="display:flex;gap:6px;flex-wrap:wrap;padding:8px 0;align-items:center">
|
|
<input id="priceFromInput" class="price-input" placeholder="From" maxlength="3" value="${escapeHtml(this.pricesOrigin)}" style="width:54px">
|
|
<span style="color:#6b7280">\u2192</span>
|
|
<input id="priceToInput" class="price-input" placeholder="To" maxlength="3" value="${escapeHtml(this.pricesDest)}" style="width:54px">
|
|
<input id="priceDepInput" class="price-input" type="date" value="${escapeHtml(this.pricesDep)}" style="width:128px">
|
|
<select id="priceCurrencySelect" class="price-input" style="width:58px">
|
|
<option value="usd"${this.pricesCurrency === 'usd' ? ' selected' : ''}>USD</option>
|
|
<option value="eur"${this.pricesCurrency === 'eur' ? ' selected' : ''}>EUR</option>
|
|
<option value="try"${this.pricesCurrency === 'try' ? ' selected' : ''}>TRY</option>
|
|
<option value="gbp"${this.pricesCurrency === 'gbp' ? ' selected' : ''}>GBP</option>
|
|
</select>
|
|
<button id="priceSearchBtn" class="icon-btn" style="padding:4px 10px">${t('common.search')}</button>
|
|
</div>
|
|
<div style="margin-bottom:6px">${providerBadge}<span style="font-size:10px;color:#6b7280;margin-left:6px">${t('components.airlineIntel.pricesIndicative')}</span></div>`;
|
|
|
|
if (!this.pricesData.length) {
|
|
this.content.innerHTML = `${searchForm}<div class="no-data">${t('components.airlineIntel.enterRoute')}</div>`;
|
|
} else {
|
|
const now = Date.now();
|
|
const active = this.pricesData.filter(q => !isPriceExpired(q));
|
|
const expired = this.pricesData.filter(q => isPriceExpired(q));
|
|
const sorted = [...active, ...expired];
|
|
|
|
const rows = sorted.map(q => {
|
|
const exp = isPriceExpired(q);
|
|
const currency = q.currency || this.pricesCurrency.toUpperCase();
|
|
return `
|
|
<div class="price-row" style="${exp ? 'opacity:0.4;' : ''}">
|
|
<div class="price-carrier">${escapeHtml(q.carrierName || q.carrierIata || '\u2014')}</div>
|
|
<div class="price-route" style="flex:1">${escapeHtml(q.origin)} \u2192 ${escapeHtml(q.destination)}</div>
|
|
<div class="price-amount" style="font-weight:700;color:${exp ? '#6b7280' : 'var(--accent,#60a5fa)'}">${currency} ${Math.round(q.priceAmount)}</div>
|
|
<div class="price-dur">${fmtMin(q.durationMinutes)}</div>
|
|
<div class="price-stops">${q.stops === 0 ? 'nonstop' : `${q.stops} stop`}</div>
|
|
${expCountdown(q.expiresAt, now)}
|
|
</div>`;
|
|
}).join('');
|
|
this.content.innerHTML = `${searchForm}<div class="prices-list">${rows}</div>`;
|
|
}
|
|
|
|
}
|
|
|
|
/* Styles moved to panels.css (PERF-012) */
|
|
}
|