Files
autofirmer-expanded/app/settings/page.tsx
T
Brandon LiandClaude Fable 5 ba8bd50da0 Backups push automatically; the token is only for restore
Matches the master's tokenless-push change: pushBackup no longer waits
for master_backup_token — every instance with a uuid backs up on its
own, and the 5-minute tick force-pushes so a poisoned master copy
self-heals. The token now only gates pulling a backup back (restore),
so setup-linux.sh asks for it only when restoring and the Settings
help text says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-22 14:08:03 -05:00

512 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<Instrument[]>([]);
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
const [tradingHours, setTradingHours] = useState<string>('full_cme');
const [masterUrl, setMasterUrl] = useState<string>('');
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<string>('');
const [instanceId, setInstanceId] = useState<string>('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [backupToken, setBackupToken] = useState<string>('');
const [proxyUrl, setProxyUrl] = useState<string>('');
const [backupStatus, setBackupStatus] = useState<{ at: string | null; error: string | null }>({ at: null, error: null });
const [restoreList, setRestoreList] = useState<BackupMeta[] | null>(null);
const [restoreBusy, setRestoreBusy] = useState(false);
const [restoreMsg, setRestoreMsg] = useState<string>('');
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<string, ResolvedContract | null>) => {
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<string, ResolvedContract | null>) => {
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 (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Link
href="/"
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
>
← Back
</Link>
<h1 className="text-2xl font-bold text-slate-900">Settings</h1>
</div>
{/* ── Trading ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Trading
</h2>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Max concurrent accounts</p>
<p className="text-xs text-slate-400 mt-0.5">
How many accounts trade in parallel per /api/trade call
</p>
</div>
<div className="flex items-center gap-3">
<input
type="number"
min={1}
max={500}
value={maxConcurrent}
onChange={(e) => 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"
/>
<button
onClick={saveSettings}
disabled={saving}
className={`text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
saved
? 'bg-green-500 text-white'
: 'bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50'
}`}
>
{saved ? 'Saved ✓' : saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Allowed trading hours</p>
<p className="text-xs text-slate-400 mt-0.5">
5 min buffer
</p>
</div>
<select
value={tradingHours}
onChange={(e) => setTradingHours(e.target.value)}
className="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"
>
<option value="full_cme">Full CME (5:00 PM – 3:00 PM CT)</option>
<option value="equity_hours">Equity Hours (8:30 AM – 3:00 PM CT)</option>
</select>
</div>
</div>
{/* ── Master Dashboard ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Master Dashboard
</h2>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Instance Name</p>
<p className="text-xs text-slate-400 mt-0.5">
Identifier for this autotrader on the master dashboard.
Renaming is safe — the master tracks this machine by its
person ID{instanceId ? (
<> (<span className="font-mono">{instanceId.slice(0, 8)}</span>)</>
) : ' once it has connected'}, so history follows the new name.
</p>
</div>
<input
type="text"
placeholder="e.g. VPS-1"
value={instanceName}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Dashboard URL</p>
<p className="text-xs text-slate-400 mt-0.5">
Reports state here every 30 seconds, and holds a control
socket open so the dashboard can trigger trades and buys
</p>
</div>
<input
type="text"
placeholder="e.g. http://master-ip:4000"
value={masterUrl}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Firm catalog</p>
<p className="text-xs text-slate-400 mt-0.5">
{catalog
? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${
catalog.lastSyncAt
? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}`
: 'never synced'
}`
: 'Loading…'}
</p>
{catalog?.lastError && (
<p className="text-xs text-red-600 mt-0.5">{catalog.lastError}</p>
)}
</div>
<button
onClick={syncCatalogNow}
disabled={syncing}
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
>
{syncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Backup token</p>
<p className="text-xs text-slate-400 mt-0.5">
Backups push automatically — this token (the master&apos;s
INSTANCE_BACKUP_TOKEN) is only needed to restore one.
</p>
<p className="text-xs text-slate-400 mt-0.5">
{backupStatus.at
? `Last backed up ${new Date(backupStatus.at).toLocaleString()}`
: 'Never backed up'}
</p>
{backupStatus.error && (
<p className="text-xs text-red-600 mt-0.5">{backupStatus.error}</p>
)}
</div>
<input
type="password"
placeholder="shared token"
value={backupToken}
onChange={(e) => 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"
/>
</div>
<div className="px-4 py-3 border-t border-slate-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-800">Restore from master</p>
<p className="text-xs text-slate-400 mt-0.5">
Replace a dead VPS: pull its backup, overwrite this machine
and adopt its identity. Stop the old machine first.
</p>
</div>
<button
onClick={loadRestoreList}
disabled={restoreBusy}
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
>
{restoreBusy ? 'Working…' : restoreList ? 'Refresh list' : 'List backups'}
</button>
</div>
{restoreMsg && (
<p className="text-xs mt-2 text-slate-600">{restoreMsg}</p>
)}
{restoreList && (
<div className="mt-3 border border-slate-200 rounded-lg divide-y divide-slate-100">
{restoreList.length === 0 ? (
<p className="px-3 py-2 text-xs text-slate-400 italic">No backups on the master yet</p>
) : restoreList.map((b) => (
<div key={b.id} className="flex items-center justify-between px-3 py-2">
<div>
<span className="text-sm font-mono text-slate-800">{b.name}</span>
<span className={`text-xs ml-2 ${isStale(b.savedAt) ? 'text-amber-600 font-semibold' : 'text-slate-400'}`}>
saved {new Date(b.savedAt).toLocaleString()}
{isStale(b.savedAt) ? ' — over a day old' : ''}
</span>
<span className="text-xs text-slate-300 ml-2 font-mono">{b.id.slice(0, 8)}</span>
</div>
<button
onClick={() => restoreBackup(b)}
disabled={restoreBusy}
className="text-sm px-3 py-1 rounded-lg font-medium bg-red-50 hover:bg-red-100 text-red-700 disabled:opacity-50 transition-colors"
>
Restore
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* ── Network ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Network
</h2>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Broker proxy</p>
<p className="text-xs text-slate-400 mt-0.5">
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.
</p>
<p className="text-xs text-amber-600 mt-0.5">
Takes effect after a restart (pm2 restart).
</p>
</div>
<input
type="text"
placeholder="e.g. socks5://user:pass@1.2.3.4:1080"
value={proxyUrl}
onChange={(e) => 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"
/>
</div>
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Instruments
</h2>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
{instruments.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
Loading…
</div>
) : (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
</tr>
</thead>
<tbody>
{instruments.map((instr) => {
const c = contracts[instr.symbol];
const wasRolled = !!c?.alternative;
return (
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
{instr.symbol}
</td>
<td className="px-4 py-2.5">
{instr.enabled && c ? (
<div className="flex items-center gap-2">
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
{c.name}
</span>
{wasRolled && (
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
rolled from {c.alternative}
</span>
)}
</div>
) : null}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggle(instr.symbol, !instr.enabled)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
instr.enabled ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}