Improve launch status UI and alert on SpaceDevs payload errors

This commit is contained in:
Edward Betts 2026-02-21 16:39:47 +00:00
parent 458dfc5136
commit 7a50ea6016
3 changed files with 78 additions and 7 deletions

View file

@ -68,7 +68,10 @@
<div class="col-md-1 text-md-nowrap"> <div class="col-md-1 text-md-nowrap">
<span class="d-md-none">launch status:</span> <span class="d-md-none">launch status:</span>
<abbr title="{{ launch.status.name }}">{{ launch.status.abbrev }}</abbr> <abbr title="{{ launch.status.name }}">{{ launch.status.abbrev }}</abbr>
{% if launch.probability %}{{ launch.probability }}%{% endif %} {% if launch.is_active_crewed %}
<span class="badge text-bg-info">In space</span>
{% endif %}
{% if launch.is_future and launch.probability %}{{ launch.probability }}%{% endif %}
</div> </div>
<div class="col"> <div class="col">
<div> <div>
@ -121,7 +124,7 @@
{% else %} {% else %}
<p>No description.</p> <p>No description.</p>
{% endif %} {% endif %}
{% if launch.weather_concerns %} {% if launch.weather_concerns and launch.status.name != "Launch Successful" %}
<h4>Weather concerns</h4> <h4>Weather concerns</h4>
{% for line in launch.weather_concerns.splitlines() %} {% for line in launch.weather_concerns.splitlines() %}
<p>{{ line }}</p> <p>{{ line }}</p>

View file

@ -247,9 +247,52 @@ def is_test_flight(launch: StrDict) -> bool:
def get_launch_by_slug(data: StrDict, slug: str) -> StrDict | None: def get_launch_by_slug(data: StrDict, slug: str) -> StrDict | None:
"""Find last update for space launch.""" """Find last update for space launch."""
return {item["slug"]: typing.cast(StrDict, item) for item in data["results"]}.get( results = data.get("results")
slug if not isinstance(results, list):
return None
by_slug: dict[str, StrDict] = {}
for item in results:
if not isinstance(item, dict):
continue
item_slug = item.get("slug")
if isinstance(item_slug, str):
by_slug[item_slug] = typing.cast(StrDict, item)
return by_slug.get(slug)
def send_thespacedevs_payload_alert(
config: flask.config.Config,
reason: str,
data: StrDict | None,
) -> None:
"""Alert admin when SpaceDevs update payload is missing expected fields."""
payload = data or {}
detail = payload.get("detail")
status_code = payload.get("status_code", payload.get("status"))
detail_text = detail if isinstance(detail, str) else ""
is_rate_limited = (
status_code == 429
or "rate" in detail_text.lower()
or "thrott" in detail_text.lower()
) )
alert_type = "rate-limit" if is_rate_limited else "error"
subject = f"⚠️ SpaceDevs {alert_type}: {reason}"
body = f"""SpaceDevs update returned an unexpected payload.
Reason: {reason}
Type: {alert_type}
Status: {status_code!r}
Detail: {detail!r}
Payload keys: {sorted(payload.keys())}
Expected payload shape includes a top-level 'results' list.
Updater: /home/edward/src/agenda/update.py
"""
agenda.mail.send_mail(config, subject, body)
def update_thespacedevs(config: flask.config.Config) -> None: def update_thespacedevs(config: flask.config.Config) -> None:
@ -283,12 +326,26 @@ def update_thespacedevs(config: flask.config.Config) -> None:
t0 = time() t0 = time()
data = agenda.thespacedevs.next_launch_api_data(rocket_dir) data = agenda.thespacedevs.next_launch_api_data(rocket_dir)
if not data: if not data:
send_thespacedevs_payload_alert(
config,
reason="API request failed or returned invalid JSON",
data=None,
)
return # thespacedevs API call failed return # thespacedevs API call failed
data_results = data.get("results")
if not isinstance(data_results, list):
send_thespacedevs_payload_alert(
config,
reason="response missing top-level results list",
data=data,
)
return
# Identify test-flight slugs present in the current data # Identify test-flight slugs present in the current data
cur_test_slugs: set[str] = { cur_test_slugs: set[str] = {
typing.cast(str, item["slug"]) typing.cast(str, item["slug"])
for item in data.get("results", []) for item in data_results
if is_test_flight(typing.cast(StrDict, item)) if is_test_flight(typing.cast(StrDict, item))
} }
@ -317,7 +374,7 @@ def update_thespacedevs(config: flask.config.Config) -> None:
time_taken = time() - t0 time_taken = time() - t0
if not sys.stdin.isatty(): if not sys.stdin.isatty():
return return
rockets = [agenda.thespacedevs.summarize_launch(item) for item in data["results"]] rockets = [agenda.thespacedevs.summarize_launch(item) for item in data_results]
print(len(rockets), "launches") print(len(rockets), "launches")
print(len(active_crewed or []), "active crewed missions") print(len(active_crewed or []), "active crewed missions")
print(f"took {time_taken:.1f} seconds") print(f"took {time_taken:.1f} seconds")

View file

@ -177,11 +177,22 @@ async def recent() -> str:
@app.route("/launches") @app.route("/launches")
def launch_list() -> str: def launch_list() -> str:
"""Web page showing List of space launches.""" """Web page showing List of space launches."""
now = datetime.now() now = datetime.now(timezone.utc)
data_dir = app.config["DATA_DIR"] data_dir = app.config["DATA_DIR"]
rocket_dir = os.path.join(data_dir, "thespacedevs") rocket_dir = os.path.join(data_dir, "thespacedevs")
launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100) launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100)
assert launches assert launches
active_crewed = agenda.thespacedevs.get_active_crewed_flights(rocket_dir) or []
active_crewed_slugs = {
launch["slug"]
for launch in active_crewed
if isinstance(launch.get("slug"), str)
}
for launch in launches:
launch_net = agenda.thespacedevs.parse_api_datetime(launch.get("net"))
launch["is_future"] = bool(launch_net and launch_net > now)
launch["is_active_crewed"] = launch.get("slug") in active_crewed_slugs
mission_type_filter = flask.request.args.get("type") mission_type_filter = flask.request.args.get("type")
rocket_filter = flask.request.args.get("rocket") rocket_filter = flask.request.args.get("rocket")