fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes
WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
@@ -50,17 +50,15 @@ export default function HashrateChart({
|
||||
const gradId = colorToId(color);
|
||||
const peak = chartSeriesPeak(data);
|
||||
const delta = chartSeriesDelta(data);
|
||||
const liveLabel =
|
||||
displayMode === 'live' ? '● LIVE' : displayMode === 'blend' ? '● SYNCING' : '● PROJECTION';
|
||||
const liveClass =
|
||||
displayMode === 'live' ? 'pulse' : displayMode === 'blend' ? 'blend' : 'sample';
|
||||
const liveLabel = displayMode === 'live' ? '● LIVE' : '○ IDLE';
|
||||
const liveClass = displayMode === 'live' ? 'pulse' : 'empty';
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="chart-empty neon-chart-panel wealth-empty">
|
||||
<div className="chart-empty-icon">◈</div>
|
||||
<p className="font-tech">{title || 'Telemetry'}</p>
|
||||
<span>Calibrating chart telemetry…</span>
|
||||
<span>No live data yet — connect miners to populate this chart</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
|
||||
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
||||
import { timeToPayout } from '../../help/fleetAnalytics';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData';
|
||||
import './FleetPanels.css';
|
||||
|
||||
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
||||
@@ -174,26 +173,6 @@ export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xm
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown when fleet hashrate is zero — keeps the deck feeling lucrative. */
|
||||
export function WealthEarningsPreview({ xmrPrice }: { xmrPrice?: number | null }) {
|
||||
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
|
||||
const xmrDay = SAMPLE_FLEET_PREVIEW.xmrPerDay;
|
||||
const usdDay = xmrDay * price;
|
||||
|
||||
return (
|
||||
<NeonCard accent="gold" className="stat-card-wrap earnings-preview wealth-earnings">
|
||||
<div className="earnings-preview-badge font-tech">PROJECTED YIELD</div>
|
||||
<div className="stat-label font-tech">Target Fleet Earnings</div>
|
||||
<div className="stat-value neon-glow-gold">~{xmrDay.toFixed(4)} XMR/day</div>
|
||||
<div className="earnings-usd-day">≈ ${usdDay.toFixed(2)}/day</div>
|
||||
<div className="stat-sub">At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target</div>
|
||||
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.55, fontSize: '0.68rem' }}>
|
||||
Deploy miners to replace projection with live pool data
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fleet Health Card ────────────────────────────────────────────────────────
|
||||
|
||||
export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||
@@ -231,24 +210,20 @@ export function ContributionBars({
|
||||
bars,
|
||||
xmrPerDay,
|
||||
xmrPrice,
|
||||
sample = false,
|
||||
}: {
|
||||
bars: ContributionBar[];
|
||||
xmrPerDay?: number;
|
||||
xmrPrice?: number | null;
|
||||
sample?: boolean;
|
||||
}) {
|
||||
if (bars.length === 0) return null;
|
||||
return (
|
||||
<NeonCard accent="cyan" className={`section contrib-panel${sample ? ' sample-contrib' : ''}`} hud>
|
||||
<NeonCard accent="cyan" className="section contrib-panel" hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Contribution Map
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
{sample
|
||||
? 'Sample contribution map — your rigs will populate this lane when they connect.'
|
||||
: "Each bar shows a machine's share of total fleet hashrate."}
|
||||
Each bar shows a machine's share of total fleet hashrate.
|
||||
</p>
|
||||
<div className="contrib-list">
|
||||
{bars.map((b) => {
|
||||
|
||||
@@ -1,103 +1,159 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { getStoredAuth, setStoredAuth } from '../api/auth';
|
||||
import { useSound } from '../context/SoundContext';
|
||||
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
||||
|
||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const { play } = useSound();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authed, setAuthed] = useState(!!getStoredAuth());
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredAuth();
|
||||
if (!token) {
|
||||
setAuthed(false);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } })
|
||||
.then((r) => {
|
||||
setAuthed(r.ok);
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setAuthed(false);
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
const token = btoa(`${user}:${pass}`);
|
||||
try {
|
||||
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
|
||||
if (!res.ok) {
|
||||
setErr('Login failed — check username and password.');
|
||||
play('error');
|
||||
return;
|
||||
}
|
||||
setStoredAuth(user, pass);
|
||||
setAuthed(true);
|
||||
play('success');
|
||||
} catch {
|
||||
setErr('Cannot reach server — check that miner-server is running.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="session-gate">
|
||||
<div className="session-gate-sacred-ring" aria-hidden>
|
||||
<FlowerOfLifeWatermark opacity={0.5} />
|
||||
</div>
|
||||
<p className="font-tech">Starting AetherForge…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div className="session-gate">
|
||||
<div className="session-gate-sacred-ring" aria-hidden>
|
||||
<FlowerOfLifeWatermark opacity={0.5} />
|
||||
</div>
|
||||
<div className="session-gate-keys" aria-hidden>
|
||||
<div className="session-gate-key session-gate-key--tl">
|
||||
<KnowledgeKey opacity={0.55} />
|
||||
</div>
|
||||
<div className="session-gate-key session-gate-key--br">
|
||||
<KnowledgeKey opacity={0.45} />
|
||||
</div>
|
||||
</div>
|
||||
<form className="session-gate-card card" onSubmit={handleLogin}>
|
||||
<h1 className="font-display">AetherForge</h1>
|
||||
<p className="form-hint">Sign in to open the command deck.</p>
|
||||
<label className="label" htmlFor="session-user">Username</label>
|
||||
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
|
||||
<label className="label" htmlFor="session-pass">Password</label>
|
||||
<input
|
||||
id="session-pass"
|
||||
className="input"
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{err && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{err}</p>}
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
Enter Command Deck
|
||||
</button>
|
||||
<p className="session-gate-whisper" aria-hidden>
|
||||
ψ · the deck remembers every key
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
|
||||
AETHERFORGE_CLIENT_HEADER,
|
||||
|
||||
AETHERFORGE_CLIENT_VALUE,
|
||||
|
||||
authHeaders,
|
||||
|
||||
clearStoredAuth,
|
||||
|
||||
consumeAuthExpiredFlag,
|
||||
|
||||
encodeBasicToken,
|
||||
|
||||
getStoredAuth,
|
||||
|
||||
setStoredAuth,
|
||||
|
||||
} from '../api/auth';
|
||||
|
||||
import { useSound } from '../context/SoundContext';
|
||||
|
||||
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
||||
|
||||
|
||||
|
||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
|
||||
const { play } = useSound();
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [authed, setAuthed] = useState(!!getStoredAuth());
|
||||
|
||||
const [degraded, setDegraded] = useState(false);
|
||||
|
||||
const [user, setUser] = useState('');
|
||||
|
||||
const [pass, setPass] = useState('');
|
||||
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const sync = () => {
|
||||
|
||||
const hasAuth = !!getStoredAuth();
|
||||
|
||||
setAuthed(hasAuth);
|
||||
|
||||
if (!hasAuth) {
|
||||
|
||||
setSessionExpired(consumeAuthExpiredFlag());
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
window.addEventListener('aetherforge-auth', sync);
|
||||
|
||||
return () => window.removeEventListener('aetherforge-auth', sync);
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const token = getStoredAuth();
|
||||
|
||||
if (!token) {
|
||||
|
||||
setAuthed(false);
|
||||
|
||||
setSessionExpired(consumeAuthExpiredFlag());
|
||||
|
||||
setReady(true);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
fetch('/api/v1/config', { headers: authHeaders() })
|
||||
|
||||
.then((r) => {
|
||||
|
||||
if (r.status === 401) {
|
||||
|
||||
clearStoredAuth({ silent: true, expired: true });
|
||||
|
||||
setAuthed(false);
|
||||
|
||||
setSessionExpired(true);
|
||||
|
||||
} else if (!r.ok) {
|
||||
|
||||
// Server reachable but unhappy — keep saved credentials (degraded mode).
|
||||
|
||||
setAuthed(true);
|
||||
|
||||
setDegraded(true);
|
||||
|
||||
} else {
|
||||
|
||||
setAuthed(true);
|
||||
|
||||
setDegraded(false);
|
||||
|
||||
}
|
||||
|
||||
setReady(true);
|
||||
|
||||
})
|
||||
|
||||
.catch(() => {
|
||||
|
||||
// Network blip — trust stored credentials until the server responds.
|
||||
|
||||
setAuthed(true);
|
||||
|
||||
setDegraded(true);
|
||||
|
||||
setReady(true);
|
||||
|
||||
});
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
setErr('');
|
||||
|
||||
setSessionExpired(false);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
|
||||
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
|
||||
|
||||
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
|
||||
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
const res = await fetch('/api/v1/config', { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
setErr('Login failed — check username and password.');
|
||||
|
||||
@@ -73,12 +73,12 @@ interface ActivityPulseProps {
|
||||
items: { id: string; label: string; ok: boolean; time?: string }[];
|
||||
}
|
||||
|
||||
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) {
|
||||
export function ActivityPulse({ items }: ActivityPulseProps) {
|
||||
if (items.length === 0) {
|
||||
return <p className="activity-empty font-tech">Awaiting fleet activity…</p>;
|
||||
}
|
||||
return (
|
||||
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
|
||||
<div className="activity-pulse">
|
||||
{items.slice(0, 12).map((item) => (
|
||||
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
|
||||
<span className="activity-blip-core" />
|
||||
|
||||
@@ -65,15 +65,22 @@ vi.mock('../context/ForgeContext', () => ({
|
||||
useForge: vi.fn(() => ({ forging: false, stage: '' })),
|
||||
}));
|
||||
|
||||
vi.mock('../api/download', () => ({
|
||||
downloadApiFile: vi.fn(),
|
||||
downloadAuthedFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('../api/download', () => {
|
||||
const downloadAuthedFile = vi.fn();
|
||||
return {
|
||||
downloadAuthedFile,
|
||||
downloadApiFile: downloadAuthedFile,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../api/auth', () => ({
|
||||
getStoredAuth: vi.fn(),
|
||||
setStoredAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock('../api/auth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/auth')>();
|
||||
return {
|
||||
...actual,
|
||||
getStoredAuth: vi.fn(),
|
||||
setStoredAuth: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
@@ -174,9 +181,7 @@ describe('DownloadButton', () => {
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: 'Save' });
|
||||
await userEvent.setup().click(btn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
|
||||
});
|
||||
|
||||
@@ -283,7 +288,7 @@ describe('SessionGate', () => {
|
||||
|
||||
it('renders children when stored auth validates', async () => {
|
||||
getStoredAuthMock.mockReturnValue('dGVzdA==');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
|
||||
render(
|
||||
<SessionGate>
|
||||
<div>protected</div>
|
||||
@@ -291,6 +296,18 @@ describe('SessionGate', () => {
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps session on network blip during startup validation', async () => {
|
||||
getStoredAuthMock.mockReturnValue('dGVzdA==');
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
||||
render(
|
||||
<SessionGate>
|
||||
<div>protected</div>
|
||||
</SessionGate>
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Cannot reach server/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GaugeRing', () => {
|
||||
@@ -320,7 +337,7 @@ describe('HashrateChart', () => {
|
||||
it('shows empty state when data is empty', () => {
|
||||
render(<HashrateChart data={[]} title="Fleet Hash" />);
|
||||
expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Calibrating chart telemetry/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/No live data yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chart with validated sample series', () => {
|
||||
@@ -329,14 +346,14 @@ describe('HashrateChart', () => {
|
||||
render(
|
||||
<HashrateChart
|
||||
data={sample}
|
||||
displayMode="sample"
|
||||
displayMode="live"
|
||||
title="Fleet Hash"
|
||||
color="#00f5ff"
|
||||
unit="H/s"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/PEAK/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/LIVE/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chart with data points', () => {
|
||||
|
||||
Reference in New Issue
Block a user