File size: 9,835 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | import { Panel } from './Panel';
import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize';
interface CrossSourceSignal {
id: string;
type: string;
theater: string;
summary: string;
severity: string;
severityScore: number;
detectedAt: number;
contributingTypes: string[];
signalCount: number;
}
interface CrossSourceSignalsData {
signals: CrossSourceSignal[];
evaluatedAt: number;
compositeCount: number;
}
const SEVERITY_COLOR: Record<string, string> = {
CROSS_SOURCE_SIGNAL_SEVERITY_CRITICAL: 'var(--semantic-critical)',
CROSS_SOURCE_SIGNAL_SEVERITY_HIGH: '#ff8c8c',
CROSS_SOURCE_SIGNAL_SEVERITY_MEDIUM: 'var(--yellow)',
CROSS_SOURCE_SIGNAL_SEVERITY_LOW: 'var(--text-dim)',
};
const SEVERITY_LABEL: Record<string, string> = {
CROSS_SOURCE_SIGNAL_SEVERITY_CRITICAL: 'CRITICAL',
CROSS_SOURCE_SIGNAL_SEVERITY_HIGH: 'HIGH',
CROSS_SOURCE_SIGNAL_SEVERITY_MEDIUM: 'MED',
CROSS_SOURCE_SIGNAL_SEVERITY_LOW: 'LOW',
};
// Filled badge styles: bg + border + text per severity
const SEVERITY_BADGE_STYLE: Record<string, string> = {
CROSS_SOURCE_SIGNAL_SEVERITY_CRITICAL: 'background:var(--semantic-critical);color:#fff;border:1px solid var(--semantic-critical)',
CROSS_SOURCE_SIGNAL_SEVERITY_HIGH: 'background:rgba(255,140,140,0.15);color:#ff8c8c;border:1px solid rgba(255,140,140,0.4)',
CROSS_SOURCE_SIGNAL_SEVERITY_MEDIUM: 'background:rgba(245,197,66,0.08);color:var(--yellow);border:1px solid rgba(245,197,66,0.35)',
CROSS_SOURCE_SIGNAL_SEVERITY_LOW: 'background:transparent;color:var(--text-dim);border:1px solid var(--border)',
};
const TYPE_LABEL: Record<string, string> = {
CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION: 'COMPOSITE',
CROSS_SOURCE_SIGNAL_TYPE_THERMAL_SPIKE: 'THERMAL',
CROSS_SOURCE_SIGNAL_TYPE_GPS_JAMMING: 'GPS JAM',
CROSS_SOURCE_SIGNAL_TYPE_MILITARY_FLIGHT_SURGE: 'MIL FLTX',
CROSS_SOURCE_SIGNAL_TYPE_UNREST_SURGE: 'UNREST',
CROSS_SOURCE_SIGNAL_TYPE_OREF_ALERT_CLUSTER: 'ADVISORY',
CROSS_SOURCE_SIGNAL_TYPE_VIX_SPIKE: 'VIX',
CROSS_SOURCE_SIGNAL_TYPE_COMMODITY_SHOCK: 'COMDTY',
CROSS_SOURCE_SIGNAL_TYPE_CYBER_ESCALATION: 'CYBER',
CROSS_SOURCE_SIGNAL_TYPE_SHIPPING_DISRUPTION: 'SHIPPING',
CROSS_SOURCE_SIGNAL_TYPE_SANCTIONS_SURGE: 'SANCTIONS',
CROSS_SOURCE_SIGNAL_TYPE_EARTHQUAKE_SIGNIFICANT: 'QUAKE',
CROSS_SOURCE_SIGNAL_TYPE_RADIATION_ANOMALY: 'RADIATION',
CROSS_SOURCE_SIGNAL_TYPE_INFRASTRUCTURE_OUTAGE: 'INFRA',
CROSS_SOURCE_SIGNAL_TYPE_WILDFIRE_ESCALATION: 'WILDFIRE',
CROSS_SOURCE_SIGNAL_TYPE_DISPLACEMENT_SURGE: 'DISPLCMT',
CROSS_SOURCE_SIGNAL_TYPE_FORECAST_DETERIORATION: 'FORECAST',
CROSS_SOURCE_SIGNAL_TYPE_MARKET_STRESS: 'MARKET',
CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME: 'WEATHER',
CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION: 'MEDIA',
CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE: 'RISK',
};
// Category icon prefix for type badges
const TYPE_ICON: Record<string, string> = {
CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION: 'β‘',
CROSS_SOURCE_SIGNAL_TYPE_THERMAL_SPIKE: 'π΄',
CROSS_SOURCE_SIGNAL_TYPE_EARTHQUAKE_SIGNIFICANT: 'π΄',
CROSS_SOURCE_SIGNAL_TYPE_RADIATION_ANOMALY: 'π΄',
CROSS_SOURCE_SIGNAL_TYPE_WILDFIRE_ESCALATION: 'π΄',
CROSS_SOURCE_SIGNAL_TYPE_GPS_JAMMING: 'π‘',
CROSS_SOURCE_SIGNAL_TYPE_CYBER_ESCALATION: 'π‘',
CROSS_SOURCE_SIGNAL_TYPE_MILITARY_FLIGHT_SURGE: 'βοΈ',
CROSS_SOURCE_SIGNAL_TYPE_VIX_SPIKE: 'π',
CROSS_SOURCE_SIGNAL_TYPE_COMMODITY_SHOCK: 'π',
CROSS_SOURCE_SIGNAL_TYPE_MARKET_STRESS: 'π',
CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE: 'π',
};
const TYPE_ICON_DEFAULT = 'β οΈ';
export class CrossSourceSignalsPanel extends Panel {
private signals: CrossSourceSignal[] = [];
private evaluatedAt: Date | null = null;
private compositeCount = 0;
constructor() {
super({
id: 'cross-source-signals',
title: 'Cross-Source Signal Aggregator',
showCount: true,
trackActivity: true,
infoTooltip: 'Aggregates 15+ real-time data streams every 15 minutes. Ranks cross-domain signals by severity and detects composite escalation when 3 or more signal categories co-fire in the same theater.',
});
// Inject keyframe once β used by the composite banner pulse dot
const style = document.createElement('style');
style.textContent = '@keyframes cross-source-pulse-dot{0%,100%{opacity:1}50%{opacity:.15}}';
document.head.appendChild(style);
this.showLoading('Loading signal data...');
}
public setData(data: CrossSourceSignalsData): void {
this.signals = data.signals ?? [];
this.evaluatedAt = data.evaluatedAt ? new Date(data.evaluatedAt) : null;
this.compositeCount = data.compositeCount ?? 0;
this.setCount(this.signals.length);
this.resetRetryBackoff();
this.render();
}
public showFetchError(): void {
this.showError('Signal data unavailable β upstream feeds unreachable.', () => {/* refreshed by scheduler */});
}
private ageSuffix(ts: number): string {
const diffMs = Date.now() - ts;
const mins = Math.floor(diffMs / 60000);
if (mins < 2) return 'just now';
if (mins < 60) return `${mins}m ago`;
return `${Math.floor(mins / 60)}h ago`;
}
private renderSignal(sig: CrossSourceSignal, index: number): string {
const isComposite = sig.type === 'CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION';
const sevColor = SEVERITY_COLOR[sig.severity] ?? 'var(--text-dim)';
const sevBadgeStyle = SEVERITY_BADGE_STYLE[sig.severity] ?? SEVERITY_BADGE_STYLE.CROSS_SOURCE_SIGNAL_SEVERITY_LOW;
const typeLabel = TYPE_LABEL[sig.type] ?? sig.type.replace('CROSS_SOURCE_SIGNAL_TYPE_', '');
const typeIcon = TYPE_ICON[sig.type] ?? TYPE_ICON_DEFAULT;
const age = this.ageSuffix(sig.detectedAt);
const cardStyle = isComposite
? 'box-shadow:0 0 0 1px rgba(255,80,80,0.3),0 2px 8px rgba(255,80,80,0.08);border-color:rgba(255,80,80,0.25)'
: 'border:1px solid var(--border)';
const contributors = isComposite && sig.contributingTypes.length > 0
? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:5px">${
sig.contributingTypes.slice(0, 5).map(t =>
`<span style="font-size:9px;font-family:var(--font-mono);padding:1px 5px;background:rgba(255,255,255,0.05);border:1px solid var(--border);color:var(--text-dim);text-transform:uppercase;letter-spacing:0.06em;border-radius:2px">${escapeHtml(t)}</span>`
).join('')
}</div>`
: '';
return `
<div style="display:flex;align-items:stretch;${cardStyle};background:rgba(255,255,255,0.02);overflow:hidden">
<div style="width:4px;flex-shrink:0;background:${sevColor}"></div>
<div style="display:flex;gap:10px;align-items:flex-start;padding:10px;flex:1;min-width:0">
<div style="font-size:12px;font-weight:700;color:var(--text-dim);min-width:18px;text-align:right;flex-shrink:0;font-family:var(--font-mono);padding-top:1px">${index + 1}</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:5px">
<span style="font-size:10px;padding:2px 6px;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.06em;display:inline-flex;align-items:center;gap:4px"><span>${typeIcon}</span>${escapeHtml(typeLabel)}</span>
<span style="font-size:10px;padding:2px 6px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.08em;font-weight:700;${sevBadgeStyle}">${escapeHtml(SEVERITY_LABEL[sig.severity] ?? '')}</span>
<span style="display:inline-flex;align-items:center;gap:5px;background:rgba(255,255,255,0.06);border-radius:3px;padding:2px 7px;font-size:10px;color:rgba(232,234,237,0.65);font-family:var(--font-mono);letter-spacing:0.04em;white-space:nowrap">${escapeHtml(sig.theater)}<span style="opacity:0.4"> Β· </span>${escapeHtml(age)}</span>
</div>
<div style="font-size:12px;line-height:1.5;color:var(--text)">${escapeHtml(sig.summary)}</div>
${contributors}
</div>
</div>
</div>
`;
}
private render(): void {
if (this.signals.length === 0) {
if (!this.evaluatedAt) {
this.showError('Signal aggregator is initializing. First evaluation runs within 15 minutes.', () => {/* refreshed by scheduler */});
} else {
this.setSafeContent(unsafeRawHtml('<div style="padding:16px 0;text-align:center;font-size:12px;color:var(--text-dim)">No cross-source signals detected.</div>', 'legacy Panel.setContent() migration'));
}
return;
}
const evalTime = this.evaluatedAt
? `Evaluated ${this.evaluatedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
: '';
const compositeNote = this.compositeCount > 0
? `<div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--semantic-critical);padding:7px 10px;border:1px solid rgba(255,80,80,0.3);background:rgba(255,80,80,0.06);margin-bottom:8px"><div style="width:7px;height:7px;border-radius:50%;background:var(--semantic-critical);flex-shrink:0;animation:cross-source-pulse-dot 2s ease-in-out infinite"></div>${this.compositeCount} composite escalation zone${this.compositeCount > 1 ? 's' : ''} detected</div>`
: '';
const signalRows = this.signals.map((s, i) => this.renderSignal(s, i)).join('');
this.setSafeContent(unsafeRawHtml(`
<div style="display:flex;flex-direction:column;gap:6px">
${compositeNote}
${signalRows}
${evalTime ? `<div style="font-size:10px;color:var(--text-dim);padding-top:8px;border-top:1px solid var(--border);text-align:center;font-family:var(--font-mono)">${escapeHtml(evalTime)}</div>` : ''}
</div>
`, 'legacy Panel.setContent() migration'));
}
}
|