mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 01:51:26 +00:00
Polish observer cards and clock drift on phones (#106)
This commit is contained in:
1 parent
8d1f30d553
commit
63344af365
8 files changed
+112
-55
No files matched your search
@@ -1,48 +1,69 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import { CopyButton } from "../../components/CopyButton";
|
||||
import { formatBattery, formatRadioParts, formatUptime, formatUtc } from "../../lib/formatters";
|
||||
import { formatAbsolute, formatBattery, formatRadioParts, formatUptime, formatUtc, timeAgoParts } from "../../lib/formatters";
|
||||
import { useTick } from "../../hooks/useTick";
|
||||
import { observerNoiseFloor } from "./observer-stats";
|
||||
import { Card, StatCard } from "../stats/cards";
|
||||
import { Card } from "../stats/cards";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
import { Field } from "../../components/DetailPanel";
|
||||
import { IataChip } from "../../components/IataChip";
|
||||
import type { Observer } from "./types";
|
||||
import type { ObserverActivity, TelemetryPoint } from "../stats/types";
|
||||
|
||||
// Numbers at full size, units and words smaller, so "30d 16h 52m" and "-119 dBm" scan as values.
|
||||
function Measure({ text }: { text: string }) {
|
||||
if (!/\d/.test(text)) return <>{text}</>;
|
||||
return <>{text.split(/(-?[\d.,]+)/).filter(Boolean).map((part, i) =>
|
||||
/\d/.test(part) ? <span key={i}>{part}</span> : <span key={i} className="text-sm font-semibold text-text-muted">{part}</span>)}</>;
|
||||
}
|
||||
|
||||
export function ObserverSummary({ observer, activity, points, pending = false }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; pending?: boolean }) {
|
||||
const { t, i18n } = useTranslation(); const now = useTick();
|
||||
const summary = activity?.summary;
|
||||
const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000;
|
||||
const reportedNoise = observerNoiseFloor(observer);
|
||||
const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb;
|
||||
const cards = [
|
||||
["records", summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—"],
|
||||
["lastHour", summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—"],
|
||||
["lastPacket", summary?.latestRecordedAt != null ? <Timestamp value={summary.latestRecordedAt} /> : "—"],
|
||||
["battery", observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—"],
|
||||
["uptime", observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—"],
|
||||
["noise", noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—"],
|
||||
] as const;
|
||||
const ago = (at: number) => {
|
||||
const { count, unit } = timeAgoParts(at);
|
||||
return t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) });
|
||||
};
|
||||
const hour = (at: number) => formatUtc(at, { timeOnly: true }).slice(0, 2);
|
||||
const cards: { key: string; label: string; value: string; title?: string }[] = [
|
||||
{ key: "records", label: t("observerPage.records"), value: summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—" },
|
||||
{
|
||||
key: "lastHour",
|
||||
label: summary ? t("observerPage.hourWindow", { start: hour(summary.lastCompleteHourStart), end: hour(summary.lastCompleteHourEnd) }) : t("observerPage.lastHour"),
|
||||
value: summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—",
|
||||
},
|
||||
{
|
||||
key: "lastPacket",
|
||||
label: t("observerPage.lastPacket"),
|
||||
value: summary?.latestRecordedAt != null ? ago(summary.latestRecordedAt) : "—",
|
||||
title: summary?.latestRecordedAt != null ? formatAbsolute(summary.latestRecordedAt) : undefined,
|
||||
},
|
||||
{ key: "battery", label: t("observerPage.battery"), value: observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—" },
|
||||
{ key: "uptime", label: t("observerPage.uptime"), value: observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—" },
|
||||
{
|
||||
key: "noise",
|
||||
label: t("observerPage.lastNoise"),
|
||||
value: noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—",
|
||||
},
|
||||
];
|
||||
return <>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0"><h1 className="break-words text-lg font-semibold text-text-bright">{observer.displayName ?? observer.id.slice(0, 8)}</h1>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<span role="img" aria-label={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} title={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} className={`inline-flex items-center ${observer.lastStatusAt == null ? "text-text-muted" : statusFresh ? "text-green" : "text-warn"}`}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="12" cy="12" r="9" />{statusFresh ? <path d="m7 12 3 3 7-7" /> : <><path d="M12 7v6" /><circle cx="12" cy="17" r=".5" fill="currentColor" /></>}</svg>
|
||||
</span>
|
||||
<IataChip>{observer.iata}</IataChip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<h1 className="min-w-0 break-words text-lg font-semibold text-text-bright">{observer.displayName ?? observer.id.slice(0, 8)}</h1>
|
||||
<span role="img" aria-label={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} title={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} className={`inline-flex items-center ${observer.lastStatusAt == null ? "text-text-muted" : statusFresh ? "text-green" : "text-warn"}`}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="12" cy="12" r="9" />{statusFresh ? <path d="m7 12 3 3 7-7" /> : <><path d="M12 7v6" /><circle cx="12" cy="17" r=".5" fill="currentColor" /></>}</svg>
|
||||
</span>
|
||||
<IataChip>{observer.iata}</IataChip>
|
||||
</div>
|
||||
<ul role="list" aria-label={t("observerPage.metrics")} className="grid grid-cols-2 gap-3 md:grid-cols-3 2xl:grid-cols-6">
|
||||
{cards.map(([key, value]) => {
|
||||
const note = key === "lastHour" && summary ? `${formatUtc(summary.lastCompleteHourStart, { timeOnly: true })}–${formatUtc(summary.lastCompleteHourEnd, { timeOnly: true })} UTC`
|
||||
: key === "noise" && noise != null ? t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")
|
||||
: undefined;
|
||||
return <li key={key} className="min-w-0">
|
||||
<StatCard label={t(`observerPage.${key}`)} value={value} accent="var(--color-primary)" />
|
||||
<p className="mt-1 min-h-4 text-xs text-text-muted">{note}</p>
|
||||
{cards.map(({ key, label, value, title }) => {
|
||||
const shown = <span className="font-mono text-xl font-bold tabular-nums text-text-bright sm:text-2xl"><Measure text={value} /></span>;
|
||||
return <li key={key} className="flex min-w-0 flex-col items-center justify-center gap-1 rounded-lg border border-border bg-bg-surface px-2 py-3 text-center">
|
||||
<span className="font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted">{label}</span>
|
||||
{title ? <Tooltip label={title}>{shown}</Tooltip> : shown}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ClockDriftTab() {
|
||||
id: "drift",
|
||||
header: t("clockDrift.drift"),
|
||||
className: "tabular-nums",
|
||||
cell: (e) => <span className={driftClass(e.clockDriftSeconds)}>{formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}</span>,
|
||||
cell: (e) => <span className={`whitespace-nowrap ${driftClass(e.clockDriftSeconds)}`}>{formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}</span>,
|
||||
sortValue: (e) => Math.abs(e.clockDriftSeconds),
|
||||
},
|
||||
{
|
||||
@@ -58,6 +58,23 @@ export function ClockDriftTab() {
|
||||
),
|
||||
},
|
||||
], [t]);
|
||||
const drift = (e: ClockDriftEntry) =>
|
||||
formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") });
|
||||
const renderCard = (e: ClockDriftEntry) => (
|
||||
<div className="flex flex-col gap-1.5 font-mono text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`min-w-0 flex-1 truncate ${e.nodeName ? "text-text-normal" : "italic text-text-dim"}`}>{e.nodeName ?? e.nodeId.slice(0, 8)}</span>
|
||||
<span className="shrink-0"><Badge variant="default">{e.nodeTypeName}</Badge></span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`tabular-nums ${driftClass(e.clockDriftSeconds)}`}>{drift(e)}</span>
|
||||
<span className="shrink-0 text-text-muted"><Timestamp value={e.clockCheckedAt} /></span>
|
||||
</div>
|
||||
{(e.iatas ?? []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">{e.iatas!.map((i) => <IataChip key={i.iata}>{i.iata}</IataChip>)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -73,6 +90,7 @@ export function ClockDriftTab() {
|
||||
isLoading={loading}
|
||||
emptyLabel={clockDrift.isError ? t("common.loadFailed") : t("clockDrift.empty")}
|
||||
defaultSort={{ id: "drift", direction: "desc" }}
|
||||
renderCard={renderCard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,7 @@ export function ScopesTab() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("scopes.title")}</h2><InfoTip text={[t("scopes.subtitle"), t("scopes.measurement")]} /></div>
|
||||
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">{t("scopes.refresh")}</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("scopes.title")}</h2><InfoTip text={[t("scopes.subtitle"), t("scopes.measurement")]} /></div>
|
||||
<label className="flex max-w-sm flex-col gap-1 text-xs text-text-muted">{t("scopes.search")}
|
||||
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="#bc, #east…" className="rounded border border-border bg-bg-raised px-3 py-2 text-base text-text-bright outline-none focus:border-primary sm:text-sm" />
|
||||
</label>
|
||||
|
||||
@@ -206,7 +206,6 @@
|
||||
"scopes": {
|
||||
"title": "Transport scopes",
|
||||
"subtitle": "Retained data and memberships for the selected region.",
|
||||
"refresh": "Refresh scopes",
|
||||
"search": "Find a scope",
|
||||
"error": "Could not load scopes. Try refreshing.",
|
||||
"active": "Scopes with data",
|
||||
@@ -266,10 +265,10 @@
|
||||
"metrics": "Observer metrics",
|
||||
"records": "Recorded packets",
|
||||
"lastHour": "Last complete hour",
|
||||
"lastPacket": "Last recorded packet",
|
||||
"lastPacket": "Last packet",
|
||||
"battery": "Battery",
|
||||
"batteryV": "Battery V",
|
||||
"uptime": "Device uptime",
|
||||
"uptime": "Uptime",
|
||||
"noise": "Noise floor",
|
||||
"noiseDbm": "Noise dBm",
|
||||
"packets": "Packets",
|
||||
@@ -310,8 +309,6 @@
|
||||
"mean": "Average",
|
||||
"minimum": "Minimum",
|
||||
"unknown": "Unknown",
|
||||
"latestStatus": "Latest status",
|
||||
"latestTelemetry": "Latest telemetry interval",
|
||||
"recording": "Loading observer…",
|
||||
"hour": "hour",
|
||||
"day": "day",
|
||||
@@ -319,7 +316,9 @@
|
||||
"directory": "All observers",
|
||||
"sidebarSearch": "Search observers",
|
||||
"noMatches": "No matches",
|
||||
"none": "No observers"
|
||||
"none": "No observers",
|
||||
"hourWindow": "Packets {{start}}–{{end}} UTC",
|
||||
"lastNoise": "Last noise floor"
|
||||
},
|
||||
"observerCompare": {
|
||||
"open": "Compare with…",
|
||||
|
||||
@@ -209,7 +209,6 @@
|
||||
"scopes": {
|
||||
"title": "Scopes de transport",
|
||||
"subtitle": "Données conservées et appartenances pour la région sélectionnée.",
|
||||
"refresh": "Actualiser les scopes",
|
||||
"search": "Rechercher un scope",
|
||||
"error": "Impossible de charger les scopes. Réessayez.",
|
||||
"active": "Scopes avec données",
|
||||
@@ -270,7 +269,7 @@
|
||||
"metrics": "Mesures de l’observateur",
|
||||
"records": "Paquets enregistrés",
|
||||
"lastHour": "Dernière heure complète",
|
||||
"lastPacket": "Dernier paquet enregistré",
|
||||
"lastPacket": "Dernier paquet",
|
||||
"battery": "Batterie",
|
||||
"batteryV": "Batterie V",
|
||||
"uptime": "Durée de fonctionnement",
|
||||
@@ -314,8 +313,6 @@
|
||||
"mean": "Moyenne",
|
||||
"minimum": "Minimum",
|
||||
"unknown": "Inconnu",
|
||||
"latestStatus": "Dernier état",
|
||||
"latestTelemetry": "Dernière période de télémétrie",
|
||||
"recording": "Chargement de l’observateur…",
|
||||
"hour": "heure",
|
||||
"day": "jour",
|
||||
@@ -323,7 +320,9 @@
|
||||
"directory": "Tous les observateurs",
|
||||
"sidebarSearch": "Rechercher les observateurs",
|
||||
"noMatches": "Aucun résultat",
|
||||
"none": "Aucun observateur"
|
||||
"none": "Aucun observateur",
|
||||
"hourWindow": "Paquets {{start}}–{{end}} h UTC",
|
||||
"lastNoise": "Dernier bruit de fond"
|
||||
},
|
||||
"observerCompare": {
|
||||
"open": "Comparer avec…",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { ObserverSummary } from "../../../src/features/observers/ObserverSummary";
|
||||
import type { Observer } from "../../../src/features/observers/types";
|
||||
import type { ObserverActivity, TelemetryPoint } from "../../../src/features/stats/types";
|
||||
@@ -22,21 +22,30 @@ describe("compact observer header", () => {
|
||||
});
|
||||
|
||||
describe("observer summary card notes", () => {
|
||||
it("wraps the last-complete-hour window under its card instead of a non-wrapping sublabel", () => {
|
||||
it("keeps the status icon and IATA chip on the name row", () => {
|
||||
render(<ObserverSummary observer={observer} points={[]} />);
|
||||
const row = screen.getByRole("heading", { level: 1 }).parentElement!;
|
||||
expect(screen.getByRole("img", { name: "Status is stale" }).parentElement).toBe(row);
|
||||
expect(screen.getByText("YKF").parentElement).toBe(row);
|
||||
});
|
||||
|
||||
it("names the last complete hour by its UTC window instead of a note under the card", () => {
|
||||
const activity = { summary: {
|
||||
recordedPackets: 5, lastCompleteHour: 3, lastCompleteHourStart: 0, lastCompleteHourEnd: 3_600_000, latestRecordedAt: null,
|
||||
} } as unknown as ObserverActivity;
|
||||
render(<ObserverSummary observer={observer} activity={activity} points={[]} />);
|
||||
const note = screen.getByText("00:00–01:00 UTC");
|
||||
expect(note).not.toHaveClass("whitespace-nowrap");
|
||||
expect(note).toHaveClass("mt-1");
|
||||
const tile = screen.getByText("Packets 00–01 UTC").closest("li")!;
|
||||
expect(tile).toHaveTextContent("3");
|
||||
expect(tile).toHaveClass("text-center");
|
||||
expect(screen.queryByText(/00:00–01:00/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wraps the noise-floor telemetry note under its card the same way", () => {
|
||||
it("labels the noise tile as the last reading and sets the unit apart from the number", () => {
|
||||
const points = [{ noiseFloorDb: -91 } as unknown as TelemetryPoint];
|
||||
render(<ObserverSummary observer={observer} points={points} />);
|
||||
const note = screen.getByText("Latest telemetry interval");
|
||||
expect(note).not.toHaveClass("whitespace-nowrap");
|
||||
expect(note).toHaveClass("mt-1");
|
||||
const tile = screen.getByText("Last noise floor").closest("li")!;
|
||||
expect(screen.queryByText(/Latest telemetry interval|Latest status/)).not.toBeInTheDocument();
|
||||
expect(within(tile).getByText("-91")).not.toHaveClass("text-sm");
|
||||
expect(within(tile).getByText("dBm")).toHaveClass("text-sm");
|
||||
});
|
||||
});
|
||||
@@ -66,3 +66,19 @@ it("retains valid values during a healthy background refresh", () => {
|
||||
expect(screen.getByText("+59m 59s ahead")).toHaveClass("text-warn");
|
||||
expect(screen.getByText("-1h 0m behind")).toHaveClass("text-danger");
|
||||
});
|
||||
|
||||
it("shows compact cards instead of a wide table on a phone, worst first", async () => {
|
||||
await act(() => i18n.changeLanguage("en"));
|
||||
const media = window.matchMedia("(max-width: 767px)");
|
||||
const spy = vi.spyOn(window, "matchMedia").mockImplementation((q) => ({ ...media, media: q, matches: q === "(max-width: 767px)" }));
|
||||
try {
|
||||
const { container } = render(<ClockDriftTab />);
|
||||
expect(container.querySelector("table")).toBeNull();
|
||||
const cards = screen.getAllByRole("button", { name: /Beta|Alpha/ });
|
||||
expect(cards.map((c) => c.textContent)).toEqual([expect.stringContaining("Beta"), expect.stringContaining("Alpha")]);
|
||||
expect(cards[0]).toHaveTextContent("-1h 0m behind");
|
||||
expect(cards[0]).toHaveTextContent("YOW");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -22,8 +22,7 @@ it("translates scope counts and membership guidance without changing scope names
|
||||
expect(screen.getByText("Appartenances d’observateurs")).toBeInTheDocument();
|
||||
expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("sans période sélectionnée");
|
||||
expect(screen.getByText("2 sur 2 scopes")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actualiser les scopes" }));
|
||||
expect(query.refetch).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByRole("button", { name: /Actualiser/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the current search and chart values when changing language", async () => {
|
||||
@@ -84,8 +83,7 @@ it("shows exact scope counts, keeps membership semantics explicit and filters th
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "WEST" } });
|
||||
expect(screen.queryByText("#east")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 2 scopes/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh scopes" }));
|
||||
expect(query.refetch).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides stale values while a region change is pending and on errors", () => {
|
||||
|
||||
Reference in new issue
Block a user