diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 050d574..9c3f48f 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -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 ( {observer.displayName ?? observer.id.slice(0, 8)} - - {observer.status} + + {status}
diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index a8599f8..21eab8e 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -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[] = [ sortValue: (obs) => obs.displayName ?? formatHex(obs.id), cell: (obs) => (
- + {obs.displayName ?? formatHex(obs.id)} @@ -61,23 +63,27 @@ const COLUMNS: Column[] = [ }, { header: "Status", - sortValue: (obs) => obs.status, - cell: (obs) => {obs.status}, + sortValue: (obs) => deriveObserverStatus(obs), + cell: (obs) => { + const status = deriveObserverStatus(obs); + return {status}; + }, }, ]; function renderObserverCard(obs: ObserverSummary) { + const status = deriveObserverStatus(obs); return (
- + {obs.displayName ?? formatHex(obs.id)}
- {obs.status} + {status}
@@ -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, diff --git a/src/features/observers/observer-status.ts b/src/features/observers/observer-status.ts new file mode 100644 index 0000000..0e9d547 --- /dev/null +++ b/src/features/observers/observer-status.ts @@ -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, + now: number = Date.now(), +): ObserverSummary["status"] { + if (obs.lastStatusAt == null) return obs.status; + return now - obs.lastStatusAt < OBSERVER_ONLINE_WINDOW_MS ? "online" : "offline"; +} diff --git a/src/features/observers/observer-updates.ts b/src/features/observers/observer-updates.ts index fc5c578..b1608e4 100644 --- a/src/features/observers/observer-updates.ts +++ b/src/features/observers/observer-updates.ts @@ -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; } diff --git a/src/features/observers/types.ts b/src/features/observers/types.ts index 0754b1a..60c67f1 100644 --- a/src/features/observers/types.ts +++ b/src/features/observers/types.ts @@ -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; - lastStatusAt?: number; // epoch ms firstSeen: number; // epoch ms lastSeen: number; // epoch ms observationCount: number; diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 67604c6..625b6ec 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -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={ {observer.displayName ?? observer.id.slice(0, 8)} - {observer.status} + {status} {observer.observerType && {observer.observerType}} } diff --git a/tests/features/observers/ObserverDetailPanel.test.tsx b/tests/features/observers/ObserverDetailPanel.test.tsx index ce52068..7d332ae 100644 --- a/tests/features/observers/ObserverDetailPanel.test.tsx +++ b/tests/features/observers/ObserverDetailPanel.test.tsx @@ -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({ diff --git a/tests/features/observers/observer-status.test.ts b/tests/features/observers/observer-status.test.ts new file mode 100644 index 0000000..7886352 --- /dev/null +++ b/tests/features/observers/observer-status.test.ts @@ -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"); + }); +}); diff --git a/tests/features/observers/observer-updates.test.ts b/tests/features/observers/observer-updates.test.ts index ca836b8..3c3dd3f 100644 --- a/tests/features/observers/observer-updates.test.ts +++ b/tests/features/observers/observer-updates.test.ts @@ -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); + }); });