Files
autofirmer-expanded/app/settings/page.tsx
T
SenofyandClaude Opus 4.6 f112118090 Add master dashboard reporter and settings UI
- New reporter module that pushes firm stats (total accounts, accounts
  traded, in trade) to a configurable master dashboard every 30 seconds
- Add Instance Name and Dashboard URL fields to the settings page
- Register master_dashboard_url and instance_name in settings API
- Seed default (empty) values for new settings in db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 03:01:48 -05:00

270 lines
13 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;
frontVolume?: number;
rolledVolume?: number;
}
interface AppSettings {
max_concurrent_accounts: string | null;
master_dashboard_url: string | null;
instance_name: 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 [masterUrl, setMasterUrl] = useState<string>('');
const [instanceName, setInstanceName] = useState<string>('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch('/api/instruments')
.then((r) => r.json())
.then(setInstruments);
fetch('/api/settings')
.then((r) => r.json())
.then((s: AppSettings) => {
if (s.max_concurrent_accounts != null) {
setMaxConcurrent(s.max_concurrent_accounts);
}
if (s.master_dashboard_url != null) {
setMasterUrl(s.master_dashboard_url);
}
if (s.instance_name != null) {
setInstanceName(s.instance_name);
}
});
// 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, 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">
<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>
{/* ── 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
</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 to this URL every 30 seconds
</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>
{/* ── 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 && c.rolledVolume != null && c.frontVolume != null && c.rolledVolume > c.frontVolume;
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>
{c.frontVolume != null && c.rolledVolume != null && (
<span className="text-xs text-slate-400">
vol {Math.max(c.frontVolume, c.rolledVolume).toLocaleString()}
</span>
)}
{wasRolled && (
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
rolled
</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>
);
}