Add fleet ops dashboard, Calibrate enforcement, and dead-code cleanup.

Ship live alerts, pool status, AI monitor, remote agent commands, build manager, and uninstall flow; wire Calibrate settings (WS ping, pool traffic log, retention limits) at runtime and exclude server/data from git.
This commit is contained in:
drjones
2026-05-27 09:16:04 -07:00
parent 9d223b8137
commit df81eb7744
75 changed files with 8891 additions and 966 deletions

View File

@@ -0,0 +1,49 @@
.agent-remote.compact {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(0, 245, 255, 0.12);
}
.agent-remote-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.agent-action-btn.warn {
border-color: rgba(255, 176, 32, 0.5);
background: rgba(255, 176, 32, 0.12);
}
.agent-action-btn.warn:hover {
background: rgba(255, 176, 32, 0.22);
}
.agent-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.agent-remote-feedback {
margin: 0.5rem 0 0;
font-size: 0.78rem;
}
.agent-remote-feedback.ok {
color: var(--neon-green);
}
.agent-remote-feedback.bad {
color: #ff3c50;
}
.agent-list-actions {
display: flex;
gap: 0.35rem;
margin-top: 0.5rem;
}
.agent-list-actions .agent-action-btn {
padding: 0.25rem 0.5rem;
font-size: 0.68rem;
}

View File

@@ -0,0 +1,119 @@
import { useState, useCallback } from 'react';
import { api } from '../../api/client';
import type { Agent } from '../../types';
import './AgentRemoteActions.css';
type AgentCommandAction = 'pause' | 'resume' | 'restart' | 'stop' | 'uninstall' | 'get_log';
interface Props {
agent: Agent;
compact?: boolean;
onCommandSent?: (action: string, message: string) => void;
}
function useAgentCommand() {
const [busy, setBusy] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{ agentId: string; message: string; ok: boolean } | null>(null);
const runCommand = useCallback(async (agent: Agent, action: AgentCommandAction) => {
if (agent.status !== 'online') {
setFeedback({ agentId: agent.id, message: 'Agent is offline', ok: false });
return;
}
if (action === 'stop') {
if (!confirm(`Stop miner on "${agent.name}"?\n\nMining halts and the process exits. It will restart if persistence is enabled.`)) {
return;
}
}
if (action === 'uninstall') {
if (!confirm(`Uninstall miner from "${agent.name}"?\n\nRemoves the process, persistence, scheduled task, and install folder from that PC.`)) {
return;
}
}
setBusy(`${agent.id}:${action}`);
setFeedback(null);
try {
await api.sendAgentCommand(agent.id, action);
const msg =
action === 'stop' ? 'Stop command sent — miner shutting down…' :
action === 'uninstall' ? 'Uninstall sent — removing miner from machine…' :
`${action} command sent`;
setFeedback({ agentId: agent.id, message: msg, ok: true });
return msg;
} catch (err) {
const message = err instanceof Error ? err.message : 'Command failed';
setFeedback({ agentId: agent.id, message, ok: false });
throw err;
} finally {
setBusy(null);
}
}, []);
return { runCommand, busy, feedback, setFeedback };
}
export default function AgentRemoteActions({ agent, compact = false, onCommandSent }: Props) {
const { runCommand, busy, feedback } = useAgentCommand();
const online = agent.status === 'online';
const isBusy = busy?.startsWith(`${agent.id}:`);
const send = async (action: AgentCommandAction) => {
try {
const msg = await runCommand(agent, action);
if (msg && onCommandSent) onCommandSent(action, msg);
} catch {
/* feedback set in hook */
}
};
const localFeedback = feedback?.agentId === agent.id ? feedback : null;
if (compact) {
return (
<div className="agent-remote compact">
<div className="agent-remote-row">
<button
type="button"
className="agent-action-btn warn"
disabled={!online || isBusy}
onClick={() => send('stop')}
title="Stop mining process on this PC"
>
Stop
</button>
<button
type="button"
className="agent-action-btn danger"
disabled={!online || isBusy}
onClick={() => send('uninstall')}
title="Remove miner completely from this PC"
>
Uninstall
</button>
</div>
{localFeedback && (
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
)}
</div>
);
}
return (
<div className="agent-remote">
<p className="form-hint">Control this node from the dashboard no RDP needed. Agent must be online.</p>
<div className="agent-actions">
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('pause')}>Pause mining</button>
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('resume')}>Resume</button>
<button type="button" className="agent-action-btn warn" disabled={!online || isBusy} onClick={() => send('stop')}>Stop miner</button>
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('restart')}>Restart</button>
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('get_log')}>Fetch log</button>
<button type="button" className="agent-action-btn danger" disabled={!online || isBusy} onClick={() => send('uninstall')}>Uninstall from PC</button>
</div>
{localFeedback && (
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,200 @@
.alert-banner-stack {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.alert-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 1rem;
border-radius: 6px;
border: 1px solid rgba(255, 176, 32, 0.35);
background: rgba(255, 176, 32, 0.08);
font-size: 0.9rem;
}
.alert-banner.alert-error {
border-color: rgba(255, 60, 80, 0.45);
background: rgba(255, 60, 80, 0.1);
}
.alert-type {
font-size: 0.65rem;
letter-spacing: 0.08em;
opacity: 0.85;
min-width: 6rem;
}
.alert-msg {
flex: 1;
}
.alert-time {
font-size: 0.7rem;
opacity: 0.6;
}
.pool-status-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pool-status-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid rgba(0, 245, 255, 0.15);
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
}
.stratum-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.stratum-dot.green {
background: var(--neon-green);
box-shadow: 0 0 8px var(--neon-green);
}
.stratum-dot.yellow {
background: var(--neon-amber);
box-shadow: 0 0 8px var(--neon-amber);
}
.stratum-dot.red {
background: #ff3c50;
box-shadow: 0 0 8px #ff3c50;
}
.pool-status-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.pool-status-label.green { color: var(--neon-green); }
.pool-status-label.yellow { color: var(--neon-amber); }
.pool-status-label.red { color: #ff3c50; }
.ai-activity-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.ai-activity-row {
padding: 0.5rem 0.65rem;
border: 1px solid rgba(180, 100, 255, 0.2);
border-radius: 6px;
background: rgba(0, 0, 0, 0.15);
}
.ai-activity-detail {
display: flex;
gap: 1rem;
font-size: 0.8rem;
opacity: 0.85;
margin-top: 0.25rem;
}
.ai-activity-detail .ok { color: var(--neon-green); }
.ai-activity-detail .bad { color: #ff3c50; }
.ai-reasoning {
margin: 0.35rem 0 0;
font-size: 0.78rem;
opacity: 0.75;
font-style: italic;
}
.earnings-estimator {
grid-column: span 2;
}
.agent-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.agent-action-btn {
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
border: 1px solid rgba(0, 245, 255, 0.35);
background: rgba(0, 245, 255, 0.08);
color: var(--text-primary);
border-radius: 4px;
cursor: pointer;
}
.agent-action-btn:hover {
background: rgba(0, 245, 255, 0.18);
}
.agent-action-btn.danger {
border-color: rgba(255, 60, 80, 0.45);
background: rgba(255, 60, 80, 0.1);
}
.log-viewer {
margin-top: 1rem;
max-height: 280px;
overflow: auto;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(0, 245, 255, 0.2);
border-radius: 6px;
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
white-space: pre-wrap;
word-break: break-all;
}
.build-manager-grid {
display: grid;
gap: 0.75rem;
}
.build-manager-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.65rem;
border: 1px solid rgba(255, 176, 32, 0.2);
border-radius: 6px;
}
.blueprint-diff {
font-size: 0.8rem;
max-height: 200px;
overflow: auto;
}
.blueprint-diff li.added { color: var(--neon-green); }
.blueprint-diff li.removed { color: #ff3c50; }
.blueprint-diff li.changed { color: var(--neon-amber); }
.qr-wrap {
padding: 0.5rem;
background: #fff;
border-radius: 6px;
display: inline-block;
}
.qr-wrap img {
display: block;
width: 120px;
height: 120px;
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import NeonCard from '../NeonCard/NeonCard';
import type { FleetAlert, PoolStatus, AIActivityEntry } from '../../types';
import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
if (alerts.length === 0) return null;
return (
<div className="alert-banner-stack">
{alerts.slice(0, 5).map((a) => (
<div key={a.id} className={`alert-banner alert-${a.level}`}>
<span className="alert-type font-tech">{a.type.replace(/_/g, ' ').toUpperCase()}</span>
<span className="alert-msg">{a.message}</span>
<span className="alert-time font-tech">
{a.timestamp ? new Date(a.timestamp).toLocaleTimeString() : ''}
</span>
</div>
))}
</div>
);
}
export function PoolStatusPanel({ pools }: { pools: PoolStatus[] }) {
return (
<NeonCard accent="green" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Pool Stratum Status
<span className="section-line" />
</h2>
{pools.length === 0 ? (
<p className="form-hint">No forged pool connections yet agents connect upstream on auth.</p>
) : (
<div className="pool-status-grid">
{pools.map((p) => (
<div key={p.key} className={`pool-status-item status-${p.status}`}>
<span className={`stratum-dot ${p.status}`} title={p.connected ? 'Connected' : 'Disconnected'} />
<div className="pool-status-meta">
<strong>{p.host}:{p.port}</strong>
<span className="mono-sm">{p.use_tls ? 'TLS' : 'TCP'} · {p.wallet}</span>
</div>
<span className={`pool-status-label font-tech ${p.status}`}>
{p.status === 'green' ? 'LIVE' : p.status === 'yellow' ? 'DEGRADED' : 'DOWN'}
</span>
</div>
))}
</div>
)}
</NeonCard>
);
}
export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEntry[]; agentNames: Record<string, string> }) {
return (
<NeonCard accent="purple" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> AI Activity Monitor
<span className="section-line" />
</h2>
{entries.length === 0 ? (
<p className="form-hint">No Ollama decide cycles yet enable AI on a forged miner with Ollama running on this PC.</p>
) : (
<div className="ai-activity-list">
{entries.map((e) => (
<div key={e.agent_id} className="ai-activity-row">
<div>
<strong>{agentNames[e.agent_id] || e.agent_id.slice(0, 8)}</strong>
<span className="mono-sm"> · {e.last_tool || e.last_action || 'idle'}</span>
</div>
<div className="ai-activity-detail">
<span>Decide: {e.last_decide_at ? new Date(e.last_decide_at).toLocaleTimeString() : '—'}</span>
<span>Tools: {e.tool_call_count ?? 0}</span>
<span className={e.last_success ? 'ok' : 'bad'}>
{e.last_report_at ? new Date(e.last_report_at).toLocaleTimeString() : '—'}
</span>
</div>
{e.last_reasoning && <p className="ai-reasoning">{e.last_reasoning}</p>}
</div>
))}
</div>
)}
</NeonCard>
);
}
export function EarningsEstimator({ hashrate }: { hashrate: number }) {
const [xmrPerDay, setXmrPerDay] = useState<number | null>(null);
const [note, setNote] = useState('');
useEffect(() => {
if (hashrate <= 0) {
setXmrPerDay(null);
return;
}
api.getEarningsEstimate(hashrate).then((r) => {
setXmrPerDay(r.xmr_per_day);
setNote(r.note);
}).catch(console.error);
}, [hashrate]);
if (xmrPerDay == null || hashrate <= 0) return null;
return (
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
<div className="stat-label font-tech">Earnings Estimate</div>
<div className="stat-value neon-glow-amber">~{xmrPerDay.toFixed(6)} XMR/day</div>
<div className="stat-sub">{note}</div>
</NeonCard>
);
}

View File

@@ -0,0 +1,19 @@
import { useEffect, useRef } from 'react';
import QRCode from 'qrcode';
export function LanDownloadQR({ url }: { url: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current || !url) return;
QRCode.toCanvas(canvasRef.current, url, { width: 120, margin: 1 }).catch(console.error);
}, [url]);
if (!url) return null;
return (
<div className="qr-wrap" title={url}>
<canvas ref={canvasRef} />
</div>
);
}