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>
|
||||
);
|
||||
}
|
||||
46
server/web/src/components/Forge/ForgeFieldHints.tsx
Normal file
46
server/web/src/components/Forge/ForgeFieldHints.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
59
server/web/src/components/Visual/SystemStatusBar.tsx
Normal file
59
server/web/src/components/Visual/SystemStatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
server/web/src/components/Visual/VisualComponents.css
Normal file
371
server/web/src/components/Visual/VisualComponents.css
Normal 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;
|
||||
}
|
||||
}
|
||||
137
server/web/src/components/Visual/VisualComponents.tsx
Normal file
137
server/web/src/components/Visual/VisualComponents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user