Add FastAPI wrapper + web console for the iTelescope portal

Read-only API over go.itelescope.net driven by username/password from a .env:
account status (balance/plan/renewal), plan discounts, telescope roster,
per-site weather, per-scope ACP reachability, and reservations (with a pointer
to the Cloudflare-protected live booking system). A dark dashboard GUI is served
on top at /. Booking is intentionally not exposed - it spends real points.
This commit is contained in:
Laurence 2026-07-24 22:47:04 +01:00
parent 67f8455e41
commit a45f939f76
7 changed files with 492 additions and 0 deletions

160
api/static/index.html Normal file
View file

@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>iTelescope Console</title>
<style>
:root{
--bg:#080a0f; --panel:#0f131b; --panel2:#141a25; --line:#1e2634;
--ink:#e9ecf3; --muted:#8b95a7; --faint:#5b6577; --star:#e8b658; --cool:#6ea8d8;
--good:#4c9f70; --warn:#d8a24a; --bad:#c0473a;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"Cascadia Code",Consolas,monospace;
}
*{box-sizing:border-box} body{margin:0;background:radial-gradient(1100px 640px at 75% -12%,#101725,var(--bg) 60%);
color:var(--ink);font-family:var(--sans);line-height:1.5}
.wrap{max-width:1100px;margin:0 auto;padding:34px 22px 70px}
header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
h1{font-size:24px;margin:0;letter-spacing:-.01em}
.tag{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--star)}
.sub{color:var(--muted);font-size:14px;margin:0 0 24px}
.grid{display:grid;gap:16px}
.cards{grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}
.card{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:16px 18px}
.card h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin:0 0 12px;font-weight:600}
.stat{font-size:26px;font-weight:650;letter-spacing:-.01em;font-variant-numeric:tabular-nums}
.stat.big{font-size:34px;color:var(--star)}
.statsub{color:var(--muted);font-size:13px;margin-top:3px}
section{margin-top:28px}
section > h2{font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--star);margin:0 0 12px;font-family:var(--mono)}
table{width:100%;border-collapse:collapse;font-size:13.5px}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line)}
th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
td{font-variant-numeric:tabular-nums}
.scroll{overflow-x:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel)}
.pill{display:inline-block;font-family:var(--mono);font-size:11px;padding:3px 9px;border-radius:999px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}
.pill.good{background:color-mix(in srgb,var(--good) 22%,transparent);color:var(--good)}
.pill.warn{background:color-mix(in srgb,var(--warn) 22%,transparent);color:var(--warn)}
.pill.bad{background:color-mix(in srgb,var(--bad) 22%,transparent);color:var(--bad)}
.pill.na{background:var(--panel2);color:var(--faint)}
.wx{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
.wx .card{padding:14px 16px}
.wx .code{font-family:var(--mono);font-size:15px;font-weight:700}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
input,button{font:inherit}
input{background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 11px;width:110px}
button{background:var(--star);color:#111;border:none;border-radius:8px;padding:8px 15px;font-weight:650;cursor:pointer}
button.ghost{background:var(--panel2);color:var(--ink);border:1px solid var(--line)}
button:hover{filter:brightness(1.06)}
.muted{color:var(--muted)} .mono{font-family:var(--mono)}
.err{color:var(--bad)}
.note{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--cool);border-radius:10px;padding:12px 15px;color:var(--muted);font-size:13.5px;margin-top:10px}
.foot{margin-top:34px;color:var(--faint);font-size:12px;font-family:var(--mono)}
.spin{color:var(--faint);font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<header>
<span class="tag">iTelescope</span><h1>Console</h1>
<span id="acctname" class="muted mono" style="margin-left:auto"></span>
</header>
<p class="sub">Read-only view of the iTelescope.net member portal. Booking is not exposed here - it spends real points.</p>
<div id="cards" class="grid cards"></div>
<section>
<h2>Weather - all sites</h2>
<div id="wx" class="grid wx"><span class="spin">loading...</span></div>
</section>
<section>
<h2>Reservations</h2>
<div id="resv"><span class="spin">loading...</span></div>
</section>
<section>
<h2>Telescope status check</h2>
<div class="row">
<input id="tid" placeholder="e.g. T33" value="T33">
<button onclick="checkScope()">Check ACP server</button>
<span id="scopeout" class="mono muted"></span>
</div>
</section>
<section>
<h2>Telescope roster</h2>
<div id="scopes" class="scroll"><span class="spin" style="display:block;padding:14px">loading...</span></div>
</section>
<div class="foot" id="foot">iTelescope API - FastAPI - read-only</div>
</div>
<script>
const $ = s => document.querySelector(s);
async function get(p){ const r = await fetch(p); if(!r.ok){ throw new Error((await r.json().catch(()=>({}))).detail || r.status);} return r.json(); }
function pill(state){
const s=(state||"").toLowerCase();
if(["safe","open"].includes(s)) return `<span class="pill good">${s}</span>`;
if(["unsafe","closed","roof"].includes(s)) return `<span class="pill bad">${s}</span>`;
return `<span class="pill na">${state||"n/a"}</span>`;
}
async function loadAccount(){
try{
const a = await get('/api/account');
$('#acctname').textContent = a.CurrentPlan || '';
const days = (a.MembershipExpiry||'').match(/\((\d+) days/);
$('#cards').innerHTML = `
<div class="card"><h2>Points balance</h2><div class="stat big">${a.BalancePoints?.toLocaleString()??a.Balance}</div><div class="statsub">available to spend</div></div>
<div class="card"><h2>Plan</h2><div class="stat">${(a.CurrentPlan||'').replace(/\s*\(.*/,'')}</div><div class="statsub">${(a.CurrentPlan||'').match(/\((.*)\)/)?.[1]||''}</div></div>
<div class="card"><h2>Renews</h2><div class="stat">${(a.PlanRenewal||'').replace(/^\w+,\s*/,'')}</div><div class="statsub">${days?days[1]+' days left':''}</div></div>
<div class="card"><h2>Member since</h2><div class="stat">${(a.SubscribedSince||'').split(/\s+\d+\s*month/)[0].replace(/^\w+,\s*/,'')}</div><div class="statsub">veteran</div></div>`;
}catch(e){ $('#cards').innerHTML = `<div class="card err">Account: ${e.message}</div>`; }
}
async function loadWeather(){
try{
const w = await get('/api/weather');
$('#wx').innerHTML = Object.entries(w).map(([s,d])=>`
<div class="card"><h2>${s}</h2><div class="code">${pill(d.state)}</div>
<div class="statsub">${d.error?('<span class=err>'+d.error+'</span>'):('HTTP '+(d.http??'-'))}</div></div>`).join('');
}catch(e){ $('#wx').innerHTML = `<span class="err">${e.message}</span>`; }
}
async function loadResv(){
try{
const r = await get('/api/reservations');
let h = '';
if(r.count){
h = '<div class="scroll"><table><tbody>' + r.legacy_rows.map(row=>'<tr>'+row.map(c=>`<td>${c}</td>`).join('')+'</tr>').join('') + '</tbody></table></div>';
} else {
h = '<p class="muted">No reservations in the legacy grid.</p>';
}
$('#resv').innerHTML = h + `<div class="note">${r.note}</div>`;
}catch(e){ $('#resv').innerHTML = `<span class="err">${e.message}</span>`; }
}
async function loadScopes(){
try{
const t = await get('/api/telescopes');
if(!t.length){ $('#scopes').innerHTML='<p class="muted" style="padding:14px">No roster.</p>'; return; }
const cols = Object.keys(t[0]).slice(0,7);
$('#scopes').innerHTML = '<table><thead><tr>'+cols.map(c=>`<th>${c}</th>`).join('')+
'</tr></thead><tbody>'+t.map(r=>'<tr>'+cols.map(c=>`<td>${r[c]??''}</td>`).join('')+'</tr>').join('')+'</tbody></table>';
}catch(e){ $('#scopes').innerHTML = `<span class="err" style="padding:14px;display:block">${e.message}</span>`; }
}
async function checkScope(){
const tid = $('#tid').value.trim(); $('#scopeout').textContent='checking...';
try{
const d = await get('/api/scope/'+encodeURIComponent(tid)+'/status');
$('#scopeout').innerHTML = `${d.telescope}: ${d.reachable?'<span class="pill good">reachable</span>':'<span class="pill bad">unreachable</span>'} ${d.url?('<span class=muted>'+d.url+' ('+d.http+')</span>'):''}`;
}catch(e){ $('#scopeout').innerHTML = `<span class="err">${e.message}</span>`; }
}
loadAccount(); loadWeather(); loadResv(); loadScopes();
</script>
</body>
</html>