Add fleet groups, agent screenshots, deploy guards, and Crucible polish.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 & 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" />
|
||||
|
||||
73
server/web/src/components/Fleet/CreateGroupModal.css
Normal file
73
server/web/src/components/Fleet/CreateGroupModal.css
Normal 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;
|
||||
}
|
||||
97
server/web/src/components/Fleet/CreateGroupModal.tsx
Normal file
97
server/web/src/components/Fleet/CreateGroupModal.tsx
Normal 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;
|
||||
}
|
||||
70
server/web/src/components/Fleet/FleetGroupsStrip.css
Normal file
70
server/web/src/components/Fleet/FleetGroupsStrip.css
Normal 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;
|
||||
}
|
||||
81
server/web/src/components/Fleet/FleetGroupsStrip.tsx
Normal file
81
server/web/src/components/Fleet/FleetGroupsStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user