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>
);
}

View File

@@ -0,0 +1,46 @@
import type { ForgeFieldMeta } from '../../help/forgeRules';
import { forgeBadgeLabel } from '../../help/forgeRules';
interface ForgeLockedHintProps {
meta?: ForgeFieldMeta;
}
export function ForgeLockedHint({ meta }: ForgeLockedHintProps) {
if (!meta?.lockedReason && !meta?.hint) return null;
return (
<p className="forge-locked-hint" title={meta.lockedReason || meta.hint}>
🔒 {meta.lockedReason || meta.hint}
</p>
);
}
interface ForgeFieldBadgeProps {
meta?: ForgeFieldMeta;
}
export function ForgeFieldBadge({ meta }: ForgeFieldBadgeProps) {
if (!meta?.badge) return null;
return (
<span className={`forge-field-badge forge-badge-${meta.badge}`} title={forgeBadgeLabel(meta.badge)}>
{meta.badge === 'baked' ? '⛏ baked' : meta.badge === 'server-only' ? '🖥 server' : '↳ if enabled'}
</span>
);
}
interface ForgeSectionHeaderProps {
title: string;
description: string;
badge: 'baked' | 'server-only';
}
export function ForgeSectionHeader({ title, description, badge }: ForgeSectionHeaderProps) {
return (
<div className="forge-section-header">
<div className="forge-section-title-row">
<h3>{title}</h3>
<span className={`forge-field-badge forge-badge-${badge}`}>{forgeBadgeLabel(badge)}</span>
</div>
<p className="form-hint forge-section-desc">{description}</p>
</div>
);
}

View File

@@ -190,6 +190,19 @@
letter-spacing: 0.15em;
}
.main-with-status {
flex: 1;
margin-left: 260px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-with-status .main-content {
margin-left: 0;
flex: 1;
}
.main-content {
flex: 1;
margin-left: 260px;
@@ -225,6 +238,10 @@
padding: 0.85rem;
}
.main-with-status {
margin-left: 72px;
}
.main-content {
margin-left: 72px;
padding: 1rem;

View File

@@ -1,6 +1,7 @@
import { ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import './Layout.css';
interface LayoutProps {
@@ -10,7 +11,8 @@ interface LayoutProps {
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/builder', label: 'Forge', icon: 'forge' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
] as const;
@@ -37,6 +39,14 @@ function NavIcon({ type }: { type: string }) {
<path d="M8 16l-2 4 4-2" />
</svg>
);
case 'guide':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
<path d="M8 7h8M8 11h6" />
</svg>
);
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -94,7 +104,10 @@ export default function Layout({ children }: LayoutProps) {
</div>
</nav>
<main className="main-content">{children}</main>
<div className="main-with-status">
<SystemStatusBar />
<main className="main-content">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import './VisualComponents.css';
export default function SystemStatusBar() {
const [serverOk, setServerOk] = useState(true);
const [agentTotal, setAgentTotal] = useState(0);
const [agentOnline, setAgentOnline] = useState(0);
const [buildCount, setBuildCount] = useState(0);
useEffect(() => {
const poll = async () => {
try {
await api.healthCheck();
setServerOk(true);
} catch {
setServerOk(false);
}
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try {
const builds = await api.listBuilds();
setBuildCount(builds.length);
} catch {
setBuildCount(0);
}
};
poll();
const id = setInterval(poll, 15000);
return () => clearInterval(id);
}, []);
return (
<div className="system-status-bar">
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>
<span className="status-pill-dot" />
SERVER {serverOk ? 'UP' : 'DOWN'}
</span>
<span className={`status-pill ${agentOnline > 0 ? 'ok' : agentTotal > 0 ? 'warn' : ''}`}>
<span className="status-pill-dot" />
FLEET {agentOnline}/{agentTotal} ONLINE
</span>
<span className={`status-pill ${buildCount > 0 ? 'ok' : 'warn'}`}>
<span className="status-pill-dot" />
{buildCount} BUILD{buildCount === 1 ? '' : 'S'}
</span>
<Link to="/guide" className="status-pill" style={{ marginLeft: 'auto', textDecoration: 'none', color: 'var(--neon-cyan)' }}>
📖 GUIDE
</Link>
</div>
);
}

View File

@@ -0,0 +1,371 @@
/* Visual pipeline, activity, guide components */
.pipeline-flow {
display: flex;
align-items: stretch;
flex-wrap: wrap;
gap: 0.25rem;
padding: 1rem 0;
}
.pipeline-step-wrap {
display: flex;
align-items: center;
flex: 1;
min-width: 120px;
}
.pipeline-step {
flex: 1;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.3);
transition: border-color 0.2s, box-shadow 0.2s;
}
.pipeline-step.pipeline-active {
border-color: var(--neon-cyan);
box-shadow: 0 0 20px rgba(0, 245, 255, 0.15);
}
.pipeline-icon {
font-size: 1.5rem;
width: 2.25rem;
height: 2.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(201, 162, 39, 0.15);
border: 1px solid rgba(201, 162, 39, 0.35);
}
.pipeline-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.pipeline-text strong {
font-size: 0.85rem;
color: var(--text-primary);
}
.pipeline-text span {
font-size: 0.7rem;
color: var(--text-muted);
font-family: var(--font-tech);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pipeline-connector {
width: 1.5rem;
height: 2px;
background: linear-gradient(90deg, var(--brass), var(--neon-cyan));
opacity: 0.5;
flex-shrink: 0;
}
.pipeline-compact .pipeline-step {
padding: 0.5rem 0.65rem;
}
.fleet-pipeline-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0.5rem;
gap: 0.25rem;
}
.fleet-pipeline-item {
display: flex;
align-items: center;
flex: 1;
}
.fleet-pipeline-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
min-width: 4rem;
}
.fleet-pipeline-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s;
}
.fleet-pipeline-node.ok .fleet-pipeline-dot {
background: var(--neon-green);
border-color: var(--neon-green);
box-shadow: 0 0 12px rgba(74, 222, 128, 0.6);
animation: pulse-dot 2s ease-in-out infinite;
}
.fleet-pipeline-node.pending .fleet-pipeline-dot {
background: rgba(251, 191, 36, 0.2);
border-color: rgba(251, 191, 36, 0.5);
}
.fleet-pipeline-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.fleet-pipeline-node.ok .fleet-pipeline-label {
color: var(--neon-green);
}
.fleet-pipeline-line {
flex: 1;
height: 2px;
background: rgba(255, 255, 255, 0.08);
margin: 0 0.25rem;
margin-bottom: 1.25rem;
}
.fleet-pipeline-line.lit {
background: linear-gradient(90deg, var(--neon-green), rgba(74, 222, 128, 0.3));
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.85; }
}
.activity-pulse {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0;
}
.activity-blip {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
width: 3rem;
}
.activity-blip-core {
width: 10px;
height: 10px;
border-radius: 50%;
}
.activity-blip.ok .activity-blip-core {
background: var(--neon-green);
box-shadow: 0 0 8px var(--neon-green);
}
.activity-blip.bad .activity-blip-core {
background: #f87171;
box-shadow: 0 0 8px #f87171;
}
.activity-blip-label {
font-size: 0.55rem;
font-family: var(--font-tech);
color: var(--text-muted);
text-align: center;
line-height: 1.2;
}
.activity-empty {
color: var(--text-muted);
font-size: 0.8rem;
margin: 0;
}
.compare-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.compare-card h4 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
.compare-card ul {
margin: 0 0 1rem;
padding-left: 1.1rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text-secondary);
}
.roadmap-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
}
.roadmap-card {
padding: 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.25);
}
.roadmap-card h4 {
margin: 0.35rem 0 0.5rem;
font-size: 0.9rem;
}
.roadmap-card p {
margin: 0;
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.45;
}
.roadmap-priority {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.roadmap-high .roadmap-priority {
color: #f87171;
border: 1px solid rgba(248, 113, 113, 0.4);
}
.roadmap-medium .roadmap-priority {
color: var(--neon-amber);
border: 1px solid rgba(251, 191, 36, 0.4);
}
.roadmap-low .roadmap-priority {
color: var(--neon-cyan);
border: 1px solid rgba(0, 245, 255, 0.35);
}
.guide-step-card {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: start;
padding: 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.2);
margin-bottom: 0.75rem;
}
.guide-step-num {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-family: var(--font-tech);
font-size: 1.1rem;
border: 1px solid var(--brass);
color: var(--neon-amber);
background: rgba(201, 162, 39, 0.1);
}
.guide-step-body h4 {
margin: 0 0 0.35rem;
}
.guide-step-body p {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.guide-tips {
margin: 0;
padding-left: 1rem;
font-size: 0.78rem;
color: var(--neon-cyan);
}
.trouble-card {
padding: 0.85rem 1rem;
border-left: 3px solid var(--neon-amber);
background: rgba(251, 191, 36, 0.06);
border-radius: 0 6px 6px 0;
margin-bottom: 0.5rem;
}
.trouble-card strong {
display: block;
margin-bottom: 0.25rem;
color: var(--neon-amber);
}
.trouble-card span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.system-status-bar {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
padding: 0.5rem 1.5rem;
background: rgba(8, 6, 4, 0.85);
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
font-size: 0.75rem;
font-family: var(--font-tech);
letter-spacing: 0.04em;
}
.status-pill {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--text-muted);
}
.status-pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
.status-pill.ok .status-pill-dot {
background: var(--neon-green);
box-shadow: 0 0 6px var(--neon-green);
}
.status-pill.warn .status-pill-dot {
background: var(--neon-amber);
}
.status-pill.bad .status-pill-dot {
background: #f87171;
}
.status-pill.ok {
color: var(--neon-green);
}
@media (max-width: 640px) {
.compare-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,137 @@
import { Link } from 'react-router-dom';
import NeonCard from '../NeonCard/NeonCard';
import {
FORGE_VS_CALIBRATE,
PIPELINE_STEPS,
ROADMAP_FEATURES,
} from '../../help/cheatSheetContent';
import './VisualComponents.css';
interface PipelineFlowProps {
activeStep?: string;
compact?: boolean;
}
export function PipelineFlow({ activeStep, compact }: PipelineFlowProps) {
return (
<div className={`pipeline-flow ${compact ? 'pipeline-compact' : ''}`}>
{PIPELINE_STEPS.map((step, i) => (
<div key={step.id} className="pipeline-step-wrap">
<div className={`pipeline-step ${activeStep === step.id ? 'pipeline-active' : ''}`}>
<div className="pipeline-icon">{step.icon}</div>
<div className="pipeline-text">
<strong>{step.title}</strong>
<span>{step.subtitle}</span>
</div>
</div>
{i < PIPELINE_STEPS.length - 1 && <div className="pipeline-connector" aria-hidden />}
</div>
))}
</div>
);
}
interface FleetPipelineStatusProps {
hasBuilds: boolean;
agentCount: number;
onlineCount: number;
hasHashrate: boolean;
hasShares: boolean;
}
export function FleetPipelineStatus({
hasBuilds,
agentCount,
onlineCount,
hasHashrate,
hasShares,
}: FleetPipelineStatusProps) {
const steps = [
{ id: 'forge', label: 'Forged', ok: hasBuilds, hint: 'At least one build exists' },
{ id: 'deploy', label: 'Deployed', ok: agentCount > 0, hint: 'Agent registered on dashboard' },
{ id: 'connect', label: 'Online', ok: onlineCount > 0, hint: `${onlineCount} node(s) live` },
{ id: 'mine', label: 'Hashing', ok: hasHashrate, hint: 'Fleet hashrate > 0' },
{ id: 'shares', label: 'Shares', ok: hasShares, hint: 'Shares submitted to pool' },
];
return (
<div className="fleet-pipeline-status">
{steps.map((s, i) => (
<div key={s.id} className="fleet-pipeline-item">
<div className={`fleet-pipeline-node ${s.ok ? 'ok' : 'pending'}`} title={s.hint}>
<span className="fleet-pipeline-dot" />
<span className="fleet-pipeline-label font-tech">{s.label}</span>
</div>
{i < steps.length - 1 && <div className={`fleet-pipeline-line ${s.ok ? 'lit' : ''}`} />}
</div>
))}
</div>
);
}
interface ActivityPulseProps {
items: { id: string; label: string; ok: boolean; time?: string }[];
}
export function ActivityPulse({ items }: ActivityPulseProps) {
if (items.length === 0) {
return <p className="activity-empty font-tech">Awaiting fleet activity</p>;
}
return (
<div className="activity-pulse">
{items.slice(0, 12).map((item) => (
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
<span className="activity-blip-core" />
<span className="activity-blip-label">{item.label}</span>
</div>
))}
</div>
);
}
interface ForgeCalibrateCompareProps {
compact?: boolean;
}
export function ForgeCalibrateCompare({ compact }: ForgeCalibrateCompareProps) {
return (
<div className={`compare-grid ${compact ? 'compare-compact' : ''}`}>
<NeonCard accent="cyan" className="compare-card">
<h4>{FORGE_VS_CALIBRATE.forge.title}</h4>
<ul>
{FORGE_VS_CALIBRATE.forge.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
{!compact && (
<Link to="/forge" className="btn btn-outline btn-sm">Go to Forge</Link>
)}
</NeonCard>
<NeonCard accent="amber" className="compare-card">
<h4>{FORGE_VS_CALIBRATE.calibrate.title}</h4>
<ul>
{FORGE_VS_CALIBRATE.calibrate.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
{!compact && (
<Link to="/settings" className="btn btn-outline btn-sm">Go to Calibrate</Link>
)}
</NeonCard>
</div>
);
}
export function RoadmapGrid() {
return (
<div className="roadmap-grid">
{ROADMAP_FEATURES.map((f) => (
<div key={f.title} className={`roadmap-card roadmap-${f.priority}`}>
<span className={`roadmap-priority font-tech`}>{f.priority}</span>
<h4>{f.title}</h4>
<p>{f.desc}</p>
</div>
))}
</div>
);
}