mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-01 16:48:19 +00:00
added channel & observers
This commit is contained in:
@@ -19,6 +19,7 @@ build/
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.references/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
+16
-7
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -95,3 +95,8 @@ export interface IataCode {
|
||||
approxLat: number | null;
|
||||
approxLng: number | null;
|
||||
}
|
||||
|
||||
export interface BrokerStatus {
|
||||
name: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user