feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -16,6 +16,8 @@ import PoolPresetPicker from '../components/PoolPresetPicker';
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { useSound } from '../context/SoundContext';
import { useVisualEffects } from '../context/VisualEffectsContext';
import './Pages.css';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
@@ -42,6 +44,8 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
}
export default function SettingsPage() {
const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound();
const { glowParticles, setGlowParticles } = useVisualEffects();
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [loading, setLoading] = useState(true);
@@ -54,6 +58,10 @@ export default function SettingsPage() {
const [userMsg, setUserMsg] = useState('');
const [rotatingSecret, setRotatingSecret] = useState(false);
const [rotateMsg, setRotateMsg] = useState('');
const [backingUp, setBackingUp] = useState(false);
const [backupMsg, setBackupMsg] = useState('');
const [testingAlerts, setTestingAlerts] = useState(false);
const [alertTestMsg, setAlertTestMsg] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -69,6 +77,15 @@ export default function SettingsPage() {
password: 'x',
backup_pools: [],
},
alerts: {
...cfg.alerts,
notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true,
notify_agent_reconnect: cfg.alerts?.notify_agent_reconnect ?? true,
notify_agent_offline: cfg.alerts?.notify_agent_offline ?? true,
notify_hashrate_drop: cfg.alerts?.notify_hashrate_drop ?? true,
notify_rejection_rate: cfg.alerts?.notify_rejection_rate ?? true,
notify_build_complete: cfg.alerts?.notify_build_complete ?? true,
},
server: {
public_url: cfg.server?.public_url ?? '',
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
@@ -145,6 +162,20 @@ export default function SettingsPage() {
URL.revokeObjectURL(url);
};
const handleFullBackup = async () => {
setBackingUp(true);
setBackupMsg('');
try {
await api.downloadBackup();
setBackupMsg('Backup downloaded.');
setTimeout(() => setBackupMsg(''), 4000);
} catch (e: unknown) {
setBackupMsg('Backup failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setBackingUp(false);
}
};
const handleImportConfig = () => fileInputRef.current?.click();
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -181,6 +212,36 @@ export default function SettingsPage() {
setTimeout(() => setUserMsg(''), 3000);
};
const handleTestAlerts = async () => {
if (!config) return;
setTestingAlerts(true);
setAlertTestMsg('');
try {
if (!config.alerts.telegram_bot_token?.trim() && !config.alerts.email_enabled) {
setAlertTestMsg('Enter Telegram token + chat ID (or enable SMTP) first.');
return;
}
await api.updateConfig(config);
const result = await api.testAlerts();
const parts: string[] = [];
const tg = result.telegram;
if (tg) {
parts.push(tg.sent ? '✓ Telegram delivered' : `✕ Telegram: ${tg.error || 'failed'}`);
}
const smtp = result.smtp;
if (smtp) {
parts.push(smtp.sent ? '✓ Email delivered' : `✕ Email: ${smtp.error || 'failed'}`);
}
setAlertTestMsg(parts.join(' · ') || 'No channels configured.');
if (tg?.sent || smtp?.sent) previewSfx('success');
} catch (e: unknown) {
setAlertTestMsg('Test failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setTestingAlerts(false);
setTimeout(() => setAlertTestMsg(''), 12000);
}
};
const handleRotateSecret = async () => {
if (!window.confirm(
'Rotate fleet secret?\n\n' +
@@ -278,7 +339,18 @@ export default function SettingsPage() {
</button>
<button className="btn btn-outline" onClick={handleExportConfig}>Export</button>
<button className="btn btn-outline" onClick={handleImportConfig}>Import</button>
<button
className="btn btn-outline"
onClick={handleFullBackup}
disabled={backingUp}
title="Downloads config, agent DB, and credentials."
>
{backingUp ? 'Backing up…' : 'Full Deck Backup'}
</button>
</div>
{backupMsg && (
<div className={`save-message ${backupMsg.includes('failed') ? 'error' : 'success'}`}>{backupMsg}</div>
)}
</header>
{saveMessage && (
@@ -333,6 +405,86 @@ export default function SettingsPage() {
)}
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section">
<h2 className="font-display">Deck Atmosphere</h2>
<p className="section-desc">
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
low-power devices if you want a calmer deck.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={glowParticles}
onChange={(e) => setGlowParticles(e.target.checked)}
/>
<span>Glow particles &amp; sparkles</span>
</label>
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<h2 className="font-display">Sound &amp; Haptics</h2>
<p className="section-desc">
Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere
on the deck first to unlock audio. Fleet events (agents, shares, alerts) use separate cues.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={sfxEnabled}
onChange={(e) => setSfxEnabled(e.target.checked)}
/>
<span>Enable sound effects &amp; haptic vibration</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-sfx-volume" className="label">
Volume ({Math.round(sfxVolume * 100)}%)
</label>
<input
id="cfg-sfx-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(sfxVolume * 100)}
disabled={!sfxEnabled}
onChange={(e) => setSfxVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
<div className="form-row" style={{ gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('click')}
>
Preview click
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('alert')}
>
Preview alert
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('share')}
>
Preview share
</button>
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Control Server</h2>
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
@@ -527,7 +679,9 @@ export default function SettingsPage() {
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Alert Notifications</h2>
<p className="section-desc">Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).</p>
<p className="section-desc">
Telegram (and optional email) for fleet events. Set bot token + chat ID, choose what to send, then save.
</p>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-tg-token" className="label">Telegram Bot Token</label>
@@ -537,9 +691,68 @@ export default function SettingsPage() {
<div className="form-group">
<label htmlFor="cfg-tg-chat" className="label">Telegram Chat ID</label>
<input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="123456789" />
</div>
</div>
<p className="section-desc" style={{ marginTop: '-0.5rem' }}>
Open your bot in Telegram, send any message (e.g. <code>/start</code>), then use{' '}
<a href="https://t.me/userinfobot" target="_blank" rel="noreferrer">@userinfobot</a> to copy your numeric ID,
or read it from <code>getUpdates</code> on the Bot API. Save Calibrate, then test.
</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={testingAlerts}
onClick={handleTestAlerts}
>
{testingAlerts ? 'Sending…' : 'Send test notification'}
</button>
{alertTestMsg && <span className="mono" style={{ fontSize: '0.85rem', color: '#9ee0ff' }}>{alertTestMsg}</span>}
</div>
<h3 className="font-display" style={{ fontSize: '1rem', margin: '1rem 0 0.5rem' }}>Notify me when</h3>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_connect !== false}
onChange={(e) => updateField('alerts.notify_agent_connect', e.target.checked)} />
<span>New agent connects to C2 (first time seen)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_reconnect !== false}
onChange={(e) => updateField('alerts.notify_agent_reconnect', e.target.checked)} />
<span>Agent reconnects (back online or session takeover)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_offline !== false}
onChange={(e) => updateField('alerts.notify_agent_offline', e.target.checked)} />
<span>Agent offline past threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_hashrate_drop !== false}
onChange={(e) => updateField('alerts.notify_hashrate_drop', e.target.checked)} />
<span>Hashrate drops below threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_rejection_rate !== false}
onChange={(e) => updateField('alerts.notify_rejection_rate', e.target.checked)} />
<span>Share rejection rate spikes</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_build_complete !== false}
onChange={(e) => updateField('alerts.notify_build_complete', e.target.checked)} />
<span>Forge completes successfully</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!config.alerts.email_enabled}