Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 47 additions & 26 deletions src/features/observers/ObserverSummary.tsx
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
20 changes: 19 additions & 1 deletion src/features/stats/ClockDriftTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
{
Expand All @@ -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">
Expand All @@ -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>
);
Expand Down
5 changes: 1 addition & 4 deletions src/features/stats/ScopesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
11 changes: 5 additions & 6 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -310,16 +309,16 @@
"mean": "Average",
"minimum": "Minimum",
"unknown": "Unknown",
"latestStatus": "Latest status",
"latestTelemetry": "Latest telemetry interval",
"recording": "Loading observer…",
"hour": "hour",
"day": "day",
"returnTo": "Back to {{page}}",
"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…",
Expand Down
9 changes: 4 additions & 5 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -314,16 +313,16 @@
"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",
"returnTo": "Retour à « {{page}} »",
"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…",
Expand Down
27 changes: 18 additions & 9 deletions tests/features/observers/ObserverSummary.compact.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");
});
});
16 changes: 16 additions & 0 deletions tests/features/stats/ClockDriftTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
6 changes: 2 additions & 4 deletions tests/features/stats/ScopesTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading