mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 12:58:11 +02:00
5e0b2c037e
On 2026-05-23, stream.aisstream.io went fully offline (TCP timeouts on port
443). The backend kept respawning the node WebSocket proxy every few
seconds with nothing arriving. From the operator's POV the ships layer
silently went empty — no banner, no log surfacing, no way to tell whether
it was their config / network / viewport filter / upstream.
Backend:
* ais_proxy_status() now also returns:
- connected (bool): true when a vessel message arrived in last 60s
- last_msg_age_seconds (int | None)
- proxy_spawn_count (int): proxy respawns — sustained growth without
connected means upstream is dead
* /api/health escalates top status to "degraded" when AIS_API_KEY is set
but the proxy is currently disconnected. Existing degraded_tls signal
preserved.
Frontend:
* useAisUpstreamHealth hook polls /api/health every 30s, derives the
outage state. Defensively only reports outage once spawn_count > 0 so
operators who haven't opted in don't see the banner.
* AisUpstreamBanner component renders a dismissible amber notice
"Ship data temporarily unavailable — AISStream upstream is offline"
mounted on the main app shell.
7 backend tests pin the status-shape contract and the /api/health
escalation behavior in both with-key and without-key configurations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
62 lines
2.1 KiB
TypeScript
62 lines
2.1 KiB
TypeScript
/**
|
|
* AisUpstreamBanner — visible notice that AIS ship data is unavailable
|
|
* because the upstream provider (AISStream) is offline.
|
|
*
|
|
* Renders nothing when AIS is healthy or when AIS isn't configured at all.
|
|
* Mounted at the app shell level so users see it before they wonder why
|
|
* the ocean looks empty.
|
|
*/
|
|
import { useState } from 'react';
|
|
import { useAisUpstreamHealth } from '@/hooks/useAisUpstreamHealth';
|
|
|
|
export function AisUpstreamBanner() {
|
|
const health = useAisUpstreamHealth();
|
|
const [dismissed, setDismissed] = useState(false);
|
|
|
|
if (!health || !health.aisEnabled || health.connected || dismissed) {
|
|
return null;
|
|
}
|
|
|
|
// Format the staleness for the operator. ``null`` means we never received
|
|
// anything since startup; otherwise show minutes if > 60s.
|
|
let stalenessLabel = 'never received';
|
|
if (health.lastMsgAgeSeconds != null) {
|
|
const minutes = Math.floor(health.lastMsgAgeSeconds / 60);
|
|
if (minutes >= 1) {
|
|
stalenessLabel = `last update ${minutes} min ago`;
|
|
} else {
|
|
stalenessLabel = `last update ${health.lastMsgAgeSeconds}s ago`;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
className="pointer-events-auto fixed top-3 left-1/2 z-[100] -translate-x-1/2 max-w-[640px] rounded-md border border-amber-500/60 bg-amber-900/85 px-4 py-2 text-sm text-amber-50 shadow-lg backdrop-blur"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<span aria-hidden className="mt-0.5 text-amber-300">⚠</span>
|
|
<div className="flex-1">
|
|
<div className="font-semibold">Ship data temporarily unavailable</div>
|
|
<div className="text-xs opacity-90">
|
|
AISStream upstream is offline ({stalenessLabel}). The map will
|
|
refill once their service comes back online — nothing is wrong
|
|
with your install.
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDismissed(true)}
|
|
aria-label="Dismiss"
|
|
className="text-amber-200 hover:text-white"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AisUpstreamBanner;
|