feat(frontend): add basic frontend
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import type { WsStatus } from "../model/types";
|
||||
|
||||
export type LiveWsClientHandlers = {
|
||||
onStatus: (status: WsStatus) => void;
|
||||
onMessage: (data: unknown) => void;
|
||||
};
|
||||
|
||||
export class LiveWsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private closedByUser = false;
|
||||
|
||||
private retryAttempt = 0;
|
||||
private retryTimer: number | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: LiveWsClientHandlers,
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
this.closedByUser = false;
|
||||
this.open("connecting");
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closedByUser = true;
|
||||
this.clearRetry();
|
||||
this.handlers.onStatus("disconnected");
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private open(status: WsStatus): void {
|
||||
this.handlers.onStatus(status);
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(this.url);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.retryAttempt = 0;
|
||||
this.handlers.onStatus("open");
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
this.handlers.onMessage(ev.data);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// let onclose handle reconnect
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
if (this.closedByUser) return;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.handlers.onStatus("reconnecting");
|
||||
|
||||
// backoff: 0.5s, 1s, 2s, 4s ... max 10s (+ jitter)
|
||||
const base = 500 * Math.pow(2, this.retryAttempt);
|
||||
const capped = Math.min(base, 10_000);
|
||||
const jitter = capped * (Math.random() * 0.2); // 0..20%
|
||||
const delay = Math.round(capped + jitter);
|
||||
|
||||
this.retryAttempt += 1;
|
||||
|
||||
this.clearRetry();
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
if (this.closedByUser) return;
|
||||
this.open("reconnecting");
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearRetry(): void {
|
||||
if (this.retryTimer !== null) {
|
||||
window.clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||
import { parseIsoToMs } from "../../../shared/lib/time";
|
||||
|
||||
type RawMessage = {
|
||||
time: unknown;
|
||||
rms_db: unknown;
|
||||
freq_hz: unknown;
|
||||
};
|
||||
|
||||
export type ParseResult =
|
||||
| { ok: true; sample: AudioSample }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function devWarn(...args: unknown[]) {
|
||||
if (import.meta.env.DEV) console.warn(...args);
|
||||
}
|
||||
|
||||
export function parseAndValidateMessage(data: unknown): ParseResult {
|
||||
let obj: RawMessage;
|
||||
try {
|
||||
obj = JSON.parse(String(data)) as RawMessage;
|
||||
} catch {
|
||||
devWarn("[liveStream] drop: invalid JSON", data);
|
||||
return { ok: false, reason: "invalid_json" };
|
||||
}
|
||||
|
||||
if (typeof obj.time !== "string") {
|
||||
devWarn("[liveStream] drop: time is not string", obj);
|
||||
return { ok: false, reason: "invalid_time_type" };
|
||||
}
|
||||
|
||||
const timeMs = parseIsoToMs(obj.time);
|
||||
if (timeMs === null) {
|
||||
devWarn("[liveStream] drop: time is not ISO", obj.time);
|
||||
return { ok: false, reason: "invalid_time_value" };
|
||||
}
|
||||
|
||||
const rms = typeof obj.rms_db === "number" ? obj.rms_db : Number.NaN;
|
||||
const freq = typeof obj.freq_hz === "number" ? obj.freq_hz : Number.NaN;
|
||||
|
||||
if (!Number.isFinite(rms) || rms < -50 || rms > 0) {
|
||||
devWarn("[liveStream] drop: rms_db out of range", rms);
|
||||
return { ok: false, reason: "rms_out_of_range" };
|
||||
}
|
||||
|
||||
if (!Number.isFinite(freq) || freq < 20 || freq > 8000) {
|
||||
devWarn("[liveStream] drop: freq_hz out of range", freq);
|
||||
return { ok: false, reason: "freq_out_of_range" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sample: {
|
||||
time: obj.time,
|
||||
timeMs,
|
||||
rms_db: rms,
|
||||
freq_hz: freq,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { create } from "zustand";
|
||||
import type { AudioSample } from "../../../entities/audioSample/model/types";
|
||||
import { env } from "../../../shared/config/env";
|
||||
import { RingBuffer } from "../../../shared/lib/ringBuffer";
|
||||
import type { WsStatus } from "./types";
|
||||
import { LiveWsClient } from "../lib/liveWsClient";
|
||||
import { parseAndValidateMessage } from "../lib/parseAndValidate";
|
||||
|
||||
type LiveStreamState = {
|
||||
status: WsStatus;
|
||||
lastMessageAt: number | null;
|
||||
|
||||
latest: AudioSample | null;
|
||||
|
||||
// snapshot for UI
|
||||
// storage is ring buffer inside module
|
||||
history60s: AudioSample[];
|
||||
peakHoldDb3s: number | null;
|
||||
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
loadLatest: (limit?: number) => Promise<void>;
|
||||
};
|
||||
|
||||
const HISTORY_POINTS = 600; // 60s * 10Hz
|
||||
const PEAK_WINDOW_MS = 3000;
|
||||
|
||||
const historyRing = new RingBuffer<AudioSample>(HISTORY_POINTS);
|
||||
let peakWindow: AudioSample[] = []; // time-based window
|
||||
let client: LiveWsClient | null = null;
|
||||
|
||||
function computePeakDb(windowSamples: AudioSample[]): number | null {
|
||||
if (windowSamples.length === 0) return null;
|
||||
let max = -Infinity;
|
||||
for (const s of windowSamples) max = Math.max(max, s.rms_db);
|
||||
return Number.isFinite(max) ? max : null;
|
||||
}
|
||||
|
||||
export const useLiveStreamStore = create<LiveStreamState>()((set, get) => ({
|
||||
status: "disconnected",
|
||||
lastMessageAt: null,
|
||||
|
||||
latest: null,
|
||||
history60s: [],
|
||||
peakHoldDb3s: null,
|
||||
|
||||
connect: () => {
|
||||
if (client) return;
|
||||
|
||||
client = new LiveWsClient(env.wsUrl, {
|
||||
onStatus: (st) => set({ status: st }),
|
||||
onMessage: (data) => {
|
||||
const parsed = parseAndValidateMessage(data);
|
||||
if (!parsed.ok) return;
|
||||
|
||||
const sample = parsed.sample;
|
||||
|
||||
// 60s ring
|
||||
historyRing.push(sample);
|
||||
|
||||
// 3s peak window
|
||||
const now = Date.now();
|
||||
peakWindow.push(sample);
|
||||
const cutoff = now - PEAK_WINDOW_MS;
|
||||
while (peakWindow.length && peakWindow[0]!.timeMs < cutoff) {
|
||||
peakWindow.shift();
|
||||
}
|
||||
|
||||
set({
|
||||
latest: sample,
|
||||
history60s: historyRing.toArray(),
|
||||
peakHoldDb3s: computePeakDb(peakWindow),
|
||||
lastMessageAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
client.connect();
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
client?.close();
|
||||
client = null;
|
||||
peakWindow = [];
|
||||
// historyRing intentionally kept:
|
||||
// UI/график не прыгает при дисконнекте
|
||||
set({ status: "disconnected" });
|
||||
},
|
||||
|
||||
loadLatest: async (limit = 100) => {
|
||||
// optional warm-up for chart after refresh
|
||||
const url = `${env.apiUrl.replace(/\/$/, "")}/api/v1/audio/latest?limit=${limit}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
|
||||
const raw = (await res.json()) as Array<{
|
||||
time: string;
|
||||
rms_db: number;
|
||||
freq_hz: number;
|
||||
}>;
|
||||
for (const item of raw) {
|
||||
const parsed = parseAndValidateMessage(JSON.stringify(item));
|
||||
if (!parsed.ok) continue;
|
||||
historyRing.push(parsed.sample);
|
||||
}
|
||||
set({ history60s: historyRing.toArray() });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
}));
|
||||
1
services/frontend/src/features/liveStream/model/types.ts
Normal file
1
services/frontend/src/features/liveStream/model/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type WsStatus = "connecting" | "open" | "reconnecting" | "disconnected";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Badge } from "../../../shared/ui/badge";
|
||||
import type { WsStatus } from "../model/types";
|
||||
|
||||
const toneByStatus: Record<WsStatus, string> = {
|
||||
connecting: "bg-slate-600",
|
||||
open: "bg-emerald-600",
|
||||
reconnecting: "bg-amber-600",
|
||||
disconnected: "bg-rose-600",
|
||||
};
|
||||
|
||||
export function WsStatusBadge({ status }: { status: WsStatus }) {
|
||||
return (
|
||||
<Badge className={`${toneByStatus[status]} text-white`}>ws: {status}</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLiveStreamStore } from "../model/liveStream.store";
|
||||
|
||||
export function useLiveStream(options?: { loadLatest?: boolean }) {
|
||||
const connect = useLiveStreamStore((s) => s.connect);
|
||||
const disconnect = useLiveStreamStore((s) => s.disconnect);
|
||||
const loadLatest = useLiveStreamStore((s) => s.loadLatest);
|
||||
|
||||
useEffect(() => {
|
||||
if (options?.loadLatest) void loadLatest(100);
|
||||
connect();
|
||||
return () => disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user