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
2 changes: 1 addition & 1 deletion src/components/DetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function Field({ label, value }: { label: string; value: ReactNode }) {
}

// Minimize/expand toggle for the mobile overlay only; at md+ the panel is a sidebar so it's hidden.
function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
export function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
return (
<button
type="button"
Expand Down
25 changes: 15 additions & 10 deletions src/features/observers/ObserverPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getBrokers } from "../../api/client";
import { CloseButton } from "../../components/CloseButton";
import { CopyButton } from "../../components/CopyButton";
import { MinimizeButton } from "../../components/DetailPanel";
import { EmptyState } from "../../components/EmptyState";
import { ACTION_BUTTON_CLASS } from "../../components/action-button";
import { useScopes } from "../../hooks/useScopes";
Expand All @@ -17,8 +19,6 @@ import { Segmented } from "../stats/Segmented";
import type { WsManager } from "../../api/ws-manager";
const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab })));

const NAV_CHIP = "inline-flex items-center gap-1.5 rounded-sm border border-border bg-bg-raised px-2 py-0.5 font-mono text-[11px] text-text-normal transition-colors hover:border-text-dim hover:text-text-bright";

export function ObserverPage({ wsManager }: { wsManager: WsManager }) {
const [params, setParams] = useSearchParams();
const { t } = useTranslation();
Expand All @@ -37,6 +37,7 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) {
const [type, setType] = useState("");
const [broker, setBroker] = useState("");
const [scope, setScope] = useState("");
const [minimized, setMinimized] = useState(false);
const directory = useObserverDirectory(wsManager, broker);
const { data: brokers } = useQuery({ queryKey: ["brokers"], queryFn: getBrokers, staleTime: 60_000 });
const scopeOptions = useScopes(scope);
Expand All @@ -45,7 +46,7 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) {
const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString();
if (id) share.searchParams.set("range", range);
if (comparing && until != null) share.searchParams.set("compareUntil", String(until));
const select = (observer: string | null) => setParams(observerDestination(params, observer, range));
const select = (observer: string | null) => { setMinimized(false); setParams(observerDestination(params, observer, range)); };
const compare = (observer: string) => {
// eslint-disable-next-line react-hooks/purity -- Capture time when the user invokes this event callback.
const clickedAt = Date.now();
Expand Down Expand Up @@ -78,17 +79,21 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) {
<Segmented ariaLabel={t("observerPage.range")} size="sm" value={range} options={[{ value: "24h", label: t("stats.ranges.24h") }, { value: "7d", label: t("stats.ranges.7d") }, { value: "30d", label: t("stats.ranges.30d") }]} onChange={v => setParams(observerDestination(params, id, observerRange(v)))} />
</span>}
/>
<div className="flex min-h-0 min-w-0 flex-1">
{/* below md the list and the dashboard take turns */}
<div className={`${id ? "hidden md:flex" : "flex"} min-h-0 w-full shrink-0 flex-col p-4 md:w-[260px] md:pr-0`}>
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="flex min-h-0 w-full shrink-0 flex-col p-4 md:w-[260px] md:pr-0">
<ObserverSidebar observers={observers} filtered={Boolean(search || status || type || broker || scope)} isPending={directory.isPending} isError={directory.isError}
onRetry={() => void directory.refetch()} range={range} selectedId={id} onSelect={select} />
</div>
{id ? <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="px-4 pt-3 md:hidden">
<button type="button" aria-label={t("observerPage.back")} onClick={() => select(null)} className={NAV_CHIP}><span aria-hidden>‹</span>{t("observerPage.directory")}</button>
{/* below md the dashboard overlays the list, like the node detail panel */}
{id ? <div className={`${minimized ? "absolute inset-x-0 bottom-0" : "absolute inset-0"} z-30 flex min-h-0 min-w-0 flex-col bg-bg-base md:static md:z-auto md:flex-1`}>
<div className="flex shrink-0 items-center justify-between border-b border-border-subtle bg-bg-surface px-3 py-2 md:hidden">
<span className="font-mono text-[13px] font-medium uppercase tracking-wider text-text-dim">{t("observerPage.detail")}</span>
<div className="-mr-1 flex items-center gap-0.5">
<MinimizeButton collapsed={minimized} onToggle={() => setMinimized(v => !v)} />
<CloseButton onClose={() => select(null)} label={t("observerPage.back")} />
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div className={`min-h-0 flex-1 overflow-auto ${minimized ? "hidden md:block" : ""}`}>
<Suspense fallback={<p role="status" className="p-4">{t("common.loading")}</p>}>
<ObserverTab range={range} selectedObserverId={id} onSelectObserver={select} wsManager={wsManager} actions={<>
<CopyButton value={share.toString()} label={t("observerPage.copyLinkShort")} copiedLabel={t("observerPage.copied")} ariaLabel={t("observerPage.copyLink")} className="justify-center py-1" />
Expand Down
2 changes: 1 addition & 1 deletion src/features/observers/ObserverSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, onRet
active ? "border-primary bg-primary/10" : "border-transparent hover:bg-text-normal/3"
}`}
>
{count != null && <div className="absolute bottom-0 left-0 h-0.5 bg-secondary/40" style={{ width: `${(count / max) * 100}%` }} aria-hidden />}
{count != null && <div className="absolute inset-y-0 left-0 bg-secondary/15" style={{ width: `${(count / max) * 100}%` }} aria-hidden />}
<div className="relative flex items-center gap-2">
<span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${deriveObserverStatus(o) === "online" ? "bg-green" : "bg-text-dim/30"}`} />
<span className={`min-w-0 flex-1 truncate font-mono text-[12px] ${active ? "text-text-bright" : "text-text-normal"}`}>{nameOf(o)}</span>
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@
},
"observerPage": {
"back": "Back to observers",
"detail": "Observer detail",
"range": "Time range",
"open": "Open observer dashboard",
"legacy": "Legacy presence count",
Expand Down Expand Up @@ -309,7 +310,6 @@
"recording": "Loading observer…",
"hour": "hour",
"day": "day",
"directory": "All observers",
"sidebarSearch": "Search observers",
"noMatches": "No matches",
"none": "No observers",
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@
},
"observerPage": {
"back": "Retour aux observateurs",
"detail": "Détail de l'observateur",
"range": "Période",
"open": "Ouvrir le tableau de bord",
"legacy": "Ancien compteur de présence",
Expand Down Expand Up @@ -313,7 +314,6 @@
"recording": "Chargement de l’observateur…",
"hour": "heure",
"day": "jour",
"directory": "Tous les observateurs",
"sidebarSearch": "Rechercher les observateurs",
"noMatches": "Aucun résultat",
"none": "Aucun observateur",
Expand Down
19 changes: 15 additions & 4 deletions tests/features/observers/ObserverPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,26 @@ describe("Observer destination", () => {
expect(within(header).getByRole("button", { name: "Copy observer link" })).toHaveTextContent("Copy link");
expect(screen.getAllByRole("button", { name: "Compare with…" })).toHaveLength(1);
});
it("only offers a return to the list on mobile, where the list is hidden", async () => {
it("closes the mobile overlay from its header, back to the list", async () => {
view("?tab=Observers&observer=observer-a&range=7d");
await screen.findByRole("heading");
const back = screen.getByRole("button", { name: /Back to observers/ });
expect(back.closest(".md\\:hidden")).not.toBeNull();
fireEvent.click(back);
const header = screen.getByText("Observer detail").parentElement!;
expect(header).toHaveClass("md:hidden");
fireEvent.click(within(header).getByRole("button", { name: "Back to observers" }));
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
expect(screen.getByRole("status").textContent).not.toContain("observer=");
});
it("minimizes the mobile overlay over the list and expands for the next pick", async () => {
view("?tab=Observers&observer=observer-a&range=7d");
const body = () => screen.getByRole("heading").closest(".overflow-auto")!;
await screen.findByRole("heading");
expect(list().getAllByRole("button")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "Minimize detail panel" }));
expect(body()).toHaveClass("hidden");
fireEvent.click(list().getByText("Basement B"));
expect(await screen.findByRole("heading")).toHaveTextContent("observer-b");
expect(body()).not.toHaveClass("hidden");
});
});

it("anchors a new comparison at the click time and accepts Refresh across an hour boundary", async () => {
Expand Down
Loading