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.
This commit is contained in:
MrAlders0n committed 2026-06-10 07:03:16 -04:00
1 parent a78b735bb2
commit aec59225f0
9 files changed
+101 -12

No files matched your search

@@ -5,6 +5,8 @@ import { Badge } from "../../components/Badge";
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
import { useTick } from "../../hooks/useTick";
import { deriveObserverStatus } from "./observer-status";
import type { BadgeVariant } from "../../components/badge-utils";
import { IataChip } from "../../components/IataChip";
import { ScopeTag } from "../../components/ScopeTag";
@@ -114,7 +116,9 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
staleTime: 30_000,
});
useTick(); // re-derive the status badge as lastStatusAt ages
const stats = observer ? getStats(observer.statusMetadata) : null;
const status = observer ? deriveObserverStatus(observer) : null;
return (
<DetailPanel
@@ -137,8 +141,8 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
<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 variant={status === "online" ? "live" : "offline"}>
{status}
</Badge>
</div>
<div className="font-mono text-[13px] text-text-muted truncate mb-1.5" title={observer.publicKey}>
+13 -5
View File
@@ -13,6 +13,8 @@ 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";
@@ -34,7 +36,7 @@ const COLUMNS: Column<ObserverSummary>[] = [
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 ${obs.status === "online" ? "bg-green" : "bg-text-dim/30"}`} />
<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>
@@ -61,23 +63,27 @@ const COLUMNS: Column<ObserverSummary>[] = [
},
{
header: "Status",
sortValue: (obs) => obs.status,
cell: (obs) => <Badge variant={obs.status === "online" ? "live" : "offline"}>{obs.status}</Badge>,
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 ${obs.status === "online" ? "bg-green" : "bg-text-dim/30"}`} />
<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={obs.status === "online" ? "live" : "offline"}>{obs.status}</Badge>
<Badge variant={status === "online" ? "live" : "offline"}>{status}</Badge>
</span>
</div>
<div className="flex items-center gap-2 text-text-muted">
@@ -99,6 +105,8 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver,
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,
+14
View File
@@ -0,0 +1,14 @@
import type { ObserverSummary } from "./types";
// matches the server's read-time rule: last_status_at within 5 minutes = online (db/queries/queries.sql)
export const OBSERVER_ONLINE_WINDOW_MS = 5 * 60_000;
// The WS observerStatus event never reports offline, so the fetched status would stay green forever.
// Re-derive it from lastStatusAt recency at render time; rows without a timestamp keep the fetched value.
export function deriveObserverStatus(
obs: Pick<ObserverSummary, "status" | "lastStatusAt">,
now: number = Date.now(),
): ObserverSummary["status"] {
if (obs.lastStatusAt == null) return obs.status;
return now - obs.lastStatusAt < OBSERVER_ONLINE_WINDOW_MS ? "online" : "offline";
}
+5 -3
View File
@@ -14,9 +14,11 @@ export function patchObserverSummary(
const prev = list[idx]!;
const status = data.online ? "online" : "offline";
const displayName = data.displayName || prev.displayName;
// an unchanged status event must keep the SAME ref so patchInfinitePages no-ops (no row re-render)
if (status === prev.status && displayName === prev.displayName) return list;
const lastStatusAt = data.lastStatusAt || prev.lastStatusAt;
// a truly unchanged event keeps the SAME ref so patchInfinitePages no-ops; a fresh lastStatusAt
// must produce a new ref so the row's recency-derived status stays live
if (status === prev.status && displayName === prev.displayName && lastStatusAt === prev.lastStatusAt) return list;
const updated = [...list];
updated[idx] = { ...prev, status, displayName };
updated[idx] = { ...prev, status, displayName, lastStatusAt };
return updated;
}
+2 -1
View File
@@ -6,6 +6,8 @@ export interface ObserverSummary {
status: "online" | "offline";
radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown
scopes?: string[]; // transport scopes this observer forwards, e.g. ["#bc", "#west"]
// epoch ms; not in REST list responses — patched in from WS status events for recency derivation
lastStatusAt?: number;
}
export interface Observer extends ObserverSummary {
@@ -21,7 +23,6 @@ export interface Observer extends ObserverSummary {
batteryLevel?: number;
uptimeSeconds?: number;
statusMetadata?: Record<string, unknown>;
lastStatusAt?: number; // epoch ms
firstSeen: number; // epoch ms
lastSeen: number; // epoch ms
observationCount: number;
+5 -1
View File
@@ -8,6 +8,8 @@ import { useRegion } from "../../hooks/useRegion";
import { useChartColors } from "./chartTheme";
import { useTopObservers } from "./useStats";
import { useObserver, useObserverTelemetry } from "./useTelemetry";
import { useTick } from "../../hooks/useTick";
import { deriveObserverStatus } from "../observers/observer-status";
import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption } from "./chartOptions";
import { Card, ChartCard } from "./cards";
import { hasTelemetry } from "./transforms";
@@ -102,6 +104,8 @@ function ObserverList({
}
function ObserverHeader({ observer }: { observer: Observer }) {
useTick(); // keep the recency-derived status badge fresh
const status = deriveObserverStatus(observer);
const radio = [
observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`,
observer.radioSf && `SF${observer.radioSf}`,
@@ -114,7 +118,7 @@ function ObserverHeader({ observer }: { observer: Observer }) {
title={
<span className="flex items-center gap-2">
<span className="text-text-bright normal-case">{observer.displayName ?? observer.id.slice(0, 8)}</span>
<Badge variant={observer.status === "online" ? "live" : "offline"}>{observer.status}</Badge>
<Badge variant={status === "online" ? "live" : "offline"}>{status}</Badge>
{observer.observerType && <Badge variant="default">{observer.observerType}</Badge>}
</span>
}
@@ -57,6 +57,25 @@ beforeEach(() => {
mockGetObserverAdverts.mockResolvedValue({ items: [], nextCursor: null, hasMore: false });
});
describe("ObserverDetailPanel status", () => {
it("renders offline when lastStatusAt is 10 minutes old even if the API status says online", async () => {
mockGetObserver.mockResolvedValue({ ...observer, status: "online", lastStatusAt: Date.now() - 10 * 60_000 });
renderPanel();
expect(await screen.findByText("offline")).toBeInTheDocument();
expect(screen.queryByText("online")).not.toBeInTheDocument();
});
it("renders online when lastStatusAt is fresh", async () => {
mockGetObserver.mockResolvedValue({ ...observer, status: "online", lastStatusAt: Date.now() - 60_000 });
renderPanel();
expect(await screen.findByText("online")).toBeInTheDocument();
});
});
describe("ObserverDetailPanel adverts", () => {
it("lists adverts heard by the observer", async () => {
mockGetObserverAdverts.mockResolvedValue({
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { deriveObserverStatus, OBSERVER_ONLINE_WINDOW_MS } from "../../../src/features/observers/observer-status";
const now = 1_750_000_000_000;
describe("deriveObserverStatus", () => {
it("derives offline when lastStatusAt is 10 minutes old, even if the fetched status says online", () => {
expect(deriveObserverStatus({ status: "online", lastStatusAt: now - 10 * 60_000 }, now)).toBe("offline");
});
it("derives online when lastStatusAt is fresh, even if the fetched status says offline", () => {
expect(deriveObserverStatus({ status: "offline", lastStatusAt: now - 60_000 }, now)).toBe("online");
});
it("flips to offline at exactly the window edge (server rule is strictly within 5 minutes)", () => {
expect(deriveObserverStatus({ status: "online", lastStatusAt: now - OBSERVER_ONLINE_WINDOW_MS }, now)).toBe("offline");
expect(deriveObserverStatus({ status: "online", lastStatusAt: now - OBSERVER_ONLINE_WINDOW_MS + 1 }, now)).toBe("online");
});
it("falls back to the fetched status when the row carries no timestamp", () => {
expect(deriveObserverStatus({ status: "online" }, now)).toBe("online");
expect(deriveObserverStatus({ status: "offline" }, now)).toBe("offline");
});
});
@@ -57,4 +57,17 @@ describe("patchObserverSummary", () => {
const out = patchObserverSummary(list, update({ observerId: "a", online: true, displayName: "" }));
expect(out).toBe(list);
});
it("carries lastStatusAt from the event so recency derivation stays live", () => {
const list = [observer({ id: "a", status: "offline" })];
const out = patchObserverSummary(list, update({ observerId: "a", online: true, lastStatusAt: 123_456 }))!;
expect(out[0]!.lastStatusAt).toBe(123_456);
});
it("updates lastStatusAt even when status and displayName are unchanged", () => {
const list = [observer({ id: "a", status: "online", displayName: "Keep", lastStatusAt: 100 })];
const out = patchObserverSummary(list, update({ observerId: "a", online: true, displayName: "", lastStatusAt: 200 }))!;
expect(out).not.toBe(list);
expect(out[0]!.lastStatusAt).toBe(200);
});
});