telemetry-dashboard/src/lib/store.svelte.ts
2026-07-04 03:23:46 +09:00

148 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Connection + sample store. Owns the WebSocket, an optional REST history
* prefetch, auto-reconnect with backoff, and the growing sample array.
* Mirrors the pattern of leaflet_svelte's telemetryStore.svelte.ts.
*/
import { parsePacket, historyUrlFromWs, type RawPacket, type Sample } from './telemetry';
export type ConnStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
const LS_KEY = 'tdash.wsUrl';
const RECONNECT_MAX_S = 30;
class TelemetryStore {
wsUrl = $state(
localStorage.getItem(LS_KEY) ??
(import.meta.env.VITE_DEFAULT_WS_URL as string | undefined) ??
'ws://localhost:8000/api/ws/satellite/<uuid>/telemetry/',
);
status = $state<ConnStatus>('idle');
error = $state<string | null>(null);
samples = $state<Sample[]>([]);
packetsReceived = $state(0);
/** Ticks once a second — drives the "age of last packet" display. */
nowS = $state(Math.floor(Date.now() / 1000));
#ws: WebSocket | null = null;
#reconnectTimer: ReturnType<typeof setTimeout> | null = null;
#backoffS = 1;
#wantConnected = false;
constructor() {
setInterval(() => (this.nowS = Math.floor(Date.now() / 1000)), 1000);
}
get latest(): Sample | null {
return this.samples[this.samples.length - 1] ?? null;
}
/** Seconds since the last packet arrived (wall clock), or null before data. */
get lastPacketAge(): number | null {
const last = this.latest;
if (!last) return null;
return Math.max(0, this.nowS - (this.#lastRxS ?? last.t));
}
#lastRxS: number | null = null;
async connect(): Promise<void> {
const url = this.wsUrl.trim();
if (!/^wss?:\/\//.test(url)) {
this.status = 'error';
this.error = 'Адрес должен начинаться с ws:// или wss://';
return;
}
localStorage.setItem(LS_KEY, url);
this.disconnect();
this.#wantConnected = true;
this.samples = [];
this.packetsReceived = 0;
this.error = null;
this.status = 'connecting';
// History prefetch — best-effort, only for the known stratoflights URL shape.
const histUrl = historyUrlFromWs(url);
if (histUrl) {
try {
const res = await fetch(histUrl);
if (res.ok) {
const body = (await res.json()) as RawPacket[] | { results: RawPacket[] };
const list = Array.isArray(body) ? body : (body.results ?? []);
// API returns newest-first; reverse to chronological order.
this.samples = [...list]
.reverse()
.map(parsePacket)
.filter((s): s is Sample => s !== null);
}
} catch {
// non-fatal: live stream still works without history
}
}
if (!this.#wantConnected) return; // disconnected while fetching history
this.#open(url);
}
#open(url: string): void {
const ws = new WebSocket(url);
this.#ws = ws;
ws.onopen = () => {
this.status = 'connected';
this.error = null;
this.#backoffS = 1;
};
ws.onmessage = ({ data }) => {
try {
const raw = JSON.parse(data) as { error?: unknown } & RawPacket;
if (raw.error) {
this.error = String(raw.error);
return;
}
const sample = parsePacket(raw);
if (sample) {
// ponytail: uPlot needs strictly ascending x — drop out-of-order packets
const last = this.latest;
if (!last || sample.t > last.t) {
this.samples.push(sample);
}
this.packetsReceived++;
this.#lastRxS = Math.floor(Date.now() / 1000);
}
} catch {
// ignore malformed frames
}
};
ws.onclose = () => {
this.#ws = null;
if (!this.#wantConnected) {
this.status = 'idle';
return;
}
// Unwanted close (server restart, network drop) — auto-reconnect.
this.status = 'reconnecting';
this.#reconnectTimer = setTimeout(() => {
if (this.#wantConnected) this.#open(url);
}, this.#backoffS * 1000);
this.#backoffS = Math.min(this.#backoffS * 2, RECONNECT_MAX_S);
};
ws.onerror = () => {
if (this.status === 'connecting') {
this.error = 'Не удалось подключиться';
}
};
}
disconnect(): void {
this.#wantConnected = false;
if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
this.#ws?.close();
this.#ws = null;
this.status = 'idle';
}
}
export const store = new TelemetryStore();