mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 23:31:30 +00:00
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:
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,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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user