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

@@ -4,6 +4,7 @@ import SessionGate from './components/SessionGate';
import Layout from './components/Layout/Layout';
import { WebSocketProvider } from './context/WebSocketProvider';
import { ForgeProvider } from './context/ForgeContext';
import { MatrixRainProvider } from './context/MatrixRainContext';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
@@ -27,6 +28,7 @@ function App() {
// No page or component should call new WebSocket() directly — use useWebSocket().
<WebSocketProvider>
<ForgeProvider>
<MatrixRainProvider>
<SessionGate>
<Layout>
<Suspense fallback={<PageFallback />}>
@@ -44,6 +46,7 @@ function App() {
</Suspense>
</Layout>
</SessionGate>
</MatrixRainProvider>
</ForgeProvider>
</WebSocketProvider>
);

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>

View File

@@ -180,6 +180,32 @@
min-height: 160px;
max-height: 320px;
background: #000;
transition: border-color 0.4s ease, box-shadow 0.4s ease;
}
/* Forge / Crucible target lock — gold heavy rain (matches forge progress vibe) */
.matrix-rain-wrap--intense {
border-top-color: rgba(255, 180, 40, 0.45);
border-bottom-color: rgba(255, 120, 0, 0.35);
box-shadow:
inset 0 0 28px rgba(255, 140, 0, 0.12),
0 0 18px rgba(255, 160, 0, 0.15);
}
.matrix-rain-wrap--intense .matrix-rain-scanlines {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 1px,
rgba(40, 20, 0, 0.22) 1px,
rgba(40, 20, 0, 0.22) 2px
);
}
.matrix-rain-wrap--crucible.matrix-rain-wrap--intense {
box-shadow:
inset 0 0 32px rgba(255, 200, 80, 0.14),
0 0 22px rgba(255, 180, 50, 0.2);
}
.matrix-rain-canvas {

View File

@@ -1,8 +1,13 @@
import { useEffect, useRef } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useForge } from '../../context/ForgeContext';
import { useMatrixRain } from '../../context/MatrixRainContext';
import {
FORGE_RAIN_STRINGS,
pickMysticWord,
wordColumnSpan,
} from '../../help/matrixRainEffects';
// Full matrix alphabet: katakana + hex + braille dots for visual density
const KATAKANA =
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
const HEX = '0123456789ABCDEFabcdef';
@@ -14,36 +19,36 @@ const FONT_SIZE = 10;
interface Column {
y: number;
speed: number;
// occasionally carry a char from live data
liveSrc: string;
livePos: number;
}
const FORGE_STRINGS = [
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
];
interface WordDrop {
text: string;
colStart: number;
y: number;
speed: number;
}
export default function MatrixRain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const { agents, recentShares, commandResults } = useWebSocket();
const { forging, stage } = useForge();
const { crucibleFocus } = useMatrixRain();
const forgingRef = useRef(false);
const crucibleRef = useRef(false);
const stageRef = useRef('');
forgingRef.current = forging;
crucibleRef.current = crucibleFocus;
stageRef.current = stage;
// ── Live data pool ──────────────────────────────────────────────────────────
// Collect strings from the fleet that will be injected character-by-character
// into the rain columns so real data scrolls through the matrix.
const livePoolRef = useRef<string[]>([]);
useEffect(() => {
const pool: string[] = [];
for (const a of agents) {
pool.push(a.id.replace(/-/g, '')); // stripped UUID
pool.push(a.id.replace(/-/g, ''));
if (a.hashrate_15s > 0) pool.push(`${a.hashrate_15s.toFixed(0)}H`);
if (a.ip) pool.push(a.ip.replace(/\./g, ''));
}
@@ -53,22 +58,24 @@ export default function MatrixRain() {
for (const r of (commandResults ?? []).slice(-5)) {
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
}
// When forging, flood the pool with build strings so they dominate the rain
if (forgingRef.current) {
pool.push(...FORGE_STRINGS);
if (stageRef.current) {
const intense = forgingRef.current || crucibleRef.current;
if (intense) {
pool.push(...FORGE_RAIN_STRINGS);
if (forgingRef.current && stageRef.current) {
pool.push(stageRef.current.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 16));
}
if (crucibleRef.current) {
pool.push('CRUCIBLE', 'TARGET', 'EXECUTE', 'REMOTE');
}
}
livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF'];
}, [agents, recentShares, commandResults]);
}, [agents, recentShares, commandResults, forging, crucibleFocus]);
// ── Event log feed ──────────────────────────────────────────────────────────
// We inject one short log line per meaningful event, shown as a dim overlay
// row scrolling through the canvas.
const eventLogRef = useRef<{ text: string; alpha: number }[]>([]);
const prevShareLen = useRef(0);
const prevAgentLen = useRef(0);
const prevCmdSeq = useRef(0);
useEffect(() => {
const newEvents: string[] = [];
if (recentShares.length > prevShareLen.current) {
@@ -81,13 +88,28 @@ export default function MatrixRain() {
newEvents.push(`AGENT ONLINE ${a.name?.slice(0, 8) ?? '??'}`);
}
prevAgentLen.current = agents.length;
const results = commandResults ?? [];
if (results.length > 0) {
const latest = results[results.length - 1];
if (latest._seq > prevCmdSeq.current) {
prevCmdSeq.current = latest._seq;
const tag = latest.success ? 'CMD OK' : 'CMD FAIL';
newEvents.push(`${tag} ${latest.action?.toUpperCase() ?? '?'}`);
}
}
for (const ev of newEvents) {
eventLogRef.current.push({ text: ev, alpha: 1 });
if (eventLogRef.current.length > 6) eventLogRef.current.shift();
}
}, [recentShares, agents]);
}, [recentShares, agents, commandResults]);
const intenseMode = forging || crucibleFocus;
const wrapClass = intenseMode
? `matrix-rain-wrap matrix-rain-wrap--intense${crucibleFocus && !forging ? ' matrix-rain-wrap--crucible' : ''}${forging ? ' matrix-rain-wrap--forge' : ''}`
: 'matrix-rain-wrap';
// ── Canvas renderer ─────────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
@@ -95,7 +117,6 @@ export default function MatrixRain() {
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Resize canvas to match wrapper
const resize = () => {
const r = wrap.getBoundingClientRect();
canvas.width = Math.floor(r.width);
@@ -106,9 +127,11 @@ export default function MatrixRain() {
ro.observe(wrap);
let cols: Column[] = [];
const wordDropsRef: WordDrop[] = [];
const resetCols = () => {
const numCols = Math.max(1, Math.floor(canvas.width / FONT_SIZE));
cols = Array.from({ length: numCols }, (_, i) => ({
cols = Array.from({ length: numCols }, () => ({
y: Math.random() * -(canvas.height * 2),
speed: 0.3 + Math.random() * 0.55,
liveSrc: '',
@@ -117,7 +140,19 @@ export default function MatrixRain() {
};
resetCols();
// Periodically inject live data strings into random columns
const spawnWordDrop = () => {
if (cols.length < 4) return;
const text = pickMysticWord();
const span = wordColumnSpan(text);
const colStart = Math.floor(Math.random() * Math.max(1, cols.length - span));
wordDropsRef.push({
text,
colStart,
y: -span - 2,
speed: 0.35 + Math.random() * 0.25,
});
};
const injectInterval = setInterval(() => {
const pool = livePoolRef.current;
if (pool.length === 0 || cols.length === 0) return;
@@ -127,13 +162,51 @@ export default function MatrixRain() {
cols[colIdx].livePos = 0;
}, 180);
const wordInterval = setInterval(() => {
if (!forgingRef.current) spawnWordDrop();
}, 7000 + Math.random() * 5000);
spawnWordDrop();
let raf: number;
let lastTime = 0;
const drawWordDrop = (wd: WordDrop, speedMult: number, intense: boolean) => {
const H = canvas.height;
let lastCharIdx = wd.text.length - 1;
while (lastCharIdx >= 0 && wd.text[lastCharIdx] === ' ') lastCharIdx--;
let colIdx = 0;
for (let i = 0; i < wd.text.length; i++) {
const ch = wd.text[i];
if (ch === ' ') {
colIdx++;
continue;
}
const row = wd.y - (wd.text.length - 1 - i);
const py = row * FONT_SIZE;
if (py < -FONT_SIZE || py > H + FONT_SIZE) {
colIdx++;
continue;
}
const x = (wd.colStart + colIdx) * FONT_SIZE;
const isHead = i === lastCharIdx;
if (intense) {
ctx.fillStyle = isHead ? 'rgba(255,235,120,1)' : 'rgba(255,140,0,0.85)';
} else {
ctx.fillStyle = isHead ? 'rgba(220,120,255,1)' : 'rgba(140,0,200,0.55)';
}
ctx.fillText(ch, x, py);
colIdx++;
}
wd.y += wd.speed * speedMult;
};
const draw = (ts: number) => {
raf = requestAnimationFrame(draw);
const isForging = forgingRef.current;
const targetFps = isForging ? 50 : 24;
const intense = forgingRef.current || crucibleRef.current;
const targetFps = intense ? 50 : 24;
const msPerFrame = 1000 / targetFps;
if (ts - lastTime < msPerFrame) return;
lastTime = ts;
@@ -141,21 +214,18 @@ export default function MatrixRain() {
const W = canvas.width;
const H = canvas.height;
// Forge mode: less fade = longer glowing trails; normal: quick fade
ctx.fillStyle = isForging ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)';
ctx.fillStyle = intense ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)';
ctx.fillRect(0, 0, W, H);
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
// Speed multiplier: 3× faster while forging
const speedMult = isForging ? 3.2 : 1.0;
const speedMult = intense ? 3.2 : 1.0;
for (let i = 0; i < cols.length; i++) {
const col = cols[i];
const x = i * FONT_SIZE;
const y = col.y;
// Pick character: live data char or random alphabet
let ch: string;
if (col.liveSrc && col.livePos < col.liveSrc.length) {
ch = col.liveSrc[col.livePos];
@@ -164,8 +234,7 @@ export default function MatrixRain() {
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
}
if (isForging) {
// Forge palette: bright amber/orange head, orange body
if (intense) {
ctx.fillStyle = 'rgba(255,220,80,0.98)';
ctx.fillText(ch, x, y * FONT_SIZE);
if (y > 1) {
@@ -173,21 +242,19 @@ export default function MatrixRain() {
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
(y - 1) * FONT_SIZE
);
}
// Extra mid-column glyph density during forge
if (Math.random() < 0.12) {
if (Math.random() < 0.14) {
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
ctx.fillStyle = 'rgba(255,120,0,0.35)';
ctx.fillStyle = 'rgba(255,120,0,0.4)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
dimY * FONT_SIZE
);
}
} else {
// Normal palette: white head, cyan-green body
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(ch, x, y * FONT_SIZE);
if (y > 1) {
@@ -195,7 +262,7 @@ export default function MatrixRain() {
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
(y - 1) * FONT_SIZE
);
}
if (Math.random() < 0.04) {
@@ -204,7 +271,7 @@ export default function MatrixRain() {
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
dimY * FONT_SIZE
);
}
}
@@ -218,18 +285,29 @@ export default function MatrixRain() {
}
}
// ── Event log overlay — bottom of canvas ──────────────────────────
for (let w = wordDropsRef.length - 1; w >= 0; w--) {
drawWordDrop(wordDropsRef[w], speedMult, intense);
if (wordDropsRef[w].y * FONT_SIZE > H + 40) {
wordDropsRef.splice(w, 1);
}
}
if (wordDropsRef.length < 3 && Math.random() < 0.02) {
spawnWordDrop();
}
const logs = eventLogRef.current;
const lineH = FONT_SIZE + 2;
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
const isForging2 = forgingRef.current;
for (let j = 0; j < logs.length; j++) {
const entry = logs[logs.length - 1 - j];
const oy = H - 6 - j * lineH;
if (oy < 0) break;
const logColor = isForging2
const fail = entry.text.includes('FAIL') || entry.text.includes('REJECT');
const logColor = intense
? `rgba(255,160,0,${(entry.alpha * 0.75).toFixed(2)})`
: `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
: fail
? `rgba(255,80,80,${(entry.alpha * 0.65).toFixed(2)})`
: `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
ctx.fillStyle = logColor;
ctx.fillText(`> ${entry.text}`, 4, oy);
entry.alpha = Math.max(0, entry.alpha - 0.003);
@@ -241,16 +319,15 @@ export default function MatrixRain() {
return () => {
cancelAnimationFrame(raf);
clearInterval(injectInterval);
clearInterval(wordInterval);
ro.disconnect();
};
}, []);
return (
<div ref={wrapRef} className="matrix-rain-wrap" aria-hidden="true">
<div ref={wrapRef} className={wrapClass} aria-hidden="true">
<canvas ref={canvasRef} className="matrix-rain-canvas" />
{/* Scanline overlay for authentic CRT feel */}
<div className="matrix-rain-scanlines" />
{/* Top and bottom vignette fades */}
<div className="matrix-rain-vignette-top" />
<div className="matrix-rain-vignette-btm" />
</div>

View File

@@ -0,0 +1,28 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
export type MatrixRainContextValue = {
/** True when Crucible has exactly one online node selected for remote ops. */
crucibleFocus: boolean;
setCrucibleFocus: (active: boolean) => void;
};
const MatrixRainContext = createContext<MatrixRainContextValue | null>(null);
export function MatrixRainProvider({ children }: { children: ReactNode }) {
const [crucibleFocus, setCrucibleFocus] = useState(false);
const value = useMemo(
() => ({ crucibleFocus, setCrucibleFocus }),
[crucibleFocus]
);
return (
<MatrixRainContext.Provider value={value}>{children}</MatrixRainContext.Provider>
);
}
export function useMatrixRain(): MatrixRainContextValue {
const ctx = useContext(MatrixRainContext);
if (!ctx) {
return { crucibleFocus: false, setCrucibleFocus: () => {} };
}
return ctx;
}

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
createFleetGroup,
groupsForAgent,
loadFleetGroups,
normalizeGroupColor,
primaryGroupForAgent,
saveFleetGroups,
} from './fleetGroups';
describe('fleetGroups', () => {
beforeEach(() => {
localStorage.clear();
});
it('normalizeGroupColor accepts hex', () => {
expect(normalizeGroupColor('#abc')).toBe('#aabbcc');
expect(normalizeGroupColor('#aabbcc')).toBe('#aabbcc');
});
it('persists and loads groups', () => {
const g = createFleetGroup('Rack A', '#ff00ff', ['a1', 'a2']);
saveFleetGroups([g]);
const loaded = loadFleetGroups();
expect(loaded).toHaveLength(1);
expect(loaded[0].name).toBe('Rack A');
expect(loaded[0].agentIds).toEqual(['a1', 'a2']);
});
it('groupsForAgent and primaryGroupForAgent', () => {
const groups = [
createFleetGroup('G1', '#00f5ff', ['x']),
createFleetGroup('G2', '#ff0000', ['x', 'y']),
];
expect(groupsForAgent(groups, 'x').map((g) => g.name)).toEqual(['G1', 'G2']);
expect(primaryGroupForAgent(groups, 'x')?.name).toBe('G1');
saveFleetGroups(groups);
expect(loadFleetGroups()).toHaveLength(2);
});
});

View File

@@ -0,0 +1,97 @@
/** Fleet node groups — persisted in localStorage, shared across Roster + Crucible. */
export interface FleetGroup {
id: string;
name: string;
color: string;
agentIds: string[];
createdAt: string;
}
export const FLEET_GROUP_COLORS = [
'#00f5ff',
'#39ff14',
'#ff2da6',
'#b24bf3',
'#ffb020',
'#ff6b35',
'#00d4aa',
'#3a86ff',
'#f72585',
'#ffd60a',
'#06d6a0',
'#e63946',
] as const;
export const FLEET_GROUPS_STORAGE_KEY = 'aetherforge_fleet_groups';
export const FLEET_GROUPS_CHANGED_EVENT = 'aetherforge-fleet-groups-changed';
export function normalizeGroupColor(color: string): string {
const c = color.trim();
if (/^#[0-9A-Fa-f]{6}$/.test(c)) return c;
if (/^#[0-9A-Fa-f]{3}$/.test(c)) {
const r = c[1];
const g = c[2];
const b = c[3];
return `#${r}${r}${g}${g}${b}${b}`;
}
return FLEET_GROUP_COLORS[0];
}
export function loadFleetGroups(): FleetGroup[] {
try {
const raw = localStorage.getItem(FLEET_GROUPS_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.map((g) => {
if (!g || typeof g !== 'object') return null;
const o = g as Record<string, unknown>;
const name = typeof o.name === 'string' ? o.name.trim() : '';
if (!name) return null;
const agentIds = Array.isArray(o.agentIds)
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
: [];
return {
id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`,
name,
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
agentIds,
createdAt: typeof o.createdAt === 'string' ? o.createdAt : new Date().toISOString(),
} satisfies FleetGroup;
})
.filter((g): g is FleetGroup => g !== null);
} catch {
return [];
}
}
export function saveFleetGroups(groups: FleetGroup[]): void {
localStorage.setItem(FLEET_GROUPS_STORAGE_KEY, JSON.stringify(groups));
window.dispatchEvent(new Event(FLEET_GROUPS_CHANGED_EVENT));
}
export function createFleetGroup(name: string, color: string, agentIds: string[]): FleetGroup {
return {
id: `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`,
name: name.trim(),
color: normalizeGroupColor(color),
agentIds: [...new Set(agentIds)],
createdAt: new Date().toISOString(),
};
}
/** Groups that contain this agent (preserves group list order). */
export function groupsForAgent(groups: FleetGroup[], agentId: string): FleetGroup[] {
return groups.filter((g) => g.agentIds.includes(agentId));
}
/** First group color for an agent (roster stripe / Crucible accent). */
export function primaryGroupForAgent(groups: FleetGroup[], agentId: string): FleetGroup | undefined {
return groups.find((g) => g.agentIds.includes(agentId));
}
export function notifyFleetGroupsChanged(): void {
window.dispatchEvent(new Event(FLEET_GROUPS_CHANGED_EVENT));
}

View File

@@ -92,9 +92,16 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
if (form.fusion_enabled) {
if (!fusionPrepSelected) {
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — upload your prep.exe.' });
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — choose a file to fuse (PDF, PNG, video, doc, or .exe).' });
} else {
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → output ${form.fusion_output_name || 'prep.exe'}.` });
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → universal ZIP with runners for ${form.fusion_output_name || 'each OS'}.` });
}
if (fusionPrepSelected && form.fusion_media_mode === 'embedded') {
checks.push({
id: 'fusion_embedded',
level: 'warn',
message: 'Embedded mode bakes the file into one .exe — use ZIP bundle (paired) for large images/videos or if the build fails.',
});
}
}

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { pickMysticWord, wordColumnSpan, MYSTIC_WORD_DROPS } from './matrixRainEffects';
describe('matrixRainEffects', () => {
it('pickMysticWord returns known words', () => {
const w = pickMysticWord();
expect(MYSTIC_WORD_DROPS).toContain(w);
});
it('wordColumnSpan matches string length', () => {
expect(wordColumnSpan('DESTROY')).toBe(7);
expect(wordColumnSpan('BLACK MAGIC')).toBe(11);
});
});

View File

@@ -0,0 +1,30 @@
/** Words that fall as intact columns through the sidebar matrix rain. */
export const MYSTIC_WORD_DROPS = [
'DESTROY',
'WITCHCRAFT',
'BLACKMAGIC',
'BLACK MAGIC',
'VOID',
'CURSE',
'BINDING',
'SIGNAL',
'EXECUTE',
'POSSESS',
'SUMMON',
'AETHER',
] as const;
export const FORGE_RAIN_STRINGS = [
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
] as const;
export function pickMysticWord(): string {
return MYSTIC_WORD_DROPS[Math.floor(Math.random() * MYSTIC_WORD_DROPS.length)];
}
export function wordColumnSpan(text: string): number {
return text.length;
}

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { sanitizeScreenshotBase64 } from './screenshotDownload';
describe('screenshotDownload', () => {
it('sanitizeScreenshotBase64 picks the longest base64 chunk', () => {
const junk = "warning\n/9j/QUJD\n";
const b64 = 'A'.repeat(120);
expect(sanitizeScreenshotBase64(`${junk}${b64}`)).toBe(b64);
});
it('returns empty when no valid base64', () => {
expect(sanitizeScreenshotBase64('not an image')).toBe('');
});
});

View File

@@ -0,0 +1,42 @@
/** Strip whitespace and download a remote desktop capture as a JPEG file. */
export function sanitizeScreenshotBase64(raw: string): string {
const trimmed = raw.trim().replace(/^\uFEFF/, '');
let best = '';
for (const part of trimmed.split(/\s+/)) {
const cleaned = part.replace(/[^A-Za-z0-9+/=]/g, '');
if (cleaned.length > best.length && cleaned.length >= 100) {
best = cleaned;
}
}
if (best.length >= 100) return best;
return trimmed.replace(/[^A-Za-z0-9+/=]/g, '');
}
export function downloadScreenshotFromBase64(
base64: string,
agentLabel: string
): boolean {
const clean = sanitizeScreenshotBase64(base64);
if (clean.length < 100) return false;
const safeName = agentLabel.replace(/[^\w.-]+/g, '_').slice(0, 64) || 'agent';
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const blob = base64ToBlob(clean, 'image/jpeg');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `screenshot-${safeName}-${stamp}.jpg`;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
return true;
}
function base64ToBlob(b64: string, mime: string): Blob {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: mime });
}

View File

@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react';
import {
createFleetGroup,
FLEET_GROUPS_CHANGED_EVENT,
FLEET_GROUPS_STORAGE_KEY,
loadFleetGroups,
saveFleetGroups,
type FleetGroup,
} from '../help/fleetGroups';
export function useFleetGroups() {
const [groups, setGroups] = useState<FleetGroup[]>(() => loadFleetGroups());
const refresh = useCallback(() => {
setGroups(loadFleetGroups());
}, []);
useEffect(() => {
const onChange = () => refresh();
window.addEventListener(FLEET_GROUPS_CHANGED_EVENT, onChange);
const onStorage = (e: StorageEvent) => {
if (e.key === FLEET_GROUPS_STORAGE_KEY) refresh();
};
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener(FLEET_GROUPS_CHANGED_EVENT, onChange);
window.removeEventListener('storage', onStorage);
};
}, [refresh]);
const persist = useCallback((next: FleetGroup[]) => {
saveFleetGroups(next);
setGroups(next);
}, []);
const addGroup = useCallback(
(name: string, color: string, agentIds: string[]) => {
const g = createFleetGroup(name, color, agentIds);
persist([...loadFleetGroups(), g]);
return g;
},
[persist]
);
const removeGroup = useCallback(
(id: string) => {
persist(loadFleetGroups().filter((g) => g.id !== id));
},
[persist]
);
const updateGroupAgents = useCallback(
(id: string, agentIds: string[]) => {
persist(
loadFleetGroups().map((g) =>
g.id === id ? { ...g, agentIds: [...new Set(agentIds)] } : g
)
);
},
[persist]
);
return { groups, addGroup, removeGroup, updateGroupAgents, refresh };
}

View File

@@ -16,6 +16,11 @@ import {
formatUptime,
} from '../help/fleetFilters';
import type { FleetFilterState } from '../help/fleetFilters';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import { groupsForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
import '../components/Fleet/FleetToolbar.css';
import './Pages.css';
@@ -96,6 +101,34 @@ export default function AgentsPage() {
const [metaMsg, setMetaMsg] = useState('');
const isConnectedRef = useRef(isConnected);
isConnectedRef.current = isConnected;
const screenshotWatchId = useRef<string | null>(null);
const screenshotSeqRef = useRef(0);
const [showGroupModal, setShowGroupModal] = useState(false);
const { groups, addGroup, removeGroup } = useFleetGroups();
const onlineAgentIds = useMemo(
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
[agents]
);
useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current;
for (const r of commandResults) {
if (r._seq <= screenshotSeqRef.current) continue;
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
screenshotSeqRef.current = r._seq;
screenshotWatchId.current = null;
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
if (r.success && r.message) {
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
} else {
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
}
break;
}
}, [commandResults, agents]);
useEffect(() => {
let cancelled = false;
@@ -280,6 +313,33 @@ export default function AgentsPage() {
return;
}
if (action === 'screenshot') {
if (onlineIds.length !== 1) {
alert('Select exactly one online machine (checkbox) for screenshot.');
return;
}
const id = onlineIds[0];
const label = agents.find((a) => a.id === id)?.name ?? 'agent';
screenshotWatchId.current = id;
if (commandResults?.length) {
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
}
setBulkBusy(true);
try {
const res = await api.sendAgentCommand(id, 'screenshot');
if (res.success === false) {
screenshotWatchId.current = null;
alert(res.error ?? 'Screenshot command rejected');
}
} catch (err) {
screenshotWatchId.current = null;
alert(err instanceof Error ? err.message : 'Screenshot failed');
} finally {
setBulkBusy(false);
}
return;
}
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
setBulkBusy(true);
@@ -333,8 +393,17 @@ export default function AgentsPage() {
filteredCount={filteredAgents.length}
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
onBulkAction={handleBulkAction}
onCreateGroup={() => setShowGroupModal(true)}
bulkBusy={bulkBusy}
/>
<FleetGroupsStrip
groups={groups}
liveAgentIds={onlineAgentIds}
selectedCount={selectedIds.size}
onSelectGroup={(g) => setSelectedIds(new Set(g.agentIds))}
onDeleteGroup={removeGroup}
onCreateGroup={() => setShowGroupModal(true)}
/>
<div className="agents-list">
{filteredAgents.map((agent) => (
<AgentListItem
@@ -348,6 +417,7 @@ export default function AgentsPage() {
onSelect={() => void selectAgent(agent)}
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
commandResults={commandResults}
memberGroups={groupsForAgent(groups, agent.id)}
/>
))}
{filteredAgents.length === 0 && (
@@ -551,6 +621,16 @@ export default function AgentsPage() {
)}
</div>
)}
<CreateGroupModal
open={showGroupModal}
agentCount={selectedIds.size}
onClose={() => setShowGroupModal(false)}
onCreate={(name, color) => {
addGroup(name, color, [...selectedIds]);
setShowGroupModal(false);
}}
/>
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -426,14 +426,23 @@ export default function BuilderPage() {
const applyFusionFileSelection = (f: File | null) => {
setFusionPrepFile(f);
if (!f) return;
const isImage = /\.(png|jpe?g|gif|webp|bmp|ico|tiff?)$/i.test(f.name);
// Images in embedded mode often blow up compile size; paired ZIP is reliable for any size.
const preferPaired = isImage || f.size > 500 * 1024 * 1024;
setForm((prev) => {
if (!prev) return prev;
return {
return normalizeForgeForm({
...prev,
fusion_payload_kind: fusionPayloadKind(f),
fusion_media_base_name: f.name,
fusion_output_name: defaultRunnerName(f.name),
};
...(preferPaired && prev.fusion_media_mode === 'embedded'
? { fusion_media_mode: 'paired' as const }
: {}),
...(!prev.fusion_enabled
? { fusion_enabled: true, spread_kit: false, target_os: 'universal', target_arch: 'all' }
: {}),
});
});
};
@@ -1547,21 +1556,44 @@ export default function BuilderPage() {
? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.'
: 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'}
/>
{deliverableType !== 'fusion' && (
<div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
<input
type="checkbox"
className="checkbox"
checked={form.fusion_enabled}
disabled={fieldMeta.fusion_enabled?.disabled}
onChange={(e) => updateField('fusion_enabled', e.target.checked)} />
onChange={(e) => {
if (e.target.checked) {
setForm((prev) =>
prev
? normalizeForgeForm({
...prev,
fusion_enabled: true,
spread_kit: false,
target_os: 'universal',
target_arch: 'all',
})
: prev
);
} else {
setDeliverableType('single');
}
}}
/>
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
</label>
<FieldHint field="fusion_enabled" />
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
{form.fusion_enabled && (
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
Uncheck <strong>Enable Fusion</strong> above to return to a plain single-platform worker build.
</p>
)}
</div>
)}
{deliverableType === 'fusion' && (
{deliverableType === 'fusion' && form.fusion_enabled && (
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
Fusion selected drop your files below and forge. Each file becomes its own universal ZIP (Windows + Mac + Linux) that can be sent to any machine.
Fusion deliverable drop your file below and forge. Output is one universal ZIP (Windows + Mac + Linux).
</p>
)}
{form.fusion_enabled && (
@@ -1785,6 +1817,20 @@ export default function BuilderPage() {
<p className="form-hint">
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
</p>
{fusionPrepFile && (
<div className="fusion-output-preview card" style={{ marginTop: '0.75rem', padding: '0.75rem 1rem', fontSize: '0.85rem' }}>
<p className="font-tech" style={{ marginBottom: '0.5rem', color: 'var(--neon-cyan)' }}>WHAT YOU GET {fusionFileTypeLabel(fusionPrepFile.name).toUpperCase()}</p>
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
<li><strong>Windows:</strong> <code>Start.bat</code> or disguised runner (e.g. <code>{disguisedDisplayName(fusionPrepFile.name)}</code> with Photos icon) opens the image in the default viewer, miner runs hidden.</li>
<li><strong>Linux:</strong> <code>start.sh</code> <code>bin/linux-amd64/{fusionPrepFile.name.replace(/\.[^.]+$/, '')}-runner</code></li>
<li><strong>macOS:</strong> <code>Start.command</code> or <code>{fusionTitleFromFilename(fusionPrepFile.name)}.app</code> bundle</li>
{fusionMediaMode === 'paired' && (
<li><strong>ZIP also includes:</strong> your original <code>{fusionPrepFile.name}</code> at the root (paired mode).</li>
)}
<li>Download: <code>{fusionTitleFromFilename(fusionPrepFile.name)}-package.zip</code> under fusion-deliverables on the server.</li>
</ul>
</div>
)}
{(estimateLoading || fusionEstimate || estimateError) && (
<div className="fusion-estimate-panel card">
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>

View File

@@ -358,6 +358,16 @@
.crucible-group-item:hover { background: rgba(178, 75, 243, 0.2); }
.cn-group-pill {
margin-left: 0.4rem;
font-size: 0.65rem;
padding: 0.05rem 0.35rem;
border-radius: 4px;
border: 1px solid;
font-weight: 600;
vertical-align: middle;
}
.cg-name { flex: 1; color: var(--text-primary); font-weight: 500; }
.cg-count { color: var(--text-muted); font-size: 0.75rem; }
.cg-del {

View File

@@ -4,7 +4,12 @@ import { api } from '../api/client';
import type { Agent, AgentService } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
import { formatHashrate } from '../help/fleetFilters';
import { primaryGroupForAgent } from '../help/fleetGroups';
import { useFleetGroups } from '../hooks/useFleetGroups';
import { useMatrixRain } from '../context/MatrixRainContext';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -68,12 +73,6 @@ interface RichPostureSummary {
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
interface NodeGroup {
id: string;
name: string;
agentIds: Set<string>;
}
// ── Helpers ────────────────────────────────────────────────────────────────
const AGENT_COLORS = [
@@ -82,7 +81,8 @@ const AGENT_COLORS = [
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
];
export function agentColor(agentId: string, allIds: string[]): string {
export function agentColor(agentId: string, allIds: string[], groupColor?: string): string {
if (groupColor) return groupColor;
const idx = allIds.indexOf(agentId);
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
}
@@ -286,11 +286,12 @@ const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONL
export default function CruciblePage() {
const { agents, commandResults } = useWebSocket();
const { setCrucibleFocus } = useMatrixRain();
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [groups, setGroups] = useState<NodeGroup[]>([]);
const [groupNameInput, setGroupNameInput] = useState('');
const [showGroupModal, setShowGroupModal] = useState(false);
const { groups, addGroup, removeGroup } = useFleetGroups();
// Terminal
const [termLines, setTermLines] = useState<TermLine[]>([]);
@@ -317,6 +318,15 @@ export default function CruciblePage() {
const online = (a: Agent) => a.status === 'online';
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
const crucibleTargetReady =
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
useEffect(() => {
setCrucibleFocus(crucibleTargetReady);
return () => setCrucibleFocus(false);
}, [crucibleTargetReady, setCrucibleFocus]);
// Prune selectedIds when agents are removed (e.g. after roster delete).
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
@@ -427,17 +437,12 @@ export default function CruciblePage() {
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
const clearSel = () => setSelectedIds(new Set());
const addGroup = () => {
if (!groupNameInput.trim() || selectedIds.size === 0) return;
setGroups((prev) => [
...prev,
{ id: mkId(), name: groupNameInput.trim(), agentIds: new Set(selectedIds) },
]);
setGroupNameInput('');
};
const onlineAgentIds = useMemo(
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
[agents]
);
const activateGroup = (g: NodeGroup) => setSelectedIds(new Set(g.agentIds));
const deleteGroup = (id: string) => setGroups((prev) => prev.filter((g) => g.id !== id));
const activateGroup = (g: { agentIds: string[] }) => setSelectedIds(new Set(g.agentIds));
// ── Dispatch command ───────────────────────────────────────────────────
@@ -464,8 +469,24 @@ export default function CruciblePage() {
: 'exec';
await Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, action, { command }).catch((err) => {
tgts.map(async (a) => {
try {
const res = await api.sendAgentCommand(a.id, action, { command });
if (res.success === false) {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] ${res.error ?? 'command rejected'}`,
ts: new Date(),
success: false,
},
]);
}
} catch (err) {
setTermLines((prev) => [
...prev,
{
@@ -478,8 +499,8 @@ export default function CruciblePage() {
success: false,
},
]);
})
)
}
})
);
setBusy(false);
cmdRef.current?.focus();
@@ -745,9 +766,23 @@ export default function CruciblePage() {
</div>
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
{selectedIds.size > 0 && (
<button className="button crucible-btn" onClick={() => setShowGroupModal(true)}>
Create group
</button>
)}
</div>
</header>
<FleetGroupsStrip
groups={groups}
liveAgentIds={onlineAgentIds}
selectedCount={selectedIds.size}
onSelectGroup={activateGroup}
onDeleteGroup={removeGroup}
onCreateGroup={() => setShowGroupModal(true)}
/>
{/* ── Node Roster ─────────────────────────────────────────────────── */}
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
<div className="crucible-section-title font-tech">
@@ -762,12 +797,16 @@ export default function CruciblePage() {
const isOn = online(a);
const ssh = sshStatus(a);
const posture = postureStatus(a);
const color = agentColor(a.id, allIds);
const pg = primaryGroupForAgent(groups, a.id);
const color = agentColor(a.id, allIds, pg?.color);
return (
<div
key={a.id}
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
style={sel ? { '--sel-color': color } as React.CSSProperties : undefined}
style={{
...(sel ? { '--sel-color': color } : {}),
...(pg ? { borderLeft: `3px solid ${pg.color}` } : {}),
} as React.CSSProperties}
onClick={() => toggle(a.id)}
>
<div className="crucible-node-check">
@@ -775,9 +814,14 @@ export default function CruciblePage() {
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
</div>
<div className="crucible-node-body">
<div className="cn-name" style={sel ? { color } : undefined}>
<div className="cn-name" style={sel || pg ? { color: pg?.color ?? color } : undefined}>
<span className="cn-platform">{platformIcon(a.platform)}</span>
{a.name}
{pg && (
<span className="cn-group-pill" style={{ color: pg.color, borderColor: `${pg.color}66` }}>
{pg.name}
</span>
)}
</div>
<div className="cn-meta">
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
@@ -921,32 +965,43 @@ export default function CruciblePage() {
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> GROUPS
</div>
<div className="crucible-groups-list">
{groups.length === 0 && (
<p className="form-hint" style={{ margin: 0 }}>
Select nodes above, name a group, save it here.
</p>
)}
{groups.map((g) => (
<div key={g.id} className="crucible-group-item" onClick={() => activateGroup(g)}>
<span className="cg-name">{g.name}</span>
<span className="cg-count">{g.agentIds.size} nodes</span>
<button className="cg-del" onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}>×</button>
</div>
))}
</div>
<div className="crucible-group-new">
<input
className="crucible-input"
value={groupNameInput}
onChange={(e) => setGroupNameInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addGroup()}
placeholder={`Name group (${selectedIds.size} selected)…`}
/>
<button className="button crucible-btn" onClick={addGroup} disabled={!groupNameInput.trim() || selectedIds.size === 0}>
Save
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
Same groups as Fleet Roster click a chip to select all members.
</p>
{groups.length === 0 ? (
<p className="form-hint" style={{ margin: 0 }}>
Select nodes, then <strong>Create group</strong> to name and color them.
</p>
) : (
<div className="crucible-groups-list">
{groups.map((g) => (
<div
key={g.id}
className="crucible-group-item"
style={{
borderColor: `${g.color}55`,
background: `${g.color}14`,
}}
onClick={() => activateGroup(g)}
>
<span className="fleet-group-chip-dot" style={{ background: g.color }} />
<span className="cg-name" style={{ color: g.color }}>{g.name}</span>
<span className="cg-count">{g.agentIds.length} nodes</span>
<button className="cg-del" onClick={(e) => { e.stopPropagation(); removeGroup(g.id); }}>×</button>
</div>
))}
</div>
)}
{selectedIds.size > 0 && (
<button
type="button"
className="button crucible-btn"
style={{ marginTop: '0.75rem' }}
onClick={() => setShowGroupModal(true)}
>
Create group from selection ({selectedIds.size})
</button>
</div>
)}
</NeonCard>
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
@@ -1186,6 +1241,16 @@ export default function CruciblePage() {
</div>
</div>
</NeonCard>
<CreateGroupModal
open={showGroupModal}
agentCount={selectedIds.size}
onClose={() => setShowGroupModal(false)}
onCreate={(name, color) => {
addGroup(name, color, [...selectedIds]);
setShowGroupModal(false);
}}
/>
</div>
);
}