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:
49
server/web/src/components/Fleet/AgentRemoteActions.css
Normal file
49
server/web/src/components/Fleet/AgentRemoteActions.css
Normal 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;
|
||||
}
|
||||
119
server/web/src/components/Fleet/AgentRemoteActions.tsx
Normal file
119
server/web/src/components/Fleet/AgentRemoteActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
server/web/src/components/Fleet/FleetPanels.css
Normal file
200
server/web/src/components/Fleet/FleetPanels.css
Normal 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;
|
||||
}
|
||||
110
server/web/src/components/Fleet/FleetPanels.tsx
Normal file
110
server/web/src/components/Fleet/FleetPanels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
server/web/src/components/Fleet/LanDownloadQR.tsx
Normal file
19
server/web/src/components/Fleet/LanDownloadQR.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user