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.
160 lines
8.2 KiB
HTML
160 lines
8.2 KiB
HTML
<!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>
|