Add fleet groups, agent screenshots, deploy guards, and Crucible polish.

This commit is contained in:
AetherForge
2026-06-02 20:51:52 -07:00
parent 01d76b3730
commit 41b5ec7a88
66 changed files with 1773 additions and 388 deletions

View File

@@ -2,6 +2,7 @@ import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import type { Agent } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import type { FleetGroup } from '../../help/fleetGroups';
import LatencyBadge from './LatencyBadge';
function formatRelTime(iso: string): string {
@@ -24,6 +25,7 @@ interface Props {
onSelect: () => void;
onCheck?: (checked: boolean) => void;
commandResults?: SeqCommandResult[];
memberGroups?: FleetGroup[];
}
export default function AgentListItem({
@@ -36,8 +38,10 @@ export default function AgentListItem({
onSelect,
onCheck,
commandResults,
memberGroups = [],
}: Props) {
const online = agent.status === 'online';
const primaryGroup = memberGroups[0];
const handleRowClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
@@ -51,6 +55,18 @@ export default function AgentListItem({
return (
<div
className={`neon-card agent-list-item compact-row ${selected ? 'selected' : ''} ${expanded ? 'expanded' : ''}`}
style={
primaryGroup
? ({
borderLeftWidth: '3px',
borderLeftStyle: 'solid',
borderLeftColor: primaryGroup.color,
boxShadow: selected
? `inset 0 0 20px ${primaryGroup.color}22, 0 0 16px ${primaryGroup.color}33`
: undefined,
} as React.CSSProperties)
: undefined
}
onClick={handleRowClick}
>
<div className="agent-list-header">
@@ -81,9 +97,18 @@ export default function AgentListItem({
</div>
</div>
{(agent.tags?.length ?? 0) > 0 && (
{(memberGroups.length > 0 || (agent.tags?.length ?? 0) > 0) && (
<div className="agent-list-tags">
{agent.tags!.map((t) => (
{memberGroups.map((g) => (
<span
key={g.id}
className="agent-tag-chip fleet-group-tag"
style={{ background: `${g.color}22`, color: g.color, borderColor: `${g.color}55` }}
>
{g.name}
</span>
))}
{agent.tags?.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>

View File

@@ -3,6 +3,7 @@ import { api } from '../../api/client';
import type { Agent, Build } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
import './AgentRemoteActions.css';
const TERMINAL_MAX_LINES = 500;
@@ -88,9 +89,19 @@ export default function AgentRemoteActions({
const { agent_id, action, success, message } = payload;
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
if (action === 'screenshot' && success && message) {
setScreenshotData(`data:image/jpeg;base64,${message}`);
addLog(`Screenshot received from ${agent_id}`);
if (action === 'screenshot') {
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
if (success && message) {
const clean = sanitizeScreenshotBase64(message);
if (downloadScreenshotFromBase64(clean, label)) {
setScreenshotData(`data:image/jpeg;base64,${clean}`);
addLog(`Screenshot saved — ${label}`);
} else {
addLog(`[SCREENSHOT] ${label}: invalid image data`);
}
} else {
addLog(`[SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
}
} else if (action) {
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
}
@@ -115,7 +126,11 @@ export default function AgentRemoteActions({
setBusy(action);
try {
if (!compact) addLog(`> Executing ${action}...`);
if (action === 'screenshot') {
addLog(`Capturing desktop on ${agentName}`);
} else if (!compact) {
addLog(`> Executing ${action}...`);
}
const res = await api.sendAgentCommand(agentId, action, args);
if (res.success === false) {
addLog(`Command rejected: ${res.error ?? 'unknown error'}`);
@@ -163,6 +178,7 @@ 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={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</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>
@@ -195,7 +211,7 @@ export default function AgentRemoteActions({
<div className="action-group recon-group">
<h3>Recon &amp; Intel</h3>
<div className="button-grid">
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">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>
@@ -350,7 +366,16 @@ export default function AgentRemoteActions({
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">
<span>Latest Capture</span>
<span>Latest capture (also downloaded)</span>
<button
type="button"
onClick={() => {
const b64 = screenshotData.replace(/^data:image\/jpeg;base64,/, '');
downloadScreenshotFromBase64(b64, agentName);
}}
>
Download again
</button>
<button type="button" onClick={() => setScreenshotData(null)}></button>
</div>
<img src={screenshotData} alt="Target Desktop" />

View File

@@ -0,0 +1,73 @@
.fleet-group-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
}
.fleet-group-modal {
width: 100%;
max-width: 420px;
padding: 1.25rem 1.5rem;
border: 1px solid rgba(178, 75, 243, 0.45);
box-shadow: 0 0 40px rgba(178, 75, 243, 0.15);
}
.fleet-group-modal h2 {
margin: 0 0 0.35rem;
font-size: 1.25rem;
}
.fleet-group-color-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-bottom: 0.75rem;
}
.fleet-group-swatch {
width: 2rem;
height: 2rem;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.12s, box-shadow 0.12s;
}
.fleet-group-swatch:hover {
transform: scale(1.08);
}
.fleet-group-swatch.selected {
border-color: #fff;
box-shadow: 0 0 12px currentColor;
transform: scale(1.1);
}
.fleet-group-custom-color {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.fleet-group-custom-color input[type='color'] {
width: 2.5rem;
height: 2.5rem;
border: none;
background: none;
cursor: pointer;
}
.fleet-group-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { FLEET_GROUP_COLORS, normalizeGroupColor } from '../../help/fleetGroups';
import './CreateGroupModal.css';
interface Props {
open: boolean;
agentCount: number;
onClose: () => void;
onCreate: (name: string, color: string) => void;
}
export default function CreateGroupModal({ open, agentCount, onClose, onCreate }: Props) {
const [name, setName] = useState('');
const [color, setColor] = useState<string>(FLEET_GROUP_COLORS[0]);
useEffect(() => {
if (open) {
setName('');
setColor(FLEET_GROUP_COLORS[groupsColorIndex(agentCount) % FLEET_GROUP_COLORS.length]);
}
}, [open, agentCount]);
if (!open || agentCount < 1) return null;
const submit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
onCreate(trimmed, normalizeGroupColor(color));
onClose();
};
return (
<div className="fleet-group-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="fleet-group-modal card"
role="dialog"
aria-labelledby="fleet-group-modal-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="fleet-group-modal-title" className="font-display">Create group</h2>
<p className="form-hint">
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} usable in Fleet Roster and Crucible.
</p>
<form onSubmit={submit}>
<label className="label" htmlFor="fleet-group-name">Group name</label>
<input
id="fleet-group-name"
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Living room PCs"
autoFocus
maxLength={64}
/>
<p className="label" style={{ marginTop: '1rem' }}>Group color</p>
<div className="fleet-group-color-grid">
{FLEET_GROUP_COLORS.map((c) => (
<button
key={c}
type="button"
className={`fleet-group-swatch ${color === c ? 'selected' : ''}`}
style={{ background: c }}
title={c}
aria-label={`Color ${c}`}
onClick={() => setColor(c)}
/>
))}
</div>
<div className="fleet-group-custom-color">
<input
type="color"
value={normalizeGroupColor(color)}
onChange={(e) => setColor(e.target.value)}
aria-label="Custom color"
/>
<span className="font-tech">{normalizeGroupColor(color)}</span>
</div>
<div className="fleet-group-modal-actions">
<button type="button" className="btn btn-outline" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={!name.trim()}>
Create group
</button>
</div>
</form>
</div>
</div>
);
}
function groupsColorIndex(n: number): number {
return Math.abs(n) % FLEET_GROUP_COLORS.length;
}

View File

@@ -0,0 +1,70 @@
.fleet-groups-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
padding: 0.65rem 0.85rem;
}
.fleet-groups-strip-label {
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--text-muted);
text-transform: uppercase;
}
.fleet-groups-strip-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
flex: 1;
}
.fleet-group-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.55rem 0.25rem 0.4rem;
border-radius: 999px;
border: 1px solid;
font-size: 0.8rem;
cursor: pointer;
transition: filter 0.15s, transform 0.12s;
}
.fleet-group-chip:hover {
filter: brightness(1.15);
transform: translateY(-1px);
}
.fleet-group-chip-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 6px var(--group-color, #0ff);
}
.fleet-group-chip-name {
font-weight: 500;
color: var(--text-primary);
}
.fleet-group-chip-count {
font-size: 0.72rem;
color: var(--text-muted);
font-family: var(--font-tech, monospace);
}
.fleet-group-chip-del {
margin-left: 0.15rem;
padding: 0 0.2rem;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
}
.fleet-group-chip-del:hover {
color: #ff6666;
}

View File

@@ -0,0 +1,81 @@
import type { FleetGroup } from '../../help/fleetGroups';
import './FleetGroupsStrip.css';
interface Props {
groups: FleetGroup[];
liveAgentIds?: Set<string>;
onSelectGroup: (group: FleetGroup) => void;
onDeleteGroup?: (id: string) => void;
onCreateGroup?: () => void;
selectedCount?: number;
}
export default function FleetGroupsStrip({
groups,
liveAgentIds,
onSelectGroup,
onDeleteGroup,
onCreateGroup,
selectedCount = 0,
}: Props) {
if (groups.length === 0 && !onCreateGroup) return null;
return (
<div className="fleet-groups-strip card">
<span className="fleet-groups-strip-label font-tech">Groups</span>
<div className="fleet-groups-strip-list">
{groups.map((g) => {
const onlineInGroup = liveAgentIds
? g.agentIds.filter((id) => liveAgentIds.has(id)).length
: g.agentIds.length;
return (
<button
key={g.id}
type="button"
className="fleet-group-chip"
style={
{
'--group-color': g.color,
borderColor: `${g.color}66`,
background: `${g.color}18`,
} as React.CSSProperties
}
title={`Select ${g.name} (${onlineInGroup} online / ${g.agentIds.length} total)`}
onClick={() => onSelectGroup(g)}
>
<span className="fleet-group-chip-dot" style={{ background: g.color }} />
<span className="fleet-group-chip-name">{g.name}</span>
<span className="fleet-group-chip-count">{g.agentIds.length}</span>
{onDeleteGroup && (
<span
className="fleet-group-chip-del"
role="button"
tabIndex={0}
aria-label={`Delete group ${g.name}`}
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`Delete group "${g.name}"?`)) onDeleteGroup(g.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (window.confirm(`Delete group "${g.name}"?`)) onDeleteGroup(g.id);
}
}}
>
×
</span>
)}
</button>
);
})}
</div>
{onCreateGroup && selectedCount > 0 && (
<button type="button" className="btn btn-outline btn-sm" onClick={onCreateGroup}>
+ Create group ({selectedCount})
</button>
)}
</div>
);
}

View File

@@ -70,6 +70,11 @@
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.agent-tag-chip.fleet-group-tag {
border: 1px solid;
font-weight: 600;
}
.agent-tag-chip {
display: inline-block;
font-size: 0.7rem;

View File

@@ -10,6 +10,7 @@ interface Props {
selectedCount: number;
onBulkAction: (action: string) => void;
onSelectAllFiltered?: () => void;
onCreateGroup?: () => void;
filteredCount?: number;
bulkBusy: boolean;
}
@@ -21,6 +22,7 @@ export default function FleetToolbar({
selectedCount,
onBulkAction,
onSelectAllFiltered,
onCreateGroup,
filteredCount,
bulkBusy,
}: Props) {
@@ -93,6 +95,27 @@ export default function FleetToolbar({
{selectedCount > 0 && (
<div className="fleet-bulk-bar">
<span className="font-tech">{selectedCount} selected</span>
{onCreateGroup && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={bulkBusy}
onClick={onCreateGroup}
>
Create group
</button>
)}
{selectedCount === 1 && (
<button
type="button"
className="btn btn-outline btn-sm"
disabled={bulkBusy}
onClick={() => onBulkAction('screenshot')}
title="Capture desktop on the selected machine and download JPEG here"
>
Screenshot
</button>
)}
<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>