New admin service — LAN-only, token-authed (ADMIN_TOKEN), card-based UI matching the owner's preferred dashboard style. Capabilities: - health: docker containers via /var/run/docker.sock (stdlib unix-socket HTTP client), DB heartbeat, Alpaca account/clock, Ollama, feed - config: whitelisted strategy knobs (TRADING_/TRIGGER_/WATCHER_/...) edited via data/overrides.env, which every entrypoint now loads with override=True at boot (runtime/env_overrides.py) — so changes apply on container restart without touching the deployment YAML; secrets are presence-only and rejected server-side if edited - profiles: named knob presets (seeded: validated-core, conservative, squeeze-hunter) applied in one click - restart buttons per container; paper/live switch guarded behind ALPACA_LIVE_* keys existing Verified live in docker: auth 401s, status/config/profiles/write paths, secret-edit rejection, page serves. Added to both compose files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
7.7 KiB
HTML
129 lines
7.7 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Momentum Admin</title>
|
|
<style>
|
|
:root{--bg:#f6f7fb;--card:#fff;--ink:#1a2233;--mut:#68738a;--line:#e5e8f0;
|
|
--ok:#16a34a;--warn:#d97706;--bad:#dc2626;--acc:#2563eb;--accbg:#eff4ff;--r:14px}
|
|
@media (prefers-color-scheme:dark){:root{--bg:#0f1420;--card:#171e2e;--ink:#e8ecf6;
|
|
--mut:#8b96ad;--line:#232c42;--accbg:#1a2540}}
|
|
*{box-sizing:border-box;margin:0}
|
|
body{font:15px/1.5 -apple-system,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--ink);padding:24px}
|
|
.wrap{max-width:1100px;margin:0 auto}
|
|
header{display:flex;align-items:center;gap:14px;margin-bottom:22px;flex-wrap:wrap}
|
|
h1{font-size:20px;font-weight:700}
|
|
.badge{padding:3px 12px;border-radius:99px;font-size:12px;font-weight:700;letter-spacing:.4px}
|
|
.paper{background:#e0edff;color:#1d4ed8}.live{background:#fee2e2;color:#b91c1c}
|
|
.spacer{flex:1}
|
|
input[type=password],input[type=text]{background:var(--card);border:1px solid var(--line);
|
|
border-radius:8px;padding:7px 10px;color:var(--ink);font-size:14px}
|
|
h2{font-size:13px;text-transform:uppercase;letter-spacing:.8px;color:var(--mut);margin:26px 0 10px}
|
|
.grid{display:grid;gap:12px}.g3{grid-template-columns:repeat(auto-fill,minmax(240px,1fr))}
|
|
.g4{grid-template-columns:repeat(auto-fill,minmax(200px,1fr))}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:14px 16px}
|
|
.row{display:flex;align-items:center;gap:10px}
|
|
.dot{width:9px;height:9px;border-radius:99px;flex:none}
|
|
.ok{background:var(--ok)}.warn{background:var(--warn)}.bad{background:var(--bad)}
|
|
.mut{color:var(--mut);font-size:13px}
|
|
button{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:7px 14px;
|
|
font-size:13px;font-weight:600;cursor:pointer}
|
|
button.sec{background:var(--accbg);color:var(--acc)}
|
|
button.danger{background:var(--bad)}
|
|
button:disabled{opacity:.45;cursor:default}
|
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
|
td,th{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left}
|
|
th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
|
td input{width:130px}
|
|
.tag{font-size:11px;background:var(--accbg);color:var(--acc);border-radius:6px;padding:1px 7px;font-weight:700}
|
|
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--ink);
|
|
color:var(--bg);padding:10px 20px;border-radius:10px;font-size:14px;opacity:0;transition:.3s}
|
|
</style>
|
|
</head>
|
|
<body><div class="wrap">
|
|
<header>
|
|
<h1>Momentum Admin</h1>
|
|
<span id="mode" class="badge paper">PAPER</span>
|
|
<span id="mkt" class="mut"></span>
|
|
<div class="spacer"></div>
|
|
<input type="password" id="token" placeholder="admin token" size="14">
|
|
<button class="sec" onclick="saveToken()">Connect</button>
|
|
</header>
|
|
|
|
<h2>Health</h2>
|
|
<div class="grid g4" id="health"></div>
|
|
|
|
<h2>Services</h2>
|
|
<div class="grid g3" id="services"></div>
|
|
|
|
<h2>Strategy profiles</h2>
|
|
<div class="grid g3" id="profiles"></div>
|
|
|
|
<h2>Configuration <span class="mut" style="text-transform:none;letter-spacing:0">— edits go to overrides.env; restart the loop to apply. <span class="tag">override</span> = differs from deployed env</span></h2>
|
|
<div class="card"><table id="cfg"><thead><tr><th>key</th><th>value</th><th></th></tr></thead><tbody></tbody></table>
|
|
<div class="row" style="margin-top:12px"><button onclick="saveCfg()">Save changes</button>
|
|
<span class="mut" id="cfgnote"></span></div></div>
|
|
|
|
<h2>Trading mode</h2>
|
|
<div class="card row">
|
|
<div style="flex:1"><b>Paper ⇄ Live switch</b><div class="mut">Live requires ALPACA_LIVE_API_KEY / ALPACA_LIVE_SECRET_KEY in the deployment env. Switching restarts nothing by itself — restart the loop after.</div></div>
|
|
<button class="sec" onclick="setMode('paper')">Paper</button>
|
|
<button class="danger" onclick="if(confirm('Switch the loop to LIVE trading with real money on next restart. Are you sure?'))setMode('live')">Go Live</button>
|
|
</div>
|
|
|
|
<h2>Secrets present</h2>
|
|
<div class="card" id="secrets"></div>
|
|
<div id="toast"></div>
|
|
</div>
|
|
<script>
|
|
const $=q=>document.querySelector(q);let staged={};
|
|
const tok=()=>localStorage.getItem('admintok')||'';
|
|
function saveToken(){localStorage.setItem('admintok',$('#token').value);load()}
|
|
async function api(p,opt={}){opt.headers={...(opt.headers||{}),'X-Admin-Token':tok(),'Content-Type':'application/json'};
|
|
const r=await fetch(p,opt);if(r.status===401){toast('bad token');throw 0}return r.json()}
|
|
function toast(m){const t=$('#toast');t.textContent=m;t.style.opacity=1;setTimeout(()=>t.style.opacity=0,2600)}
|
|
function dot(ok){return `<span class="dot ${ok===true?'ok':ok===false?'bad':'warn'}"></span>`}
|
|
async function load(){
|
|
const s=await api('/api/status');
|
|
const c=s.checks||{};
|
|
$('#health').innerHTML=[
|
|
`<div class="card row">${dot(c.db?.ok)}<div><b>Database</b><div class="mut">${c.db?.ok?`event ${c.db.newest_event_age_s}s ago`:c.db?.error||''}</div></div></div>`,
|
|
`<div class="card row">${dot(c.alpaca?.ok)}<div><b>Alpaca</b><div class="mut">${c.alpaca?.ok?`$${(+c.alpaca.equity).toLocaleString()} · market ${c.alpaca.market_open?'open':'closed'}`:c.alpaca?.error||''}</div></div></div>`,
|
|
`<div class="card row">${dot(c.ollama?.ok)}<div><b>Ollama</b><div class="mut">${c.ollama?.ok?'enrichment up':'down (fails safe)'}</div></div></div>`,
|
|
`<div class="card row">${dot(c.feed==='sip')}<div><b>Data feed</b><div class="mut">${c.feed}</div></div></div>`].join('');
|
|
$('#mode').className='badge '+(c.alpaca?.mode==='LIVE'?'live':'paper');
|
|
$('#mode').textContent=c.alpaca?.mode==='LIVE'?'LIVE':'PAPER';
|
|
$('#mkt').textContent=c.alpaca?.market_open?'market open':'market closed';
|
|
$('#services').innerHTML=(s.containers||[]).map(x=>`<div class="card row">
|
|
${dot(x.health?x.health==='healthy':x.state==='running')}
|
|
<div style="flex:1"><b>${x.name.replace(/^ix-|-1$/g,'')}</b><div class="mut">${x.status||x.state}</div></div>
|
|
<button class="sec" onclick="restart('${x.name}')">restart</button></div>`).join('');
|
|
const cfg=await api('/api/config');staged={};
|
|
$('#cfg tbody').innerHTML=Object.entries(cfg.effective).map(([k,v])=>`<tr>
|
|
<td>${k} ${cfg.overrides[k]!==undefined?'<span class="tag">override</span>':''}</td>
|
|
<td><input type="text" value="${v}" oninput="staged['${k}']=this.value"></td>
|
|
<td>${cfg.overrides[k]!==undefined?`<button class="sec" onclick="delKey('${k}')">reset</button>`:''}</td></tr>`).join('');
|
|
$('#secrets').innerHTML=Object.entries(cfg.secrets_present).map(([k,v])=>
|
|
`<span style="margin-right:18px">${v?'✅':'❌'} ${k}</span>`).join('');
|
|
const prof=await api('/api/profiles');
|
|
$('#profiles').innerHTML=Object.entries(prof).map(([n,p])=>`<div class="card">
|
|
<b>${n}</b><div class="mut" style="min-height:40px">${p._note||''}</div>
|
|
<button onclick="applyProfile('${n}')">Apply</button></div>`).join('');
|
|
}
|
|
async function saveCfg(){if(!Object.keys(staged).length)return toast('nothing changed');
|
|
await api('/api/config',{method:'POST',body:JSON.stringify({set:staged})});
|
|
toast('saved — restart the loop to apply');load()}
|
|
async function delKey(k){await api('/api/config',{method:'POST',body:JSON.stringify({delete:[k]})});load()}
|
|
async function applyProfile(n){await api('/api/profiles/apply',{method:'POST',body:JSON.stringify({name:n})});
|
|
toast(`profile ${n} staged — restart the loop to apply`);load()}
|
|
async function restart(n){toast('restarting '+n+'…');
|
|
const r=await api('/api/restart',{method:'POST',body:JSON.stringify({service:n})});
|
|
toast(r.ok?'restarted '+n:'restart failed');setTimeout(load,3000)}
|
|
async function setMode(m){const r=await api('/api/mode',{method:'POST',body:JSON.stringify({mode:m})});
|
|
toast(r.error||('mode -> '+m+' — restart the loop to apply'));load()}
|
|
$('#token').value=tok();load().catch(()=>{});setInterval(()=>load().catch(()=>{}),15000);
|
|
</script>
|
|
</body>
|
|
</html>
|