diff --git a/.gitignore b/.gitignore index c2da866..f5d595f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build/ # Testing coverage/ +.references/ # Claude .claude/ diff --git a/src/App.tsx b/src/App.tsx index 10b561e..27694e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,7 +32,7 @@ function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) { const region = useRegion(); useEffect(() => { - mgr.updateSubscription({ iatas: region === "*" ? undefined : [region], events: ["packetObservation"] }); + mgr.updateSubscription({ iatas: region === "*" ? undefined : [region], events: ["packetObservation", "channelMessage", "observerStatus"] }); }, [mgr, region]); return null; @@ -41,12 +41,21 @@ function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) { // tab state and region init function AppInner() { - const [activeTab, setActiveTab] = useState("Packets"); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const [activeTab, setActiveTab] = useState(() => searchParams.get("tab") ?? "Packets"); const initialRegion = searchParams.get("region") ?? localStorage.getItem("tower-region") ?? "*"; + const handleTabChange = (tab: string) => { + setActiveTab(tab); + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set("tab", tab); + return next; + }); + }; + useEffect(() => { - wsManager.connect({ iatas: initialRegion === "*" ? undefined : [initialRegion], events: ["packetObservation"] }); + wsManager.connect({ iatas: initialRegion === "*" ? undefined : [initialRegion], events: ["packetObservation", "channelMessage", "observerStatus"] }); return () => wsManager.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -54,8 +63,8 @@ function AppInner() { const tabContent: Record = { Packets: , Nodes: , - Observers: , - Channels: , + Observers: , + Channels: , Stats: , Map: , }; @@ -63,7 +72,7 @@ function AppInner() { return ( - + {tabContent[activeTab]} diff --git a/src/api/client.ts b/src/api/client.ts index 2165d37..b50900a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,7 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; -import type { CursorPage, PacketSummary, PacketDetail, IataCode } from "../types/api"; +import type { CursorPage, PacketSummary, PacketDetail, IataCode, BrokerStatus } from "../types/api"; +import type { ChannelSummary, ChannelMessage } from "../features/channels/types"; +import type { ObserverSummary, Observer } from "../features/observers/types"; // typed fetch wrapper with query params @@ -16,7 +18,7 @@ class ApiError extends Error { } async function request(path: string, params?: Record): Promise { - const url = new URL(`${API_BASE}${path}`); + const url = new URL(`${API_BASE}${path}`, window.location.origin); if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined) { @@ -57,4 +59,40 @@ export function getIatas(): Promise { return request("/iatas"); } +export function getChannels(params?: { iata?: string; limit?: number }): Promise { + return request("/channels", { + iata: params?.iata, + limit: params?.limit, + }); +} + +export function getChannelMessages( + channelId: number, + params?: { iata?: string; limit?: number }, +): Promise { + return request(`/channels/${channelId}/messages`, { + iata: params?.iata, + limit: params?.limit ?? DEFAULT_PAGE_SIZE, + }); +} + +export function getBrokers(): Promise { + return request("/brokers"); +} + +export function getObservers( + params?: { iata?: string; type?: string; broker?: string; status?: string }, +): Promise { + return request("/observers", { + iata: params?.iata, + type: params?.type, + broker: params?.broker, + status: params?.status, + }); +} + +export function getObserver(observerId: string): Promise { + return request(`/observers/${observerId}`); +} + export { ApiError }; diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts index 01cf9e2..4528f5b 100644 --- a/src/api/ws-manager.ts +++ b/src/api/ws-manager.ts @@ -1,4 +1,4 @@ -import type { SubscriptionFilter, WsServerMessage, WsPacketObservation, WsLagged } from "../types/ws"; +import type { SubscriptionFilter, WsServerMessage, WsPacketObservation, WsLagged, WsChannelMessage, WsObserverStatus } from "../types/ws"; import { WS_PING_INTERVAL_MS, WS_RECONNECT_BASE_MS, @@ -12,6 +12,8 @@ export type WsStatus = "connected" | "connecting" | "disconnected" | "error"; type PacketHandler = (data: WsPacketObservation["data"]) => void; type LaggedHandler = (data: WsLagged) => void; +type ChannelMessageHandler = (data: WsChannelMessage["data"]) => void; +type ObserverStatusHandler = (data: WsObserverStatus["data"]) => void; type StatusHandler = (status: WsStatus) => void; export class WsManager { @@ -28,6 +30,8 @@ export class WsManager { private packetHandlers: PacketHandler[] = []; private laggedHandlers: LaggedHandler[] = []; + private channelMessageHandlers: ChannelMessageHandler[] = []; + private observerStatusHandlers: ObserverStatusHandler[] = []; private statusHandlers: StatusHandler[] = []; constructor(url: string) { @@ -56,6 +60,20 @@ export class WsManager { }; } + onChannelMessage(handler: ChannelMessageHandler): () => void { + this.channelMessageHandlers.push(handler); + return () => { + this.channelMessageHandlers = this.channelMessageHandlers.filter((h) => h !== handler); + }; + } + + onObserverStatus(handler: ObserverStatusHandler): () => void { + this.observerStatusHandlers.push(handler); + return () => { + this.observerStatusHandlers = this.observerStatusHandlers.filter((h) => h !== handler); + }; + } + onStatusChange(handler: StatusHandler): () => void { this.statusHandlers.push(handler); return () => { @@ -148,6 +166,14 @@ export class WsManager { for (const handler of this.packetHandlers) { handler(msg.data); } + } else if (msg.event === "channelMessage") { + for (const handler of this.channelMessageHandlers) { + handler(msg.data); + } + } else if (msg.event === "observerStatus") { + for (const handler of this.observerStatusHandlers) { + handler(msg.data); + } } break; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 68c4d48..fe292a3 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -8,7 +8,7 @@ import { Dropdown } from "./Dropdown"; import { getIatas } from "../api/client"; import type { WsManager } from "../api/ws-manager"; -const TABS = ["Packets", "Nodes", "Observers", "Channels", "Stats", "Map"] as const; +const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Stats"] as const; // header widgets: WS status, region picker, theme picker diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx index 11860a4..ac2ef54 100644 --- a/src/features/channels/ChannelList.tsx +++ b/src/features/channels/ChannelList.tsx @@ -1,7 +1,114 @@ -export function ChannelList() { +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getChannels } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { useWsChannelMessageHandler } from "../../hooks/useWsHandlers"; +import { ChannelSidebar } from "./ChannelSidebar"; +import { MessagePanel } from "./MessagePanel"; +import type { ChannelMessage, ChannelSummary } from "./types"; +import type { WsManager } from "../../api/ws-manager"; + +interface ChannelListProps { + wsManager: WsManager; +} + +export function ChannelList({ wsManager }: ChannelListProps) { + const region = useRegion(); + const iata = region === "*" ? undefined : region; + const [selectedId, setSelectedId] = useState(null); + const [heardCounts, setHeardCounts] = useState>({}); + const queryClient = useQueryClient(); + + const prevRegion = useRef(region); + useEffect(() => { + if (prevRegion.current !== region) { + prevRegion.current = region; + setSelectedId(null); + setHeardCounts({}); + } + }, [region]); + + const handleSelect = useCallback((id: number) => { + setSelectedId(id); + setHeardCounts({}); + }, []); + + const { data: channels, isLoading } = useQuery({ + queryKey: ["channels", region], + queryFn: () => getChannels({ iata }), + staleTime: 60_000, + }); + + // "Public" always first, then named channels, then unnamed by most recent + const sortedChannels = useMemo( + () => + [...(channels ?? [])].sort((a, b) => { + const aPub = a.name === "Public" ? 1 : 0; + const bPub = b.name === "Public" ? 1 : 0; + if (aPub !== bPub) return bPub - aPub; + if (a.name && !b.name) return -1; + if (!a.name && b.name) return 1; + return new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime(); + }), + [channels], + ); + + const selectedChannel = sortedChannels.find((ch) => ch.id === selectedId) ?? null; + + const handleChannelMessage = useCallback( + (data: ChannelMessage) => { + // bump lastSeen in the channel list, or refetch if it's a channel we haven't seen + queryClient.setQueryData(["channels", region], (old) => { + if (!old) return old; + const idx = old.findIndex((ch) => ch.channelHash === data.channelHash); + if (idx === -1) { + queryClient.invalidateQueries({ queryKey: ["channels", region] }); + return old; + } + const updated = [...old]; + updated[idx] = { ...updated[idx]!, lastSeen: data.sentAt }; + return updated; + }); + + // use cache directly to avoid stale closure over selectedChannel + const cached = queryClient.getQueryData(["channels", region]); + const selected = cached?.find((ch) => ch.id === selectedId); + if (selected && data.channelHash === selected.channelHash) { + // track how many observers heard this packet (same content, multiple paths) + setHeardCounts((prev) => ({ + ...prev, + [data.packetHash]: (prev[data.packetHash] ?? 0) + 1, + })); + queryClient.setQueryData( + ["channel-messages", selectedId, region], + (old) => { + if (old?.some((msg) => msg.packetHash === data.packetHash)) return old; + return old ? [...old, data] : [data]; + }, + ); + } + }, + [queryClient, selectedId, region], + ); + + useWsChannelMessageHandler(wsManager, handleChannelMessage); + + if (isLoading) { + return ( +
+ loading channels… +
+ ); + } + return ( -
- Channels — coming soon +
+ +
); } diff --git a/src/features/channels/ChannelSidebar.tsx b/src/features/channels/ChannelSidebar.tsx new file mode 100644 index 0000000..db94ca9 --- /dev/null +++ b/src/features/channels/ChannelSidebar.tsx @@ -0,0 +1,54 @@ +import { Badge } from "../../components/Badge"; +import { timeAgo } from "../../lib/formatters"; +import { useTick } from "../../hooks/useTick"; +import { channelDisplayName } from "./types"; +import type { ChannelSummary } from "./types"; + +interface ChannelSidebarProps { + channels: ChannelSummary[]; + selectedId: number | null; + onSelect: (id: number) => void; +} + +export function ChannelSidebar({ channels, selectedId, onSelect }: ChannelSidebarProps) { + useTick(); + + return ( +
+
+ Channels +
+
+ {channels.map((ch) => { + const isSelected = ch.id === selectedId; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx new file mode 100644 index 0000000..e5e5098 --- /dev/null +++ b/src/features/channels/MessagePanel.tsx @@ -0,0 +1,149 @@ +import { useMemo, useRef, useEffect, useState, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getChannelMessages } from "../../api/client"; +import { Badge } from "../../components/Badge"; +import { channelDisplayName } from "./types"; +import type { ChannelSummary, ChannelMessage } from "./types"; + +// hash the sender name so their color stays consistent +const SENDER_COLORS = [ + "text-primary", + "text-secondary", + "text-green", + "text-warn", + "text-danger", +]; + +function senderColor(name: string): string { + let h = 5381; + for (let i = 0; i < name.length; i++) h = ((h << 5) + h + name.charCodeAt(i)) | 0; + return SENDER_COLORS[Math.abs(h) % SENDER_COLORS.length] ?? "text-primary"; +} + +function formatMessageTime(iso: string): string { + return new Date(iso).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +function MessageRow({ msg, heardCount }: { msg: ChannelMessage; heardCount?: number }) { + return ( +
+
+ + {msg.senderName} + + {formatMessageTime(msg.sentAt)} +
+
{msg.content}
+ {heardCount != null && heardCount > 1 && ( +
+ heard {heardCount}× +
+ )} +
+ ); +} + +interface MessagePanelProps { + channel: ChannelSummary | null; + heardCounts: Record; + iata?: string; + region: string; +} + +export function MessagePanel({ channel, heardCounts, iata, region }: MessagePanelProps) { + const { data: messages, isLoading } = useQuery({ + queryKey: ["channel-messages", channel?.id, region], + queryFn: () => getChannelMessages(channel!.id, { iata, limit: 50 }), + enabled: channel !== null, + staleTime: 30_000, + }); + + const sorted = useMemo( + () => [...(messages ?? [])].sort((a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime()), + [messages], + ); + + const bottomRef = useRef(null); + const prevCount = useRef(0); + const [userScrolled, setUserScrolled] = useState(false); + const scrollContainerRef = useRef(null); + + useEffect(() => { + if (sorted.length > prevCount.current && !userScrolled) { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + } + prevCount.current = sorted.length; + }, [sorted.length, userScrolled]); + + // reset scroll tracking when switching channels + useEffect(() => { + prevCount.current = 0; + setUserScrolled(false); + }, [channel?.id]); + + const handleScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + setUserScrolled(!atBottom); + }, []); + + if (!channel) { + return ( +
+ Select a channel +
+ ); + } + + return ( +
+
+
+ + {channelDisplayName(channel)} + + hash: {channel.channelHash} +
+
+ {channel.keyKnown ? ( + key known + ) : ( + no key + )} + {channel.isHashtag && hashtag} +
+
+ + {!channel.keyKnown && ( +
+ Key not known, messages may not be decrypted +
+ )} + +
+ {isLoading ? ( +
+ Loading... +
+ ) : messages && messages.length > 0 ? ( +
+ {sorted.map((msg) => ( + + ))} +
+
+ ) : ( +
+ No messages +
+ )} +
+
+ ); +} diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts index c08771a..36bf02e 100644 --- a/src/features/channels/types.ts +++ b/src/features/channels/types.ts @@ -1,14 +1,29 @@ -export interface Channel { +export interface ChannelSummary { + id: number; + name: string | null; channelHash: string; - memberCount: number; - lastActivityAt: number; + lastSeen: string; + isHashtag: boolean; + keyKnown: boolean; +} + +export interface ChannelDetail extends ChannelSummary { + hashtag: string | null; + keyFingerprint: string | null; + messageCount: number; +} + +export function channelDisplayName(ch: ChannelSummary): string { + if (!ch.name) return ch.channelHash; + if (ch.isHashtag || ch.name === "Public") return ch.name; + return `#${ch.name}`; } export interface ChannelMessage { - id: string; + id: number; + packetHash: string; channelHash: string; - senderName: string | null; + senderName: string; content: string; - decrypted: boolean; - timestamp: number; + sentAt: string; } diff --git a/src/features/map/types.ts b/src/features/map/types.ts deleted file mode 100644 index debc2f9..0000000 --- a/src/features/map/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface MapObserver { - id: string; - name: string; - iata: string; - lat: number; - lng: number; - online: boolean; -} - -export interface PacketArc { - fromLat: number; - fromLng: number; - toLat: number; - toLng: number; - packetHash: string; -} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts deleted file mode 100644 index 2496653..0000000 --- a/src/features/nodes/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface Node { - id: string; - shortId: string; - name: string; - publicKey: string; - iata: string | null; - firmware: string | null; - lastSeenAt: number; - latitude: number | null; - longitude: number | null; -} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx new file mode 100644 index 0000000..59f48f8 --- /dev/null +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -0,0 +1,212 @@ +import type { Observer } from "./types"; +import { useQuery } from "@tanstack/react-query"; +import { getObserver } from "../../api/client"; +import { Badge } from "../../components/Badge"; +import { formatUptime, formatBattery, timeAgo, timeAgoMs } from "../../lib/formatters"; +import type { BadgeVariant } from "../../components/badge-utils"; + +interface Stats { + noise_floor?: number; + rx_air_secs?: number; + tx_air_secs?: number; + queue_len?: number; + recv_errors?: number; + errors?: number; + internal_heap?: number; +} + +// stats shape depends on the observer's firmware, so we just grab what we recognize +function getStats(metadata: Record | undefined): Stats | null { + if (!metadata?.stats || typeof metadata.stats !== "object") return null; + return metadata.stats as Stats; +} + +function formatAirtime(secs: number): string { + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return `${h}h ${m}m`; +} + +function Section({ title, children, first }: { title: string; children: React.ReactNode; first?: boolean }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function Field({ label, value }: { label: string; value: React.ReactNode }) { + return ( + {label} {value} + ); +} + +function RadioSection({ observer, noiseFloor }: { observer: Observer; noiseFloor?: number | null }) { + const parts = [ + observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`, + observer.radioSf && `SF${observer.radioSf}`, + observer.radioBwKhz && `${observer.radioBwKhz} kHz`, + observer.radioCr && `CR 4/${observer.radioCr}`, + ].filter(Boolean) as string[]; + + return ( +
+
+ {parts.join(" · ")} +
+ {noiseFloor != null && ( +
+ +
+ )} +
+ ); +} + +interface ObserverDetailPanelProps { + observerId: string; + onClose: () => void; +} + +export function ObserverDetailPanel({ observerId, onClose }: ObserverDetailPanelProps) { + const { data: observer, isLoading } = useQuery({ + queryKey: ["observer", observerId], + queryFn: () => getObserver(observerId), + staleTime: 30_000, + }); + + const stats = observer ? getStats(observer.statusMetadata) : null; + + return ( +
+
+ Observer Detail + +
+ +
+ {isLoading ? ( +
+ Loading... +
+ ) : observer ? ( + <> +
+
+ + {observer.displayName ?? observer.id.slice(0, 8)} + + + {observer.status} + +
+
+ {observer.publicKey} +
+
+ +
+
+ {observer.observerType && {observer.observerType}} + + {observer.iata} + +
+
+ + {(observer.radioFreqMhz || observer.radioSf || observer.radioBwKhz || observer.radioCr) && ( + + )} + + {(observer.firmwareVersion || observer.softwareVersion || observer.hardwareModel) && ( +
+
+ {observer.firmwareVersion && } + {observer.softwareVersion && } + {observer.hardwareModel && } +
+
+ )} + +
+
+ {observer.batteryLevel != null && } + {observer.uptimeSeconds != null && } + {stats?.queue_len != null && } +
+ {observer.lastStatusAt && ( +
+ +
+ )} +
+ + {stats && (stats.rx_air_secs != null || stats.tx_air_secs != null || stats.recv_errors != null) && ( +
+
+ {stats.rx_air_secs != null && } + {stats.tx_air_secs != null && } +
+ {(stats.recv_errors != null || stats.errors != null) && ( +
+ {stats.recv_errors != null && } + {stats.errors != null && } +
+ )} +
+ )} + + {observer.brokers.length > 0 && ( +
+
+ {[...observer.brokers].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).map((b) => { + const ageMs = b.lastPacketAt ? Date.now() - b.lastPacketAt : Infinity; + // <5m = live, <30m = stale (typical for HA plugins that report infrequently) + const variant: BadgeVariant = ageMs < 5 * 60_000 ? "live" : ageMs < 30 * 60_000 ? "stale" : "offline"; + return ( +
+ {b.name} +
+ + +
+
+ ); + })} +
+
+ )} + +
+
+ + · + +
+
+ + ) : ( +
+ + + + + Observer not found +
+ )} +
+
+ ); +} diff --git a/src/features/observers/ObserverFilterBar.tsx b/src/features/observers/ObserverFilterBar.tsx new file mode 100644 index 0000000..18809c1 --- /dev/null +++ b/src/features/observers/ObserverFilterBar.tsx @@ -0,0 +1,150 @@ +import { Dropdown } from "../../components/Dropdown"; + +const STATUSES = [ + { value: "", label: "All" }, + { value: "online", label: "Online" }, + { value: "offline", label: "Offline" }, +]; + +interface ObserverFilterBarProps { + statusFilter: string; + onStatusChange: (s: string) => void; + typeFilter: string; + onTypeChange: (t: string) => void; + typeOptions: string[]; + brokerFilter: string; + onBrokerChange: (b: string) => void; + brokerOptions: string[]; +} + +export function ObserverFilterBar({ + statusFilter, + onStatusChange, + typeFilter, + onTypeChange, + typeOptions, + brokerFilter, + onBrokerChange, + brokerOptions, +}: ObserverFilterBarProps) { + return ( +
+
+ {STATUSES.map((s) => ( + + ))} +
+ + {typeOptions.length > 0 && ( + ( + + )} + > + {(close) => ( + <> + + {typeOptions.map((t) => ( + + ))} + + )} + + )} + + {brokerOptions.length > 0 && ( + ( + + )} + > + {(close) => ( + <> + + {brokerOptions.map((b) => ( + + ))} + + )} + + )} +
+ ); +} diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index add55ff..786e04e 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -1,7 +1,185 @@ -export function ObserverTable() { +import { useState, useCallback, useMemo } from "react"; +import { useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query"; +import { getObservers, getBrokers } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { useTick } from "../../hooks/useTick"; +import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; +import { formatHex } from "../../lib/formatters"; +import { Badge } from "../../components/Badge"; +import { ObserverFilterBar } from "./ObserverFilterBar"; +import { ObserverDetailPanel } from "./ObserverDetailPanel"; +import type { ObserverSummary } from "./types"; +import type { WsManager } from "../../api/ws-manager"; +import type { WsObserverStatus } from "../../types/ws"; + +interface ObserverTableProps { + wsManager: WsManager; +} + +export function ObserverTable({ wsManager }: ObserverTableProps) { + const region = useRegion(); + const queryClient = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + const [statusFilter, setStatusFilter] = useState(""); + const [typeFilter, setTypeFilter] = useState(""); + const [brokerFilter, setBrokerFilter] = useState(""); + + const { data: brokers } = useQuery({ + queryKey: ["brokers"], + queryFn: getBrokers, + staleTime: 60_000, + }); + + const brokerNames = useMemo( + () => brokers?.map((b) => b.name) ?? [], + [brokers], + ); + + useTick(); + + const queryKey = useMemo( + () => ["observers", region, statusFilter, typeFilter, brokerFilter], + [region, statusFilter, typeFilter, brokerFilter], + ); + + const { data: observers, isLoading } = useQuery({ + queryKey, + queryFn: () => + getObservers({ + iata: region === "*" ? undefined : region, + status: statusFilter || undefined, + type: typeFilter || undefined, + broker: brokerFilter || undefined, + }), + staleTime: 30_000, + placeholderData: keepPreviousData, + }); + + const typeOptions = useMemo(() => { + if (!observers) return []; + const types = new Set(); + for (const obs of observers) { + if (obs.observerType) types.add(obs.observerType); + } + return [...types].sort(); + }, [observers]); + + const handleObserverStatus = useCallback( + (data: WsObserverStatus["data"]) => { + queryClient.setQueryData(queryKey, (old) => { + if (!old) return old; + const idx = old.findIndex((o) => o.id === data.observerId); + if (idx === -1) { + queryClient.invalidateQueries({ queryKey: ["observers"] }); + return old; + } + const updated = [...old]; + const prev = updated[idx]!; + updated[idx] = { + ...prev, + status: data.online ? "online" : "offline", + displayName: data.displayName || prev.displayName, + }; + return updated; + }); + // refresh detail panel if it's showing this observer + if (selectedId === data.observerId) { + queryClient.invalidateQueries({ queryKey: ["observer", data.observerId] }); + } + }, + [queryClient, queryKey, selectedId], + ); + + useWsObserverStatusHandler(wsManager, handleObserverStatus); + + if (isLoading) { + return ( +
+ loading… +
+ ); + } + return ( -
- Observers — coming soon +
+
+ + +
+ {observers && observers.length > 0 ? ( + + + + + + + + + + + {observers.map((obs) => { + const isSelected = obs.id === selectedId; + return ( + setSelectedId(isSelected ? null : obs.id)} + > + + + + + + ); + })} + +
NameTypeIATAStatus
+
+ + + {obs.displayName ?? formatHex(obs.id)} + +
+
+ {obs.observerType ?? "—"} + + {obs.iata} + + + {obs.status} + +
+ ) : ( +
+ No observers +
+ )} +
+
+ + {selectedId && ( + setSelectedId(null)} + /> + )}
); } diff --git a/src/features/observers/types.ts b/src/features/observers/types.ts index be2e632..3289f43 100644 --- a/src/features/observers/types.ts +++ b/src/features/observers/types.ts @@ -1,13 +1,33 @@ -export interface Observer { +export interface ObserverSummary { id: string; - displayName: string; + displayName?: string; + observerType?: string; iata: string; - online: boolean; - lastSeenAt: number; - brokers: string[]; - telemetry: { - batteryPct: number | null; - uptimeSec: number | null; - queueDepth: number | null; - } | null; + status: "online" | "offline"; +} + +export interface Observer extends ObserverSummary { + publicKey: string; + softwareVersion?: string; + hardwareModel?: string; + firmwareVersion?: string; + firmwareBuild?: string; + radioFreqMhz?: number; + radioSf?: number; + radioBwKhz?: number; + radioCr?: number; + batteryLevel?: number; + uptimeSeconds?: number; + statusMetadata?: Record; + lastStatusAt?: string; + firstSeen: string; + lastSeen: string; + observationCount: number; + brokers: ObserverBroker[]; +} + +export interface ObserverBroker { + name: string; + lastSeenAt: number; + lastPacketAt: number; } diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts deleted file mode 100644 index e6857be..0000000 --- a/src/features/stats/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface PacketVolume { - timestamp: number; - count: number; - byType: Record; -} - -export interface ObserverCoverage { - observerId: string; - observerName: string; - packetCount: number; - uniqueNodes: number; -} diff --git a/src/hooks/useTick.ts b/src/hooks/useTick.ts new file mode 100644 index 0000000..f85cca4 --- /dev/null +++ b/src/hooks/useTick.ts @@ -0,0 +1,9 @@ +import { useState, useEffect } from "react"; + +export function useTick(intervalMs = 10_000): void { + const [, set] = useState(0); + useEffect(() => { + const id = setInterval(() => set((n) => n + 1), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); +} diff --git a/src/hooks/useWsHandlers.ts b/src/hooks/useWsHandlers.ts index b9e0c78..eb03d0b 100644 --- a/src/hooks/useWsHandlers.ts +++ b/src/hooks/useWsHandlers.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import type { WsManager } from "../api/ws-manager"; -import type { WsPacketObservation, WsLagged } from "../types/ws"; +import type { WsPacketObservation, WsLagged, WsChannelMessage, WsObserverStatus } from "../types/ws"; export function useWsPacketHandler( manager: WsManager, @@ -19,3 +19,21 @@ export function useWsLaggedHandler( return manager.onLagged(handler); }, [manager, handler]); } + +export function useWsChannelMessageHandler( + manager: WsManager, + handler: (data: WsChannelMessage["data"]) => void, +): void { + useEffect(() => { + return manager.onChannelMessage(handler); + }, [manager, handler]); +} + +export function useWsObserverStatusHandler( + manager: WsManager, + handler: (data: WsObserverStatus["data"]) => void, +): void { + useEffect(() => { + return manager.onObserverStatus(handler); + }, [manager, handler]); +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 25583e9..99eab7f 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -48,3 +48,31 @@ export function formatPropagation(ms: number | null): string { if (ms === null) return "—"; return `${(ms / 1000).toFixed(3)}s`; } + +export function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +export function formatBattery(volts: number): string { + return `${volts.toFixed(2)}V`; +} + +// clamp negative values from clock skew +export function timeAgoMs(epochMs: number): string { + const seconds = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +export function timeAgo(iso: string): string { + return timeAgoMs(new Date(iso).getTime()); +} diff --git a/src/types/api.ts b/src/types/api.ts index b1c6261..6f34e84 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -95,3 +95,8 @@ export interface IataCode { approxLat: number | null; approxLng: number | null; } + +export interface BrokerStatus { + name: string; + connected: boolean; +} diff --git a/src/types/ws.ts b/src/types/ws.ts index e7e2b26..9fd2a1e 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -1,4 +1,5 @@ import type { Observation } from "./api"; +import type { ChannelMessage } from "../features/channels/types"; // individual server-sent message shapes @@ -78,6 +79,13 @@ export interface WsNodeUpdate { }; } +export interface WsChannelMessage { + v: 1; + type: "event"; + event: "channelMessage"; + data: ChannelMessage; +} + export interface WsLagged { v: 1; type: "lagged"; @@ -103,6 +111,7 @@ export type WsServerMessage = | WsPacketObservation | WsObserverStatus | WsNodeUpdate + | WsChannelMessage | WsLagged | WsError; diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts index 5bc1616..6dec2ab 100644 --- a/tests/api/ws-manager.test.ts +++ b/tests/api/ws-manager.test.ts @@ -183,4 +183,47 @@ describe("WsManager", () => { expect(handler).toHaveBeenCalledOnce(); expect(handler.mock.calls[0]![0].droppedCount).toBe(47); }); + + it("dispatches channelMessage events to handlers", () => { + const mgr = new WsManager("ws://test/ws"); + const handler = vi.fn(); + mgr.onChannelMessage(handler); + + mgr.connect({ events: ["channelMessage"] }); + const ws = MockWebSocket.instances[0]; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" }); + + const msgData = { + id: 1, + packetHash: "abc123", + channelHash: "f3", + senderName: "TestNode", + content: "hello mesh", + sentAt: "2026-05-26T14:00:00Z", + }; + + ws.simulateMessage({ v: 1, type: "event", event: "channelMessage", data: msgData }); + expect(handler).toHaveBeenCalledWith(msgData); + }); + + it("unsubscribes channelMessage handler on cleanup", () => { + const mgr = new WsManager("ws://test/ws"); + const handler = vi.fn(); + const unsub = mgr.onChannelMessage(handler); + unsub(); + + mgr.connect({ events: ["channelMessage"] }); + const ws = MockWebSocket.instances[0]; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" }); + ws.simulateMessage({ + v: 1, + type: "event", + event: "channelMessage", + data: { id: 1, packetHash: "x", channelHash: "f3", senderName: "N", content: "hi", sentAt: "2026-05-26T14:00:00Z" }, + }); + + expect(handler).not.toHaveBeenCalled(); + }); }); diff --git a/vite.config.ts b/vite.config.ts index c63978e..4b5a980 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,32 @@ /// -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -export default defineConfig({ - plugins: [react(), tailwindcss()], - test: { - globals: true, - environment: "jsdom", - }, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), "VITE_"); + // set VITE_DEV_PROXY to point at a running tower-server instance + const proxyTarget = env.VITE_DEV_PROXY; + + return { + plugins: [react(), tailwindcss()], + server: proxyTarget + ? { + proxy: { + "/api": { target: proxyTarget, changeOrigin: true, secure: true }, + "/ws": { + target: proxyTarget.replace(/^http/, "ws"), + changeOrigin: true, + secure: true, + ws: true, + headers: { Origin: proxyTarget }, + }, + }, + } + : undefined, + test: { + globals: true, + environment: "jsdom", + }, + }; });