Files
beacon-web/src/features/observers/ObserverTable.tsx
T
MrAlders0n aec59225f0 observers: derive online/offline from lastStatusAt recency
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.
2026-06-10 07:03:16 -04:00

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>
);
}