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:
@@ -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 & sparkles</span>
|
||||
</label>
|
||||
</div>
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="green" className="settings-section">
|
||||
<h2 className="font-display">Sound & 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 & 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}
|
||||
|
||||
Reference in New Issue
Block a user