The welcome now carries a personId; it lands in the instance_id setting and rides on every hello and HTTP report from then on. The master keys the person by that uuid, so changing instance_name moves our history to the new name instead of starting a stranger. Read-only in Settings on purpose — editing it by hand would detach this machine from its person. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
343 lines
17 KiB
TypeScript
343 lines
17 KiB
TypeScript
'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;
|
||
}
|
||
|
||
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 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);
|
||
}
|
||
});
|
||
|
||
// 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 }),
|
||
});
|
||
setSaving(false);
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 2000);
|
||
}
|
||
|
||
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>
|
||
|
||
{/* ── 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>
|
||
);
|
||
}
|