added channel & observers

This commit is contained in:
MrAlders0n
2026-05-27 19:27:48 -04:00
parent 134289af92
commit a0caf65a18
23 changed files with 1133 additions and 81 deletions
+1
View File
@@ -19,6 +19,7 @@ build/
# Testing
coverage/
.references/
# Claude
.claude/
+16 -7
View File
@@ -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<string, React.ReactNode> = {
Packets: <PacketList wsManager={wsManager} />,
Nodes: <NodeTable />,
Observers: <ObserverTable />,
Channels: <ChannelList />,
Observers: <ObserverTable wsManager={wsManager} />,
Channels: <ChannelList wsManager={wsManager} />,
Stats: <StatsOverview />,
Map: <MapView />,
};
@@ -63,7 +72,7 @@ function AppInner() {
return (
<RegionProvider defaultRegion={initialRegion}>
<RegionWatcher wsManager={wsManager} />
<AppShell activeTab={activeTab} onTabChange={setActiveTab} wsManager={wsManager}>
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
{tabContent[activeTab]}
</AppShell>
</RegionProvider>
+40 -2
View File
@@ -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<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
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<IataCode[]> {
return request("/iatas");
}
export function getChannels(params?: { iata?: string; limit?: number }): Promise<ChannelSummary[]> {
return request("/channels", {
iata: params?.iata,
limit: params?.limit,
});
}
export function getChannelMessages(
channelId: number,
params?: { iata?: string; limit?: number },
): Promise<ChannelMessage[]> {
return request(`/channels/${channelId}/messages`, {
iata: params?.iata,
limit: params?.limit ?? DEFAULT_PAGE_SIZE,
});
}
export function getBrokers(): Promise<BrokerStatus[]> {
return request("/brokers");
}
export function getObservers(
params?: { iata?: string; type?: string; broker?: string; status?: string },
): Promise<ObserverSummary[]> {
return request("/observers", {
iata: params?.iata,
type: params?.type,
broker: params?.broker,
status: params?.status,
});
}
export function getObserver(observerId: string): Promise<Observer> {
return request(`/observers/${observerId}`);
}
export { ApiError };
+27 -1
View File
@@ -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;
+1 -1
View File
@@ -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
+110 -3
View File
@@ -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<number | null>(null);
const [heardCounts, setHeardCounts] = useState<Record<string, number>>({});
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<ChannelSummary[]>(["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<ChannelSummary[]>(["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<ChannelMessage[]>(
["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 (
<div className="flex items-center justify-center flex-1 text-text-dim text-xs font-mono">
loading channels
</div>
);
}
return (
<div className="flex items-center justify-center flex-1 text-text-muted text-sm font-mono">
Channels coming soon
<div className="flex flex-1 min-h-0">
<ChannelSidebar
channels={sortedChannels}
selectedId={selectedId}
onSelect={handleSelect}
/>
<MessagePanel channel={selectedChannel} heardCounts={heardCounts} iata={iata} region={region} />
</div>
);
}
+54
View File
@@ -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 (
<div className="w-60 min-w-60 border-r border-border bg-bg-surface overflow-y-auto">
<div className="text-text-muted text-[11px] uppercase tracking-wider px-3 pt-3 pb-2 font-mono">
Channels
</div>
<div className="flex flex-col divide-y divide-border/40 px-1">
{channels.map((ch) => {
const isSelected = ch.id === selectedId;
return (
<button
key={ch.id}
onClick={() => onSelect(ch.id)}
className={`w-full text-left px-2 py-1.5 rounded transition-colors cursor-pointer ${
isSelected
? "bg-primary/10 border border-primary"
: "border border-transparent hover:bg-bg-raised"
}`}
>
<div className="flex items-center justify-between">
<span className={`font-mono text-xs truncate ${isSelected ? "text-text-bright" : "text-text-normal"}`}>
{channelDisplayName(ch)}
</span>
<span className="text-[11px] text-text-dim ml-2 shrink-0">{timeAgo(ch.lastSeen)}</span>
</div>
<div className="flex gap-1 mt-1">
{ch.keyKnown ? (
<Badge variant="advert">key</Badge>
) : (
<Badge variant="offline">no key</Badge>
)}
{ch.isHashtag && <Badge variant="group">hashtag</Badge>}
</div>
</button>
);
})}
</div>
</div>
);
}
+149
View File
@@ -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 (
<div className="px-3 py-2">
<div className="flex items-baseline gap-2">
<span className={`text-xs font-semibold font-mono ${senderColor(msg.senderName)}`}>
{msg.senderName}
</span>
<span className="text-[11px] text-text-dim">{formatMessageTime(msg.sentAt)}</span>
</div>
<div className="text-text-normal text-xs mt-0.5">{msg.content}</div>
{heardCount != null && heardCount > 1 && (
<div className="text-[11px] text-text-dim mt-0.5 font-mono">
heard {heardCount}×
</div>
)}
</div>
);
}
interface MessagePanelProps {
channel: ChannelSummary | null;
heardCounts: Record<string, number>;
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<HTMLDivElement>(null);
const prevCount = useRef(0);
const [userScrolled, setUserScrolled] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(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 (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm font-mono">
Select a channel
</div>
);
}
return (
<div className="flex-1 flex flex-col min-w-0 bg-bg-base">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="flex items-baseline gap-2">
<span className="text-text-bright text-sm font-mono">
{channelDisplayName(channel)}
</span>
<span className="text-text-dim text-[11px] font-mono">hash: {channel.channelHash}</span>
</div>
<div className="flex gap-1">
{channel.keyKnown ? (
<Badge variant="advert">key known</Badge>
) : (
<Badge variant="offline">no key</Badge>
)}
{channel.isHashtag && <Badge variant="group">hashtag</Badge>}
</div>
</div>
{!channel.keyKnown && (
<div className="px-3 py-1.5 bg-warn/5 border-b border-warn/20 text-warn text-xs font-mono">
Key not known, messages may not be decrypted
</div>
)}
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}>
{isLoading ? (
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
Loading...
</div>
) : messages && messages.length > 0 ? (
<div className="py-2 flex flex-col divide-y divide-border/40">
{sorted.map((msg) => (
<MessageRow key={msg.id} msg={msg} heardCount={heardCounts[msg.packetHash]} />
))}
<div ref={bottomRef} />
</div>
) : (
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
No messages
</div>
)}
</div>
</div>
);
}
+22 -7
View File
@@ -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;
}
-16
View File
@@ -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;
}
-11
View File
@@ -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;
}
@@ -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<string, unknown> | 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 (
<div className={`px-3 py-2.5 ${first ? "" : "border-t border-border-subtle"}`}>
<div className="text-xs font-mono font-medium text-text-bright uppercase tracking-wider mb-1.5">{title}</div>
{children}
</div>
);
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<span><span className="text-text-dim">{label} </span><span className="text-text-normal">{value}</span></span>
);
}
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 (
<Section title="Radio">
<div className="font-mono text-[13px] text-text-muted">
{parts.join(" · ")}
</div>
{noiseFloor != null && (
<div className="font-mono text-[13px] mt-1">
<Field label="Noise floor" value={`${noiseFloor} dBm`} />
</div>
)}
</Section>
);
}
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 (
<div className="shrink-0 w-[400px] border-l border-border bg-bg-surface flex flex-col min-h-0 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle shrink-0">
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">Observer Detail</span>
<button
type="button"
className="text-text-dim hover:text-text-normal cursor-pointer transition-colors"
onClick={onClose}
aria-label="Close detail panel"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M6 4L10 8L6 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-2.5 text-text-dim">
<span className="text-[13px] font-mono">Loading...</span>
</div>
) : observer ? (
<>
<Section title="Summary" first>
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-xs font-semibold text-primary tracking-wider">
{observer.displayName ?? observer.id.slice(0, 8)}
</span>
<Badge variant={observer.status === "online" ? "live" : "offline"}>
{observer.status}
</Badge>
</div>
<div className="font-mono text-[13px] text-text-muted truncate mb-1.5" title={observer.publicKey}>
{observer.publicKey}
</div>
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label="Observations" value={observer.observationCount.toLocaleString()} />
</div>
<div className="flex items-center gap-2 mt-1.5">
{observer.observerType && <Badge variant="default">{observer.observerType}</Badge>}
<span className="text-[13px] text-primary font-semibold font-mono bg-primary/6 px-1.5 py-px rounded-sm">
{observer.iata}
</span>
</div>
</Section>
{(observer.radioFreqMhz || observer.radioSf || observer.radioBwKhz || observer.radioCr) && (
<RadioSection observer={observer} noiseFloor={stats?.noise_floor} />
)}
{(observer.firmwareVersion || observer.softwareVersion || observer.hardwareModel) && (
<Section title="Firmware">
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{observer.firmwareVersion && <Field label="Version" value={observer.firmwareVersion} />}
{observer.softwareVersion && <Field label="Software" value={observer.softwareVersion} />}
{observer.hardwareModel && <Field label="Hardware" value={observer.hardwareModel} />}
</div>
</Section>
)}
<Section title="Status">
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px]">
{observer.batteryLevel != null && <Field label="Battery" value={formatBattery(observer.batteryLevel)} />}
{observer.uptimeSeconds != null && <Field label="Uptime" value={formatUptime(observer.uptimeSeconds)} />}
{stats?.queue_len != null && <Field label="Queue" value={stats.queue_len} />}
</div>
{observer.lastStatusAt && (
<div className="font-mono text-[13px] mt-1">
<Field label="Last status" value={timeAgo(observer.lastStatusAt)} />
</div>
)}
</Section>
{stats && (stats.rx_air_secs != null || stats.tx_air_secs != null || stats.recv_errors != null) && (
<Section title="Airtime">
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px]">
{stats.rx_air_secs != null && <Field label="RX" value={formatAirtime(stats.rx_air_secs)} />}
{stats.tx_air_secs != null && <Field label="TX" value={formatAirtime(stats.tx_air_secs)} />}
</div>
{(stats.recv_errors != null || stats.errors != null) && (
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px] mt-1">
{stats.recv_errors != null && <Field label="Recv errors" value={stats.recv_errors.toLocaleString()} />}
{stats.errors != null && <Field label="Errors" value={stats.errors.toLocaleString()} />}
</div>
)}
</Section>
)}
{observer.brokers.length > 0 && (
<Section title="Brokers">
<div className="flex flex-col gap-1.5">
{[...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 (
<div key={b.name} className="flex items-center gap-3">
<Badge variant={variant}>{b.name}</Badge>
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label="Seen" value={timeAgoMs(b.lastSeenAt)} />
<Field label="Packet" value={b.lastPacketAt ? timeAgoMs(b.lastPacketAt) : "—"} />
</div>
</div>
);
})}
</div>
</Section>
)}
<Section title="Timestamps">
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label="First" value={timeAgo(observer.firstSeen)} />
<span className="text-[6px] text-border" aria-hidden>·</span>
<Field label="Last" value={timeAgo(observer.lastSeen)} />
</div>
</Section>
</>
) : (
<div className="flex flex-col items-center justify-center h-full gap-2.5 text-text-dim">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" className="text-border">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.2" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<span className="text-[13px] font-mono">Observer not found</span>
</div>
)}
</div>
</div>
);
}
@@ -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 (
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-bg-surface shrink-0">
<div className="flex items-center gap-1 rounded-md border border-border bg-bg-base overflow-hidden">
{STATUSES.map((s) => (
<button
key={s.value}
type="button"
className={`px-3 py-1 text-[11px] font-mono font-medium tracking-wider uppercase transition-colors cursor-pointer ${
statusFilter === s.value
? "bg-primary/12 text-primary"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
}`}
onClick={() => onStatusChange(s.value)}
>
{s.label}
</button>
))}
</div>
{typeOptions.length > 0 && (
<Dropdown
align="left"
width="w-48"
renderTrigger={({ toggle }) => (
<button
type="button"
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-muted font-mono text-[11px] hover:text-text-normal hover:border-text-dim/30 transition-colors"
onClick={toggle}
>
<span className="text-text-dim">TYPE</span>
<span className={typeFilter ? "text-text-bright" : "text-text-muted"}>
{typeFilter || "All"}
</span>
<span className="text-text-dim text-[11px]"></span>
</button>
)}
>
{(close) => (
<>
<button
type="button"
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors ${
!typeFilter
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
}`}
onClick={() => { onTypeChange(""); close(); }}
>
All Types
</button>
{typeOptions.map((t) => (
<button
key={t}
type="button"
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors ${
typeFilter === t
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
}`}
onClick={() => { onTypeChange(t); close(); }}
>
{t}
</button>
))}
</>
)}
</Dropdown>
)}
{brokerOptions.length > 0 && (
<Dropdown
align="left"
width="w-48"
renderTrigger={({ toggle }) => (
<button
type="button"
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-muted font-mono text-[11px] hover:text-text-normal hover:border-text-dim/30 transition-colors"
onClick={toggle}
>
<span className="text-text-dim">BROKER</span>
<span className={brokerFilter ? "text-text-bright" : "text-text-muted"}>
{brokerFilter || "All"}
</span>
<span className="text-text-dim text-[11px]"></span>
</button>
)}
>
{(close) => (
<>
<button
type="button"
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors ${
!brokerFilter
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
}`}
onClick={() => { onBrokerChange(""); close(); }}
>
All Brokers
</button>
{brokerOptions.map((b) => (
<button
key={b}
type="button"
className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors ${
brokerFilter === b
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
}`}
onClick={() => { onBrokerChange(b); close(); }}
>
{b}
</button>
))}
</>
)}
</Dropdown>
)}
</div>
);
}
+181 -3
View File
@@ -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<string | null>(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<string>();
for (const obs of observers) {
if (obs.observerType) types.add(obs.observerType);
}
return [...types].sort();
}, [observers]);
const handleObserverStatus = useCallback(
(data: WsObserverStatus["data"]) => {
queryClient.setQueryData<ObserverSummary[]>(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 (
<div className="flex items-center justify-center flex-1 text-text-dim text-xs font-mono tracking-wider">
loading
</div>
);
}
return (
<div className="flex items-center justify-center flex-1 text-text-muted text-sm font-mono">
Observers coming soon
<div className="flex flex-1 min-h-0">
<div className="flex flex-col flex-1 min-w-0">
<ObserverFilterBar
statusFilter={statusFilter}
onStatusChange={setStatusFilter}
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
typeOptions={typeOptions}
brokerFilter={brokerFilter}
onBrokerChange={setBrokerFilter}
brokerOptions={brokerNames}
/>
<div className="flex-1 overflow-y-auto">
{observers && observers.length > 0 ? (
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-bg-surface z-10">
<tr className="text-text-muted text-[11px] uppercase tracking-wider border-b border-border">
<th className="text-left px-4 py-2 font-medium">Name</th>
<th className="text-left px-4 py-2 font-medium">Type</th>
<th className="text-left px-4 py-2 font-medium">IATA</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{observers.map((obs) => {
const isSelected = obs.id === selectedId;
return (
<tr
key={obs.id}
className={`border-b border-border/40 cursor-pointer transition-colors ${
isSelected
? "bg-primary/10"
: "hover:bg-bg-raised"
}`}
onClick={() => setSelectedId(isSelected ? null : obs.id)}
>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span
className={`w-1.5 h-1.5 rounded-full shrink-0 ${
obs.status === "online" ? "bg-green" : "bg-text-dim/30"
}`}
/>
<span className={`truncate ${obs.displayName ? "text-text-normal" : "text-text-dim italic"}`}>
{obs.displayName ?? formatHex(obs.id)}
</span>
</div>
</td>
<td className="px-4 py-2 text-text-muted">
{obs.observerType ?? "—"}
</td>
<td className="px-4 py-2 text-text-normal">
{obs.iata}
</td>
<td className="px-4 py-2">
<Badge variant={obs.status === "online" ? "live" : "offline"}>
{obs.status}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
No observers
</div>
)}
</div>
</div>
{selectedId && (
<ObserverDetailPanel
observerId={selectedId}
onClose={() => setSelectedId(null)}
/>
)}
</div>
);
}
+30 -10
View File
@@ -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<string, unknown>;
lastStatusAt?: string;
firstSeen: string;
lastSeen: string;
observationCount: number;
brokers: ObserverBroker[];
}
export interface ObserverBroker {
name: string;
lastSeenAt: number;
lastPacketAt: number;
}
-12
View File
@@ -1,12 +0,0 @@
export interface PacketVolume {
timestamp: number;
count: number;
byType: Record<number, number>;
}
export interface ObserverCoverage {
observerId: string;
observerName: string;
packetCount: number;
uniqueNodes: number;
}
+9
View File
@@ -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]);
}
+19 -1
View File
@@ -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]);
}
+28
View File
@@ -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());
}
+5
View File
@@ -95,3 +95,8 @@ export interface IataCode {
approxLat: number | null;
approxLng: number | null;
}
export interface BrokerStatus {
name: string;
connected: boolean;
}
+9
View File
@@ -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;
+43
View File
@@ -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();
});
});
+27 -7
View File
@@ -1,12 +1,32 @@
/// <reference types="vitest/config" />
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",
},
};
});