Improve fleet control, Crucible ops, and multi-machine identity.
Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
This commit is contained in:
@@ -2,6 +2,17 @@ import AgentRemoteActions from './AgentRemoteActions';
|
||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import LatencyBadge from './LatencyBadge';
|
||||
|
||||
function formatRelTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 2) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
@@ -64,7 +75,10 @@ export default function AgentListItem({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
<LatencyBadge ms={agent.status === 'online' ? agent.latency_ms : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
@@ -78,7 +92,12 @@ export default function AgentListItem({
|
||||
<div className="agent-list-details">
|
||||
<span>{formatHashrate(agent.hashrate_15m)}</span>
|
||||
<span>{agent.ip || '—'}</span>
|
||||
{!expanded && <span className="form-hint">click for details</span>}
|
||||
{agent.status !== 'online' && agent.last_seen && (
|
||||
<span className="form-hint" title={new Date(agent.last_seen).toLocaleString()}>
|
||||
last seen {formatRelTime(agent.last_seen)}
|
||||
</span>
|
||||
)}
|
||||
{!expanded && agent.status === 'online' && <span className="form-hint">click for details</span>}
|
||||
</div>
|
||||
|
||||
{!expanded && agent.notes?.trim() && (
|
||||
|
||||
@@ -96,7 +96,16 @@ export default function FleetToolbar({
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle miners</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={bulkBusy}
|
||||
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
|
||||
onClick={() => onBulkAction('delete')}
|
||||
>
|
||||
🗑 Delete selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
83
server/web/src/components/Fleet/LatencyBadge.tsx
Normal file
83
server/web/src/components/Fleet/LatencyBadge.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* LatencyBadge — 4-bar cell-signal style indicator for WebSocket RTT.
|
||||
*
|
||||
* Bar fill thresholds:
|
||||
* 4 bars (green) : < 50 ms — excellent
|
||||
* 3 bars (cyan) : < 150 ms — good
|
||||
* 2 bars (amber) : < 400 ms — fair
|
||||
* 1 bar (red) : ≥ 400 ms — poor
|
||||
* 0 bars (grey) : no data — waiting for first pong
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
ms?: number;
|
||||
/** Compact variant — bars only, no ms label */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function latencyLevel(ms: number): 0 | 1 | 2 | 3 | 4 {
|
||||
if (ms < 50) return 4;
|
||||
if (ms < 150) return 3;
|
||||
if (ms < 400) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<number, string> = {
|
||||
4: '#39ff14', // neon green
|
||||
3: '#00f5ff', // cyan
|
||||
2: '#ffb020', // amber
|
||||
1: '#ff4466', // red
|
||||
0: '#444', // grey
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = [5, 8, 11, 14]; // px, bottom-aligned
|
||||
|
||||
export default function LatencyBadge({ ms, compact = false }: Props) {
|
||||
const level = ms !== undefined ? latencyLevel(ms) : 0;
|
||||
const color = LEVEL_COLORS[level];
|
||||
const label = ms !== undefined ? `${ms}ms` : '—';
|
||||
|
||||
return (
|
||||
<span
|
||||
title={ms !== undefined ? `Latency: ${ms} ms` : 'Latency unknown — waiting for ping'}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: '2px',
|
||||
verticalAlign: 'middle',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{BAR_HEIGHTS.map((h, i) => {
|
||||
const filled = (i + 1) <= level;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 3,
|
||||
height: h,
|
||||
borderRadius: 1,
|
||||
background: filled ? color : 'rgba(255,255,255,0.12)',
|
||||
transition: 'background 0.4s ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!compact && ms !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: 'monospace',
|
||||
color,
|
||||
marginLeft: 3,
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,6 @@ interface PoolPresetPickerProps {
|
||||
pass: string;
|
||||
backups?: BackupPool[];
|
||||
onChange: (next: PoolForgeFields) => void;
|
||||
/** Show manual host/port fields below presets (Forge advanced). */
|
||||
showManualFields?: boolean;
|
||||
}
|
||||
|
||||
export default function PoolPresetPicker({
|
||||
@@ -29,7 +27,6 @@ export default function PoolPresetPicker({
|
||||
pass,
|
||||
backups = [],
|
||||
onChange,
|
||||
showManualFields = false,
|
||||
}: PoolPresetPickerProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(() => {
|
||||
const detected = detectPresetIds(host, port, tls, backups);
|
||||
@@ -185,60 +182,6 @@ export default function PoolPresetPicker({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showManualFields && (
|
||||
<div className="pool-preset-manual form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Host</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
value={host}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
pool_host: e.target.value,
|
||||
pool_port: port,
|
||||
pool_tls: tls,
|
||||
backup_pools: backups,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
pool_host: host,
|
||||
pool_port: e.target.valueAsNumber || 3333,
|
||||
pool_tls: tls,
|
||||
backup_pools: backups,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className="checkbox-label" style={{ alignSelf: 'flex-end' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={tls}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
pool_host: host,
|
||||
pool_port: port,
|
||||
pool_tls: e.target.checked,
|
||||
backup_pools: backups,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,9 +57,6 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
<form className="session-gate-card card" onSubmit={handleLogin}>
|
||||
<h1 className="font-display">AetherForge</h1>
|
||||
<p className="form-hint">Sign in to open the command deck.</p>
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
First run: password is in the LAUNCH console or <code className="mono-sm">data\login-credentials.json</code> next to the server data folder.
|
||||
</p>
|
||||
<label className="label" htmlFor="session-user">Username</label>
|
||||
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
|
||||
<label className="label" htmlFor="session-pass">Password</label>
|
||||
|
||||
Reference in New Issue
Block a user