feat: add realtime GNSS monitor with telemetry websocket
All checks were successful
Deploy to Production / deploy (push) Successful in 1m10s

This commit is contained in:
魏风
2026-03-20 11:48:04 +08:00
parent e8846f4831
commit e81aadcd99
16 changed files with 1951 additions and 22 deletions

View File

@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, TelemetryReadTelemetryLatestData, TelemetryReadTelemetryLatestResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class LocationsService {
/**
@@ -235,6 +235,31 @@ export class PrivateService {
}
}
export class TelemetryService {
/**
* Read Telemetry Latest
* Return the latest parsed GNSS telemetry snapshot.
* @param data The data for the request.
* @param data.deviceId
* @param data.limit
* @returns unknown Successful Response
* @throws ApiError
*/
public static readTelemetryLatest(data: TelemetryReadTelemetryLatestData = {}): CancelablePromise<TelemetryReadTelemetryLatestResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/telemetry/latest',
query: {
device_id: data.deviceId,
limit: data.limit
},
errors: {
422: 'Validation Error'
}
});
}
}
export class UsersService {
/**
* Read Users

View File

@@ -177,6 +177,15 @@ export type PrivateCreateUserData = {
export type PrivateCreateUserResponse = (UserPublic);
export type TelemetryReadTelemetryLatestData = {
deviceId?: (string | null);
limit?: number;
};
export type TelemetryReadTelemetryLatestResponse = ({
[key: string]: unknown;
});
export type UsersReadUsersData = {
limit?: number;
skip?: number;

View File

@@ -1,4 +1,4 @@
import { Home, MapPin, Users } from "lucide-react"
import { Home, MapPin, Satellite, Users } from "lucide-react"
import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo"
@@ -15,6 +15,7 @@ import { User } from "./User"
const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" },
{ icon: MapPin, title: "Locations", path: "/locations" },
{ icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" },
]
export function AppSidebar() {

View File

@@ -17,6 +17,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
import { Route as LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor'
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
const SignupRoute = SignupRouteImport.update({
@@ -58,6 +59,11 @@ const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
path: '/locations',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutGnssMonitorRoute = LayoutGnssMonitorRouteImport.update({
id: '/gnss-monitor',
path: '/gnss-monitor',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutAdminRoute = LayoutAdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/gnss-monitor': typeof LayoutGnssMonitorRoute
'/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute
}
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute
'/gnss-monitor': typeof LayoutGnssMonitorRoute
'/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute
@@ -92,6 +100,7 @@ export interface FileRoutesById {
'/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute
'/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute
'/_layout/locations': typeof LayoutLocationsRoute
'/_layout/settings': typeof LayoutSettingsRoute
'/_layout/': typeof LayoutIndexRoute
@@ -105,6 +114,7 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/admin'
| '/gnss-monitor'
| '/locations'
| '/settings'
fileRoutesByTo: FileRoutesByTo
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/admin'
| '/gnss-monitor'
| '/locations'
| '/settings'
| '/'
@@ -125,6 +136,7 @@ export interface FileRouteTypes {
| '/reset-password'
| '/signup'
| '/_layout/admin'
| '/_layout/gnss-monitor'
| '/_layout/locations'
| '/_layout/settings'
| '/_layout/'
@@ -196,6 +208,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutLocationsRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/gnss-monitor': {
id: '/_layout/gnss-monitor'
path: '/gnss-monitor'
fullPath: '/gnss-monitor'
preLoaderRoute: typeof LayoutGnssMonitorRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/admin': {
id: '/_layout/admin'
path: '/admin'
@@ -208,6 +227,7 @@ declare module '@tanstack/react-router' {
interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute
LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute
LayoutLocationsRoute: typeof LayoutLocationsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute
@@ -215,6 +235,7 @@ interface LayoutRouteChildren {
const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute,
LayoutGnssMonitorRoute: LayoutGnssMonitorRoute,
LayoutLocationsRoute: LayoutLocationsRoute,
LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute,

View File

@@ -1,4 +1,9 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import {
createFileRoute,
Outlet,
redirect,
useRouterState,
} from "@tanstack/react-router"
import { Footer } from "@/components/Common/Footer"
import AppSidebar from "@/components/Sidebar/AppSidebar"
@@ -21,6 +26,11 @@ export const Route = createFileRoute("/_layout")({
})
function Layout() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const isGnssMonitorPage = pathname === "/gnss-monitor"
return (
<SidebarProvider>
<AppSidebar />
@@ -29,7 +39,9 @@ function Layout() {
<SidebarTrigger className="-ml-1 text-muted-foreground" />
</header>
<main className="flex-1 p-6 md:p-8">
<div className="mx-auto max-w-7xl">
<div
className={`mx-auto ${isGnssMonitorPage ? "max-w-none" : "max-w-7xl"}`}
>
<Outlet />
</div>
</main>

View File

@@ -0,0 +1,826 @@
import { createFileRoute } from "@tanstack/react-router"
import { Compass, Gauge, MapPin, Radio, Satellite, Signal } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type SignalHealthLevel = "good" | "fair" | "poor" | "unknown"
type SignalHealth = {
level: SignalHealthLevel
score: number
strong_signal_count?: number
average_snr_dbhz: number | null
best_snr_dbhz: number | null
message: string
}
type SatellitePoint = {
satellite_id: string
constellation: string
azimuth_deg: number | null
elevation_deg: number | null
snr_dbhz: number | null
used_in_navigation: boolean
}
type SatelliteSummary = {
satellites_in_view: number | null
snr_values: number[]
constellations: Record<
string,
{ count?: number; with_signal?: number; used_in_navigation?: number }
>
used_satellite_ids: string[]
satellites: SatellitePoint[]
}
type TelemetryDevice = {
device_id: string
last_event_time: string | null
updated_at: string
fix_status: string | null
lat: number | null
lon: number | null
alt_m: number | null
satellites_used: number | null
satellite_signal_summary: SatelliteSummary
signal_health: SignalHealth
}
type TelemetrySnapshot = {
generated_at: string
devices: TelemetryDevice[]
}
const CONSTELLATION_COLORS: Record<string, string> = {
GPS: "#f59e0b",
SBAS: "#9ca3af",
BeiDou: "#8b5cf6",
Galileo: "#84cc16",
GLONASS: "#06b6d4",
QZSS: "#f97316",
NavIC: "#ec4899",
Mixed: "#64748b",
Unknown: "#94a3b8",
}
const CONSTELLATION_ORDER = [
"GPS",
"SBAS",
"Galileo",
"BeiDou",
"GLONASS",
"QZSS",
"NavIC",
"Mixed",
"Unknown",
] as const
const CONSTELLATION_PREFIX: Record<string, string> = {
GPS: "G",
SBAS: "S",
Galileo: "E",
BeiDou: "B",
GLONASS: "R",
QZSS: "Q",
NavIC: "I",
Mixed: "M",
Unknown: "U",
}
const SIGNAL_STATUS_CLASS: Record<SignalHealthLevel, string> = {
good: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
fair: "text-amber-500 bg-amber-500/10 border-amber-500/20",
poor: "text-red-500 bg-red-500/10 border-red-500/20",
unknown: "text-muted-foreground bg-muted border-border",
}
const FIX_STATUS_META: Record<
string,
{ label: string; className: string; description: string }
> = {
"0": {
label: "No Fix",
className: "text-red-500 bg-red-500/10 border-red-500/20",
description: "No position solution",
},
"1": {
label: "Single",
className: "text-amber-500 bg-amber-500/10 border-amber-500/20",
description: "Standalone GNSS fix",
},
"2": {
label: "DGPS",
className: "text-sky-500 bg-sky-500/10 border-sky-500/20",
description: "Differential correction active",
},
"4": {
label: "RTK Fixed",
className: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
description: "Carrier fixed solution",
},
"5": {
label: "RTK Float",
className: "text-orange-500 bg-orange-500/10 border-orange-500/20",
description: "Carrier float solution",
},
A: {
label: "Valid",
className: "text-sky-500 bg-sky-500/10 border-sky-500/20",
description: "Valid navigation status",
},
V: {
label: "Invalid",
className: "text-red-500 bg-red-500/10 border-red-500/20",
description: "Invalid navigation status",
},
}
export const Route = createFileRoute("/_layout/gnss-monitor")({
component: GnssMonitor,
head: () => ({
meta: [
{
title: "GNSS Monitor - FastAPI Template",
},
],
}),
})
function buildTelemetryWsUrl(token: string): string {
const apiBase = (
import.meta.env.VITE_API_URL || window.location.origin
).replace(/\/$/, "")
const wsBase = apiBase.startsWith("https://")
? `wss://${apiBase.slice("https://".length)}`
: apiBase.startsWith("http://")
? `ws://${apiBase.slice("http://".length)}`
: apiBase
return `${wsBase}/api/v1/telemetry/ws?token=${encodeURIComponent(token)}`
}
function formatCoord(value: number | null): string {
if (value === null) {
return "-"
}
return value.toFixed(6)
}
function formatTime(value: string | null): string {
if (!value) {
return "-"
}
return new Date(value).toLocaleTimeString()
}
function formatAge(value: string | null): string {
if (!value) {
return "-"
}
const diffSeconds = Math.max(
0,
Math.floor((Date.now() - new Date(value).getTime()) / 1000),
)
if (diffSeconds < 60) {
return `${diffSeconds}s`
}
const minutes = Math.floor(diffSeconds / 60)
const seconds = diffSeconds % 60
return `${minutes}m ${seconds}s`
}
function getFixMeta(fixStatus: string | null) {
if (!fixStatus) {
return {
label: "Unknown",
className: "text-muted-foreground bg-muted border-border",
description: "No fix state received yet",
}
}
return (
FIX_STATUS_META[fixStatus] ?? {
label: `Fix ${fixStatus}`,
className: "text-muted-foreground bg-muted border-border",
description: "Unrecognized fix code",
}
)
}
function satColor(constellation: string): string {
return CONSTELLATION_COLORS[constellation] ?? CONSTELLATION_COLORS.Unknown
}
function satPrefix(constellation: string): string {
return CONSTELLATION_PREFIX[constellation] ?? CONSTELLATION_PREFIX.Unknown
}
function satelliteSortValue(satelliteId: string): number {
const digits = satelliteId.replace(/\D+/g, "")
return digits ? Number(digits) : Number.POSITIVE_INFINITY
}
function satelliteName(satellite: SatellitePoint): string {
const satId = satellite.satellite_id.trim()
if (/^[A-Za-z]/.test(satId)) {
return satId
}
return `${satPrefix(satellite.constellation)}${satId}`
}
function SignalBars({ satellites }: { satellites: SatellitePoint[] }) {
const withSnr = satellites.filter(
(sat) => sat.snr_dbhz !== null && sat.snr_dbhz > 0,
)
if (withSnr.length === 0) {
return (
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground border rounded-lg">
No SNR data yet
</div>
)
}
const grouped = CONSTELLATION_ORDER.map((name) => ({
name,
satellites: withSnr
.filter((sat) => sat.constellation === name)
.sort(
(a, b) =>
satelliteSortValue(a.satellite_id) -
satelliteSortValue(b.satellite_id),
),
})).filter((group) => group.satellites.length > 0)
const unknownGroup = withSnr
.filter(
(sat) =>
!CONSTELLATION_ORDER.includes(
sat.constellation as (typeof CONSTELLATION_ORDER)[number],
),
)
.sort(
(a, b) =>
satelliteSortValue(a.satellite_id) - satelliteSortValue(b.satellite_id),
)
if (unknownGroup.length > 0) {
grouped.push({ name: "Unknown", satellites: unknownGroup })
}
const bars = grouped.flatMap((group, groupIndex) =>
group.satellites.map((satellite, index) => ({
groupName: group.name,
groupStart: groupIndex > 0 && index === 0,
satellite,
})),
)
const columnStyle = {
gridTemplateColumns: `repeat(${bars.length}, minmax(0, 1fr))`,
} as const
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{grouped.map((group) => (
<div
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5"
key={group.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: satColor(group.name) }}
/>
<span>
{group.name} ({satPrefix(group.name)})
</span>
</div>
))}
<div className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<span className="size-2 rounded-full border-2 border-blue-500" />
<span>Not used in navigation</span>
</div>
</div>
<div className="flex gap-2 min-w-0">
<div className="w-10 h-[200px] text-[11px] text-muted-foreground shrink-0 flex flex-col justify-between">
{[50, 40, 30, 20, 10, 0].map((tick) => (
<span className="leading-none text-right" key={tick}>
{tick}
</span>
))}
</div>
<div className="relative h-[250px] flex-1 min-w-0">
<div className="absolute inset-0 pointer-events-none">
{[0, 10, 20, 30, 40, 50].map((tick) => (
<div
className="absolute left-0 right-0 border-t border-dashed border-border/70"
key={tick}
style={{ bottom: `${(tick / 50) * 200}px` }}
/>
))}
</div>
<div
className="relative h-full grid gap-x-1 min-w-0"
style={columnStyle}
>
{bars.map((entry) => {
const snr = entry.satellite.snr_dbhz ?? 0
const color = satColor(entry.groupName)
const height = Math.max(Math.round((snr / 50) * 200), 4)
return (
<div
className={`min-w-0 ${entry.groupStart ? "border-l border-border/80 pl-1" : ""}`}
key={`${entry.groupName}-${entry.satellite.satellite_id}`}
>
<div className="h-[200px] flex items-end justify-center">
<div
className="w-full max-w-4 rounded-sm border"
style={{
height: `${height}px`,
backgroundColor: entry.satellite.used_in_navigation
? color
: `${color}33`,
borderColor: entry.satellite.used_in_navigation
? color
: "#2563eb",
borderWidth: entry.satellite.used_in_navigation
? "1px"
: "2px",
}}
title={`${satelliteName(entry.satellite)}${entry.groupName}${snr} dB-Hz`}
/>
</div>
<div className="mt-1 flex flex-col items-center leading-none gap-1">
<span className="text-[10px] text-center whitespace-nowrap">
{satelliteName(entry.satellite)}
</span>
<span className="text-[10px] text-muted-foreground text-center">
{snr}
</span>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
function SkyPlot({ satellites }: { satellites: SatellitePoint[] }) {
const points = satellites.filter(
(sat) =>
sat.azimuth_deg !== null &&
sat.elevation_deg !== null &&
sat.elevation_deg >= 0 &&
sat.elevation_deg <= 90,
)
return (
<div className="relative w-full max-w-[420px] mx-auto aspect-square rounded-full border bg-muted/20">
<div className="absolute inset-[16%] rounded-full border border-dashed border-border" />
<div className="absolute inset-[33%] rounded-full border border-dashed border-border" />
<div className="absolute inset-[50%] rounded-full border border-dashed border-border" />
<div className="absolute left-1/2 top-0 -translate-x-1/2 text-xs text-muted-foreground">
N
</div>
<div className="absolute left-1/2 bottom-0 -translate-x-1/2 text-xs text-muted-foreground">
S
</div>
<div className="absolute left-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
W
</div>
<div className="absolute right-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
E
</div>
{points.map((satellite) => {
const azimuth = satellite.azimuth_deg ?? 0
const elevation = satellite.elevation_deg ?? 0
const radialPercent = ((90 - elevation) / 90) * 45
const angle = ((azimuth - 90) * Math.PI) / 180
const x = Math.cos(angle) * radialPercent
const y = Math.sin(angle) * radialPercent
const color = satColor(satellite.constellation)
return (
<div
className="absolute size-4 rounded-full border-2"
key={`${satellite.constellation}-${satellite.satellite_id}-sky`}
style={{
left: `${50 + x}%`,
top: `${50 + y}%`,
transform: "translate(-50%, -50%)",
backgroundColor: color,
borderColor: satellite.used_in_navigation
? color
: "var(--border)",
opacity: satellite.used_in_navigation ? 1 : 0.5,
}}
title={`${satellite.constellation}-${satellite.satellite_id} (${satellite.snr_dbhz ?? "-"} dB-Hz)`}
/>
)
})}
</div>
)
}
function ConnectionBadge({
status,
}: {
status: "connecting" | "live" | "retrying"
}) {
const statusText =
status === "live"
? "Live"
: status === "retrying"
? "Reconnecting"
: "Connecting"
const statusClass =
status === "live"
? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20"
: "text-amber-500 bg-amber-500/10 border-amber-500/20"
return (
<Badge variant="outline" className={statusClass}>
<Radio className="size-3" />
{statusText}
</Badge>
)
}
function GnssMonitor() {
const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)
const [status, setStatus] = useState<"connecting" | "live" | "retrying">(
"connecting",
)
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("")
useEffect(() => {
const token = localStorage.getItem("access_token")
if (!token) {
return
}
let reconnectTimer: number | undefined
let socket: WebSocket | undefined
let shouldReconnect = true
const connect = () => {
setStatus((prev) => (prev === "live" ? prev : "connecting"))
socket = new WebSocket(buildTelemetryWsUrl(token))
socket.onopen = () => {
setStatus("live")
}
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as TelemetrySnapshot
setSnapshot(payload)
setStatus("live")
} catch {
setStatus("retrying")
}
}
socket.onclose = () => {
if (!shouldReconnect) {
return
}
setStatus("retrying")
reconnectTimer = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
connect()
return () => {
shouldReconnect = false
if (reconnectTimer) {
window.clearTimeout(reconnectTimer)
}
socket?.close()
}
}, [])
useEffect(() => {
if (!snapshot?.devices.length) {
setSelectedDeviceId("")
return
}
const exists = snapshot.devices.some(
(device) => device.device_id === selectedDeviceId,
)
if (!exists) {
setSelectedDeviceId(snapshot.devices[0].device_id)
}
}, [snapshot, selectedDeviceId])
const currentDevice = useMemo(() => {
if (!snapshot?.devices.length) {
return null
}
return (
snapshot.devices.find(
(device) => device.device_id === selectedDeviceId,
) ?? snapshot.devices[0]
)
}, [selectedDeviceId, snapshot])
const fixMeta = getFixMeta(currentDevice?.fix_status ?? null)
const satellites = currentDevice?.satellite_signal_summary.satellites ?? []
const constellations =
currentDevice?.satellite_signal_summary.constellations ?? {}
const signalHealth = currentDevice?.signal_health
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">GNSS Monitor</h1>
<p className="text-muted-foreground">
Real-time positioning and satellite signal watch
</p>
</div>
<div className="flex items-center gap-3">
<ConnectionBadge status={status} />
<Badge variant="outline">
{snapshot?.generated_at
? `Updated ${formatTime(snapshot.generated_at)}`
: "Waiting for stream"}
</Badge>
</div>
</div>
<Card>
<CardHeader className="gap-3">
<CardTitle>Device</CardTitle>
<CardDescription>
Choose a device to inspect live fix and satellite conditions
</CardDescription>
</CardHeader>
<CardContent>
<select
className="h-10 rounded-md border bg-background px-3 text-sm min-w-[280px]"
onChange={(event) => setSelectedDeviceId(event.target.value)}
value={selectedDeviceId}
>
{snapshot?.devices.map((device) => (
<option key={device.device_id} value={device.device_id}>
{device.device_id}
</option>
))}
</select>
</CardContent>
</Card>
{!currentDevice ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
Waiting for MQTT parsed telemetry...
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Gauge className="size-4" />
Fix Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge variant="outline" className={fixMeta.className}>
{fixMeta.label}
</Badge>
<p className="text-sm text-muted-foreground">
{fixMeta.description}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Signal className="size-4" />
Signal Health
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge
variant="outline"
className={
SIGNAL_STATUS_CLASS[signalHealth?.level ?? "unknown"]
}
>
{(signalHealth?.level ?? "unknown").toUpperCase()}
</Badge>
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>Score</span>
<span className="font-medium">
{signalHealth?.score ?? 0}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${signalHealth?.score ?? 0}%` }}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{signalHealth?.message ?? "No signal data"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Satellite className="size-4" />
Satellites
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Used in fix</span>
<span>{currentDevice.satellites_used ?? "-"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">In view</span>
<span>
{currentDevice.satellite_signal_summary
.satellites_in_view ?? "-"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Best SNR</span>
<span>{signalHealth?.best_snr_dbhz ?? "-"} dB-Hz</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-4" />
Position
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Latitude</span>
<span>{formatCoord(currentDevice.lat)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Longitude</span>
<span>{formatCoord(currentDevice.lon)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Altitude</span>
<span>
{currentDevice.alt_m === null
? "-"
: `${currentDevice.alt_m.toFixed(2)} m`}
</span>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Signal className="size-4" />
Satellite Signal View
</CardTitle>
<CardDescription>
Grouped by constellation, with per-satellite CN0 bars and clear
non-navigation marking.
</CardDescription>
</CardHeader>
<CardContent>
<SignalBars satellites={satellites} />
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[460px_1fr]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Compass className="size-4" />
Satellite Position View
</CardTitle>
<CardDescription>
Sky plot by azimuth/elevation for quick distribution checks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<SkyPlot satellites={satellites} />
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(constellations).map(([name, detail]) => (
<div
className="rounded-md border px-2 py-1 flex items-center justify-between"
key={name}
>
<span className="flex items-center gap-1">
<span
className="size-2 rounded-full"
style={{ backgroundColor: satColor(name) }}
/>
{name}
</span>
<span className="text-muted-foreground">
{detail.used_in_navigation ?? 0}/{detail.count ?? 0}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data View</CardTitle>
<CardDescription>
Lightweight runtime details for monitoring, without offline
analysis.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-3">
<div>
<p className="text-muted-foreground">Device ID</p>
<p className="font-medium break-all">
{currentDevice.device_id}
</p>
</div>
<div>
<p className="text-muted-foreground">Last Event Time</p>
<p className="font-medium">
{formatTime(currentDevice.last_event_time)}
</p>
</div>
<div>
<p className="text-muted-foreground">Data Age</p>
<p className="font-medium">
{formatAge(currentDevice.updated_at)}
</p>
</div>
<div>
<p className="text-muted-foreground">Average SNR</p>
<p className="font-medium">
{signalHealth?.average_snr_dbhz ?? "-"} dB-Hz
</p>
</div>
<div>
<p className="text-muted-foreground">{"Strong CN0 (>=40)"}</p>
<p className="font-medium">
{signalHealth?.strong_signal_count ?? 0}
</p>
</div>
<div className="md:col-span-2 xl:col-span-3">
<p className="text-muted-foreground mb-2">Map Jump</p>
{currentDevice.lat === null || currentDevice.lon === null ? (
<p className="text-sm text-muted-foreground">
Waiting for valid coordinates
</p>
) : (
<Button asChild size="sm" variant="outline">
<a
href={`https://www.openstreetmap.org/?mlat=${currentDevice.lat}&mlon=${currentDevice.lon}&zoom=16`}
rel="noreferrer"
target="_blank"
>
Open in OpenStreetMap
</a>
</Button>
)}
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
)
}