feat: fleet intelligence dashboard -- health score, XMR price, contribution map, analytics
This commit is contained in:
@@ -209,3 +209,192 @@
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
/* ── Earnings USD ─────────────────────────────────────────────────────────── */
|
||||
.earnings-usd-day {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--neon-green, #39ff14);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.er-usd { opacity: 0.7; margin-left: 0.25rem; }
|
||||
.er-highlight { color: var(--neon-cyan, #00f5ff); font-weight: 700; }
|
||||
|
||||
/* ── Fleet Health Card ────────────────────────────────────────────────────── */
|
||||
.fleet-health-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.fh-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.fh-label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.7;
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.fh-status-chip {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.fh-green { background: rgba(57, 255, 20, 0.15); color: var(--neon-green, #39ff14); border: 1px solid rgba(57, 255, 20, 0.35); }
|
||||
.fh-amber { background: rgba(255, 176, 32, 0.15); color: var(--neon-amber, #ffb020); border: 1px solid rgba(255, 176, 32, 0.35); }
|
||||
.fh-red { background: rgba(255, 60, 80, 0.15); color: #ff3c50; border: 1px solid rgba(255, 60, 80, 0.35); }
|
||||
.fh-score {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-display, sans-serif);
|
||||
line-height: 1;
|
||||
}
|
||||
.fh-bar-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.fh-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.fh-sentence {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Contribution Bars ────────────────────────────────────────────────────── */
|
||||
.contrib-panel {}
|
||||
.contrib-list { display: flex; flex-direction: column; gap: 0.45rem; }
|
||||
.contrib-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 80px 52px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.contrib-row.has-usd {
|
||||
grid-template-columns: 140px 1fr 80px 52px 70px;
|
||||
}
|
||||
.contrib-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.contrib-track {
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.contrib-fill {
|
||||
height: 100%;
|
||||
background: var(--neon-cyan, #00f5ff);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 6px var(--neon-cyan, #00f5ff);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.contrib-hash, .contrib-pct { opacity: 0.75; font-size: 0.75rem; text-align: right; }
|
||||
.contrib-usd { color: var(--neon-green, #39ff14); font-size: 0.72rem; text-align: right; }
|
||||
|
||||
/* ── Underperformer Panel ─────────────────────────────────────────────────── */
|
||||
.underperf-panel {}
|
||||
.underperf-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.underperf-count { color: var(--neon-amber, #ffb020); margin-left: 0.25rem; }
|
||||
.underperf-restart-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.underperf-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; }
|
||||
.underperf-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr 90px 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(255, 176, 32, 0.05);
|
||||
border-left: 2px solid rgba(255, 176, 32, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.underperf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.underperf-hash { text-align: right; color: var(--neon-amber, #ffb020); }
|
||||
.underperf-ratio { text-align: right; font-size: 0.72rem; }
|
||||
|
||||
/* ── OS / Arch Card ───────────────────────────────────────────────────────── */
|
||||
.os-arch-card { }
|
||||
.os-arch-bars { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 0.6rem; }
|
||||
.os-arch-row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 28px;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.os-arch-label { font-weight: 600; font-size: 0.78rem; }
|
||||
.os-arch-track {
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.os-arch-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
opacity: 0.85;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.os-arch-count { text-align: right; opacity: 0.7; font-size: 0.75rem; }
|
||||
|
||||
/* ── LAN Group Card ───────────────────────────────────────────────────────── */
|
||||
.lan-group-card {}
|
||||
.lan-group-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.6rem; }
|
||||
.lan-group-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: rgba(57, 255, 20, 0.04);
|
||||
border-left: 2px solid rgba(57, 255, 20, 0.25);
|
||||
border-radius: 3px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.lan-subnet { color: var(--neon-cyan, #00f5ff); font-size: 0.78rem; }
|
||||
.lan-meta { display: flex; gap: 1rem; font-size: 0.76rem; opacity: 0.75; }
|
||||
.lan-online { color: var(--neon-green, #39ff14); }
|
||||
.lan-hash { font-family: var(--font-mono, monospace); }
|
||||
|
||||
/* ── Dashboard toggle ─────────────────────────────────────────────────────── */
|
||||
.dash-mode-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0,245,255,0.35);
|
||||
color: var(--neon-cyan, #00f5ff);
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.dash-mode-btn:hover { background: rgba(0,245,255,0.1); }
|
||||
.dash-mode-btn.active { background: rgba(0,245,255,0.18); font-weight: 700; }
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import NeonCard from '../NeonCard/NeonCard';
|
||||
import type { FleetAlert, PoolStatus, AIActivityEntry } from '../../types';
|
||||
import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types';
|
||||
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
||||
import { timeToPayout } from '../../help/fleetAnalytics';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import './FleetPanels.css';
|
||||
|
||||
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
||||
@@ -94,14 +97,11 @@ interface EarningsData {
|
||||
last_payment_time?: string;
|
||||
}
|
||||
|
||||
export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xmrPrice?: number | null }) {
|
||||
const [data, setData] = useState<EarningsData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hashrate <= 0) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
if (hashrate <= 0) { setData(null); return; }
|
||||
const controller = new AbortController();
|
||||
api.getEarningsEstimate(hashrate).then((r: EarningsData) => {
|
||||
if (!controller.signal.aborted) setData(r);
|
||||
@@ -112,6 +112,9 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
if (!data || hashrate <= 0) return null;
|
||||
|
||||
const isReal = data.source === 'pool_api';
|
||||
const usdPerDay = data.xmr_per_day != null && xmrPrice ? data.xmr_per_day * xmrPrice : null;
|
||||
const usdPending = data.pending_xmr != null && xmrPrice ? data.pending_xmr * xmrPrice : null;
|
||||
const daysLeft = timeToPayout(data.pending_xmr, data.xmr_per_day);
|
||||
|
||||
return (
|
||||
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
|
||||
@@ -123,10 +126,27 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
{isReal ? '' : '~'}{data.xmr_per_day.toFixed(6)} XMR/day
|
||||
</div>
|
||||
)}
|
||||
{usdPerDay != null && (
|
||||
<div className="earnings-usd-day">≈ ${usdPerDay.toFixed(2)}/day</div>
|
||||
)}
|
||||
{isReal ? (
|
||||
<div className="earnings-real-grid">
|
||||
{data.pending_xmr != null && (
|
||||
<span className="er-row"><span className="er-lbl">Pending</span><span className="er-val">{data.pending_xmr.toFixed(8)} XMR</span></span>
|
||||
<span className="er-row">
|
||||
<span className="er-lbl">Pending</span>
|
||||
<span className="er-val">
|
||||
{data.pending_xmr.toFixed(8)} XMR
|
||||
{usdPending != null && <span className="er-usd"> (${usdPending.toFixed(2)})</span>}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{daysLeft != null && (
|
||||
<span className="er-row">
|
||||
<span className="er-lbl">Payout In</span>
|
||||
<span className="er-val er-highlight">
|
||||
{daysLeft < 1 ? `${(daysLeft * 24).toFixed(1)} hrs` : `${daysLeft.toFixed(1)} days`}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{data.paid_xmr != null && (
|
||||
<span className="er-row"><span className="er-lbl">Total Paid</span><span className="er-val">{data.paid_xmr.toFixed(4)} XMR</span></span>
|
||||
@@ -141,8 +161,205 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
) : (
|
||||
<div className="stat-sub">{data.note}</div>
|
||||
)}
|
||||
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.5, fontSize: '0.7rem' }}>
|
||||
{isReal ? 'SupportXMR live data · refreshes every 5 min' : 'Formula estimate · connect wallet for live data'}
|
||||
{xmrPrice && (
|
||||
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.6, fontSize: '0.7rem' }}>
|
||||
XMR = ${xmrPrice.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-sub" style={{ marginTop: 2, opacity: 0.45, fontSize: '0.68rem' }}>
|
||||
{isReal ? 'SupportXMR live · 5 min cache' : 'Formula estimate · connect wallet for live data'}
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fleet Health Card ────────────────────────────────────────────────────────
|
||||
|
||||
export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||
const accent = health.color === 'green' ? 'green' : health.color === 'amber' ? 'amber' : 'red';
|
||||
const barColor =
|
||||
health.color === 'green'
|
||||
? 'var(--neon-green, #39ff14)'
|
||||
: health.color === 'amber'
|
||||
? 'var(--neon-amber, #ffb020)'
|
||||
: '#ff3c50';
|
||||
|
||||
return (
|
||||
<NeonCard accent={accent as any} className="fleet-health-card" hud>
|
||||
<div className="fh-header">
|
||||
<div>
|
||||
<span className="fh-label font-tech">FLEET HEALTH</span>
|
||||
<span className={`fh-status-chip fh-${health.color}`}>{health.label}</span>
|
||||
</div>
|
||||
<div className="fh-score" style={{ color: barColor }}>{health.score}</div>
|
||||
</div>
|
||||
<div className="fh-bar-track">
|
||||
<div
|
||||
className="fh-bar-fill"
|
||||
style={{ width: `${health.score}%`, background: barColor, boxShadow: `0 0 8px ${barColor}` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="fh-sentence">{health.sentence}</p>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Contribution Bars ───────────────────────────────────────────────────────
|
||||
|
||||
export function ContributionBars({
|
||||
bars,
|
||||
xmrPerDay,
|
||||
xmrPrice,
|
||||
}: {
|
||||
bars: ContributionBar[];
|
||||
xmrPerDay?: number;
|
||||
xmrPrice?: number | null;
|
||||
}) {
|
||||
if (bars.length === 0) return null;
|
||||
return (
|
||||
<NeonCard accent="cyan" className="section contrib-panel" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Contribution Map
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Each bar shows a machine's share of total fleet hashrate.
|
||||
</p>
|
||||
<div className="contrib-list">
|
||||
{bars.map((b) => {
|
||||
const agentXmr = xmrPerDay != null ? xmrPerDay * (b.pct / 100) : null;
|
||||
const agentUsd = agentXmr != null && xmrPrice ? agentXmr * xmrPrice : null;
|
||||
return (
|
||||
<div key={b.id} className="contrib-row">
|
||||
<span className="contrib-name" title={b.name}>{b.name}</span>
|
||||
<div className="contrib-track">
|
||||
<div className="contrib-fill" style={{ width: `${b.pct}%` }} />
|
||||
</div>
|
||||
<span className="contrib-hash font-tech">{formatHashrate(b.hashrate)}</span>
|
||||
<span className="contrib-pct font-tech">{b.pct.toFixed(1)}%</span>
|
||||
{agentUsd != null && (
|
||||
<span className="contrib-usd font-tech">${agentUsd.toFixed(3)}/d</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Underperformer List ──────────────────────────────────────────────────────
|
||||
|
||||
export function UnderperformerList({
|
||||
underperformers,
|
||||
medianHashrate,
|
||||
}: {
|
||||
underperformers: Agent[];
|
||||
medianHashrate: number;
|
||||
}) {
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
if (underperformers.length === 0) return null;
|
||||
|
||||
const handleRestart = async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await api.sendBulkCommand(underperformers.map((a) => a.id), 'restart');
|
||||
setMsg(`Restart sent to ${underperformers.length} node(s).`);
|
||||
} catch {
|
||||
setMsg('Restart failed — check agent connections.');
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
setTimeout(() => setMsg(''), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NeonCard accent="amber" className="section underperf-panel" hud>
|
||||
<div className="underperf-header">
|
||||
<h2 className="section-title font-display" style={{ margin: 0 }}>
|
||||
<span className="section-ornament">◆</span> Underperformers
|
||||
<span className="underperf-count"> ({underperformers.length})</span>
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<button
|
||||
className="btn btn-outline underperf-restart-btn"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
title="Send restart command to all underperforming nodes"
|
||||
>
|
||||
{restarting ? 'Restarting…' : '↺ Restart All'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint" style={{ marginTop: '0.25rem' }}>
|
||||
Nodes below 70% of fleet median ({formatHashrate(medianHashrate)}).
|
||||
</p>
|
||||
{msg && <p className="form-hint" style={{ color: 'var(--neon-amber)' }}>{msg}</p>}
|
||||
<div className="underperf-list">
|
||||
{underperformers.map((a) => (
|
||||
<div key={a.id} className="underperf-row">
|
||||
<span className={`status-dot ${a.status}`} />
|
||||
<span className="underperf-name">{a.name}</span>
|
||||
<span className="underperf-hash font-tech">{formatHashrate(a.hashrate_15m)}</span>
|
||||
<span className="underperf-ratio font-tech" style={{ color: '#ff3c50' }}>
|
||||
{medianHashrate > 0 ? `${((a.hashrate_15m / medianHashrate) * 100).toFixed(0)}% of median` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OS / Arch Breakdown ──────────────────────────────────────────────────────
|
||||
|
||||
export function OSArchBreakdown({ platforms }: { platforms: PlatformCount[] }) {
|
||||
if (platforms.length === 0) return null;
|
||||
const total = platforms.reduce((s, p) => s + p.count, 0);
|
||||
const colors = ['var(--neon-cyan)', 'var(--neon-purple)', 'var(--neon-amber)', 'var(--neon-green)', '#ff3c50'];
|
||||
return (
|
||||
<NeonCard accent="purple" className="os-arch-card" hud>
|
||||
<div className="stat-label font-tech">OS / Arch Breakdown</div>
|
||||
<div className="os-arch-bars">
|
||||
{platforms.map((p, i) => (
|
||||
<div key={p.key} className="os-arch-row">
|
||||
<span className="os-arch-label" style={{ color: colors[i % colors.length] }}>{p.label}</span>
|
||||
<div className="os-arch-track">
|
||||
<div
|
||||
className="os-arch-fill"
|
||||
style={{ width: `${(p.count / total) * 100}%`, background: colors[i % colors.length] }}
|
||||
/>
|
||||
</div>
|
||||
<span className="os-arch-count font-tech">{p.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── LAN Group View ───────────────────────────────────────────────────────────
|
||||
|
||||
export function LANGroupView({ groups }: { groups: SubnetGroup[] }) {
|
||||
if (groups.length < 2) return null;
|
||||
return (
|
||||
<NeonCard accent="green" className="lan-group-card" hud>
|
||||
<div className="stat-label font-tech">Network Segments</div>
|
||||
<div className="lan-group-list">
|
||||
{groups.map((g) => {
|
||||
const online = g.agents.filter((a) => a.status === 'online').length;
|
||||
const hash = g.agents.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return (
|
||||
<div key={g.subnet} className="lan-group-row">
|
||||
<span className="lan-subnet font-tech">{g.subnet}</span>
|
||||
<span className="lan-meta">
|
||||
<span className="lan-online">{online}/{g.agents.length} online</span>
|
||||
<span className="lan-hash">{formatHashrate(hash)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
|
||||
@@ -26,11 +26,18 @@ function LaserPulse({ start, end, color }: { start: [number, number, number], en
|
||||
);
|
||||
}
|
||||
|
||||
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [number, number, number], serverPos: [number, number, number] }) {
|
||||
const isOnline = agent.status === 'online';
|
||||
const isHashing = agent.hashrate_15m > 0;
|
||||
const color = isOnline ? (isHashing ? '#00f5ff' : '#00aa55') : '#ff4444';
|
||||
const pulseRef = useRef<THREE.Mesh>(null);
|
||||
const ringRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
// Stale = online status but last_seen older than 5 min (silently dead)
|
||||
const isStale = isOnline && !!agent.last_seen &&
|
||||
(Date.now() - new Date(agent.last_seen).getTime()) > STALE_THRESHOLD_MS;
|
||||
|
||||
useFrame((state) => {
|
||||
if (isOnline && pulseRef.current) {
|
||||
@@ -41,6 +48,11 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
|
||||
pulseRef.current.rotation.x += 0.05;
|
||||
}
|
||||
}
|
||||
// Slowly spin the staleness warning ring
|
||||
if (isStale && ringRef.current) {
|
||||
ringRef.current.rotation.z += 0.01;
|
||||
ringRef.current.rotation.x = Math.sin(state.clock.elapsedTime * 0.5) * 0.3;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -48,11 +60,20 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
|
||||
<Sphere ref={pulseRef} args={[0.3, 16, 16]}>
|
||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={isOnline ? (isHashing ? 2 : 1) : 0.2} wireframe />
|
||||
</Sphere>
|
||||
|
||||
{/* Staleness warning ring — amber halo for online-but-silent nodes */}
|
||||
{isStale && (
|
||||
<mesh ref={ringRef}>
|
||||
<torusGeometry args={[0.55, 0.04, 8, 40]} />
|
||||
<meshBasicMaterial color="#ffb020" opacity={0.85} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Connection Line */}
|
||||
<Line points={[[0,0,0], [serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]]} color={isOnline ? '#004455' : '#330000'} lineWidth={1} transparent opacity={0.4} />
|
||||
<Line points={[[0,0,0], [serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]]} color={isStale ? '#665500' : isOnline ? '#004455' : '#330000'} lineWidth={1} transparent opacity={0.4} />
|
||||
|
||||
{/* Laser Pulse simulating hashing packets */}
|
||||
{isOnline && isHashing && (
|
||||
{isOnline && isHashing && !isStale && (
|
||||
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00ffff" />
|
||||
)}
|
||||
</group>
|
||||
|
||||
Reference in New Issue
Block a user