mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-09 15:05:34 +00:00
The WS observerStatus event never says offline (the backend only derives it at REST read time, 5-minute window) and the list never refetches, so badges stayed green all session once an observer went quiet. Status is now re-derived on a tick from lastStatusAt — the WS patch keeps the timestamp fresh — using the same 5-minute rule as the server.
222 lines
8.3 KiB
TypeScript
222 lines
8.3 KiB
TypeScript
import { useState, useCallback, useMemo } from "react";
|
|
import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
|
import { getObserversPage, getBrokers } from "../../api/client";
|
|
import { useRegion } from "../../hooks/useRegion";
|
|
import { useScopes } from "../../hooks/useScopes";
|
|
import { useInfinitePages } from "../../hooks/useInfinitePages";
|
|
import { patchInfinitePages } from "../../lib/infinite-pages";
|
|
import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers";
|
|
import { formatHex, formatRadio } from "../../lib/formatters";
|
|
import { Badge } from "../../components/Badge";
|
|
import { DataTable, type Column } from "../../components/DataTable";
|
|
import { LoadingPill } from "../../components/LoadingPill";
|
|
import { ObserverFilterBar } from "./ObserverFilterBar";
|
|
import { ObserverDetailPanel } from "./ObserverDetailPanel";
|
|
import { patchObserverSummary } from "./observer-updates";
|
|
import { deriveObserverStatus } from "./observer-status";
|
|
import { useTick } from "../../hooks/useTick";
|
|
import type { ObserverSummary } from "./types";
|
|
import type { CursorPage } from "../../types/api";
|
|
import type { WsManager } from "../../api/ws-manager";
|
|
import type { WsObserverStatus } from "../../types/ws";
|
|
|
|
const observerId = (o: ObserverSummary) => o.id; // stable id accessor for the paged hook's dedup
|
|
|
|
interface ObserverTableProps {
|
|
wsManager: WsManager;
|
|
selectedObserverId: string | null;
|
|
onSelectObserver: (id: string | null) => void;
|
|
onAnalyzePacket?: (hash: string) => void;
|
|
onViewStats?: (observerId: string) => void;
|
|
}
|
|
|
|
const COLUMNS: Column<ObserverSummary>[] = [
|
|
{
|
|
header: "Name",
|
|
sortValue: (obs) => obs.displayName ?? formatHex(obs.id),
|
|
cell: (obs) => (
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${deriveObserverStatus(obs) === "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>
|
|
),
|
|
},
|
|
{
|
|
header: "Type",
|
|
className: "text-text-muted",
|
|
sortValue: (obs) => obs.observerType ?? null,
|
|
cell: (obs) => obs.observerType ?? "—",
|
|
},
|
|
{
|
|
header: "Radio",
|
|
className: "text-text-muted",
|
|
sortValue: (obs) => formatRadio(obs.radio) ?? null,
|
|
cell: (obs) => formatRadio(obs.radio) ?? "—",
|
|
},
|
|
{
|
|
header: "IATA",
|
|
className: "text-text-normal",
|
|
sortValue: (obs) => obs.iata,
|
|
cell: (obs) => obs.iata,
|
|
},
|
|
{
|
|
header: "Status",
|
|
sortValue: (obs) => deriveObserverStatus(obs),
|
|
cell: (obs) => {
|
|
const status = deriveObserverStatus(obs);
|
|
return <Badge variant={status === "online" ? "live" : "offline"}>{status}</Badge>;
|
|
},
|
|
},
|
|
];
|
|
|
|
function renderObserverCard(obs: ObserverSummary) {
|
|
const status = deriveObserverStatus(obs);
|
|
return (
|
|
<div className="flex flex-col gap-1.5 font-mono text-xs">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${status === "online" ? "bg-green" : "bg-text-dim/30"}`} />
|
|
<span className={`flex-1 min-w-0 truncate ${obs.displayName ? "text-text-normal" : "text-text-dim italic"}`}>
|
|
{obs.displayName ?? formatHex(obs.id)}
|
|
</span>
|
|
</div>
|
|
<span className="shrink-0">
|
|
<Badge variant={status === "online" ? "live" : "offline"}>{status}</Badge>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-text-muted">
|
|
<span className="text-text-normal">{obs.iata}</span>
|
|
<span>· {obs.observerType ?? "—"}</span>
|
|
<span>· {formatRadio(obs.radio) ?? "—"}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, onAnalyzePacket, onViewStats }: ObserverTableProps) {
|
|
const { iatas, regionKey } = useRegion();
|
|
const queryClient = useQueryClient();
|
|
const [search, setSearch] = useState("");
|
|
const [searchField, setSearchField] = useState("name");
|
|
const [statusFilter, setStatusFilter] = useState("");
|
|
const [typeFilter, setTypeFilter] = useState("");
|
|
const [brokerFilter, setBrokerFilter] = useState("");
|
|
const [scopeFilter, setScopeFilter] = useState(""); // "" = Any; applied client-side over the loaded set
|
|
|
|
useTick(); // keep recency-derived status badges fresh
|
|
|
|
const { data: brokers } = useQuery({
|
|
queryKey: ["brokers"],
|
|
queryFn: getBrokers,
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
const brokerNames = useMemo(
|
|
() => brokers?.map((b) => b.name) ?? [],
|
|
[brokers],
|
|
);
|
|
|
|
const queryKey = useMemo(
|
|
() => ["observers", regionKey, statusFilter, typeFilter, brokerFilter, search, searchField],
|
|
[regionKey, statusFilter, typeFilter, brokerFilter, search, searchField],
|
|
);
|
|
|
|
// page the region's observers 50 at a time (filters stay server-side, in the query key); rows
|
|
// stream in as each batch lands. Loads once per filter set — WS status events keep them live.
|
|
const { items: observers, loadedCount, isPaging, isError, isLoading } = useInfinitePages<ObserverSummary>({
|
|
queryKey,
|
|
queryFn: (cursor) =>
|
|
getObserversPage(iatas, {
|
|
cursor,
|
|
status: statusFilter || undefined,
|
|
type: typeFilter || undefined,
|
|
broker: brokerFilter || undefined,
|
|
name: searchField === "name" ? search || undefined : undefined,
|
|
}),
|
|
getId: observerId,
|
|
keepPrevious: true,
|
|
});
|
|
|
|
const typeOptions = useMemo(() => {
|
|
const types = new Set<string>();
|
|
for (const obs of observers) {
|
|
if (obs.observerType) types.add(obs.observerType);
|
|
}
|
|
return [...types].sort();
|
|
}, [observers]);
|
|
|
|
// scope options are the configured scopes; the filter itself is applied client-side on obs.scopes
|
|
const scopeOptions = useScopes();
|
|
|
|
const displayObservers = useMemo(
|
|
() => (scopeFilter ? observers.filter((o) => o.scopes?.includes(scopeFilter)) : observers),
|
|
[observers, scopeFilter],
|
|
);
|
|
|
|
// patch the live status into the paged cache (mirrors NodeTable). A brand-new observer not on any
|
|
// loaded page isn't pulled in here — it surfaces on the next reload/region switch (see the
|
|
// beacon-docs ticket about carrying the full summary in WS events for true live insertion).
|
|
const handleObserverStatus = useCallback(
|
|
(data: WsObserverStatus["data"]) => {
|
|
queryClient.setQueryData<InfiniteData<CursorPage<ObserverSummary>>>(queryKey, (old) =>
|
|
patchInfinitePages(old, (items) => patchObserverSummary(items, data) ?? items),
|
|
);
|
|
// refresh detail panel if it's showing this observer
|
|
if (selectedObserverId === data.observerId) {
|
|
queryClient.invalidateQueries({ queryKey: ["observer", data.observerId] });
|
|
}
|
|
},
|
|
[queryClient, queryKey, selectedObserverId],
|
|
);
|
|
|
|
useWsObserverStatusHandler(wsManager, handleObserverStatus);
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0">
|
|
<div className="relative flex flex-col flex-1 min-w-0">
|
|
<ObserverFilterBar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
searchField={searchField}
|
|
onSearchFieldChange={setSearchField}
|
|
statusFilter={statusFilter}
|
|
onStatusChange={setStatusFilter}
|
|
typeFilter={typeFilter}
|
|
onTypeChange={setTypeFilter}
|
|
typeOptions={typeOptions}
|
|
brokerFilter={brokerFilter}
|
|
onBrokerChange={setBrokerFilter}
|
|
brokerOptions={brokerNames}
|
|
scopeFilter={scopeFilter}
|
|
onScopeChange={setScopeFilter}
|
|
scopeOptions={scopeOptions}
|
|
/>
|
|
|
|
<DataTable
|
|
columns={COLUMNS}
|
|
rows={displayObservers}
|
|
rowKey={(o) => o.id}
|
|
selectedKey={selectedObserverId}
|
|
onSelect={onSelectObserver}
|
|
isLoading={isLoading}
|
|
emptyLabel="No observers"
|
|
defaultSort={{ header: "Name" }}
|
|
renderCard={renderObserverCard}
|
|
/>
|
|
<LoadingPill loading={isPaging} error={isError} count={loadedCount} noun="observers" position="bottom-3 right-3" />
|
|
</div>
|
|
|
|
{selectedObserverId && (
|
|
<ObserverDetailPanel
|
|
observerId={selectedObserverId}
|
|
onClose={() => onSelectObserver(null)}
|
|
onAnalyzePacket={onAnalyzePacket}
|
|
onViewStats={onViewStats}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|