Compare commits
2 commits
6691525246
...
62a4f23c39
| Author | SHA1 | Date | |
|---|---|---|---|
| 62a4f23c39 | |||
| 9f09b12aeb |
7 changed files with 0 additions and 492 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
# Copy to .env and fill in. Never commit the real .env.
|
|
||||||
ITELESCOPE_USERNAME=your_itelescope_username
|
|
||||||
ITELESCOPE_PASSWORD=your_itelescope_password
|
|
||||||
3
api/.gitignore
vendored
3
api/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
.env
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# iTelescope API + Console
|
|
||||||
|
|
||||||
A small FastAPI wrapper over the iTelescope.net member portal, plus a web GUI on
|
|
||||||
top of it. Credentials come from a `.env` file - nothing is hard-coded.
|
|
||||||
|
|
||||||
## What it exposes (all read-only)
|
|
||||||
|
|
||||||
| Endpoint | What |
|
|
||||||
|---|---|
|
|
||||||
| `GET /api/health` | is it configured, known site codes |
|
|
||||||
| `GET /api/account` | points balance, plan, renewal, membership |
|
|
||||||
| `GET /api/plans` | per-scope access discount for the current plan |
|
|
||||||
| `GET /api/telescopes` | the telescope roster (from `../data/itelescope-telescopes.csv`) |
|
|
||||||
| `GET /api/weather` | safe/unsafe state for all six observatory sites |
|
|
||||||
| `GET /api/weather/{site}` | one site (SSO, UDRO, DSC, SRO, AC, EYE) |
|
|
||||||
| `GET /api/scope/{tid}/status` | reachability of a scope's own ACP control server |
|
|
||||||
| `GET /api/reservations` | legacy reservation grid + pointer to the live system |
|
|
||||||
| `GET /api/raw?path=...` | escape hatch: fetch any portal path (read-only) |
|
|
||||||
|
|
||||||
The GUI dashboard is at `/`; interactive OpenAPI docs at `/docs`.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd api
|
|
||||||
python -m pip install -r requirements.txt
|
|
||||||
cp .env.example .env # then edit .env with your username/password
|
|
||||||
uvicorn main:app --reload
|
|
||||||
# open http://127.0.0.1:8000/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deliberate omissions
|
|
||||||
|
|
||||||
- **Booking is not exposed.** Reservations spend real points, and the live
|
|
||||||
booking system (`lookup.itelescope.online`) is Cloudflare-protected and needs a
|
|
||||||
browser session. If booking is ever added it must sit behind an explicit,
|
|
||||||
confirmed action - see the drain campaign policy in `../CAMPAIGN.md`.
|
|
||||||
- Session cookies live only in memory for the running process.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Auth is the portal's WebForms login (viewstate POST) captured in
|
|
||||||
`itelescope_client.py`; the session re-authenticates automatically if it expires.
|
|
||||||
- `DataService.svc` methods are WCF (HTTP GET, JSON under a `d` key).
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
"""Thin client for iTelescope.net's member portal (go.itelescope.net).
|
|
||||||
|
|
||||||
Authenticates once with the WebForms login (username/password from the
|
|
||||||
environment), holds the session cookie, and exposes the portal's read features
|
|
||||||
as plain Python. Booking is deliberately NOT a convenience method here - it
|
|
||||||
spends real points and lives behind an explicit, guarded call.
|
|
||||||
|
|
||||||
Env: ITELESCOPE_USERNAME, ITELESCOPE_PASSWORD (see .env.example).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import html
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
BASE = "https://go.itelescope.net"
|
|
||||||
LOGIN = BASE + "/login.aspx?PreviousPage=%2fdefault.aspx"
|
|
||||||
# The six observatory site codes used by the weather pages.
|
|
||||||
SITES = ["SSO", "UDRO", "DSC", "SRO", "AC", "EYE"]
|
|
||||||
|
|
||||||
|
|
||||||
class ITelescopeError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NotConfigured(ITelescopeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_html(s: str) -> str:
|
|
||||||
return html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("", "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
class ITelescopeClient:
|
|
||||||
"""One authenticated session against the iTelescope portal.
|
|
||||||
|
|
||||||
Thread-safe for the simple case: a lock serialises (re)login so concurrent
|
|
||||||
FastAPI requests share a single cookie jar. Re-authenticates automatically
|
|
||||||
if the session has expired (a portal call bounces to the login page).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, username: str | None = None, password: str | None = None,
|
|
||||||
timeout: int = 30):
|
|
||||||
self.username = username or os.environ.get("ITELESCOPE_USERNAME", "")
|
|
||||||
self.password = password or os.environ.get("ITELESCOPE_PASSWORD", "")
|
|
||||||
self.timeout = timeout
|
|
||||||
self._s = requests.Session()
|
|
||||||
self._s.headers["User-Agent"] = "itelescope-api/1.0"
|
|
||||||
self._authed = False
|
|
||||||
self._lock = threading.RLock() # reentrant: a requests.Session is not
|
|
||||||
# thread-safe, so all portal calls (and
|
|
||||||
# the login they may trigger) serialise.
|
|
||||||
|
|
||||||
# -- auth ---------------------------------------------------------------
|
|
||||||
def _hidden(self, page_text: str, name: str) -> str:
|
|
||||||
m = re.search(r'name="' + re.escape(name) + r'"[^>]*value="([^"]*)"', page_text)
|
|
||||||
return m.group(1) if m else ""
|
|
||||||
|
|
||||||
def login(self) -> None:
|
|
||||||
if not self.username or not self.password:
|
|
||||||
raise NotConfigured("ITELESCOPE_USERNAME / ITELESCOPE_PASSWORD not set")
|
|
||||||
with self._lock:
|
|
||||||
r = self._s.get(LOGIN, timeout=self.timeout)
|
|
||||||
form = {
|
|
||||||
"__EVENTTARGET": "", "__EVENTARGUMENT": "", "__LASTFOCUS": "",
|
|
||||||
"__VIEWSTATE": self._hidden(r.text, "__VIEWSTATE"),
|
|
||||||
"__VIEWSTATEGENERATOR": self._hidden(r.text, "__VIEWSTATEGENERATOR"),
|
|
||||||
"__EVENTVALIDATION": self._hidden(r.text, "__EVENTVALIDATION"),
|
|
||||||
"UsernameTextBox": self.username, "PasswordTextBox": self.password,
|
|
||||||
"LoginButton": "Login",
|
|
||||||
}
|
|
||||||
r2 = self._s.post(LOGIN, data=form, timeout=self.timeout, allow_redirects=True)
|
|
||||||
ok = ("login.aspx" not in r2.url.lower()) or ("logout" in r2.text.lower())
|
|
||||||
if not ok:
|
|
||||||
raise ITelescopeError("login failed (check credentials)")
|
|
||||||
self._authed = True
|
|
||||||
|
|
||||||
def _ensure(self) -> None:
|
|
||||||
if not self._authed:
|
|
||||||
self.login()
|
|
||||||
|
|
||||||
def _get(self, path: str, **kw) -> requests.Response:
|
|
||||||
with self._lock:
|
|
||||||
self._ensure()
|
|
||||||
url = path if path.startswith("http") else BASE + "/" + path.lstrip("/")
|
|
||||||
r = self._s.get(url, timeout=self.timeout, **kw)
|
|
||||||
if "login.aspx" in r.url.lower(): # session expired -> re-auth once
|
|
||||||
self._authed = False
|
|
||||||
self.login()
|
|
||||||
r = self._s.get(url, timeout=self.timeout, **kw)
|
|
||||||
return r
|
|
||||||
|
|
||||||
def _svc(self, method: str) -> Any:
|
|
||||||
"""Call a DataService.svc method (WCF, HTTP GET, JSON reply under 'd')."""
|
|
||||||
r = self._get(f"DataService.svc/{method}")
|
|
||||||
if r.status_code != 200:
|
|
||||||
raise ITelescopeError(f"{method} -> HTTP {r.status_code}")
|
|
||||||
try:
|
|
||||||
return r.json().get("d", r.json())
|
|
||||||
except ValueError:
|
|
||||||
raise ITelescopeError(f"{method} returned non-JSON")
|
|
||||||
|
|
||||||
# -- read features ------------------------------------------------------
|
|
||||||
def account_status(self) -> dict:
|
|
||||||
d = self._svc("GetAccountStatus")
|
|
||||||
clean = {k: _strip_html(v) if isinstance(v, str) else v
|
|
||||||
for k, v in d.items() if not k.startswith("__")}
|
|
||||||
# pull the numeric balance out of e.g. "2001 points"
|
|
||||||
bal = clean.get("Balance", "")
|
|
||||||
m = re.search(r"([\d,]+)", bal)
|
|
||||||
clean["BalancePoints"] = int(m.group(1).replace(",", "")) if m else None
|
|
||||||
return clean
|
|
||||||
|
|
||||||
def available_plans(self) -> Any:
|
|
||||||
"""Per-scope access discount for the current membership plan."""
|
|
||||||
return self._svc("GetMyAvailablePlans")
|
|
||||||
|
|
||||||
def weather(self, site: str) -> dict:
|
|
||||||
site = site.upper()
|
|
||||||
if site not in SITES:
|
|
||||||
raise ITelescopeError(f"unknown site {site!r}; one of {SITES}")
|
|
||||||
r = self._get(f"Weather/Weather.aspx?site={site}")
|
|
||||||
text = _strip_html(r.text)
|
|
||||||
# surface any obvious "safe/unsafe/open/closed" state words
|
|
||||||
state = None
|
|
||||||
for w in ("UNSAFE", "SAFE", "OPEN", "CLOSED", "ROOF"):
|
|
||||||
if re.search(rf"\b{w}\b", r.text, re.I):
|
|
||||||
state = w.lower(); break
|
|
||||||
return {"site": site, "state": state, "http": r.status_code,
|
|
||||||
"excerpt": text[:600]}
|
|
||||||
|
|
||||||
def scope_status(self, tid: str) -> dict:
|
|
||||||
"""Best-effort reach of a telescope's own ACP control web server."""
|
|
||||||
n = re.sub(r"\D", "", tid)
|
|
||||||
out = {"telescope": f"T{n}", "reachable": False, "url": None, "http": None}
|
|
||||||
for url in (f"https://t{n}.itelescope.online/", f"http://t{n}.itelescope.net:80{n}/"):
|
|
||||||
try:
|
|
||||||
with self._lock:
|
|
||||||
r = self._s.get(url, auth=(self.username, self.password), timeout=15)
|
|
||||||
out.update(reachable=r.status_code < 500, url=url, http=r.status_code)
|
|
||||||
if r.ok:
|
|
||||||
break
|
|
||||||
except requests.RequestException:
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
def reservations(self) -> dict:
|
|
||||||
"""Current reservations. The live booking system moved to the
|
|
||||||
Cloudflare-protected lookup.itelescope.online, which needs a browser, so
|
|
||||||
this returns the legacy grid when present and flags the new system."""
|
|
||||||
r = self._get("Reservation/Default.aspx")
|
|
||||||
rows = []
|
|
||||||
for tr in re.findall(r"<tr[^>]*>(.*?)</tr>", r.text, re.S):
|
|
||||||
cells = [_strip_html(c) for c in re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", tr, re.S)]
|
|
||||||
cells = [c for c in cells if c]
|
|
||||||
if cells and re.search(r"20\d\d|T\d\d|GRAS", " ".join(cells)):
|
|
||||||
rows.append(cells)
|
|
||||||
return {"legacy_rows": rows,
|
|
||||||
"note": "Live reservations are managed at lookup.itelescope.online "
|
|
||||||
"(Cloudflare-protected; a browser session is required to read/write them).",
|
|
||||||
"count": len(rows)}
|
|
||||||
|
|
||||||
def raw(self, path: str) -> dict:
|
|
||||||
"""Escape hatch: fetch any portal path (read-only) for exploring."""
|
|
||||||
r = self._get(path)
|
|
||||||
return {"url": r.url, "http": r.status_code, "excerpt": _strip_html(r.text)[:1500]}
|
|
||||||
108
api/main.py
108
api/main.py
|
|
@ -1,108 +0,0 @@
|
||||||
"""FastAPI wrapper over the iTelescope member portal.
|
|
||||||
|
|
||||||
Run: uvicorn main:app --reload (from this directory, with .env present)
|
|
||||||
GUI: http://127.0.0.1:8000/ (dashboard)
|
|
||||||
Docs: http://127.0.0.1:8000/docs (OpenAPI)
|
|
||||||
|
|
||||||
Credentials come from the environment / a .env file - never hard-coded. Every
|
|
||||||
endpoint here is READ-ONLY. Booking spends real points and is intentionally not
|
|
||||||
exposed; add it deliberately behind its own confirmation if ever needed.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import os
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from itelescope_client import ITelescopeClient, ITelescopeError, NotConfigured, SITES
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
HERE = Path(__file__).parent
|
|
||||||
app = FastAPI(title="iTelescope API", version="1.0",
|
|
||||||
description="Read-only wrapper over the iTelescope.net member portal.")
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def client() -> ITelescopeClient:
|
|
||||||
return ITelescopeClient()
|
|
||||||
|
|
||||||
|
|
||||||
def _guard(fn):
|
|
||||||
try:
|
|
||||||
return fn()
|
|
||||||
except NotConfigured as e:
|
|
||||||
raise HTTPException(503, str(e))
|
|
||||||
except ITelescopeError as e:
|
|
||||||
raise HTTPException(502, str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
def health():
|
|
||||||
c = client()
|
|
||||||
return {"configured": bool(c.username and c.password), "sites": SITES}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/account")
|
|
||||||
def account():
|
|
||||||
return _guard(lambda: client().account_status())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/plans")
|
|
||||||
def plans():
|
|
||||||
return _guard(lambda: client().available_plans())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/telescopes")
|
|
||||||
def telescopes():
|
|
||||||
"""The telescope roster from the repo's snapshotted spec sheet."""
|
|
||||||
csv = HERE.parent / "data" / "itelescope-telescopes.csv"
|
|
||||||
if not csv.exists():
|
|
||||||
raise HTTPException(404, "telescope csv not found")
|
|
||||||
import csv as _csv
|
|
||||||
with open(csv, encoding="utf-8-sig", newline="") as fh:
|
|
||||||
return list(_csv.DictReader(fh))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/weather/{site}")
|
|
||||||
def weather(site: str):
|
|
||||||
return _guard(lambda: client().weather(site))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/weather")
|
|
||||||
def weather_all():
|
|
||||||
c = client()
|
|
||||||
out = {}
|
|
||||||
for s in SITES:
|
|
||||||
try:
|
|
||||||
out[s] = c.weather(s)
|
|
||||||
except ITelescopeError as e:
|
|
||||||
out[s] = {"site": s, "error": str(e)}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/scope/{tid}/status")
|
|
||||||
def scope_status(tid: str):
|
|
||||||
return _guard(lambda: client().scope_status(tid))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/reservations")
|
|
||||||
def reservations():
|
|
||||||
return _guard(lambda: client().reservations())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/raw")
|
|
||||||
def raw(path: str):
|
|
||||||
return _guard(lambda: client().raw(path))
|
|
||||||
|
|
||||||
|
|
||||||
# --- GUI ------------------------------------------------------------------
|
|
||||||
app.mount("/static", StaticFiles(directory=HERE / "static"), name="static")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def index():
|
|
||||||
return FileResponse(HERE / "static" / "index.html")
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
fastapi>=0.110
|
|
||||||
uvicorn[standard]>=0.27
|
|
||||||
requests>=2.31
|
|
||||||
python-dotenv>=1.0
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<!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>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue