'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; interface Instrument { symbol: string; enabled: boolean; } interface ResolvedContract { name: string; alternative?: string; } interface AppSettings { max_concurrent_accounts: string | null; master_dashboard_url: string | null; instance_name: string | null; instance_id: string | null; trading_hours: string | null; master_backup_token: string | null; proxy_url: string | null; backup_last_push_at: string | null; backup_last_error: string | null; } interface BackupMeta { id: string; name: string; savedAt: string; sizeBytes: number; } export default function SettingsPage() { const [instruments, setInstruments] = useState([]); const [contracts, setContracts] = useState>({}); const [maxConcurrent, setMaxConcurrent] = useState('5'); const [tradingHours, setTradingHours] = useState('full_cme'); const [masterUrl, setMasterUrl] = useState(''); const [catalog, setCatalog] = useState<{ version: number; firmCount: number; lastSyncAt: string | null; lastError: string | null; } | null>(null); const [syncing, setSyncing] = useState(false); const [instanceName, setInstanceName] = useState(''); const [instanceId, setInstanceId] = useState(''); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [backupToken, setBackupToken] = useState(''); const [proxyUrl, setProxyUrl] = useState(''); const [backupStatus, setBackupStatus] = useState<{ at: string | null; error: string | null }>({ at: null, error: null }); const [restoreList, setRestoreList] = useState(null); const [restoreBusy, setRestoreBusy] = useState(false); const [restoreMsg, setRestoreMsg] = useState(''); const fetchCatalog = () => { fetch('/api/firm-catalog') .then((r) => r.json()) .then(setCatalog) .catch(() => {}); }; const syncCatalogNow = async () => { setSyncing(true); try { await fetch('/api/firm-catalog', { method: 'POST' }); } catch { /* the error lands in lastError */ } fetchCatalog(); setSyncing(false); }; useEffect(() => { fetch('/api/instruments') .then((r) => r.json()) .then(setInstruments); fetchCatalog(); fetch('/api/settings') .then((r) => r.json()) .then((s: AppSettings) => { if (s.max_concurrent_accounts != null) { setMaxConcurrent(s.max_concurrent_accounts); } if (s.trading_hours != null) { setTradingHours(s.trading_hours); } if (s.master_dashboard_url != null) { setMasterUrl(s.master_dashboard_url); } if (s.instance_name != null) { setInstanceName(s.instance_name); } if (s.instance_id != null) { setInstanceId(s.instance_id); } if (s.master_backup_token != null) { setBackupToken(s.master_backup_token); } if (s.proxy_url != null) { setProxyUrl(s.proxy_url); } setBackupStatus({ at: s.backup_last_push_at, error: s.backup_last_error || null }); }); // Load cached contracts; if cache is empty, auto-resolve fetch('/api/instruments/contracts') .then((r) => r.json()) .then((data: Record) => { if (data.error) return; const hasData = Object.values(data).some((v) => v !== null); if (hasData) { setContracts(data); } else { // Cache empty — trigger a fresh resolve automatically fetch('/api/instruments/contracts', { method: 'POST' }) .then((r) => r.json()) .then((fresh) => { if (!fresh.error) setContracts(fresh); }) .catch(() => {}); } }) .catch(() => {}); }, []); async function toggle(symbol: string, enabled: boolean) { setInstruments((prev) => prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i)) ); await fetch(`/api/instruments/${symbol}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), }); // When enabling a symbol, auto-resolve its active contract if (enabled) { fetch('/api/instruments/contracts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbols: [symbol] }), }) .then((r) => r.json()) .then((fresh: Record) => { if (!fresh.error) { setContracts((prev) => ({ ...prev, ...fresh })); } }) .catch(() => {}); } } async function saveSettings() { setSaving(true); setSaved(false); await fetch('/api/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }), }); setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 2000); } async function loadRestoreList() { setRestoreBusy(true); setRestoreMsg(''); try { const res = await fetch('/api/backup/restore'); const body = await res.json(); if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`); setRestoreList(body.backups ?? []); } catch (err) { setRestoreMsg(err instanceof Error ? err.message : String(err)); } setRestoreBusy(false); } async function restoreBackup(b: BackupMeta) { const warning = `Restore "${b.name}" (saved ${new Date(b.savedAt).toLocaleString()})?\n\n` + 'This OVERWRITES this machine\'s settings and firm credentials and adopts ' + 'that instance\'s identity on the master.\n\n' + 'Make sure the old machine is STOPPED first — two live instances with one ' + 'identity will fight over the connection.'; if (!window.confirm(warning)) return; setRestoreBusy(true); setRestoreMsg(''); try { const res = await fetch('/api/backup/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: b.id }), }); const body = await res.json(); if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`); setRestoreMsg( `Restored ${body.restored.settings} settings and ${body.restored.credentials} credential(s) as ` + `"${body.adopted.name}". Restart the autotrader now (pm2 restart) to finish.`, ); setRestoreList(null); } catch (err) { setRestoreMsg(err instanceof Error ? err.message : String(err)); } setRestoreBusy(false); } /** >24h old backups get flagged — a dead VPS's last push may be stale. */ function isStale(savedAt: string): boolean { return Date.now() - new Date(savedAt).getTime() > 24 * 60 * 60 * 1000; } return (
← Back

Settings

{/* ── Trading ── */}

Trading

Max concurrent accounts

How many accounts trade in parallel per /api/trade call

setMaxConcurrent(e.target.value)} className="w-20 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-right font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" />

Allowed trading hours

5 min buffer

{/* ── Master Dashboard ── */}

Master Dashboard

Instance Name

Identifier for this autotrader on the master dashboard. Renaming is safe — the master tracks this machine by its person ID{instanceId ? ( <> ({instanceId.slice(0, 8)}) ) : ' once it has connected'}, so history follows the new name.

setInstanceName(e.target.value)} className="w-48 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" />

Dashboard URL

Reports state here every 30 seconds, and holds a control socket open so the dashboard can trigger trades and buys

setMasterUrl(e.target.value)} className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" />

Firm catalog

{catalog ? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${ catalog.lastSyncAt ? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}` : 'never synced' }` : 'Loading…'}

{catalog?.lastError && (

{catalog.lastError}

)}

Backup token

Enables encrypted settings + credentials backups to the master (its INSTANCE_BACKUP_TOKEN). Blank = backups off.

{backupStatus.at ? `Last backed up ${new Date(backupStatus.at).toLocaleString()}` : 'Never backed up'}

{backupStatus.error && (

{backupStatus.error}

)}
setBackupToken(e.target.value)} className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" />

Restore from master

Replace a dead VPS: pull its backup, overwrite this machine and adopt its identity. Stop the old machine first.

{restoreMsg && (

{restoreMsg}

)} {restoreList && (
{restoreList.length === 0 ? (

No backups on the master yet

) : restoreList.map((b) => (
{b.name} saved {new Date(b.savedAt).toLocaleString()} {isStale(b.savedAt) ? ' — over a day old' : ''} {b.id.slice(0, 8)}
))}
)}
{/* ── Network ── */}

Network

Broker proxy

Routes broker traffic (TopstepX, Tradovate, quotes) through a proxy — http://, https:// or socks5:// with optional user:pass. Use socks5h:// to resolve DNS on the proxy. The master connection stays direct.

Takes effect after a restart (pm2 restart).

setProxyUrl(e.target.value)} className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{/* ── Instruments ── */}

Instruments

{instruments.length === 0 ? (
Loading…
) : ( {instruments.map((instr) => { const c = contracts[instr.symbol]; const wasRolled = !!c?.alternative; return ( ); })}
Symbol Active Contract Enabled
{instr.symbol} {instr.enabled && c ? (
{c.name} {wasRolled && ( rolled from {c.alternative} )}
) : null}
)}
); }