Release validation: tests green, USB pack, fleet UX and API hardening.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Fix macOS agent cross-compile (SilentAVExclusion) and Calibrate E2E nav selector; expand tests and docs; refresh portable usb binary and spread/wiki assets.
This commit is contained in:
AetherForge
2026-06-06 16:57:39 -07:00
parent 5229854f00
commit 415b5dc6a3
119 changed files with 7005 additions and 3082 deletions

View File

@@ -123,9 +123,18 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
resize();
window.addEventListener('resize', resize);
const tick = () => {
if (document.hidden) {
let paused = document.visibilityState === 'hidden';
const onVis = () => {
paused = document.visibilityState === 'hidden';
if (!paused && !rafRef.current) {
rafRef.current = requestAnimationFrame(tick);
}
};
document.addEventListener('visibilitychange', onVis);
const tick = () => {
if (paused) {
rafRef.current = 0;
return;
}
@@ -182,6 +191,7 @@ export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowPa
rafRef.current = requestAnimationFrame(tick);
return () => {
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('resize', resize);
cancelAnimationFrame(rafRef.current);
};

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import {
Area,
AreaChart,
@@ -32,17 +33,17 @@ interface HashrateChartProps {
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
function colorToId(color: string): string {
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff') return 'cyan';
if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan';
if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta';
if (color.includes('b020') || color.includes('eab308')) return 'amber';
if (color.includes('39ff') || color.includes('22c55e')) return 'green';
return 'purple';
}
export default function HashrateChart({
function HashrateChart({
data,
title,
color = '#00f5ff',
color = 'var(--neon-cyan)',
unit = 'H/s',
height = 280,
displayMode = 'live',
@@ -187,3 +188,5 @@ function formatFull(v: number, unit: string): string {
}
return `${v.toFixed(1)}${unit}`;
}
export default memo(HashrateChart);

View File

@@ -177,15 +177,13 @@ export default function SupplyChainExportWizard({
return (
<>
<div className="spread-section spread-section--violet supply-chain-wizard operator-deck-card operator-interactive">
<div className="supply-chain-wizard">
<div className="supply-chain-wizard-header">
<h3>Supply-chain export wizard</h3>
<p className="form-hint" style={{ margin: 0 }}>
WordPress plugin ZIP (<code>/get?c=wp-{'{site}'}</code>) or npm helper (postinstall curls{' '}
<code>install.sh</code>).{' '}
<a href={wikiUrl} target="_blank" rel="noreferrer">
Wiki playbook §
{family === 'wordpress' ? 'WordPress playbook' : 'npm helper playbook'}
</a>
{' '}for hosting steps after export.
</p>
</div>
@@ -228,7 +226,7 @@ export default function SupplyChainExportWizard({
<div className="supply-chain-step-panel">
{step === 'pick-build' && (
<>
<p className="form-hint">Choose the pinned build embedded in the export artifact.</p>
<p className="form-hint">Uses Build A from campaign setup above unless you change it here.</p>
<div className="form-group">
<label className="label" htmlFor="sc-build">Build (pin)</label>
<select
@@ -257,6 +255,7 @@ export default function SupplyChainExportWizard({
{step === 'configure' && (
<>
<p className="form-hint">Synced with campaign setup edit here or above.</p>
<div className="form-group">
<label className="label" htmlFor="sc-server">Command deck URL</label>
<input
@@ -343,7 +342,7 @@ export default function SupplyChainExportWizard({
: 'Export npm package template ZIP'}
</button>
<a className="btn btn-outline btn-sm" href={wikiUrl} target="_blank" rel="noreferrer">
Read wiki §
Read playbook
</a>
</div>
</>

View File

@@ -33,7 +33,7 @@
border-radius: 50%;
}
.agent-glow { background: #00e5ff; box-shadow: 0 0 10px #00e5ff, 0 0 20px #00e5ff; }
.agent-glow { background: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan), 0 0 20px var(--neon-cyan); }
.fleet-glow { background: #ff00ff; box-shadow: 0 0 10px #ff00ff, 0 0 20px #ff00ff; }
.tactical-grid {
@@ -82,7 +82,7 @@
box-shadow: 0 0 10px rgba(187, 134, 252, 0.2);
}
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: #00e5ff; }
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: var(--neon-cyan); }
.button-grid button.btn-cyan:hover { background: rgba(0, 229, 255, 0.1); box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); }
.button-grid button.btn-amber { border-color: rgba(255, 171, 0, 0.3); color: #ffab00; }
@@ -100,7 +100,7 @@
.screenshot-viewer {
margin-bottom: 20px;
border: 1px solid #00e5ff;
border: 1px solid var(--neon-cyan);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
@@ -113,7 +113,7 @@
justify-content: space-between;
align-items: center;
font-weight: bold;
color: #00e5ff;
color: var(--neon-cyan);
}
.viewer-header button {
@@ -181,7 +181,7 @@
border-bottom: 1px solid rgba(0,229,255,0.15);
flex-shrink: 0;
}
.terminal-title { font-size: 0.7rem; color: #00e5ff; letter-spacing: 0.06em; font-weight: bold; }
.terminal-title { font-size: 0.7rem; color: var(--neon-cyan); letter-spacing: 0.06em; font-weight: bold; }
.terminal-clear-btn { background: none; border: 1px solid #333; color: #555; font-size: 0.65rem; padding: 2px 8px; border-radius: 3px; cursor: pointer; }
.terminal-clear-btn:hover { border-color: #888; color: #ccc; }
@@ -196,7 +196,7 @@
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
.terminal-input-bar button:hover { background: var(--neon-cyan); color: #000; }
.terminal-input-bar button:disabled { opacity: 0.4; cursor: not-allowed; }
/* Upgrade section */
@@ -250,10 +250,10 @@
background: rgba(255,171,0,0.2);
border: 1px solid rgba(255,171,0,0.4);
border-radius: 4px;
color: #ffcc44;
color: var(--neon-amber);
animation: pulse-amber 1.2s ease-in-out infinite;
}
.offline-dot { background: #555; box-shadow: 0 0 6px #555; }
.offline-dot { background: var(--text-muted); box-shadow: 0 0 6px var(--text-muted); }
@keyframes pulse-amber {
0%, 100% { opacity: 1; }
@@ -268,7 +268,7 @@
border-radius: 4px;
border: 1px solid rgba(0,229,255,0.3);
background: rgba(0,229,255,0.06);
color: #00e5ff;
color: var(--neon-cyan);
cursor: pointer;
transition: all 0.2s;
text-align: left;
@@ -292,7 +292,7 @@
font-family: 'Consolas', monospace;
outline: none;
}
.wol-mac-input:focus { border-color: #00e5ff; }
.wol-mac-input:focus { border-color: var(--neon-cyan); }
.wol-form button { white-space: nowrap; padding: 6px 14px; font-size: 0.8rem; }
/* Compact mode extras */

View File

@@ -0,0 +1,36 @@
import { useState, type ReactNode } from 'react';
import { HelpTip } from '../HelpTip';
interface Props {
label: string;
className: string;
defaultOpen?: boolean;
helpField?: string;
children: ReactNode;
}
/** Steampunk accordion row for dense Crucible op groups. */
export default function CrucibleCollapsibleSection({
label,
className,
defaultOpen = false,
helpField,
children,
}: Props) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`crucible-op-group ${className} crucible-op-collapsible`}>
<div className="cop-toggle-row">
<button type="button" className="cop-toggle" onClick={() => setOpen((v) => !v)}>
<span className="cop-label">{label}</span>
<span className="cop-chevron" aria-hidden>
{open ? '▲' : '▼'}
</span>
</button>
{helpField ? <HelpTip field={helpField} /> : null}
</div>
{open ? <div className="cop-body">{children}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,225 @@
/**
* @vitest-environment happy-dom
*/
import { type ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CrucibleExpandedOps from './CrucibleExpandedOps';
import { api } from '../../api/client';
import { mockAgent } from '../../test/fixtures';
vi.mock('../../api/client', () => ({
api: {
listBuilds: vi.fn(),
sendAgentCommand: vi.fn(),
sendWOL: vi.fn(),
sendBulkCommand: vi.fn(),
},
}));
vi.mock('./CruciblePortForwardMatrix', () => ({
default: () => <div data-testid="port-forward-matrix" />,
}));
vi.mock('./ProtocolTunnelPanel', () => ({
default: () => <div data-testid="protocol-tunnel-panel" />,
}));
vi.mock('./FileManager', () => ({
default: () => <div data-testid="file-manager" />,
}));
const listBuildsMock = vi.mocked(api.listBuilds);
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
const sendWOLMock = vi.mocked(api.sendWOL);
const sendBulkCommandMock = vi.mocked(api.sendBulkCommand);
const fullCaps = {
hole_punch: true,
remote_aggressive: true,
mesh_p2p: true,
auto_spread: true,
process_hollowing: false,
ai_enabled: false,
};
const onlineWin = mockAgent({
id: 'win-1',
name: 'WinNode',
status: 'online',
platform: 'windows',
mac_address: 'AA:BB:CC:DD:EE:FF',
capabilities: fullCaps,
});
function defaultProps(overrides: Partial<ComponentProps<typeof CrucibleExpandedOps>> = {}) {
return {
activeTab: 'ops' as const,
selectedAgents: [onlineWin],
selectedCount: 1,
singleSelectedAgent: onlineWin,
allAgents: [onlineWin],
commandResults: [],
onEcho: vi.fn(),
onAgentError: vi.fn(),
onScanSelected: vi.fn(),
onProbePosture: vi.fn(),
onProbeSSH: vi.fn(),
onWakeSSH: vi.fn(),
onShellDispatch: vi.fn(),
browseAgent: onlineWin,
encryptTargets: [{ id: 'win-1', name: 'WinNode' }],
fmCommandResults: [],
tunnelStatusMsg: '',
onDispatchTunnel: vi.fn(),
...overrides,
};
}
describe('CrucibleExpandedOps', () => {
beforeEach(() => {
vi.resetAllMocks();
listBuildsMock.mockResolvedValue([
{
id: 'build-1',
file_name: 'worker.exe',
download_url: 'http://127.0.0.1:8080/api/download/agent-build-1',
platform: 'windows',
},
]);
sendAgentCommandMock.mockResolvedValue({ success: true });
sendBulkCommandMock.mockResolvedValue({ sent: 1, failed: 0 });
sendWOLMock.mockResolvedValue({ success: true, mac: 'AA:BB:CC:DD:EE:FF' });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it('renders ops tab section headers', () => {
render(<CrucibleExpandedOps {...defaultProps()} />);
expect(screen.getByText('Mining')).toBeInTheDocument();
expect(screen.getByText('Agent Control')).toBeInTheDocument();
expect(screen.getByText('Persistence')).toBeInTheDocument();
});
it('disables persistence audit when no online targets', () => {
render(
<CrucibleExpandedOps
{...defaultProps({
selectedAgents: [mockAgent({ id: 'off-1', status: 'offline' })],
selectedCount: 1,
singleSelectedAgent: mockAgent({ id: 'off-1', status: 'offline' }),
})}
/>
);
expect(screen.getByRole('button', { name: 'Persistence Audit' })).toBeDisabled();
});
it('dispatches persistence audit to online agents', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
await user.click(screen.getByRole('button', { name: 'Persistence Audit' }));
await waitFor(() => {
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'persistence_audit', {});
});
expect(onEcho).toHaveBeenCalledWith('persistence_audit → 1 node(s)', true);
});
it('sends wake-on-lan for selected agents', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ onEcho })} />);
await user.click(screen.getByRole('button', { name: 'Wake-on-LAN' }));
await waitFor(() => {
expect(sendWOLMock).toHaveBeenCalledWith('win-1', 'AA:BB:CC:DD:EE:FF');
});
expect(onEcho).toHaveBeenCalledWith(expect.stringContaining('WOL → WinNode'), true);
});
it('parses camera_list results into device picker on recon tab', async () => {
const user = userEvent.setup();
const { rerender } = render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon' })} />);
await user.click(screen.getByRole('button', { name: /Media & Desktop Capture/i }));
rerender(
<CrucibleExpandedOps
{...defaultProps({
activeTab: 'recon',
commandResults: [
{
agent_id: 'win-1',
action: 'camera_list',
success: true,
message: '"USB WebCam"\n/dev/video0',
},
],
})}
/>
);
await waitFor(() => {
expect(screen.getByDisplayValue('"USB WebCam"')).toBeInTheDocument();
});
});
it('dispatches ARP neighbors from recon tab', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'recon', onEcho })} />);
await user.click(screen.getByRole('button', { name: /Network & Firewall Posture/i }));
await user.click(screen.getByRole('button', { name: 'ARP Neighbors' }));
await waitFor(() => {
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'arp_neighbors', {});
});
expect(onEcho).toHaveBeenCalledWith('arp_neighbors → 1 node(s)', true);
});
it('shows spread_now on spread tab', () => {
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread' })} />);
expect(screen.getByRole('button', { name: 'Spread Now' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /SUPP Seek Mode/i })).toBeInTheDocument();
});
it('shows SSH probe controls on tunnels tab', async () => {
const user = userEvent.setup();
const onProbeSSH = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'tunnels', onProbeSSH })} />);
await user.click(screen.getByRole('button', { name: 'Probe SSH' }));
expect(onProbeSSH).toHaveBeenCalled();
expect(screen.getByTestId('protocol-tunnel-panel')).toBeInTheDocument();
expect(screen.getByText('Port-Forward Matrix')).toBeInTheDocument();
});
it('renders file manager only on files tab', () => {
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'files' })} />);
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Remote File Browser/i })).toBeInTheDocument();
});
it('requires build selection before push upgrade', async () => {
const user = userEvent.setup();
render(<CrucibleExpandedOps {...defaultProps()} />);
const pushBtn = screen.getByRole('button', { name: 'Push Upgrade' });
expect(pushBtn).toBeDisabled();
await waitFor(() => {
expect(screen.getByRole('option', { name: /worker\.exe/ })).toBeInTheDocument();
});
await user.selectOptions(screen.getByRole('combobox'), 'build-1');
expect(pushBtn).not.toBeDisabled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,118 +1,339 @@
.file-manager {
border: 1px solid var(--clr-border, #333);
border-radius: 4px;
border: 1px solid var(--border-brass);
border-radius: 6px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.25);
background: linear-gradient(165deg, rgba(22, 18, 14, 0.92), rgba(8, 6, 4, 0.96));
font-size: 0.85rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.fm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.fm-title {
font-family: var(--font-tech);
font-size: 0.7rem;
letter-spacing: 0.06em;
color: var(--clr-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.fm-breadcrumb {
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.fm-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan);
cursor: pointer;
font-size: 0.75rem;
padding: 0;
transition: color 0.2s ease, text-shadow 0.2s ease;
}
.fm-crumb:hover {
color: var(--brass-light);
text-shadow: 0 0 8px rgba(0, 232, 245, 0.35);
}
.fm-crumb:focus-visible {
outline: 2px solid var(--neon-cyan);
outline-offset: 2px;
}
.fm-sep {
opacity: 0.5;
margin: 0 0.15rem;
}
.fm-filter {
width: 100%;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.fm-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid #222;
border: 1px solid rgba(140, 120, 60, 0.2);
border-radius: 4px;
}
.fm-list li.selected {
background: rgba(0, 255, 255, 0.08);
background: rgba(0, 232, 245, 0.08);
}
.fm-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.25rem 0.4rem;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.fm-row:hover {
background: rgba(0, 232, 245, 0.04);
}
.fm-name {
flex: 1;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-size {
font-size: 0.7rem;
color: var(--clr-dim);
color: var(--text-muted);
}
.fm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
align-items: center;
}
.fm-upload-path {
flex: 1;
min-width: 120px;
font-size: 0.75rem;
}
.fm-preview {
margin-top: 0.5rem;
max-height: 120px;
overflow: auto;
font-size: 0.7rem;
background: #111;
background: rgba(6, 6, 12, 0.92);
padding: 0.4rem;
border: 1px solid rgba(140, 120, 60, 0.15);
border-radius: 4px;
}
.fm-err {
color: #f66;
color: var(--error-color);
font-size: 0.8rem;
}
.fm-offline {
color: var(--clr-dim);
color: var(--text-muted);
font-size: 0.8rem;
}
.fm-path {
font-size: 0.72rem;
color: var(--neon-cyan);
margin-bottom: 0.35rem;
word-break: break-all;
}
.fm-upload-wrap {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.fm-encrypt-section {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
margin-top: 0.4rem;
border-top: 1px solid rgba(140, 120, 60, 0.2);
padding-top: 0.4rem;
}
.fm-recursive-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
color: var(--text-secondary);
}
.fm-encrypt-btn {
background: linear-gradient(135deg, rgba(123, 0, 0, 0.95) 0%, rgba(204, 0, 0, 0.9) 100%);
border: 1px solid var(--error-color);
color: var(--text-primary);
font-weight: 700;
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
cursor: pointer;
border-radius: 3px;
}

View File

@@ -0,0 +1,560 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FileManager from './FileManager';
import { api } from '../../api/client';
import { readFileAsBase64 } from '../../help/desktopPush';
vi.mock('../../api/client', () => ({
api: {
sendAgentCommand: vi.fn(),
},
}));
vi.mock('../../help/desktopPush', () => ({
readFileAsBase64: vi.fn(),
}));
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
const readFileAsBase64Mock = vi.mocked(readFileAsBase64);
describe('FileManager', () => {
const defaultProps = {
agentId: 'test-agent-id-12345678',
agentName: 'TestAgentName',
platform: 'windows',
online: true,
encryptTargets: [] as { id: string; name: string }[],
commandResults: [] as { agentId: string; action: string; success: boolean; message: string }[],
onTerminalLine: vi.fn(),
};
beforeEach(() => {
vi.resetAllMocks();
sendAgentCommandMock.mockResolvedValue({ success: true });
readFileAsBase64Mock.mockResolvedValue('mock_base64_payload');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it('renders offline message and disables actions when agent is offline', () => {
render(<FileManager {...defaultProps} online={false} />);
expect(screen.getByText('FILE BROWSER — TestAgentName')).toBeInTheDocument();
expect(screen.getByText('Agent offline')).toBeInTheDocument();
// Refresh button should be disabled
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
expect(refreshBtn).toBeDisabled();
// Check action buttons disabled
const downloadBtn = screen.getByRole('button', { name: 'Download selected' });
expect(downloadBtn).toBeDisabled();
const uploadInput = screen.getByLabelText('Upload') as HTMLInputElement;
expect(uploadInput).toBeDisabled();
});
it('performs list_dir refresh on load and when clicking Refresh', async () => {
const { rerender } = render(<FileManager {...defaultProps} />);
// Triggered automatically on mount
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
// Simulate list_dir reply to clear busy state
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '', entries: [] }),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click refresh manually
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
await userEvent.setup().click(refreshBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', { path: '' });
});
it('renders directory listing when list_dir results are received via commandResults', () => {
const listDirPayload = {
path: 'C:\\Users\\Admin',
home_dir: 'C:\\Users\\Admin',
entries: [
{ name: 'Documents', is_dir: true, size: 0 },
{ name: 'notes.txt', is_dir: false, size: 500 },
{ name: 'backup.zip', is_dir: false, size: 2048 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(<FileManager {...defaultProps} commandResults={[]} />);
// Initially no list elements rendered other than parent row navigation
expect(screen.queryByText(/Documents/)).not.toBeInTheDocument();
expect(screen.queryByText(/notes.txt/)).not.toBeInTheDocument();
// Pass in command results
rerender(<FileManager {...defaultProps} commandResults={commandResults} />);
expect(screen.getByText(/Documents/)).toBeInTheDocument();
expect(screen.getByText(/notes.txt/)).toBeInTheDocument();
expect(screen.getByText('500 B')).toBeInTheDocument(); // 500 B < 1024
expect(screen.getByText('2.0 KB')).toBeInTheDocument(); // 2048 B / 1024 = 2.0 KB
expect(screen.getByText('C:\\Users\\Admin')).toBeInTheDocument();
});
it('handles navigation by clicking folders or breadcrumbs', async () => {
const listDirPayload = {
path: 'C:\\Users\\Admin',
home_dir: 'C:\\Users\\Admin',
entries: [
{ name: 'Documents', is_dir: true, size: 0 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
sendAgentCommandMock.mockClear();
// Click on Documents folder to navigate in
const docFolderBtn = screen.getByText(/Documents/);
await userEvent.setup().click(docFolderBtn);
// Verify it updates cwd and sends list_dir for the nested path
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users\\Admin\\Documents',
});
// Simulate list_dir response for the new nested path to clear busy
rerender(
<FileManager
{...defaultProps}
commandResults={[
...commandResults,
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({
path: 'C:\\Users\\Admin\\Documents',
home_dir: 'C:\\Users\\Admin',
entries: [],
}),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click parent navigation ".."
const parentNavBtn = screen.getByText('..');
await userEvent.setup().click(parentNavBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users\\Admin',
});
// Simulate list_dir response back to parent
rerender(
<FileManager
{...defaultProps}
commandResults={[
...commandResults,
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({
path: 'C:\\Users\\Admin',
home_dir: 'C:\\Users\\Admin',
entries: [{ name: 'Documents', is_dir: true, size: 0 }],
}),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click crumb item "Users" in breadcrumbs
const usersCrumbBtn = screen.getByRole('button', { name: 'Users' });
await userEvent.setup().click(usersCrumbBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users',
});
// Simulate response for C:\Users
rerender(
<FileManager
{...defaultProps}
commandResults={[
...commandResults,
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({
path: 'C:\\Users',
home_dir: 'C:\\Users\\Admin',
entries: [],
}),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click home breadcrumb
const homeCrumbBtn = screen.getByRole('button', { name: 'home' });
await userEvent.setup().click(homeCrumbBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'list_dir', {
path: 'C:\\Users\\Admin', // Should navigate back to homeDir
});
});
it('filters directory entries based on filter input', () => {
const listDirPayload = {
path: '/var/log',
home_dir: '/var/log',
entries: [
{ name: 'syslog', is_dir: false, size: 500 },
{ name: 'nginx.log', is_dir: false, size: 1200 },
{ name: 'auth.log', is_dir: false, size: 800 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
render(<FileManager {...defaultProps} commandResults={commandResults} />);
expect(screen.getByText(/syslog/)).toBeInTheDocument();
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
// Filter for "log" (matches all)
const filterInput = screen.getByPlaceholderText('Filter names…');
fireEvent.change(filterInput, { target: { value: 'log' } });
expect(screen.getByText(/syslog/)).toBeInTheDocument();
expect(screen.getByText(/nginx.log/)).toBeInTheDocument();
expect(screen.getByText(/auth.log/)).toBeInTheDocument();
// Filter for "sys" (matches syslog only)
fireEvent.change(filterInput, { target: { value: 'sys' } });
expect(screen.getByText(/syslog/)).toBeInTheDocument();
expect(screen.queryByText(/nginx.log/)).not.toBeInTheDocument();
expect(screen.queryByText(/auth.log/)).not.toBeInTheDocument();
});
it('reads file and renders preview when clicking a file entry', async () => {
const listDirPayload = {
path: '/root',
home_dir: '/root',
entries: [
{ name: 'config.json', is_dir: false, size: 100 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
// Clear initial mount busy state
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
},
]}
/>
);
sendAgentCommandMock.mockClear();
// Click file config.json
const fileBtn = screen.getByText(/config.json/);
await userEvent.setup().click(fileBtn);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'read_file', {
path: '/root/config.json',
});
// Verify preview container does not exist yet
expect(container.querySelector('pre')).not.toBeInTheDocument();
// Simulate WS response for read_file
const readResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/root', home_dir: '/root', entries: listDirPayload.entries }),
},
{
agentId: 'test-agent-id-12345678',
action: 'read_file',
success: true,
message: '{"host":"127.0.0.1"}',
},
];
rerender(<FileManager {...defaultProps} commandResults={readResults} />);
// Preview should now be shown
const preElement = container.querySelector('pre');
expect(preElement).toBeInTheDocument();
expect(preElement).toHaveClass('fm-preview');
expect(preElement).toHaveTextContent('{"host":"127.0.0.1"}');
});
it('selects multiple files and triggers bulk download', async () => {
const listDirPayload = {
path: '/app',
home_dir: '/app',
entries: [
{ name: 'a.txt', is_dir: false, size: 100 },
{ name: 'b.txt', is_dir: false, size: 200 },
],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
// Rerender with matched path to clear busy state
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: listDirPayload.entries }),
},
]}
/>
);
// Find checkboxes
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBe(2);
// Check both directly via fireEvent to avoid label double-click issue
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[1]);
sendAgentCommandMock.mockClear();
// Click Download selected
const dlBtn = screen.getByRole('button', { name: 'Download selected' });
await userEvent.setup().click(dlBtn);
// Verify api.sendAgentCommand was called for each file
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'test-agent-id-12345678', 'download', {
path: '/app/a.txt',
});
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'test-agent-id-12345678', 'download', {
path: '/app/b.txt',
});
});
it('handles file upload action', async () => {
const listDirPayload = {
path: '/app',
home_dir: '/app',
entries: [],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { container, rerender } = render(<FileManager {...defaultProps} commandResults={commandResults} />);
// Clear busy
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/app', home_dir: '/app', entries: [] }),
},
]}
/>
);
const uploadInput = container.querySelector('input[type="file"]')!;
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' });
sendAgentCommandMock.mockClear();
// Trigger upload
fireEvent.change(uploadInput, { target: { files: [file] } });
await waitFor(() => {
expect(readFileAsBase64Mock).toHaveBeenCalledWith(file);
expect(sendAgentCommandMock).toHaveBeenCalledWith('test-agent-id-12345678', 'upload', {
path: '/app/hello.txt',
data: 'mock_base64_payload',
});
});
});
it('renders path encryption and triggers it with multiple targets when approved', async () => {
const encryptTargets = [
{ id: 'target-1', name: 'Agent-One' },
{ id: 'target-2', name: 'Agent-Two' },
];
const confirmMock = vi.fn().mockReturnValue(true);
vi.stubGlobal('confirm', confirmMock);
const listDirPayload = {
path: '/data',
home_dir: '/data',
entries: [],
};
const commandResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify(listDirPayload),
},
];
const { rerender } = render(
<FileManager
{...defaultProps}
commandResults={commandResults}
encryptTargets={encryptTargets}
/>
);
// Clear busy
rerender(
<FileManager
{...defaultProps}
commandResults={[
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: true,
message: JSON.stringify({ path: '/data', home_dir: '/data', entries: [] }),
},
]}
encryptTargets={encryptTargets}
/>
);
// Verify path encryption section is rendered
expect(screen.getByText('Recursive')).toBeInTheDocument();
const encryptBtn = screen.getByRole('button', { name: '🔒 Encrypt path (2)' });
expect(encryptBtn).toBeInTheDocument();
sendAgentCommandMock.mockClear();
// Click Encrypt path
await userEvent.setup().click(encryptBtn);
expect(confirmMock).toHaveBeenCalled();
expect(sendAgentCommandMock).toHaveBeenCalledTimes(2);
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(1, 'target-1', 'encrypt_path', {
path: '/data',
command: 'recursive',
});
expect(sendAgentCommandMock).toHaveBeenNthCalledWith(2, 'target-2', 'encrypt_path', {
path: '/data',
command: 'recursive',
});
expect(defaultProps.onTerminalLine).toHaveBeenCalledWith(
'encrypt_path → /data [recursively] on 2 node(s)',
true
);
});
it('handles error messages from command results', () => {
const errorResults = [
{
agentId: 'test-agent-id-12345678',
action: 'list_dir',
success: false,
message: 'Permission denied',
},
];
render(<FileManager {...defaultProps} commandResults={errorResults} />);
expect(screen.getByText('Permission denied')).toBeInTheDocument();
expect(screen.getByText('Permission denied')).toHaveClass('fm-err');
});
});

View File

@@ -1,42 +1,46 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import {
defaultBrowseRoot,
joinRemotePath,
parseListDirMessage,
pathBreadcrumbs,
type DirEntry,
} from '../../help/remoteDirBrowser';
import { HelpTip } from '../HelpTip';
import './FileManager.css';
interface DirEntry {
name: string;
is_dir: boolean;
size: number;
}
interface Props {
agentId: string;
agentName?: string;
platform?: string;
online: boolean;
/** Encrypt targets — all online selected agents when multi-select */
encryptTargets?: { id: string; name: string }[];
/** Called when a command_result arrives (from parent WS hook) */
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
onTerminalLine?: (text: string, isCmd?: boolean) => void;
}
function parseListDir(message: string): { path: string; entries: DirEntry[] } | null {
try {
const j = JSON.parse(message) as { path?: string; entries?: DirEntry[] };
if (j.entries && Array.isArray(j.entries)) {
return { path: j.path ?? '', entries: j.entries };
}
} catch {
/* not JSON */
}
return null;
}
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
export default function FileManager({
agentId,
agentName,
platform,
online,
encryptTargets,
commandResults,
onTerminalLine,
}: Props) {
const [cwd, setCwd] = useState('');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [homeDir, setHomeDir] = useState('');
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState('');
const [err, setErr] = useState('');
const [uploadPath, setUploadPath] = useState('');
const [recursive, setRecursive] = useState(true);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
@@ -54,48 +58,66 @@ export default function FileManager({ agentId, agentName, online, commandResults
});
}, [agentId, cwd, online]);
useEffect(() => {
setCwd(defaultBrowseRoot(platform));
setEntries([]);
setHomeDir('');
setErr('');
setSelected(new Set());
setPreview('');
}, [agentId, platform]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults].reverse().find((r) => r.agentId === agentId);
const last = [...commandResults]
.reverse()
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'read_file' || r.action === 'download' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
if (!last) return;
if (last.action === 'list_dir' && last.success) {
const parsed = parseListDir(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (last.action === 'list_dir') {
if (last.success) {
const parsed = parseListDirMessage(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (parsed.home_dir) setHomeDir(parsed.home_dir);
}
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'list_dir' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'read_file' && last.success) {
setPreview(last.message.slice(0, 8000));
setBusy(false);
} else if (last.action === 'read_file' && !last.success) {
setErr(last.message);
} else if (last.action === 'read_file') {
if (last.success) {
setPreview(last.message.slice(0, 8000));
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'download') {
setBusy(false);
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
setBusy(false);
onTerminalLine?.(
`${last.action} ${last.success ? 'OK' : 'FAIL'}${last.message.slice(0, 500)}`,
false
);
}
}, [commandResults, agentId]);
}, [commandResults, agentId, onTerminalLine]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir) return;
const sep = cwd.includes('/') ? '/' : '\\';
let next = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
if (name === '..') {
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/);
parts.pop();
next = parts.join(sep) || (sep === '/' ? '/' : 'C:\\');
}
setCwd(next);
if (!isDir && name !== '..') return;
setCwd(joinRemotePath(cwd, name));
setSelected(new Set());
};
const goHome = () => {
setCwd(homeDir || '');
};
const toggleSelect = (name: string) => {
setSelected((prev) => {
const n = new Set(prev);
@@ -111,7 +133,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
if (!online || selected.size === 0) return;
setBusy(true);
for (const name of selected) {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
const p = joinRemotePath(cwd, name);
try {
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
if (res && typeof res === 'object' && 'success' in res) {
@@ -125,7 +147,7 @@ export default function FileManager({ agentId, agentName, online, commandResults
};
const readFile = (name: string) => {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
const p = joinRemotePath(cwd, name);
setBusy(true);
setPreview('');
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
@@ -134,19 +156,60 @@ export default function FileManager({ agentId, agentName, online, commandResults
});
};
const crumbs = cwd.split(/[/\\]/).filter(Boolean);
const runEncrypt = () => {
const targets = (encryptTargets ?? []).filter(Boolean);
if (targets.length === 0) {
alert('Select at least one online node.');
return;
}
const pathLabel = cwd || homeDir || '(agent home)';
const scope = recursive ? 'recursively' : 'non-recursively';
const warn =
targets.length > 1
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
if (!confirm(warn)) return;
setBusy(true);
onTerminalLine?.(
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
true
);
for (const t of targets) {
api
.sendAgentCommand(t.id, 'encrypt_path', {
path: cwd || homeDir,
command: recursive ? 'recursive' : '',
})
.catch((e) => {
onTerminalLine?.(
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
false
);
});
}
};
const crumbs = pathBreadcrumbs(cwd);
const browseLabel = homeDir || cwd || 'agent home';
return (
<div className="file-manager">
<div className="fm-header">
<span className="font-tech fm-title">FILE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<span className="font-tech fm-title">
FILE BROWSER {agentName ?? agentId.slice(0, 8)} <HelpTip field="fm_remote_browse" />
</span>
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="fm-offline">Agent offline</p>}
<div className="fm-path font-tech" title={cwd || homeDir}>
{browseLabel}
</div>
<div className="fm-breadcrumb font-tech">
<button type="button" className="fm-crumb" onClick={() => setCwd(cwd.startsWith('/') ? '/' : 'C:\\')}>root</button>
<button type="button" className="fm-crumb" onClick={goHome}>home</button>
{crumbs.map((c, i) => (
<span key={i}>
<span className="fm-sep">/</span>
@@ -155,6 +218,11 @@ export default function FileManager({ agentId, agentName, online, commandResults
className="fm-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
// Normalise Windows drive roots: 'C:' → 'C:\\'
if (sep === '\\' && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
setCwd(`${parts[0]}\\`);
return;
}
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
}}
>
@@ -190,24 +258,46 @@ export default function FileManager({ agentId, agentName, online, commandResults
Download selected
</button>
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
<label className="btn btn-outline btn-sm">
Upload
<input
type="file"
hidden
disabled={!online || busy}
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
const { readFileAsBase64 } = await import('../../help/desktopPush');
const b64 = await readFileAsBase64(file);
const dest = uploadPath.trim() || `${cwd}${sep}${file.name}`;
setBusy(true);
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 }).finally(() => setBusy(false));
ev.target.value = '';
}}
/>
</label>
<span className="fm-upload-wrap">
<label className="btn btn-outline btn-sm">
Upload
<input
type="file"
hidden
disabled={!online || busy}
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
const { readFileAsBase64 } = await import('../../help/desktopPush');
const b64 = await readFileAsBase64(file);
const dest = uploadPath.trim() || joinRemotePath(cwd, file.name);
setBusy(true);
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 })
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
.finally(() => setBusy(false));
ev.target.value = '';
}}
/>
</label>
<HelpTip field="fm_upload" />
</span>
{encryptTargets && encryptTargets.length > 0 && (
<div className="fm-encrypt-section">
<label className="fm-recursive-label">
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
Recursive
</label>
<button
type="button"
className="button btn-sm fm-encrypt-btn"
disabled={!online || busy}
onClick={runEncrypt}
>
🔒 Encrypt path ({encryptTargets.length})
</button>
<HelpTip field="fm_encrypt_path" />
</div>
)}
</div>
{err && <p className="fm-err">{err}</p>}
{preview && <pre className="fm-preview">{preview}</pre>}

View File

@@ -1,35 +1,41 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useVisibleInterval } from '../../hooks/usePageVisible';
import { api } from '../../api/client';
import type { AuditEntry } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
import { HelpTip } from '../HelpTip';
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
const [entries, setEntries] = useState<AuditEntry[]>([]);
useEffect(() => {
const refresh = useCallback(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
const t = setInterval(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {});
}, 60000);
return () => clearInterval(t);
}, [limit]);
useEffect(() => {
refresh();
}, [refresh]);
useVisibleInterval(refresh, 60000);
return (
<NeonCard accent="purple" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
OPERATOR AUDIT <HelpTip field="dash_operator_audit" />
</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
{entries.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No audit entries yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
{entries.map((e) => (
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid rgba(140, 120, 60, 0.15)' }}>
<span style={{ color: 'var(--text-muted)' }}>{new Date(e.timestamp).toLocaleString()}</span>
{' '}
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
<span style={{ color: 'var(--neon-cyan)' }}>{e.username || '—'}</span>
{' · '}
<strong>{e.action}</strong>
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
{e.agent_id && <span style={{ color: 'var(--text-muted)' }}> @{e.agent_id.slice(0, 8)}</span>}
</li>
))}
</ul>
@@ -50,24 +56,26 @@ export function SpreadFunnelWidget() {
return (
<NeonCard accent="cyan" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>
INSTALL FUNNEL <HelpTip field="dash_install_funnel" />
</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
<span>New today: <strong>{stats.new_connects_today}</strong></span>
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
</div>
{byBuild.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
) : (
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
<tr style={{ textAlign: 'left', color: 'var(--text-muted)' }}>
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
</tr>
</thead>
<tbody>
{byBuild.slice(0, 10).map((r, i) => (
<tr key={i} style={{ borderTop: '1px solid #222' }}>
<tr key={i} style={{ borderTop: '1px solid rgba(140, 120, 60, 0.15)' }}>
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
<td>{r.worker_name}</td>
<td>{r.count}</td>

View File

@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
import { timeToPayout } from '../../help/fleetAnalytics';
import { formatHashrate } from '../../help/fleetFilters';
import { HelpTip } from '../HelpTip';
import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
@@ -28,7 +29,7 @@ export function PoolStatusPanel({ pools }: { pools: PoolStatus[] }) {
return (
<NeonCard accent="green" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Pool Stratum Status
<span className="section-ornament"></span> Pool Stratum Status <HelpTip field="dash_pool_stratum" />
<span className="section-line" />
</h2>
{pools.length === 0 ? (
@@ -57,7 +58,7 @@ export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEn
return (
<NeonCard accent="purple" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> AI Activity Monitor
<span className="section-ornament"></span> AI Activity Monitor <HelpTip field="dash_ai_activity" />
<span className="section-line" />
</h2>
{entries.length === 0 ? (
@@ -188,7 +189,7 @@ export function FleetHealthCard({ health }: { health: FleetHealth }) {
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
<div className="fh-header">
<div>
<span className="fh-label font-tech">FLEET HEALTH</span>
<span className="fh-label font-tech">FLEET HEALTH <HelpTip field="dash_fleet_health" /></span>
<span className={`fh-status-chip fh-${health.color}`}>{health.label}</span>
</div>
<div className="fh-score" style={{ color: barColor }}>{health.score}</div>

View File

@@ -5,6 +5,7 @@ import { useWebSocket } from '../../hooks/useWebSocket';
import { useFleetGroups } from '../../hooks/useFleetGroups';
import type { FleetModuleManifest } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
import { HelpTip } from '../HelpTip';
import './FleetRuntimePanel.css';
type TargetMode = 'all' | 'group';
@@ -147,7 +148,7 @@ export default function FleetRuntimePanel() {
<>
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
<h2 className="font-display">Live Fleet Policy</h2>
<h2 className="font-display">Live Fleet Policy <HelpTip field="fleet_runtime_policy" /></h2>
<p className="section-desc">
Push live mining rules to connected workers schedule, CPU cap, and optional pool overrides apply in memory
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
@@ -376,7 +377,7 @@ export default function FleetRuntimePanel() {
</NeonCard>
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
<h2 className="font-display">Push Module to Fleet</h2>
<h2 className="font-display">Push Module to Fleet <HelpTip field="fleet_runtime_modules" /></h2>
<p className="section-desc">
Stage signed feature packs from <code className="mono-sm">data/modules/</code> agents fetch via{' '}
<code className="mono-sm">GET /api/v1/agent/module/&#123;name&#125;</code> and enable flags without a full

View File

@@ -56,7 +56,7 @@
margin: 0 0 0.35rem;
font-size: 0.72rem;
letter-spacing: 0.1em;
color: var(--accent-cyan, #00f5ff);
color: var(--neon-cyan);
text-transform: uppercase;
}

View File

@@ -1,137 +0,0 @@
.remote-dir-browser {
border: 1px solid rgba(255, 34, 34, 0.35);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(40, 0, 0, 0.25);
margin-top: 0.5rem;
}
.rdb-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.rdb-title {
font-size: 0.68rem;
letter-spacing: 0.08em;
color: #ff8888;
}
.rdb-hint {
margin: 0 0 0.45rem;
font-size: 0.68rem;
}
.rdb-offline {
color: #ff6666;
font-size: 0.78rem;
margin: 0.25rem 0;
}
.rdb-path {
font-size: 0.72rem;
color: var(--neon-cyan);
margin-bottom: 0.35rem;
word-break: break-all;
}
.rdb-breadcrumb {
margin-bottom: 0.4rem;
flex-wrap: wrap;
display: flex;
align-items: center;
}
.rdb-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
cursor: pointer;
font-size: 0.72rem;
padding: 0;
}
.rdb-sep {
opacity: 0.45;
margin: 0 0.15rem;
}
.rdb-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 180px;
overflow-y: auto;
border: 1px solid #331111;
background: rgba(0, 0, 0, 0.35);
}
.rdb-row {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
text-align: left;
background: none;
border: none;
color: #ddd;
padding: 0.3rem 0.5rem;
cursor: pointer;
font-family: var(--font-tech);
font-size: 0.78rem;
}
.rdb-row:hover {
background: rgba(255, 34, 34, 0.12);
}
.rdb-dir {
color: #9fdcff;
}
.rdb-size {
color: #888;
font-size: 0.68rem;
flex-shrink: 0;
}
.rdb-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.rdb-recursive {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: #bbb;
cursor: pointer;
}
.rdb-encrypt-btn {
background: linear-gradient(135deg, #7b0000 0%, #cc0000 100%);
border: 1px solid #ff2222;
color: #fff;
font-weight: 700;
letter-spacing: 0.05em;
font-size: 0.78rem;
padding: 0.35rem 0.75rem;
}
.rdb-encrypt-btn:disabled {
opacity: 0.45;
}
.rdb-err {
color: #ff6666;
font-size: 0.75rem;
margin: 0.35rem 0 0;
}

View File

@@ -1,211 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import {
defaultBrowseRoot,
joinRemotePath,
parseListDirMessage,
pathBreadcrumbs,
type DirEntry,
} from '../../help/remoteDirBrowser';
import './RemoteDirBrowser.css';
interface Props {
agentId: string;
agentName?: string;
platform?: string;
online: boolean;
/** Encrypt targets — all online selected agents when multi-select */
encryptTargets: { id: string; name: string }[];
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
onTerminalLine?: (text: string, isCmd?: boolean) => void;
}
export default function RemoteDirBrowser({
agentId,
agentName,
platform,
online,
encryptTargets,
commandResults,
onTerminalLine,
}: Props) {
const [cwd, setCwd] = useState(() => defaultBrowseRoot(platform));
const [entries, setEntries] = useState<DirEntry[]>([]);
const [homeDir, setHomeDir] = useState('');
const [recursive, setRecursive] = useState(true);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const sep = cwd.includes('/') ? '/' : '\\';
const crumbs = useMemo(() => pathBreadcrumbs(cwd), [cwd]);
const browseLabel = homeDir || cwd || 'agent home';
const refresh = useCallback(() => {
if (!online || !agentId) return;
setBusy(true);
setErr('');
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
}, [agentId, cwd, online]);
useEffect(() => {
setCwd(defaultBrowseRoot(platform));
setEntries([]);
setHomeDir('');
setErr('');
}, [agentId, platform]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults]
.reverse()
.find((r) => r.agentId === agentId && (r.action === 'list_dir' || r.action === 'encrypt_path' || r.action === 'sys_crypt'));
if (!last) return;
if (last.action === 'list_dir') {
if (last.success) {
const parsed = parseListDirMessage(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
if (parsed.home_dir) setHomeDir(parsed.home_dir);
}
} else {
setErr(last.message);
}
setBusy(false);
} else if (last.action === 'encrypt_path' || last.action === 'sys_crypt') {
setBusy(false);
onTerminalLine?.(
`${last.action} ${last.success ? 'OK' : 'FAIL'}${last.message.slice(0, 500)}`,
false
);
}
}, [commandResults, agentId, onTerminalLine]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir && name !== '..') return;
setCwd(joinRemotePath(cwd, name));
};
const goHome = () => {
setCwd(homeDir || defaultBrowseRoot(platform));
};
const runEncrypt = () => {
const targets = encryptTargets.filter(Boolean);
if (targets.length === 0) {
alert('Select at least one online node.');
return;
}
const pathLabel = cwd || homeDir || '(agent home)';
const scope = recursive ? 'recursively' : 'non-recursively';
const warn =
targets.length > 1
? `Encrypt ${pathLabel} ${scope} on ${targets.length} nodes?\n\nThis is IRREVERSIBLE without the key.`
: `Encrypt ${pathLabel} ${scope} on ${targets[0].name}?\n\nThis is IRREVERSIBLE without the key.`;
if (!confirm(warn)) return;
setBusy(true);
onTerminalLine?.(
`encrypt_path → ${pathLabel} [${scope}] on ${targets.length} node(s)`,
true
);
for (const t of targets) {
api
.sendAgentCommand(t.id, 'encrypt_path', {
path: cwd || homeDir,
command: recursive ? 'recursive' : '',
})
.catch((e) => {
onTerminalLine?.(
`[ERROR] encrypt_path @ ${t.name}: ${e instanceof Error ? e.message : String(e)}`,
false
);
});
}
};
return (
<div className="remote-dir-browser">
<div className="rdb-header">
<span className="font-tech rdb-title">REMOTE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<button type="button" className="crucible-op-btn" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="rdb-offline">Agent offline browse unavailable</p>}
<p className="rdb-hint form-hint">
Browse the remote machine filesystem. Encrypt runs on {encryptTargets.length} selected online node
{encryptTargets.length !== 1 ? 's' : ''}.
</p>
<div className="rdb-path font-tech" title={cwd || homeDir}>
{browseLabel}
</div>
<div className="rdb-breadcrumb font-tech">
<button type="button" className="rdb-crumb" onClick={goHome}>home</button>
{crumbs.map((c, i) => (
<span key={`${c}-${i}`}>
<span className="rdb-sep">/</span>
<button
type="button"
className="rdb-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
const root = cwd.startsWith('/') ? '/' : '';
setCwd(root + parts.join(sep));
}}
>
{c}
</button>
</span>
))}
</div>
<ul className="rdb-list">
<li>
<button type="button" className="rdb-row" onClick={() => navigate('..', true)}>..</button>
</li>
{entries.map((e) => (
<li key={e.name}>
<button
type="button"
className={`rdb-row ${e.is_dir ? 'rdb-dir' : 'rdb-file'}`}
onClick={() => navigate(e.name, e.is_dir)}
title={e.is_dir ? 'Open folder' : `${e.size} bytes`}
>
{e.is_dir ? '📁' : '📄'} {e.name}
{!e.is_dir && (
<span className="rdb-size">
{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}
</span>
)}
</button>
</li>
))}
</ul>
<div className="rdb-actions">
<label className="rdb-recursive">
<input type="checkbox" checked={recursive} onChange={(ev) => setRecursive(ev.target.checked)} />
Recursive
</label>
<button
type="button"
className="button rdb-encrypt-btn"
disabled={!online || busy || encryptTargets.length === 0}
title="AES-256-GCM encrypt files at the current path on selected node(s)"
onClick={runEncrypt}
>
🔒 Encrypt path
</button>
</div>
{err && <p className="rdb-err">{err}</p>}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { docAnchorForField } from '../help/docAnchors';
import { FIELD_HELP } from '../help/settingHelp';
import { UI_HELP } from '../help/uiHelp';
import './HelpTip.css';
interface HelpTipProps {
@@ -24,7 +25,7 @@ function DocReadMoreLink({ href }: { href: string }) {
}
export function HelpTip({ field, label }: HelpTipProps) {
const text = FIELD_HELP[field];
const text = FIELD_HELP[field] ?? UI_HELP[field];
const docAnchor = docAnchorForField(field);
const triggerRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);

View File

@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
@@ -15,7 +15,7 @@ import { resolvePageWeather } from '../../help/pageWeather';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
import ComradeAvatar from '../Presence/ComradeAvatar';
import type { ServerConfig } from '../../types';
import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css';
import './Layout.css';
import './MobileNav.css';
@@ -145,12 +145,19 @@ function formatHashrate(hs: number): string {
return `${hs.toFixed(0)} H/s`;
}
function FleetReadout() {
const FleetReadout = memo(function FleetReadout() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const totalHashrate = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
const fillPct = total > 0 ? Math.round((online / total) * 100) : 0;
const { online, total, totalHashrate, fillPct } = useMemo(() => {
const on = agents.filter((a) => a.status === 'online').length;
const tot = agents.length;
const hr = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
return {
online: on,
total: tot,
totalHashrate: hr,
fillPct: tot > 0 ? Math.round((on / tot) * 100) : 0,
};
}, [agents]);
// Tick animation: flash hashrate value whenever it meaningfully changes
const [flash, setFlash] = useState(false);
@@ -191,13 +198,18 @@ function FleetReadout() {
</div>
</div>
);
}
});
function MobileTopStats() {
const MobileTopStats = memo(function MobileTopStats() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
const { online, total, hr } = useMemo(() => {
const on = agents.filter((a) => a.status === 'online').length;
return {
online: on,
total: agents.length,
hr: agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0),
};
}, [agents]);
return (
<div className="mobile-top-stats">
<strong>
@@ -206,17 +218,23 @@ function MobileTopStats() {
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
</div>
);
}
});
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const { othersOnline, comrades } = usePresence();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
useEffect(() => {
api.getConfig().then(setServerConfig).catch(() => {});
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([cfg, info]) => {
setServerConfig(cfg);
setServerInfo(info);
})
.catch(() => {});
}, []);
useEffect(() => {
@@ -232,7 +250,7 @@ export default function Layout({ children }: LayoutProps) {
};
}, [moreOpen]);
const setupStatus = getSetupStatus(serverConfig);
const setupStatus = getSetupStatus(serverConfig, serverInfo);
const pageWeather = resolvePageWeather(location.pathname);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {

View File

@@ -170,6 +170,15 @@ export default function MatrixRain() {
let raf: number;
let lastTime = 0;
let paused = document.visibilityState === 'hidden';
const onVis = () => {
paused = document.visibilityState === 'hidden';
if (!paused && !raf) {
raf = requestAnimationFrame(draw);
}
};
document.addEventListener('visibilitychange', onVis);
const drawWordDrop = (wd: WordDrop, speedMult: number, intense: boolean) => {
const H = canvas.height;
@@ -204,6 +213,10 @@ export default function MatrixRain() {
};
const draw = (ts: number) => {
if (paused) {
raf = 0;
return;
}
raf = requestAnimationFrame(draw);
const intense = forgingRef.current || crucibleRef.current;
const targetFps = intense ? 50 : 24;
@@ -317,6 +330,7 @@ export default function MatrixRain() {
raf = requestAnimationFrame(draw);
return () => {
document.removeEventListener('visibilitychange', onVis);
cancelAnimationFrame(raf);
clearInterval(injectInterval);
clearInterval(wordInterval);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useVisibleInterval } from '../../hooks/usePageVisible';
import { api } from '../../api/client';
import ComradeIndicators from '../Presence/ComradeIndicators';
import { usePresence } from '../../context/PresenceContext';
@@ -12,34 +13,37 @@ export default function SystemStatusBar() {
const [buildCount, setBuildCount] = useState(0);
const { othersOnline } = usePresence();
useEffect(() => {
const poll = async () => {
try {
await api.healthCheck();
setServerOk(true);
} catch {
setServerOk(false);
}
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try {
const builds = await api.listBuilds();
setBuildCount(builds.length);
} catch {
setBuildCount(0);
}
};
poll();
const id = setInterval(poll, 15000);
return () => clearInterval(id);
const poll = useCallback(async () => {
try {
await api.healthCheck();
setServerOk(true);
} catch {
setServerOk(false);
}
try {
const agents = await api.listAgents();
setAgentTotal(agents.length);
setAgentOnline(agents.filter((a) => a.status === 'online').length);
} catch {
setAgentTotal(0);
setAgentOnline(0);
}
try {
const builds = await api.listBuilds();
setBuildCount(builds.length);
} catch {
setBuildCount(0);
}
}, []);
useEffect(() => {
void poll();
}, [poll]);
useVisibleInterval(() => {
void poll();
}, 15000);
return (
<div className={`system-status-bar${othersOnline ? ' system-status-bar--comrades-online' : ''}`}>
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>

View File

@@ -132,9 +132,7 @@ export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<path
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
stroke="#00f5ff"
strokeWidth="0.16"
opacity="0.6"
stroke="var(--neon-cyan)"
/>
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
</g>
@@ -211,7 +209,7 @@ export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
<circle cx="38" cy="38" r="8" stroke="var(--neon-cyan)" strokeWidth="0.25" />
<polygon
points="38,22 46,30 38,38 30,30"
strokeWidth="0.2"
@@ -243,7 +241,7 @@ export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
<polygon points={tri(32, false)} />
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
<polygon points={tri(16, false)} stroke="var(--neon-cyan)" opacity="0.55" />
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
</g>
</svg>

View File

@@ -67,9 +67,10 @@ vi.mock('../context/ForgeContext', () => ({
vi.mock('../api/download', () => {
const downloadAuthedFile = vi.fn();
const downloadApiFile = vi.fn();
return {
downloadAuthedFile,
downloadApiFile: downloadAuthedFile,
downloadApiFile,
};
});
@@ -188,15 +189,22 @@ describe('DownloadButton', () => {
});
it('downloads on click and shows busy label', async () => {
downloadApiFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 50)));
let resolveDownload!: () => void;
downloadApiFileMock.mockImplementation(
() => new Promise<void>((r) => { resolveDownload = r; })
);
render(
<DownloadButton apiPath="/api/x" filename="a.bin">
Save
</DownloadButton>
);
const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn);
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
const user = userEvent.setup();
await user.click(btn);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
resolveDownload();
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
});
@@ -222,14 +230,21 @@ describe('AuthDownloadButton', () => {
});
it('calls downloadAuthedFile and shows ellipsis while busy', async () => {
downloadAuthedFileMock.mockImplementation(() => new Promise((r) => setTimeout(r, 30)));
let resolveDownload!: () => void;
downloadAuthedFileMock.mockImplementation(
() => new Promise<void>((r) => { resolveDownload = r; })
);
render(
<AuthDownloadButton apiPath="/api/build/1" filename="b.exe">
DL
</AuthDownloadButton>
);
await userEvent.setup().click(screen.getByRole('button', { name: 'DL' }));
expect(screen.getByRole('button', { name: '' })).toBeDisabled();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'DL' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: '…' })).toBeDisabled();
});
resolveDownload();
await waitFor(() =>
expect(downloadAuthedFileMock).toHaveBeenCalledWith('/api/build/1', 'b.exe')
);