From de9784211d1ab94ac47f9dce46bd314fc12f002b Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 23:24:58 +0100 Subject: [PATCH] feat(web): adopt TanStack Query for SPA data layer; consolidate shared UI Migrate the React SPA off the bespoke useApiFetch hook and raw useEffect fetches to TanStack Query: useQuery/useQueries for reads, useMutation + invalidateQueries for writes; a central query-key factory and invalidation helpers mirroring the backend cache_invalidation prefixes (channels/routes/nodes/messages/profiles/dashboard/adoptions); polling via refetchInterval (useAutoRefresh now returns pause state only); RouteCard self-fetches its detail/history, dropping the hand-rolled caches. Adds QueryClientProvider in App, a renderWithProviders test helper, and deletes useApiFetch. Also consolidates recurring UI into reusable components (Breadcrumbs, ListToolbar, Modal/ConfirmDialog, NotFoundState, TimeAgo, Definition, CopyableValue, MeshQrCode, Badges, SectionGroup, PageHeader) and fixes the FilterForm clear button, merged toggle wrappers, and role-aware channels/messages cache keys so client invalidation matches the server. Verified: tsc --noEmit clean, vitest 113 passed, pytest test_web 251 + test_cache 119 passed, pre-commit --all-files green. --- package-lock.json | 30 ++ package.json | 1 + src/meshcore_hub/api/routes/channels.py | 2 +- src/meshcore_hub/api/routes/messages.py | 2 +- .../web/static/js/spa-react/App.tsx | 13 +- .../components/AutoRefreshToggle.test.tsx | 47 +++ .../components/AutoRefreshToggle.tsx | 34 ++ .../js/spa-react/components/Badges.test.tsx | 27 ++ .../static/js/spa-react/components/Badges.tsx | 13 + .../spa-react/components/Breadcrumbs.test.tsx | 63 ++++ .../js/spa-react/components/Breadcrumbs.tsx | 30 ++ .../components/ConfirmDialog.test.tsx | 86 +++++ .../js/spa-react/components/ConfirmDialog.tsx | 55 +++ .../components/CopyableValue.test.tsx | 27 ++ .../js/spa-react/components/CopyableValue.tsx | 23 ++ .../spa-react/components/Definition.test.tsx | 34 ++ .../js/spa-react/components/Definition.tsx | 30 ++ .../spa-react/components/EmptyState.test.tsx | 27 ++ .../js/spa-react/components/EmptyState.tsx | 21 ++ .../spa-react/components/FilterForm.test.tsx | 108 ++++++ .../js/spa-react/components/FilterForm.tsx | 116 ++++++- .../spa-react/components/ListToolbar.test.tsx | 72 ++++ .../js/spa-react/components/ListToolbar.tsx | 43 +++ .../spa-react/components/MeshQrCode.test.tsx | 20 ++ .../js/spa-react/components/MeshQrCode.tsx | 25 ++ .../js/spa-react/components/Modal.test.tsx | 48 +++ .../static/js/spa-react/components/Modal.tsx | 30 ++ .../js/spa-react/components/NodeDisplay.tsx | 16 + .../components/NotFoundState.test.tsx | 24 ++ .../js/spa-react/components/NotFoundState.tsx | 20 ++ .../spa-react/components/PacketParts.test.tsx | 48 +++ .../js/spa-react/components/PacketParts.tsx | 58 ++++ .../spa-react/components/PageHeader.test.tsx | 44 +++ .../js/spa-react/components/PageHeader.tsx | 24 ++ .../components/SectionGroup.test.tsx | 29 ++ .../js/spa-react/components/SectionGroup.tsx | 24 ++ .../js/spa-react/components/TimeAgo.test.tsx | 39 +++ .../js/spa-react/components/TimeAgo.tsx | 17 + .../components/TimezoneIndicator.tsx | 7 - .../js/spa-react/hooks/useAutoRefresh.ts | 49 +-- .../js/spa-react/pages/Advertisements.tsx | 260 +++++--------- .../static/js/spa-react/pages/Channels.tsx | 167 +++++---- .../static/js/spa-react/pages/CustomPage.tsx | 4 + .../static/js/spa-react/pages/Dashboard.tsx | 154 +++++---- .../web/static/js/spa-react/pages/Home.tsx | 104 +++--- .../web/static/js/spa-react/pages/MapPage.tsx | 87 ++--- .../web/static/js/spa-react/pages/Members.tsx | 66 ++-- .../static/js/spa-react/pages/Messages.tsx | 217 +++++------- .../static/js/spa-react/pages/NodeDetail.tsx | 323 ++++++++---------- .../web/static/js/spa-react/pages/Nodes.tsx | 252 ++++++-------- .../js/spa-react/pages/PacketDetail.tsx | 185 ++++------ .../js/spa-react/pages/PacketGroupDetail.tsx | 196 ++++------- .../web/static/js/spa-react/pages/Packets.tsx | 218 +++++------- .../web/static/js/spa-react/pages/Profile.tsx | 130 ++++--- .../web/static/js/spa-react/pages/Routes.tsx | 237 +++++-------- .../js/spa-react/test/renderWithProviders.tsx | 48 +++ .../static/js/spa-react/utils/format.test.ts | 33 ++ .../web/static/js/spa-react/utils/format.ts | 15 + .../static/js/spa-react/utils/packets.test.ts | 41 +++ .../web/static/js/spa-react/utils/packets.ts | 17 + .../static/js/spa-react/utils/queryClient.ts | 15 + .../static/js/spa-react/utils/queryKeys.ts | 78 +++++ tests/test_api/test_cache.py | 46 ++- 63 files changed, 2703 insertions(+), 1616 deletions(-) create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx delete mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/packets.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts diff --git a/package-lock.json b/package-lock.json index c5a9d8c..1c33fa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "@fontsource-variable/ibm-plex-sans": "^5", "@fontsource/ibm-plex-mono": "^5", "@tailwindcss/cli": "^4", + "@tanstack/react-query": "^5.101.4", "chart.js": "^4", "daisyui": "^5", "i18next": "^25", @@ -34,6 +35,9 @@ "typescript": "^5.8", "vite": "^6", "vitest": "^4.1.10" + }, + "engines": { + "node": ">=20" } }, "node_modules/@adobe/css-tools": { @@ -1697,6 +1701,32 @@ "node": ">= 20" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 725f724..963694e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@fontsource-variable/ibm-plex-sans": "^5", "@fontsource/ibm-plex-mono": "^5", "@tailwindcss/cli": "^4", + "@tanstack/react-query": "^5.101.4", "chart.js": "^4", "daisyui": "^5", "i18next": "^25", diff --git a/src/meshcore_hub/api/routes/channels.py b/src/meshcore_hub/api/routes/channels.py index a9d40d6..3d3e794 100644 --- a/src/meshcore_hub/api/routes/channels.py +++ b/src/meshcore_hub/api/routes/channels.py @@ -25,7 +25,7 @@ router = APIRouter() def _channels_key_builder(request: Request) -> str: role = resolve_user_role(request) or "anonymous" - return f"channels:role={role}:{sorted_query_string(request)}" + return f"{request.url.path}:role={role}:{sorted_query_string(request)}" def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead: diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index cd1ad76..86f06e0 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -30,7 +30,7 @@ VALID_MSG_SORT_COLUMNS = {"time", "type", "from", "message"} def _messages_key_builder(request: Request) -> str: role = resolve_user_role(request) or "anonymous" - return f"messages:role={role}:{sorted_query_string(request)}" + return f"{request.url.path}:role={role}:{sorted_query_string(request)}" def _get_tag_name(node: Optional[Node]) -> Optional[str]: diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx index d45e67a..63989c9 100644 --- a/src/meshcore_hub/web/static/js/spa-react/App.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, @@ -7,6 +8,7 @@ import { useLocation, useParams, } from "react-router"; +import { createQueryClient } from "@/utils/queryClient"; import { useAppConfig } from "@/context/AppConfigContext"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { Navbar } from "@/components/Navbar"; @@ -266,9 +268,12 @@ function Shell() { } export function App() { + const [queryClient] = useState(createQueryClient); return ( - - - + + + + + ); } diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx new file mode 100644 index 0000000..ce9b4bd --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AutoRefreshToggle } from "@/components/AutoRefreshToggle"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +describe("AutoRefreshToggle", () => { + it("renders nothing when the interval is 0", () => { + const { container } = render( + {}} + intervalSeconds={0} + />, + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows the interval and a checked toggle while running", () => { + const onToggle = vi.fn(); + render( + , + ); + expect(screen.getByText("30s")).toBeInTheDocument(); + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeChecked(); + fireEvent.click(checkbox); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("shows an unchecked toggle while paused", () => { + render( + {}} intervalSeconds={30} />, + ); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx new file mode 100644 index 0000000..a2a0d11 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/AutoRefreshToggle.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from "react-i18next"; +import { IconRefresh } from "@/components/icons"; + +interface AutoRefreshToggleProps { + paused: boolean; + onToggle: () => void; + intervalSeconds: number; +} + +export function AutoRefreshToggle({ + paused, + onToggle, + intervalSeconds, +}: AutoRefreshToggleProps) { + const { t } = useTranslation(); + if (intervalSeconds <= 0) return null; + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx new file mode 100644 index 0000000..185ddd8 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Badges.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { CallsignBadge, CountBadge, RoleBadge } from "@/components/Badges"; + +describe("badge recipes", () => { + it("CountBadge renders a large badge", () => { + render(42 things); + const el = screen.getByText("42 things"); + expect(el).toHaveClass("badge"); + expect(el).toHaveClass("badge-lg"); + }); + + it("RoleBadge renders a primary small badge", () => { + render(); + const el = screen.getByText("operator"); + expect(el).toHaveClass("badge-primary"); + expect(el).toHaveClass("badge-sm"); + }); + + it("CallsignBadge renders a neutral small badge", () => { + render(); + const el = screen.getByText("AB1CDE"); + expect(el).toHaveClass("badge-neutral"); + expect(el).toHaveClass("badge-sm"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx new file mode 100644 index 0000000..74c5b8d --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Badges.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +export function CountBadge({ children }: { children: ReactNode }) { + return {children}; +} + +export function RoleBadge({ role }: { role: string }) { + return {role}; +} + +export function CallsignBadge({ callsign }: { callsign: string }) { + return {callsign}; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx new file mode 100644 index 0000000..b1382d9 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; + +import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs"; + +function renderCrumbs(items: Crumb[]) { + return render( + + + , + ); +} + +const items: Crumb[] = [ + { label: "Home", to: "/" }, + { label: "Nodes", to: "/nodes" }, + { label: "AB1234" }, +]; + +describe("Breadcrumbs", () => { + it("renders a nav landmark labelled Breadcrumb", () => { + renderCrumbs(items); + expect( + screen.getByRole("navigation", { name: "Breadcrumb" }), + ).toBeInTheDocument(); + }); + + it("links non-final crumbs to their targets", () => { + renderCrumbs(items); + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute( + "href", + "/", + ); + expect(screen.getByRole("link", { name: "Nodes" })).toHaveAttribute( + "href", + "/nodes", + ); + }); + + it("renders the final crumb as plain text with aria-current=page", () => { + renderCrumbs(items); + expect( + screen.queryByRole("link", { name: "AB1234" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("AB1234").closest("li")).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("renders a crumb without a target as plain text even mid-trail", () => { + renderCrumbs([ + { label: "Home", to: "/" }, + { label: "Static" }, + { label: "Leaf" }, + ]); + expect( + screen.queryByRole("link", { name: "Static" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Static")).toBeInTheDocument(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx new file mode 100644 index 0000000..1ee7bcf --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Breadcrumbs.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router"; + +export interface Crumb { + label: ReactNode; + to?: string; +} + +export function Breadcrumbs({ items }: { items: Crumb[] }) { + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000..4c3f143 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ConfirmDialog } from "@/components/ConfirmDialog"; + +function renderDialog(props: Partial[0]> = {}) { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { onConfirm, onCancel }; +} + +describe("ConfirmDialog", () => { + it("renders the title and message", () => { + renderDialog(); + expect( + screen.getByRole("heading", { name: "Delete thing" }), + ).toBeInTheDocument(); + expect(screen.getByText("Are you sure?")).toBeInTheDocument(); + }); + + it("calls onConfirm and onCancel from the respective buttons", () => { + const { onConfirm, onCancel } = renderDialog(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(onConfirm).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it("uses the error tone by default and primary when requested", () => { + const { rerender } = render( + {}} + onCancel={() => {}} + />, + ); + expect(screen.getByRole("button", { name: "Go" })).toHaveClass( + "btn-error", + ); + rerender( + {}} + onCancel={() => {}} + />, + ); + expect(screen.getByRole("button", { name: "Go" })).toHaveClass( + "btn-primary", + ); + }); + + it("disables both buttons and shows a spinner while saving", () => { + const { container } = render( + {}} + onCancel={() => {}} + />, + ); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(container.querySelector(".loading-spinner")).not.toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx new file mode 100644 index 0000000..80568a3 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; + +import { Modal } from "@/components/Modal"; + +export function ConfirmDialog({ + title, + message, + confirmLabel, + cancelLabel, + saving = false, + tone = "error", + onConfirm, + onCancel, +}: { + title: ReactNode; + message: ReactNode; + confirmLabel: ReactNode; + cancelLabel: ReactNode; + saving?: boolean; + tone?: "error" | "primary"; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + + + + + } + > + {message} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx new file mode 100644 index 0000000..9f5818a --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CopyableValue } from "@/components/CopyableValue"; +import { copyToClipboard } from "@/utils/clipboard"; + +vi.mock("@/utils/clipboard", () => ({ + copyToClipboard: vi.fn(), +})); + +describe("CopyableValue", () => { + it("copies the value on click (inline variant)", () => { + render(); + const el = screen.getByText("abc123"); + expect(el).toHaveClass("font-mono"); + fireEvent.click(el); + expect(copyToClipboard).toHaveBeenCalledWith(expect.anything(), "abc123"); + }); + + it("renders the block variant with block classes", () => { + render(); + const el = screen.getByText("deadbeef"); + expect(el).toHaveClass("block"); + expect(el).toHaveClass("break-all"); + expect(el).not.toHaveClass("font-mono"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx new file mode 100644 index 0000000..8852d55 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/CopyableValue.tsx @@ -0,0 +1,23 @@ +import { copyToClipboard } from "@/utils/clipboard"; + +export function CopyableValue({ + value, + variant = "inline", +}: { + value: string; + variant?: "inline" | "block"; +}) { + return ( + copyToClipboard(e, value)} + title="Click to copy" + > + {value} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx new file mode 100644 index 0000000..2cf8b32 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Definition.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DefinitionField, DefinitionGrid } from "@/components/Definition"; + +describe("DefinitionField", () => { + it("renders the label above the value", () => { + render(5 (chan)); + expect(screen.getByText("Channel")).toBeInTheDocument(); + expect(screen.getByText("5 (chan)")).toBeInTheDocument(); + }); +}); + +describe("DefinitionGrid", () => { + it("uses the default two-column grid classes", () => { + const { container } = render( + + x + , + ); + expect(container.firstChild).toHaveClass("grid"); + expect(container.firstChild).toHaveClass("md:grid-cols-2"); + }); + + it("allows a custom className override", () => { + const { container } = render( + + x + , + ); + expect(container.firstChild).toHaveClass("grid-cols-3"); + expect(container.firstChild).not.toHaveClass("md:grid-cols-2"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx new file mode 100644 index 0000000..5484da1 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Definition.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +export function DefinitionField({ + label, + children, +}: { + label: ReactNode; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +export function DefinitionGrid({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx new file mode 100644 index 0000000..62c7f7b --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { EmptyState, EmptyRow } from "@/components/EmptyState"; + +describe("EmptyState", () => { + it("renders its children", () => { + render(No nodes found); + expect(screen.getByText("No nodes found")).toBeInTheDocument(); + }); +}); + +describe("EmptyRow", () => { + it("renders a table cell spanning the given columns", () => { + const { container } = render( + + + Nothing here + +
, + ); + const td = container.querySelector("td"); + expect(td).not.toBeNull(); + expect(td!.getAttribute("colspan")).toBe("5"); + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx new file mode 100644 index 0000000..9470f46 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/EmptyState.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +export function EmptyState({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function EmptyRow({ + colSpan, + children, +}: { + colSpan: number; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx new file mode 100644 index 0000000..b6963e0 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import { + FilterField, + FilterForm, + OperatorSelect, + submitOnEnter, +} from "@/components/FilterForm"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const profiles = [ + { id: "1", name: "Alice", callsign: "AL", user_id: "u1" }, + { id: "2", name: "Bob", callsign: null, user_id: "u2" }, +]; + +describe("OperatorSelect", () => { + it("renders an all-operators option plus formatted profile options", () => { + render(); + expect(screen.getByText("common.all_operators")).toBeInTheDocument(); + expect(screen.getByText("Alice (AL)")).toBeInTheDocument(); + // No callsign -> falls back to the plain name + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); + + it("supports controlled value and onChange", () => { + const onChange = vi.fn(); + render( + , + ); + const select = screen.getByRole("combobox") as HTMLSelectElement; + expect(select.value).toBe("1"); + fireEvent.change(select, { target: { value: "2" } }); + expect(onChange).toHaveBeenCalledOnce(); + }); +}); + +describe("FilterField", () => { + it("renders a label wrapping the control", () => { + render( + + + , + ); + expect(screen.getByText("Search")).toBeInTheDocument(); + expect(screen.getByTestId("control")).toBeInTheDocument(); + }); +}); + +describe("submitOnEnter", () => { + it("submits the form on Enter", () => { + const requestSubmit = vi + .spyOn(HTMLFormElement.prototype, "requestSubmit") + .mockImplementation(() => {}); + render( +
+ +
, + ); + fireEvent.keyDown(screen.getByTestId("inp"), { key: "Enter" }); + expect(requestSubmit).toHaveBeenCalledOnce(); + requestSubmit.mockRestore(); + }); + + it("does nothing for other keys", () => { + const requestSubmit = vi + .spyOn(HTMLFormElement.prototype, "requestSubmit") + .mockImplementation(() => {}); + render( +
+ +
, + ); + fireEvent.keyDown(screen.getByTestId("inp"), { key: "a" }); + expect(requestSubmit).not.toHaveBeenCalled(); + requestSubmit.mockRestore(); + }); +}); + +describe("FilterForm clear navigation", () => { + function LocationProbe() { + const location = useLocation(); + return ( +
{location.pathname + location.search}
+ ); + } + + it("clears filters via client-side navigation (no full reload)", () => { + render( + + + + + + , + ); + expect(screen.getByTestId("loc").textContent).toBe("/nodes?search=foo"); + fireEvent.click(screen.getByText("common.clear")); + expect(screen.getByTestId("loc").textContent).toBe("/nodes"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx index 8c65c75..869fcf4 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; +import { Link, useNavigate } from "react-router"; import { IconFilter } from "@/components/icons"; interface FilterFormProps { @@ -44,9 +44,9 @@ export function FilterForm({ - + {clearLabel || t("common.clear")} - + ); @@ -74,3 +74,113 @@ export function FilterToggle({ open, onChange }: FilterToggleProps) { ); } + +export function autoSubmit( + e: React.ChangeEvent, +) { + e.currentTarget.form?.requestSubmit(); +} + +export function submitOnEnter(e: React.KeyboardEvent) { + if (e.key === "Enter") e.currentTarget.form?.requestSubmit(); +} + +export function FilterField({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ + {children} +
+ ); +} + +interface FilterSelectOption { + value: string; + label: string; +} + +interface FilterSelectProps { + name: string; + options: FilterSelectOption[]; + defaultValue?: string; + onChange?: (e: React.ChangeEvent) => void; + className?: string; +} + +export function FilterSelect({ + name, + options, + defaultValue, + onChange, + className, +}: FilterSelectProps) { + return ( + + ); +} + +export interface OperatorOption { + id: string; + name?: string | null; + callsign?: string | null; + user_id?: string; +} + +interface OperatorSelectProps { + name?: string; + profiles: OperatorOption[]; + value?: string; + defaultValue?: string; + onChange?: (e: React.ChangeEvent) => void; + className?: string; +} + +export function OperatorSelect({ + name, + profiles, + value, + defaultValue, + onChange, + className, +}: OperatorSelectProps) { + const { t } = useTranslation(); + const controlled = value !== undefined; + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx new file mode 100644 index 0000000..8cbdba3 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ListToolbar } from "@/components/ListToolbar"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +const autoRefresh = { + paused: false, + onToggle: () => {}, + intervalSeconds: 30, +}; + +describe("ListToolbar", () => { + it("renders the total badge when total is provided", () => { + render(); + expect(screen.getByText("common.total")).toBeInTheDocument(); + }); + + it("hides the total badge when total is null", () => { + render(); + expect(screen.queryByText("common.total")).not.toBeInTheDocument(); + }); + + it("renders a warning badge only when there is an error", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector(".badge-warning")).toBeNull(); + rerender( + , + ); + expect(container.querySelector(".badge-warning")).not.toBeNull(); + }); + + it("renders the auto-refresh toggle when interval is positive", () => { + const { container } = render( + , + ); + expect(container.querySelector('input[type="checkbox"]')).not.toBeNull(); + }); + + it("omits the auto-refresh toggle when interval is not positive", () => { + const { container } = render( + , + ); + expect(container.querySelector('input[type="checkbox"]')).toBeNull(); + }); + + it("renders the filter toggle only when provided", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector("#filter-toggle")).toBeNull(); + rerender( + {} }} + />, + ); + expect(container.querySelector("#filter-toggle")).not.toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx new file mode 100644 index 0000000..d515917 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/ListToolbar.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from "react-i18next"; + +import { WarningBadge } from "@/components/Alerts"; +import { AutoRefreshToggle } from "@/components/AutoRefreshToggle"; +import { CountBadge } from "@/components/Badges"; +import { FilterToggle } from "@/components/FilterForm"; +import { formatNumber } from "@/utils/format"; + +export interface ListToolbarAutoRefresh { + paused: boolean; + onToggle: () => void; + intervalSeconds: number; +} + +export function ListToolbar({ + total, + error, + autoRefresh, + filterToggle, +}: { + total: number | null; + error?: string | null; + autoRefresh: ListToolbarAutoRefresh; + filterToggle?: { open: boolean; onChange: () => void }; +}) { + const { t } = useTranslation(); + return ( +
+ {total !== null && ( + {t("common.total", { count: formatNumber(total) })} + )} + {error && } +
+ + {filterToggle && } +
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx new file mode 100644 index 0000000..5094b91 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.test.tsx @@ -0,0 +1,20 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { MeshQrCode } from "@/components/MeshQrCode"; + +describe("MeshQrCode", () => { + it("renders an svg QR code inside the white padded wrapper by default", () => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeNull(); + expect(container.firstChild).toHaveClass("bg-white"); + expect(container.firstChild).toHaveClass("rounded-box"); + }); + + it("accepts a custom className override", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("shadow-lg"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx new file mode 100644 index 0000000..3a387dd --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx @@ -0,0 +1,25 @@ +import QRCode from "react-qr-code"; + +export function MeshQrCode({ + value, + size = 140, + level = "L", + className = "bg-white p-2 rounded-box", +}: { + value: string; + size?: number; + level?: "L" | "M" | "Q" | "H"; + className?: string; +}) { + return ( +
+ +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx new file mode 100644 index 0000000..00581b0 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Modal.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { Modal } from "@/components/Modal"; + +describe("Modal", () => { + it("renders the title, children and footer", () => { + render( + {}} footer={foot}> +

body content

+
, + ); + expect( + screen.getByRole("heading", { name: "My Title" }), + ).toBeInTheDocument(); + expect(screen.getByText("body content")).toBeInTheDocument(); + expect(screen.getByText("foot")).toBeInTheDocument(); + }); + + it("omits the footer action row when no footer is given", () => { + const { container } = render( + {}}> +

body

+
, + ); + expect(container.querySelector(".modal-action")).toBeNull(); + }); + + it("applies the large size class", () => { + const { container } = render( + {}}> +

body

+
, + ); + expect(container.querySelector(".modal-box-lg")).not.toBeNull(); + }); + + it("calls onClose when the backdrop button is clicked", () => { + const onClose = vi.fn(); + render( + +

body

+
, + ); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx new file mode 100644 index 0000000..a55d9a3 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/Modal.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +export function Modal({ + title, + children, + footer, + size = "md", + onClose, +}: { + title: ReactNode; + children: ReactNode; + footer?: ReactNode; + size?: "md" | "lg"; + onClose: () => void; +}) { + return ( + +
+

{title}

+ {children} + {footer &&
{footer}
} +
+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx index 4c4dc64..d75abb7 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; import { getNodeEmoji } from "@/utils/format"; interface NodeDisplayProps { @@ -45,3 +46,18 @@ export function NodeDisplay({ ); } + +interface NodeLinkProps extends NodeDisplayProps { + className?: string; +} + +export function NodeLink({ className, ...display }: NodeLinkProps) { + return ( + + + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx new file mode 100644 index 0000000..8155a55 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { NotFoundState } from "@/components/NotFoundState"; + +describe("NotFoundState", () => { + it("renders an error alert with the message by default", () => { + const { container } = render(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveClass("alert-error"); + expect(alert).toHaveTextContent("No such node"); + expect(container.querySelector("svg")).not.toBeNull(); + }); + + it("renders a warning alert without an icon when tone is warning", () => { + const { container } = render( + , + ); + const alert = screen.getByRole("alert"); + expect(alert).toHaveClass("alert-warning"); + expect(alert).toHaveTextContent("Gone after retention"); + expect(container.querySelector("svg")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx new file mode 100644 index 0000000..b977487 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/NotFoundState.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +import { IconError } from "@/components/icons"; + +export function NotFoundState({ + message, + tone = "error", +}: { + message: ReactNode; + tone?: "error" | "warning"; +}) { + return ( +
+ {tone === "error" && ( + + )} + {message} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx new file mode 100644 index 0000000..f666e34 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + Field, + RedactedNotice, + channelNameDisplay, +} from "@/components/PacketParts"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +describe("Field", () => { + it("renders a label and its value", () => { + render(12:00); + expect(screen.getByText("Time")).toBeInTheDocument(); + expect(screen.getByText("12:00")).toBeInTheDocument(); + }); +}); + +describe("channelNameDisplay", () => { + it("renders an em dash for a null channel index", () => { + render(<>{channelNameDisplay(new Map(), null)}); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("renders 'name (idx)' for a known channel", () => { + render(<>{channelNameDisplay(new Map([[3, "General"]]), 3)}); + expect(screen.getByText("General (3)")).toBeInTheDocument(); + }); + + it("renders just the index for an unknown channel", () => { + render(<>{channelNameDisplay(new Map(), 7)}); + expect(screen.getByText("7")).toBeInTheDocument(); + }); +}); + +describe("RedactedNotice", () => { + it("renders a warning notice", () => { + const { container } = render(); + expect(container.querySelector(".alert-warning")).not.toBeNull(); + expect(container.textContent).toContain("packets.redacted_notice"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx new file mode 100644 index 0000000..0819b80 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PacketParts.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { copyToClipboard } from "@/utils/clipboard"; +import { JsonTree } from "@/components/JsonTree"; + +export { DefinitionField as Field } from "@/components/Definition"; + +export function RedactedNotice() { + const { t } = useTranslation(); + return ( +
+ {"\u{1F512}"} {t("packets.redacted_notice")} +
+ ); +} + +export function channelNameDisplay( + names: Map, + channelIdx: number | null, +): ReactNode { + if (channelIdx == null) return ; + const name = names.get(channelIdx); + return name ? `${name} (${channelIdx})` : `${channelIdx}`; +} + +export function RawHexBlock({ hex }: { hex: string | null }) { + const { t } = useTranslation(); + return ( +
+
+ {t("packets.col_raw")} + {hex && ( + + )} +
+
+        {hex || "—"}
+      
+
+ ); +} + +export function DecodedJsonBlock({ value }: { value: unknown }) { + const { t } = useTranslation(); + return ( +
+ {t("packets.decoded")} +
+ +
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx new file mode 100644 index 0000000..2fa187e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { PageHeader } from "@/components/PageHeader"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +function renderHeader(config: AppConfig = makeConfig(), children?: ReactNode) { + return render( + + {children} + , + ); +} + +describe("PageHeader", () => { + it("renders the title", () => { + renderHeader(); + expect( + screen.getByRole("heading", { name: "Nodes" }), + ).toBeInTheDocument(); + }); + + it("hides the timezone indicator for UTC", () => { + const { container } = renderHeader(makeConfig({ timezone: "UTC" })); + expect(container.textContent).not.toContain("UTC"); + }); + + it("shows a non-UTC timezone", () => { + renderHeader(makeConfig({ timezone: "America/New_York" })); + expect(screen.getByText("America/New_York")).toBeInTheDocument(); + }); + + it("renders right-side children alongside the timezone", () => { + renderHeader( + makeConfig({ timezone: "EST" }), + extra badge, + ); + expect(screen.getByText("EST")).toBeInTheDocument(); + expect(screen.getByText("extra badge")).toBeInTheDocument(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx new file mode 100644 index 0000000..68f376c --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import { useAppConfig } from "@/context/AppConfigContext"; + +export function PageHeader({ + title, + children, +}: { + title: ReactNode; + children?: ReactNode; +}) { + const config = useAppConfig(); + const tz = config.timezone || ""; + return ( +
+

{title}

+
+ {tz && tz !== "UTC" && ( + {tz} + )} + {children} +
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx new file mode 100644 index 0000000..ba27375 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SectionGroup } from "@/components/SectionGroup"; + +describe("SectionGroup", () => { + it("renders the title heading and children in the default grid", () => { + const { container } = render( + + card + , + ); + expect( + screen.getByRole("heading", { name: "Community" }), + ).toBeInTheDocument(); + expect(screen.getByText("card")).toBeInTheDocument(); + expect(container.querySelector("div")).toHaveClass("lg:grid-cols-3"); + }); + + it("allows a custom grid className", () => { + const { container } = render( + + c + , + ); + expect(container.querySelector(".grid-cols-2")).not.toBeNull(); + expect(container.querySelector(".lg\\:grid-cols-3")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx new file mode 100644 index 0000000..2d381e1 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/SectionGroup.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +export function SectionGroup({ + title, + className, + children, +}: { + title: ReactNode; + className?: string; + children: ReactNode; +}) { + return ( + <> +

{title}

+
+ {children} +
+ + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx new file mode 100644 index 0000000..d6b4910 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.test.tsx @@ -0,0 +1,39 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { TimeAgo } from "@/components/TimeAgo"; + +vi.mock("@/utils/format", async () => { + const actual = + await vi.importActual>("@/utils/format"); + return { + ...actual, + formatRelativeTime: () => "2 hours ago", + useFormatDateTime: () => ({ + formatDateTime: () => "Jan 1, 2026 12:00", + }), + }; +}); + +describe("TimeAgo", () => { + it("renders relative text with the full time as title and datetime", () => { + const { container } = render(); + const time = container.querySelector("time"); + expect(time).not.toBeNull(); + expect(time).toHaveAttribute("datetime", "2026-01-01T12:00:00Z"); + expect(time).toHaveAttribute("title", "Jan 1, 2026 12:00"); + expect(time).toHaveTextContent("2 hours ago"); + }); + + it("applies a custom className", () => { + const { container } = render( + , + ); + expect(container.querySelector("time")).toHaveClass("text-xs"); + }); + + it("renders nothing when iso is null", () => { + const { container } = render(); + expect(container.querySelector("time")).toBeNull(); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx new file mode 100644 index 0000000..287c444 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/components/TimeAgo.tsx @@ -0,0 +1,17 @@ +import { formatRelativeTime, useFormatDateTime } from "@/utils/format"; + +export function TimeAgo({ + iso, + className, +}: { + iso: string | null; + className?: string; +}) { + const { formatDateTime } = useFormatDateTime(); + if (!iso) return null; + return ( + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx b/src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx deleted file mode 100644 index 356fc36..0000000 --- a/src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { useAppConfig } from "@/context/AppConfigContext"; - -export function TimezoneIndicator() { - const config = useAppConfig(); - const tz = config.timezone || "UTC"; - return ({tz}); -} diff --git a/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts b/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts index a3cd130..0095966 100644 --- a/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts +++ b/src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts @@ -1,58 +1,21 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useAppConfig } from "@/context/AppConfigContext"; -interface UseAutoRefreshOptions { - onRefresh: () => Promise; -} - interface UseAutoRefreshReturn { paused: boolean; toggle: () => void; intervalSeconds: number; + refetchInterval: number | false; } -export function useAutoRefresh({ - onRefresh, -}: UseAutoRefreshOptions): UseAutoRefreshReturn { +export function useAutoRefresh(): UseAutoRefreshReturn { const config = useAppConfig(); const intervalSeconds = config.auto_refresh_seconds || 0; const [paused, setPaused] = useState(false); - const isPendingRef = useRef(false); - const timerRef = useRef | null>(null); - const onRefreshRef = useRef(onRefresh); - onRefreshRef.current = onRefresh; - const toggle = useCallback(() => setPaused((p) => !p), []); - useEffect(() => { - if (!intervalSeconds || paused) { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - return; - } + const refetchInterval = + !intervalSeconds || paused ? false : intervalSeconds * 1000; - const tick = async () => { - if (isPendingRef.current) return; - isPendingRef.current = true; - try { - await onRefreshRef.current(); - } catch { - // handled by caller - } finally { - isPendingRef.current = false; - } - }; - - timerRef.current = setInterval(tick, intervalSeconds * 1000); - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, [intervalSeconds, paused]); - - return { paused, toggle, intervalSeconds }; + return { paused, toggle, intervalSeconds, refetchInterval }; } diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx index ff2c333..3ee3fed 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx @@ -1,16 +1,25 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import { useAppConfig } from "@/context/AppConfigContext"; -import { apiGet, isAbortError } from "@/utils/api"; -import { formatNumber, useFormatDateTime } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; +import { useFormatDateTime } from "@/utils/format"; import { usePageTitle } from "@/hooks/usePageTitle"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { Pagination } from "@/components/Pagination"; -import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { + FilterForm, + FilterField, + FilterSelect, + OperatorSelect, + autoSubmit, + submitOnEnter, +} from "@/components/FilterForm"; import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable"; import { NodeDisplay } from "@/components/NodeDisplay"; +import { CopyableValue } from "@/components/CopyableValue"; import { ObserverFilterBadges, ObserverIcons, @@ -18,8 +27,10 @@ import { toggleObserverArea, } from "@/components/ObserverBadges"; import { RouteTypeBadge } from "@/components/RouteTypeBadge"; -import { Loading, WarningBadge } from "@/components/Alerts"; -import { IconRefresh } from "@/components/icons"; +import { Loading } from "@/components/Alerts"; +import { ListToolbar } from "@/components/ListToolbar"; +import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, EmptyRow } from "@/components/EmptyState"; interface ObserverInfo { node_id?: string; @@ -62,14 +73,6 @@ interface ListResponse { total?: number; } -function submitOnEnter(e: React.KeyboardEvent) { - if (e.key === "Enter") e.currentTarget.form?.requestSubmit(); -} - -function autoSubmit(e: React.ChangeEvent) { - e.currentTarget.form?.requestSubmit(); -} - export function Advertisements() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -89,13 +92,7 @@ export function Advertisements() { const features = config.features ?? {}; const packetsEnabled = features.packets !== false; - const tz = config.timezone || ""; - const [items, setItems] = useState(null); - const [total, setTotal] = useState(null); - const [error, setError] = useState(null); - const [sortedAreas, setSortedAreas] = useState([]); - const [operators, setOperators] = useState([]); const [disabledAreas, setDisabledAreas] = useState>(() => getDisabledObserverAreas(), ); @@ -105,16 +102,24 @@ export function Advertisements() { routeType !== "flood,transport_flood", ); - const disabledAreasRef = useRef(disabledAreas); - disabledAreasRef.current = disabledAreas; - const abortRef = useRef(null); + const { paused, toggle, intervalSeconds, refetchInterval } = + useAutoRefresh(); - const fetchData = useCallback(async () => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - const { signal } = controller; - try { + const { data, error: queryError } = useQuery({ + queryKey: qk.advertisements.list({ + limit, + offset, + search, + sort, + order, + routeType, + adoptedBy, + oidcEnabled: config.oidc_enabled, + operatorRole: config.role_names?.operator || "operator", + disabledAreas: [...disabledAreas].sort(), + }), + refetchInterval, + queryFn: async ({ signal }) => { const nodesPromise = apiGet>( "/api/v1/nodes", { limit: 500, observer: true }, @@ -133,14 +138,13 @@ export function Advertisements() { ]); const operatorRole = config.role_names?.operator || "operator"; - const profiles = (profilesData?.items ?? []) + const operators = (profilesData?.items ?? []) .filter((p) => p.roles?.includes(operatorRole)) .sort((a, b) => (a.name || a.callsign || "").localeCompare( b.name || b.callsign || "", ), ); - setOperators(profiles); const areaMap = new Map(); for (const n of nodesData.items ?? []) { @@ -150,13 +154,13 @@ export function Advertisements() { if (!areaMap.has(key)) areaMap.set(key, []); areaMap.get(key)!.push(n.public_key); } - const areas = [...areaMap.keys()].sort((a, b) => + const sortedAreas = [...areaMap.keys()].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()), ); - setSortedAreas(areas); - const disabled = disabledAreasRef.current; - const observerFilterActive = areas.some((a) => disabled.has(a)); + const observerFilterActive = sortedAreas.some((a) => + disabledAreas.has(a), + ); const apiParams: Record = { limit, offset, @@ -166,34 +170,31 @@ export function Advertisements() { route_type: routeType, }; if (observerFilterActive) { - apiParams.observed_by = areas - .filter((a) => !disabled.has(a)) + apiParams.observed_by = sortedAreas + .filter((a) => !disabledAreas.has(a)) .flatMap((a) => areaMap.get(a) ?? []); } if (adoptedBy) apiParams.adopted_by = adoptedBy; - const data = await apiGet>( + const adData = await apiGet>( "/api/v1/advertisements", apiParams, { signal }, ); - setItems(data.items ?? []); - setTotal(data.total ?? 0); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError(e instanceof Error ? e.message : String(e)); - } - }, [limit, offset, search, sort, order, routeType, adoptedBy, config]); - - useEffect(() => { - fetchData(); - return () => abortRef.current?.abort(); - }, [fetchData, disabledAreas]); - - const { paused, toggle, intervalSeconds } = useAutoRefresh({ - onRefresh: fetchData, + return { + items: adData.items ?? [], + total: adData.total ?? 0, + operators, + sortedAreas, + }; + }, }); + const error = queryError ? queryError.message : null; + + const items = data?.items ?? null; + const total = data?.total ?? null; + const operators = data?.operators ?? []; + const sortedAreas = data?.sortedAreas ?? []; const handleObserverToggle = (area: string) => { const updated = toggleObserverArea(area, sortedAreas.length); @@ -234,62 +235,19 @@ export function Advertisements() { return ( <> -
-

- {t("entities.advertisements")} -

- {tz && tz !== "UTC" && ( - {tz} - )} -
+ -
- {total !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
- {intervalSeconds > 0 && ( - - )} -
-
- setFilterOpen((o) => !o)} - /> -
-
+ setFilterOpen((o) => !o) }} + /> {filterOpen && (
-
- + -
-
- - -
+ options={[ + { + value: "flood,transport_flood", + label: t("advertisements.route_type_flood"), + }, + { value: "all", label: t("advertisements.route_type_all") }, + { + value: "direct", + label: t("advertisements.route_type_direct"), + }, + ]} + /> + {config.oidc_enabled && operators.length > 0 && ( -
- - -
+ profiles={operators} + /> + )}
@@ -400,9 +345,7 @@ export function Advertisements() {
{items.length === 0 ? ( -
- {emptyMessage} -
+ {emptyMessage} ) : ( items.map((ad, idx) => { const adName = @@ -487,14 +430,7 @@ export function Advertisements() { {items.length === 0 ? ( - - - {emptyMessage} - - + {emptyMessage} ) : ( items.map((ad, idx) => { const adName = @@ -527,15 +463,7 @@ export function Advertisements() { - - copyToClipboard(e, ad.public_key) - } - title="Click to copy" - > - {ad.public_key} - + diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx index ef7a327..18dff39 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx @@ -1,12 +1,19 @@ -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; -import QRCode from "react-qr-code"; import { useAppConfig, hasRole } from "@/context/AppConfigContext"; import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; +import { qk, invalidate } from "@/utils/queryKeys"; import { usePageTitle } from "@/hooks/usePageTitle"; import { Loading, ErrorAlert } from "@/components/Alerts"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { EmptyState } from "@/components/EmptyState"; +import { MeshQrCode } from "@/components/MeshQrCode"; +import { Modal } from "@/components/Modal"; +import { PageHeader } from "@/components/PageHeader"; +import { SectionGroup } from "@/components/SectionGroup"; import { IconChannel, IconPlus, IconEdit, IconTrash } from "@/components/icons"; interface Channel { @@ -36,11 +43,7 @@ type ModalState = function ChannelQrCode({ channel }: { channel: Channel }) { if (!channel.key_hex) return null; const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`; - return ( -
- -
- ); + return ; } interface ChannelCardProps { @@ -167,9 +170,7 @@ function ChannelModal({ : t("channels.add_channel"); return ( - -
-

{title}

+
-
-
- -
-
+ ); } @@ -268,32 +265,15 @@ function DeleteChannelModal({ const { t } = useTranslation(); return ( - -
-

- {t("channels.delete_channel")} -

-

{t("channels.delete_confirm", { name: channel.name })}

-
- - -
-
-
- -
-
+ {t("channels.delete_confirm", { name: channel.name })}

} + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + saving={saving} + onConfirm={onConfirm} + onCancel={onCancel} + /> ); } @@ -305,56 +285,70 @@ export function Channels() { const isAdmin = hasRole("admin"); usePageTitle("channels.title"); - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + const { + data, + isLoading: loading, + error: queryError, + } = useQuery({ + queryKey: qk.channels.list({}), + queryFn: async ({ signal }) => { + const resp = await apiGet( + "/api/v1/channels", + {}, + { signal }, + ); + return resp.items || []; + }, + }); + const channels = data ?? []; + const error = queryError ? queryError.message : null; const [modal, setModal] = useState(null); - const [saving, setSaving] = useState(false); - const fetchChannels = useCallback(async () => { - try { - const data = await apiGet("/api/v1/channels"); - setChannels(data.items || []); - setError(null); - } catch (e) { - setError((e as Error).message || t("common.failed_to_load_page")); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - fetchChannels(); - }, [fetchChannels]); - - const handleSave = async (body: Record) => { - setSaving(true); - try { - if (modal?.type === "edit") { - await apiPut(`/api/v1/channels/${modal.channel.id}`, body); + const saveMutation = useMutation({ + mutationFn: async ({ + id, + body, + }: { + id?: string; + body: Record; + }) => { + if (id) { + await apiPut(`/api/v1/channels/${id}`, body); } else { await apiPost("/api/v1/channels", body); } + }, + onSuccess: () => invalidate.channels(queryClient), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/v1/channels/${id}`), + onSuccess: () => invalidate.channels(queryClient), + }); + + const saving = saveMutation.isPending || deleteMutation.isPending; + + const handleSave = async (body: Record) => { + try { + await saveMutation.mutateAsync({ + id: modal?.type === "edit" ? modal.channel.id : undefined, + body, + }); setModal(null); - await fetchChannels(); } catch (e) { alert((e as Error).message || "Failed to save channel"); - } finally { - setSaving(false); } }; const handleDeleteConfirm = async () => { if (modal?.type !== "delete") return; - setSaving(true); try { - await apiDelete(`/api/v1/channels/${modal.channel.id}`); + await deleteMutation.mutateAsync(modal.channel.id); setModal(null); - await fetchChannels(); } catch (e) { alert((e as Error).message || "Failed to delete channel"); - } finally { - setSaving(false); } }; @@ -376,12 +370,14 @@ export function Channels() { return (
-
-

- - {t("channels.title")} -

-
+ + + {t("channels.title")} + + } + /> {error && } @@ -397,11 +393,11 @@ export function Channels() { )} {channels.length === 0 && ( -
+ {t("common.no_entity_found", { entity: t("entities.channels").toLowerCase(), })} -
+ )} {VISIBILITY_ORDER.map((vis) => { @@ -409,10 +405,7 @@ export function Channels() { if (!group || group.length === 0) return null; return (
-

- {t(`channels.visibility_${vis}`)} -

-
+ {group.map((ch) => ( ))} -
+
); })} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx index ea359b2..eebee13 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx @@ -3,6 +3,7 @@ import { useParams } from "react-router"; import { useTranslation } from "react-i18next"; import { ErrorAlert, Loading } from "@/components/Alerts"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; import { useAppConfig } from "@/context/AppConfigContext"; import { apiGet, isAbortError } from "@/utils/api"; @@ -59,6 +60,9 @@ export function CustomPagePage() { return (
+
(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - const { signal } = controller; - (async () => { - try { - const [ - stats, - recentActivity, - advertActivity, - messageActivity, - nodeCount, - packetActivity, - packetBreakdown, - routesOverview, - channelsData, - ] = await Promise.all([ + const queries = useQueries({ + queries: [ + { + queryKey: qk.dashboard.stats(), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet("/api/v1/dashboard/stats", {}, { signal }), + }, + { + queryKey: qk.dashboard.recent({}), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/recent-activity", {}, { signal }, ), + }, + { + queryKey: qk.dashboard.series("activity", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/activity", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("message-activity", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/message-activity", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("node-count", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/node-count", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("packet-activity", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/packet-activity", { days: 7 }, { signal }, ), + }, + { + queryKey: qk.dashboard.series("packet-breakdown", { days: 7 }), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet( "/api/v1/dashboard/packet-breakdown", { days: 7 }, { signal }, ), - showRoutes - ? apiGet( - "/api/v1/dashboard/routes-overview", - { days: 7 }, - { signal }, - ) - : Promise.resolve(null), + }, + { + queryKey: qk.dashboard.routesOverview(), + queryFn: ({ signal }: { signal: AbortSignal }) => + apiGet( + "/api/v1/dashboard/routes-overview", + { days: 7 }, + { signal }, + ), + enabled: showRoutes, + }, + { + queryKey: qk.channels.list({}), + queryFn: ({ signal }: { signal: AbortSignal }) => apiGet("/api/v1/channels", {}, { signal }), - ]); - setData({ - stats, - recentActivity, - advertActivity, - messageActivity, - nodeCount, - packetActivity, - packetBreakdown, - routesOverview, - channelsData, - }); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError( - e instanceof Error && e.message - ? e.message - : t("common.failed_to_load_page"), - ); - } finally { - setLoading(false); - } - })(); - return () => controller.abort(); - }, [showRoutes, t]); + }, + ], + }); + + const [ + statsQ, + recentQ, + advertQ, + messageQ, + nodeCountQ, + packetActivityQ, + packetBreakdownQ, + routesOverviewQ, + channelsQ, + ] = queries; + + const loading = queries.some((q) => q.isLoading); + const firstError = queries.find((q) => q.error)?.error ?? null; + const error = firstError + ? firstError instanceof Error && firstError.message + ? firstError.message + : t("common.failed_to_load_page") + : null; + + const data: DashboardData | null = + !loading && !error + ? { + stats: statsQ.data as DashboardStats, + recentActivity: recentQ.data as RecentActivity, + advertActivity: (advertQ.data as ActivitySeries | undefined) ?? null, + messageActivity: + (messageQ.data as ActivitySeries | undefined) ?? null, + nodeCount: (nodeCountQ.data as ActivitySeries | undefined) ?? null, + packetActivity: + (packetActivityQ.data as ActivitySeries | undefined) ?? null, + packetBreakdown: packetBreakdownQ.data as PacketBreakdown, + routesOverview: + (routesOverviewQ.data as RoutesOverview | undefined) ?? null, + channelsData: channelsQ.data as ChannelsResponse, + } + : null; const channelLabels = useMemo(() => { if (!data) return new Map(); @@ -400,9 +428,7 @@ export function DashboardPage() { return ( <> -
-

{t("entities.dashboard")}

-
+ {visibleChartCount > 0 && ( <> diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx index b08cc0a..942c78b 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx @@ -1,11 +1,5 @@ -import { - useCallback, - useEffect, - useRef, - useState, - type ComponentType, - type SVGProps, -} from "react"; +import { type ComponentType, type SVGProps } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; @@ -38,7 +32,8 @@ import { useAppConfig, useFeatures } from "@/context/AppConfigContext"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { usePageTitle } from "@/hooks/usePageTitle"; import type { RadioConfigDisplay } from "@/types/config"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { getPageColor } from "@/utils/format"; interface DashboardStats { @@ -141,17 +136,6 @@ export function HomePage() { const features = useFeatures(); usePageTitle(); - const [stats, setStats] = useState(null); - const [advertActivity, setAdvertActivity] = useState( - null, - ); - const [messageActivity, setMessageActivity] = useState( - null, - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const hasDataRef = useRef(false); - const networkName = config.network_name || "MeshCore Network"; const logoUrl = config.logo_url || "/static/img/logo.svg"; const logoInvertLight = config.logo_invert_light !== false; @@ -168,50 +152,46 @@ export function HomePage() { const showMembersPanel = features.members !== false; const showRadioPanel = features.radio_config !== false; - const load = useCallback( - async (signal?: AbortSignal) => { - try { - const [statsData, advertData, messageData] = await Promise.all([ - apiGet("/api/v1/dashboard/stats", {}, { signal }), - apiGet( - "/api/v1/dashboard/activity", - { days: 7 }, - { signal }, - ), - apiGet( - "/api/v1/dashboard/message-activity", - { days: 7 }, - { signal }, - ), - ]); - setStats(statsData); - setAdvertActivity(advertData); - setMessageActivity(messageData); - hasDataRef.current = true; - setError(null); - } catch (e) { - if (isAbortError(e)) return; - if (!hasDataRef.current) { - setError( - e instanceof Error && e.message - ? e.message - : t("common.failed_to_load_page"), - ); - } - } finally { - setLoading(false); - } - }, - [t], - ); + const { refetchInterval } = useAutoRefresh(); - useEffect(() => { - const controller = new AbortController(); - void load(controller.signal); - return () => controller.abort(); - }, [load]); + const statsQuery = useQuery({ + queryKey: qk.dashboard.stats(), + queryFn: ({ signal }) => + apiGet("/api/v1/dashboard/stats", {}, { signal }), + refetchInterval, + }); + const advertQuery = useQuery({ + queryKey: qk.dashboard.series("activity", { days: 7 }), + queryFn: ({ signal }) => + apiGet( + "/api/v1/dashboard/activity", + { days: 7 }, + { signal }, + ), + refetchInterval, + }); + const messageQuery = useQuery({ + queryKey: qk.dashboard.series("message-activity", { days: 7 }), + queryFn: ({ signal }) => + apiGet( + "/api/v1/dashboard/message-activity", + { days: 7 }, + { signal }, + ), + refetchInterval, + }); - useAutoRefresh({ onRefresh: load }); + const stats = statsQuery.data ?? null; + const advertActivity = advertQuery.data ?? null; + const messageActivity = messageQuery.data ?? null; + const loading = + statsQuery.isLoading || advertQuery.isLoading || messageQuery.isLoading; + const firstError = + statsQuery.error ?? advertQuery.error ?? messageQuery.error; + const error = + !stats && firstError + ? firstError.message || t("common.failed_to_load_page") + : null; if (loading) return ; if (error) return ; diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx index 585c9b5..6baaafa 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; @@ -12,10 +13,12 @@ import "leaflet/dist/leaflet.css"; import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { formatNumber, formatRelativeTime, typeEmoji } from "@/utils/format"; -import { FilterToggle } from "@/components/FilterForm"; +import { FilterToggle, OperatorSelect } from "@/components/FilterForm"; import { ErrorAlert, Loading } from "@/components/Alerts"; +import { PageHeader } from "@/components/PageHeader"; const MAX_BOUNDS_RADIUS_KM = 20; @@ -326,18 +329,28 @@ export function MapPage() { usePageTitle("entities.map"); const oidcEnabled = config.oidc_enabled; - const tz = config.timezone || ""; const operatorRole = config.role_names?.operator || "operator"; - const [mapData, setMapData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [filterOpen, setFilterOpen] = useState(false); const [category, setCategory] = useState(""); const [typeFilter, setTypeFilter] = useState(""); const [operatorFilter, setOperatorFilter] = useState(""); const [showLabels, setShowLabels] = useState(false); + const mapQuery = useQuery({ + queryKey: qk.map.data({ adopted_by: operatorFilter || undefined }), + queryFn: ({ signal }) => { + const params: Record = {}; + if (operatorFilter) params.adopted_by = operatorFilter; + return apiGet("/map/data", params, { signal }); + }, + }); + const mapData = mapQuery.data ?? null; + const loading = mapQuery.isLoading; + const error = mapQuery.error + ? mapQuery.error.message || t("common.failed_to_load_page") + : null; + const operatorProfiles = useMemo( () => (mapData?.profiles || []) @@ -350,23 +363,6 @@ export function MapPage() { [mapData, operatorRole], ); - useEffect(() => { - const ac = new AbortController(); - const params: Record = {}; - if (operatorFilter) params.adopted_by = operatorFilter; - apiGet("/map/data", params, { signal: ac.signal }) - .then((data) => { - setMapData(data); - setError(null); - }) - .catch((e) => { - if (isAbortError(e)) return; - setError((e as Error).message || t("common.failed_to_load_page")); - }) - .finally(() => setLoading(false)); - return () => ac.abort(); - }, [operatorFilter, t]); - const allNodes = useMemo(() => mapData?.nodes ?? [], [mapData]); const filteredNodes = useMemo( @@ -431,24 +427,18 @@ export function MapPage() { return (
-
-

{t("entities.map")}

-
- {tz && tz !== "UTC" && ( - {tz} - )} - {countBadgeText} - {showFilteredBadge && ( - - {t("common.shown", { count: formatNumber(filteredCount) })} - - )} - setFilterOpen((open) => !open)} - /> -
-
+ + {countBadgeText} + {showFilteredBadge && ( + + {t("common.shown", { count: formatNumber(filteredCount) })} + + )} + setFilterOpen((open) => !open)} + /> + {filterOpen && (
@@ -485,20 +475,11 @@ export function MapPage() { - + profiles={operatorProfiles} + />
)}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx index 16f3ac1..f6ff0a2 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx @@ -1,16 +1,19 @@ import { - useEffect, - useState, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; import { Link, useNavigate } from "react-router"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useAppConfig } from "@/context/AppConfigContext"; -import { apiGet, isAbortError } from "@/utils/api"; -import { formatNumber } from "@/utils/format"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; +import { formatNumber, resolveNodeName } from "@/utils/format"; import { Loading, ErrorAlert } from "@/components/Alerts"; +import { CallsignBadge, RoleBadge } from "@/components/Badges"; +import { EmptyState } from "@/components/EmptyState"; +import { PageHeader } from "@/components/PageHeader"; import { IconAntenna, IconUsers } from "@/components/icons"; import { usePageTitle } from "@/hooks/usePageTitle"; @@ -58,18 +61,12 @@ function ProfileTile({ profile }: { profile: MemberProfile }) {

{profile.name || t("common.unnamed")} - {profile.callsign && ( - - {profile.callsign} - - )} + {profile.callsign && }

{profile.roles && profile.roles.length > 0 && (
{profile.roles.map((role) => ( - - {role} - + ))}
)} @@ -101,7 +98,7 @@ function ProfileTile({ profile }: { profile: MemberProfile }) { {profile.adopted_nodes && profile.adopted_nodes.length > 0 && (
{profile.adopted_nodes.map((node) => { - const label = node.name || node.public_key.slice(0, 12) + "..."; + const label = resolveNodeName(node); return ( (null); - const [error, setError] = useState(null); - useEffect(() => { - const controller = new AbortController(); - apiGet( - "/api/v1/user/profiles", - { limit: 500 }, - { signal: controller.signal }, - ) - .then((resp) => setProfiles(resp.items || [])) - .catch((e) => { - if (!isAbortError(e)) { - setError((e as Error).message || t("common.failed_to_load_page")); - } - }); - return () => controller.abort(); - }, [t]); + const { data, error: queryError } = useQuery({ + queryKey: qk.profiles.list({ limit: 500 }), + queryFn: async ({ signal }) => { + const resp = await apiGet( + "/api/v1/user/profiles", + { limit: 500 }, + { signal }, + ); + return resp.items || []; + }, + }); + const profiles = data ?? null; + const error = queryError ? queryError.message : null; if (error) return ; if (profiles === null) return ; @@ -187,13 +180,11 @@ export function Members() { if (visible.length === 0) { return ( <> -
-

{t("entities.members")}

-
-
+ +

{t("members_page.empty_state")}

{t("members_page.empty_description")}

-
+ ); } @@ -212,15 +203,14 @@ export function Members() { return ( <> -
-

{t("entities.members")}

+ {t("common.count_entity", { count: formatNumber(operators.length + members.length), entity: t("entities.members").toLowerCase(), })} -
+ { total?: number; } -function autoSubmit(e: React.ChangeEvent) { - e.currentTarget.form?.requestSubmit(); -} - function parseSenderFromText(text: string | null): { sender: string | null; text: string; @@ -243,21 +248,7 @@ export function Messages() { typeof config.spam_score_threshold === "number" ? config.spam_score_threshold : 0.65; - const tz = config.timezone || ""; - const [items, setItems] = useState(null); - const [total, setTotal] = useState(null); - const [error, setError] = useState(null); - const [sortedAreas, setSortedAreas] = useState([]); - const [builtinLabels, setBuiltinLabels] = useState>( - () => new Map(), - ); - const [customLabels, setCustomLabels] = useState>( - () => new Map(), - ); - const [channelLabels, setChannelLabels] = useState>( - () => new Map(), - ); const [disabledAreas, setDisabledAreas] = useState>(() => getDisabledObserverAreas(), ); @@ -265,16 +256,23 @@ export function Messages() { messageType !== "" || channelIdx !== "" || includeSpam, ); - const disabledAreasRef = useRef(disabledAreas); - disabledAreasRef.current = disabledAreas; - const abortRef = useRef(null); + const { paused, toggle, intervalSeconds, refetchInterval } = + useAutoRefresh(); - const fetchData = useCallback(async () => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - const { signal } = controller; - try { + const { data, error: queryError } = useQuery({ + queryKey: qk.messages.list({ + limit, + offset, + messageType, + channelIdx, + includeSpam, + sort, + order, + channelLabels: config.channel_labels, + disabledAreas: [...disabledAreas].sort(), + }), + refetchInterval, + queryFn: async ({ signal }) => { const [nodesData, channelsData] = await Promise.all([ apiGet>( "/api/v1/nodes", @@ -293,9 +291,7 @@ export function Messages() { ]) .filter(([idx]) => Number.isInteger(idx)), ); - setBuiltinLabels(builtin); - setCustomLabels(custom); - setChannelLabels(new Map([...builtin, ...custom])); + const channelLabels = new Map([...builtin, ...custom]); const areaMap = new Map(); for (const n of nodesData.items ?? []) { @@ -305,13 +301,13 @@ export function Messages() { if (!areaMap.has(key)) areaMap.set(key, []); areaMap.get(key)!.push(n.public_key); } - const areas = [...areaMap.keys()].sort((a, b) => + const sortedAreas = [...areaMap.keys()].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()), ); - setSortedAreas(areas); - const disabled = disabledAreasRef.current; - const observerFilterActive = areas.some((a) => disabled.has(a)); + const observerFilterActive = sortedAreas.some((a) => + disabledAreas.has(a), + ); const apiParams: Record = { limit, offset, @@ -321,34 +317,35 @@ export function Messages() { order, }; if (observerFilterActive) { - apiParams.observed_by = areas - .filter((a) => !disabled.has(a)) + apiParams.observed_by = sortedAreas + .filter((a) => !disabledAreas.has(a)) .flatMap((a) => areaMap.get(a) ?? []); } if (includeSpam) apiParams.include_spam = true; - const data = await apiGet>( + const messagesData = await apiGet>( "/api/v1/messages", apiParams, { signal }, ); - setItems(dedupeBySignature(data.items ?? [])); - setTotal(data.total ?? 0); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError(e instanceof Error ? e.message : String(e)); - } - }, [limit, offset, messageType, channelIdx, includeSpam, sort, order, config]); - - useEffect(() => { - fetchData(); - return () => abortRef.current?.abort(); - }, [fetchData, disabledAreas]); - - const { paused, toggle, intervalSeconds } = useAutoRefresh({ - onRefresh: fetchData, + return { + items: dedupeBySignature(messagesData.items ?? []), + total: messagesData.total ?? 0, + sortedAreas, + builtinLabels: builtin, + customLabels: custom, + channelLabels, + }; + }, }); + const error = queryError ? queryError.message : null; + + const items = data?.items ?? null; + const total = data?.total ?? null; + const sortedAreas = data?.sortedAreas ?? []; + const builtinLabels = data?.builtinLabels ?? new Map(); + const customLabels = data?.customLabels ?? new Map(); + const channelLabels = data?.channelLabels ?? new Map(); const handleObserverToggle = (area: string) => { const updated = toggleObserverArea(area, sortedAreas.length); @@ -425,76 +422,32 @@ export function Messages() { return ( <> -
-

{t("entities.messages")}

- {tz && tz !== "UTC" && ( - {tz} - )} -
+ -
- {total !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
- {intervalSeconds > 0 && ( - - )} -
-
- setFilterOpen((o) => !o)} - /> -
-
+ setFilterOpen((o) => !o) }} + /> {filterOpen && (
-
- - -
-
- + options={[ + { value: "", label: t("common.all_types") }, + { value: "contact", label: t("messages.type_direct") }, + { value: "channel", label: t("messages.type_channel") }, + ]} + /> + + -
+ {spamEnabled && ( -
- + -
+ )}
@@ -587,9 +535,7 @@ export function Messages() {
{items.length === 0 ? ( -
- {emptyMessage} -
+ {emptyMessage} ) : ( items.map((msg, idx) => { const isChannel = msg.message_type === "channel"; @@ -699,14 +645,7 @@ export function Messages() { {items.length === 0 ? ( - - - {emptyMessage} - - + {emptyMessage} ) : ( items.map((msg, idx) => { const isChannel = msg.message_type === "channel"; diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx index eeef57b..fdda2d4 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx @@ -1,23 +1,28 @@ import { - useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode, } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useNavigate, useParams, useSearchParams } from "react-router"; import { MapContainer, Marker, TileLayer, useMap } from "react-leaflet"; import { divIcon, point as leafletPoint } from "leaflet"; import "leaflet/dist/leaflet.css"; -import QRCode from "react-qr-code"; import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts"; -import { IconEdit, IconError, IconPlus, IconTrash } from "@/components/icons"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { CopyableValue } from "@/components/CopyableValue"; +import { IconEdit, IconPlus, IconTrash } from "@/components/icons"; +import { MeshQrCode } from "@/components/MeshQrCode"; +import { Modal } from "@/components/Modal"; +import { NotFoundState } from "@/components/NotFoundState"; import { hasRole, useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; import { apiDelete, apiGet, apiPost, apiPut, isAbortError } from "@/utils/api"; -import { copyToClipboard } from "@/utils/clipboard"; +import { qk, invalidate } from "@/utils/queryKeys"; import { typeEmoji, truncateKey, useFormatDateTime } from "@/utils/format"; interface NodeTag { @@ -82,20 +87,6 @@ function OffsetCenter({ lat, lon }: { lat: number; lon: number }) { return null; } -function NodeQrCode({ url, className }: { url: string; className: string }) { - return ( -
- -
- ); -} - export function NodeDetailPage() { const { t } = useTranslation(); const config = useAppConfig(); @@ -106,18 +97,14 @@ export function NodeDetailPage() { usePageTitle("entities.node_detail"); const publicKey = publicKeyParam ?? ""; - const searchKey = searchParams.toString(); + const isFullKey = publicKey.length === 64; const flashMessage = searchParams.get("message") || ""; const flashError = searchParams.get("error") || ""; - const [node, setNode] = useState(null); - const [advertisements, setAdvertisements] = useState( - [], - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [notFound, setNotFound] = useState(false); + const queryClient = useQueryClient(); const [flash, setFlash] = useState(null); + const [prefixNotFound, setPrefixNotFound] = useState(false); + const [prefixError, setPrefixError] = useState(null); const [addKey, setAddKey] = useState(""); const [addValue, setAddValue] = useState(""); @@ -132,9 +119,10 @@ export function NodeDetailPage() { const [deleteKey, setDeleteKey] = useState(null); const [deleteSaving, setDeleteSaving] = useState(false); + const [confirmRelease, setConfirmRelease] = useState(false); useEffect(() => { - if (!publicKey || publicKey.length === 64) return; + if (!publicKey || isFullKey) return; const ac = new AbortController(); (async () => { try { @@ -147,64 +135,57 @@ export function NodeDetailPage() { } catch (e) { if (isAbortError(e)) return; if (errorMessage(e).includes("404")) { - setNotFound(true); + setPrefixNotFound(true); } else { - setError(errorMessage(e)); + setPrefixError(errorMessage(e)); } - setLoading(false); } })(); return () => ac.abort(); - }, [publicKey, navigate]); + }, [publicKey, isFullKey, navigate]); - const loadData = useCallback( - async (signal: AbortSignal) => { - try { - const [nodeData, adsData] = await Promise.all([ - apiGet( - `/api/v1/nodes/${publicKey}`, - {}, - { signal }, - ), - apiGet( - "/api/v1/advertisements", - { public_key: publicKey, limit: 10 }, - { signal }, - ), - apiGet( - "/api/v1/telemetry", - { node_public_key: publicKey, limit: 10 }, - { signal }, - ), - ]); - if (!nodeData) { - setNotFound(true); - return; - } - setNode(nodeData); - setAdvertisements(adsData.items || []); - setNotFound(false); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - if (errorMessage(e).includes("404")) { - setNotFound(true); - } else { - setError(errorMessage(e)); - } - } finally { - if (!signal.aborted) setLoading(false); - } - }, - [publicKey], - ); + const nodeQuery = useQuery({ + queryKey: qk.nodes.detail(publicKey), + queryFn: ({ signal }) => + apiGet( + `/api/v1/nodes/${publicKey}`, + {}, + { signal }, + ), + enabled: isFullKey, + }); + const advertisementsQuery = useQuery({ + queryKey: qk.advertisements.list({ public_key: publicKey, limit: 10 }), + queryFn: ({ signal }) => + apiGet( + "/api/v1/advertisements", + { public_key: publicKey, limit: 10 }, + { signal }, + ), + enabled: isFullKey, + }); - useEffect(() => { - if (!publicKey || publicKey.length !== 64) return; - const ac = new AbortController(); - loadData(ac.signal); - return () => ac.abort(); - }, [loadData, searchKey]); + const node = nodeQuery.data ?? null; + const advertisements = advertisementsQuery.data?.items ?? []; + const nodeErrorMsg = nodeQuery.error ? errorMessage(nodeQuery.error) : null; + const notFound = + prefixNotFound || + (nodeErrorMsg?.includes("404") ?? false) || + (isFullKey && !nodeQuery.isPending && nodeQuery.data === null); + const error = + prefixError || + (nodeErrorMsg && !nodeErrorMsg.includes("404") ? nodeErrorMsg : null); + const loading = isFullKey ? nodeQuery.isLoading : !prefixNotFound && !prefixError; + + const adoptMutation = useMutation({ + mutationFn: (key: string) => + apiPost("/api/v1/adoptions", { public_key: key }), + onSuccess: () => invalidate.adoptions(queryClient), + }); + const releaseMutation = useMutation({ + mutationFn: (key: string) => apiDelete(`/api/v1/adoptions/${key}`), + onSuccess: () => invalidate.adoptions(queryClient), + }); let lat: number | null = node?.lat ?? null; let lon: number | null = node?.lon ?? null; @@ -271,8 +252,8 @@ export function NodeDetailPage() { setFlash({ type, message }); }; - const reloadNode = () => { - navigate(`/nodes/${publicKey}?refresh=${Date.now()}`, { replace: true }); + const invalidateNodeData = () => { + invalidate.nodeTags(queryClient); }; const validateTagValue = (value: string, type: string): string | null => { @@ -292,7 +273,7 @@ export function NodeDetailPage() { const handleAdopt = async () => { if (!node) return; try { - await apiPost("/api/v1/adoptions", { public_key: node.public_key }); + await adoptMutation.mutateAsync(node.public_key); navigate( `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.adopt_success"))}`, { replace: true }, @@ -307,9 +288,9 @@ export function NodeDetailPage() { const handleRelease = async () => { if (!node) return; - if (!confirm(t("nodes.release_confirm"))) return; + setConfirmRelease(false); try { - await apiDelete(`/api/v1/adoptions/${node.public_key}`); + await releaseMutation.mutateAsync(node.public_key); navigate( `/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.release_success"))}`, { replace: true }, @@ -344,7 +325,7 @@ export function NodeDetailPage() { "success", t("common.entity_added_success", { entity: t("entities.tag") }), ); - reloadNode(); + invalidateNodeData(); } catch (e) { showFlash("error", errorMessage(e)); } @@ -377,7 +358,7 @@ export function NodeDetailPage() { "success", t("common.entity_updated_success", { entity: t("entities.tag") }), ); - reloadNode(); + invalidateNodeData(); } catch (e) { setEditError(errorMessage(e)); } finally { @@ -397,7 +378,7 @@ export function NodeDetailPage() { "success", t("common.entity_deleted_success", { entity: t("entities.tag") }), ); - reloadNode(); + invalidateNodeData(); } catch (e) { setDeleteKey(null); showFlash("error", errorMessage(e)); @@ -410,26 +391,19 @@ export function NodeDetailPage() { if (notFound) { return ( <> -
-
    -
  • - {t("entities.home")} -
  • -
  • - {t("entities.nodes")} -
  • -
  • {t("common.page_not_found")}
  • -
-
-
- - - {t("common.entity_not_found_details", { - entity: t("entities.node"), - details: publicKey, - })} - -
+ + {t("common.view_entity", { entity: t("entities.nodes") })} @@ -476,7 +450,7 @@ export function NodeDetailPage() { {canRelease && ( @@ -509,13 +483,7 @@ export function NodeDetailPage() {

{t("common.public_key")}

- copyToClipboard(e, node.public_key)} - title="Click to copy" - > - {node.public_key} - +
@@ -622,17 +590,13 @@ export function NodeDetailPage() { return ( <> -
-
    -
  • - {t("entities.home")} -
  • -
  • - {t("entities.nodes")} -
  • -
  • {tagName || node.name || truncateKey(node.public_key)}
  • -
-
+
-
@@ -695,7 +659,7 @@ export function NodeDetailPage() { ) : (
- +

{t("nodes.scan_to_add")}

@@ -843,15 +807,20 @@ export function NodeDetailPage() {
{canEditTags && editTag && ( -
-
-

+ {t("common.edit_entity", { entity: t("entities.tag") })}:{" "} {editTag.key} -

-
+ + } + onClose={() => { + if (!editSaving) setEditTag(null); + }} + > +
-
-
!editSaving && setEditTag(null)} - /> -
+ )} {canEditTags && deleteKey !== null && ( -
-
-

- {t("common.delete_entity", { entity: t("entities.tag") })} -

-

-

- {t("common.cannot_be_undone")} -
-
- - -
-
-
!deleteSaving && setDeleteKey(null)} - /> -
+ +

+

+ {t("common.cannot_be_undone")} +
+ + } + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + saving={deleteSaving} + onConfirm={handleDeleteTag} + onCancel={() => { + if (!deleteSaving) setDeleteKey(null); + }} + /> + )} + + {confirmRelease && ( + setConfirmRelease(false)} + /> )} ); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx index 87bdecf..f3aa3a5 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx @@ -1,22 +1,32 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useSearchParams } from "react-router"; import { useAppConfig } from "@/context/AppConfigContext"; import { apiGet } from "@/utils/api"; -import { useFormatDateTime, formatNumber } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; +import { qk } from "@/utils/queryKeys"; +import { useFormatDateTime } from "@/utils/format"; import { usePageTitle } from "@/hooks/usePageTitle"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { Pagination } from "@/components/Pagination"; -import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { + FilterForm, + FilterField, + FilterSelect, + OperatorSelect, + autoSubmit, +} from "@/components/FilterForm"; import { SortableTableHeader, MobileSortSelect, } from "@/components/SortableTable"; -import { NodeDisplay } from "@/components/NodeDisplay"; -import { Loading, WarningBadge } from "@/components/Alerts"; -import { IconRefresh } from "@/components/icons"; +import { NodeDisplay, NodeLink } from "@/components/NodeDisplay"; +import { CopyableValue } from "@/components/CopyableValue"; +import { Loading } from "@/components/Alerts"; +import { ListToolbar } from "@/components/ListToolbar"; +import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, EmptyRow } from "@/components/EmptyState"; interface NodeTag { key: string; @@ -72,7 +82,6 @@ export function Nodes() { const sort = searchParams.get("sort") || "last_seen"; const order = searchParams.get("order") || "desc"; - const tz = config.timezone || ""; const hasActiveFilters = search !== "" || advType !== "" || @@ -80,17 +89,32 @@ export function Nodes() { (config.oidc_enabled && adoptedBy !== ""); const [filterOpen, setFilterOpen] = useState(hasActiveFilters); - const [nodes, setNodes] = useState([]); - const [total, setTotal] = useState(null); - const [profiles, setProfiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const oidcEnabled = config.oidc_enabled; const operatorRole = config.role_names?.operator || "operator"; - const fetchData = useCallback(async () => { - try { + const { paused, toggle, intervalSeconds, refetchInterval } = + useAutoRefresh(); + + const { + data, + isLoading: loading, + error: queryError, + } = useQuery({ + queryKey: qk.nodes.list({ + limit, + offset, + search, + advType, + sort, + order, + adoptedBy, + pubkeyPrefix, + oidcEnabled, + operatorRole, + }), + refetchInterval, + queryFn: async ({ signal }) => { const apiParams: Record = { limit, offset, @@ -103,50 +127,37 @@ export function Nodes() { if (pubkeyPrefix) apiParams.pubkey_prefix = pubkeyPrefix; const fetches: Promise[] = [ - apiGet("/api/v1/nodes", apiParams), + apiGet("/api/v1/nodes", apiParams, { signal }), ]; if (oidcEnabled) { fetches.push( - apiGet("/api/v1/user/profiles", { limit: 500 }), + apiGet( + "/api/v1/user/profiles", + { limit: 500 }, + { signal }, + ), ); } const results = await Promise.all(fetches); - const data = results[0] as NodeListResponse; + const nodeData = results[0] as NodeListResponse; const profs = oidcEnabled ? ((results[1] as ProfileListResponse)?.items || []).filter( (p) => p.roles && p.roles.includes(operatorRole), ) : []; - setNodes(data.items || []); - setTotal(data.total || 0); - setProfiles(profs); - setError(null); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - }, [ - limit, - offset, - search, - advType, - sort, - order, - adoptedBy, - pubkeyPrefix, - oidcEnabled, - operatorRole, - ]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - const { paused, toggle, intervalSeconds } = useAutoRefresh({ - onRefresh: fetchData, + return { + nodes: nodeData.items || [], + total: nodeData.total || 0, + profiles: profs, + }; + }, }); + const error = queryError ? queryError.message : null; + + const nodes = data?.nodes ?? []; + const total = data?.total ?? null; + const profiles = data?.profiles ?? []; const sortedProfiles = useMemo( () => @@ -167,17 +178,13 @@ export function Nodes() { limit: String(limit), }; - const autoSubmit = (e: React.ChangeEvent) => { - e.currentTarget.form?.requestSubmit(); - }; - const noEntity = t("common.no_entity_found", { entity: t("entities.nodes").toLowerCase(), }); const mobileCards = nodes.length === 0 ? ( -
{noEntity}
+ {noEntity} ) : ( nodes.map((node) => { const displayName = tagValue(node.tags, "name") || node.name; @@ -212,11 +219,7 @@ export function Nodes() { const tableRows = nodes.length === 0 ? ( - - - {noEntity} - - + {noEntity} ) : ( nodes.map((node) => { const displayName = tagValue(node.tags, "name") || node.name; @@ -225,27 +228,16 @@ export function Nodes() { return ( - - - + - copyToClipboard(e, node.public_key)} - title="Click to copy" - > - {node.public_key} - + {lastSeen} @@ -257,48 +249,17 @@ export function Nodes() { return (
-
-

{t("entities.nodes")}

- {tz && tz !== "UTC" && ( - {tz} - )} -
+ -
- {total !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
- {intervalSeconds > 0 && ( - - )} -
-
- setFilterOpen((open) => !open)} - /> -
-
+ setFilterOpen((open) => !open), + }} + /> {filterOpen && (
@@ -306,10 +267,7 @@ export function Nodes() { key={`filters-${search}-${advType}-${adoptedBy}-${pubkeyPrefix}`} basePath="/nodes" > -
- + -
-
- - -
+ options={[ + { value: "", label: t("common.all_types") }, + { value: "chat", label: t("node_types.chat") }, + { value: "repeater", label: t("node_types.repeater") }, + { value: "companion", label: t("node_types.companion") }, + { value: "room", label: t("node_types.room") }, + ]} + /> + {oidcEnabled && sortedProfiles.length > 0 && ( -
- - -
+ profiles={sortedProfiles} + /> + )}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx index b18bd7f..717e299 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx @@ -1,13 +1,26 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router"; -import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { useFormatDateTime } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; import { Loading, WarningBadge } from "@/components/Alerts"; -import { JsonTree } from "@/components/JsonTree"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; +import { NotFoundState } from "@/components/NotFoundState"; +import { DefinitionGrid } from "@/components/Definition"; +import { + buildChannelNames, + isNotFoundError, + type ChannelItem, +} from "@/utils/packets"; +import { + Field, + RedactedNotice, + RawHexBlock, + DecodedJsonBlock, + channelNameDisplay, +} from "@/components/PacketParts"; interface PacketDetailData { packet_hash: string | null; @@ -28,129 +41,71 @@ interface PacketDetailData { decoded: unknown; } -interface ChannelItem { - name: string; - channel_hash: string; -} - interface ChannelsResponse { items: ChannelItem[]; } -function buildChannelNames(items: ChannelItem[]): Map { - const names = new Map(); - for (const c of items) { - const idx = parseInt(c.channel_hash, 16); - if (!Number.isNaN(idx)) names.set(idx, c.name); - } - return names; -} - -function isNotFoundError(e: unknown): boolean { - return e instanceof Error && e.message.includes("404"); -} - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - export function PacketDetail() { const { t } = useTranslation(); usePageTitle("packets.detail_title"); const { id } = useParams(); - const config = useAppConfig(); const { formatDateTime } = useFormatDateTime(); - const [packet, setPacket] = useState(null); - const [channelNames, setChannelNames] = useState>( - new Map(), - ); - const [notFound, setNotFound] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - setPacket(null); - setNotFound(false); - setError(null); - Promise.all([ - apiGet(`/api/v1/packets/${id}`, {}, { - signal: controller.signal, - }), + const packetQuery = useQuery({ + queryKey: qk.packets.detail(id ?? ""), + queryFn: ({ signal }) => + apiGet(`/api/v1/packets/${id}`, {}, { signal }), + enabled: !!id, + }); + const channelsQuery = useQuery({ + queryKey: qk.channels.list({ limit: 200 }), + queryFn: ({ signal }) => apiGet("/api/v1/channels", { limit: 200 }, { - signal: controller.signal, + signal, }).catch(() => ({ items: [] as ChannelItem[] })), - ]) - .then(([p, channelsData]) => { - setPacket(p); - setChannelNames(buildChannelNames(channelsData.items || [])); - }) - .catch((e) => { - if (isAbortError(e)) return; - if (isNotFoundError(e)) { - setNotFound(true); - } else { - setError(e instanceof Error ? e.message : String(e)); - } - }); - return () => controller.abort(); - }, [id]); + }); + + const packet = packetQuery.data ?? null; + const channelNames = buildChannelNames(channelsQuery.data?.items || []); + const notFound = packetQuery.error + ? isNotFoundError(packetQuery.error) + : false; + const error = + packetQuery.error && !isNotFoundError(packetQuery.error) + ? packetQuery.error instanceof Error + ? packetQuery.error.message + : String(packetQuery.error) + : null; - const tz = config.timezone || ""; const leaf = packet?.packet_hash || packet?.event_type || ""; - - let channelDisplay: ReactNode = ; - if (packet && packet.channel_idx != null) { - const name = channelNames.get(packet.channel_idx); - channelDisplay = name - ? `${name} (${packet.channel_idx})` - : `${packet.channel_idx}`; - } + const channelDisplay = channelNameDisplay(channelNames, packet?.channel_idx ?? null); return (
-
-
    -
  • - {t("entities.home")} -
  • -
  • - {t("entities.packets")} -
  • -
  • {leaf || t("packets.detail_title")}
  • -
-
- -
-

{t("packets.detail_title")}

- {tz && tz !== "UTC" && {tz}} -
+ {notFound && ( -
- {t("common.entity_not_found_details", { + + /> )} {error && } {!packet && !notFound && !error && } {packet && ( <> - {packet.redacted && ( -
- {"\u{1F512}"} {t("packets.redacted_notice")} -
- )} + {packet.redacted && }
-
+ {formatDateTime(packet.received_at)} @@ -205,38 +160,12 @@ export function PacketDetail() { {packet.path_len != null ? packet.path_len : "—"} -
+ - {!packet.redacted && ( -
-
- - {t("packets.col_raw")} - - {packet.raw_hex && ( - - )} -
-
-                    {packet.raw_hex || "—"}
-                  
-
- )} + {!packet.redacted && } {!packet.redacted && packet.decoded != null && ( -
- - {t("packets.decoded")} - -
- -
-
+ )}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx index 56d2423..bede1a2 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx @@ -6,21 +6,36 @@ import { type ReactNode, } from "react"; import { createPortal } from "react-dom"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router"; -import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; import { apiGet, isAbortError } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { formatNumber, - formatRelativeTime, + resolveNodeName, truncateKey, useFormatDateTime, } from "@/utils/format"; -import { copyToClipboard } from "@/utils/clipboard"; import { Loading, WarningBadge } from "@/components/Alerts"; -import { JsonTree } from "@/components/JsonTree"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; import { IconSatelliteDish } from "@/components/icons"; +import { NotFoundState } from "@/components/NotFoundState"; +import { TimeAgo } from "@/components/TimeAgo"; +import { DefinitionGrid } from "@/components/Definition"; +import { + buildChannelNames, + isNotFoundError, + type ChannelItem, +} from "@/utils/packets"; +import { + Field, + RedactedNotice, + RawHexBlock, + DecodedJsonBlock, + channelNameDisplay, +} from "@/components/PacketParts"; const PATH_MAX_BADGES = 16; const PATH_HEAD = 7; @@ -55,11 +70,6 @@ interface PacketGroupData { receptions: Reception[]; } -interface ChannelItem { - name: string; - channel_hash: string; -} - interface ChannelsResponse { items: ChannelItem[]; } @@ -82,19 +92,6 @@ interface PopoverAnchor { top: number; } -function buildChannelNames(items: ChannelItem[]): Map { - const names = new Map(); - for (const c of items) { - const idx = parseInt(c.channel_hash, 16); - if (!Number.isNaN(idx)) names.set(idx, c.name); - } - return names; -} - -function isNotFoundError(e: unknown): boolean { - return e instanceof Error && e.message.includes("404"); -} - function groupByObserver(receptions: Reception[]): Map { const groups = new Map(); for (const r of receptions) { @@ -109,20 +106,6 @@ function groupByObserver(receptions: Reception[]): Map { return groups; } -function nodeDisplayName(n: NodeItem): string { - const tagName = n.tags?.find((tag) => tag.key === "name")?.value; - return tagName || n.name || truncateKey(n.public_key, 12); -} - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - function Stat({ label, value }: { label: string; value: ReactNode }) { return (
@@ -238,16 +221,8 @@ export function PacketGroupDetail() { const { t } = useTranslation(); usePageTitle("packets.detail_title"); const { hash } = useParams(); - const config = useAppConfig(); const { formatDateTime } = useFormatDateTime(); - const [group, setGroup] = useState(null); - const [channelNames, setChannelNames] = useState>( - new Map(), - ); - const [notFound, setNotFound] = useState(false); - const [error, setError] = useState(null); - const [popover, setPopover] = useState(null); const [popoverPos, setPopoverPos] = useState<{ left: number; @@ -258,33 +233,31 @@ export function PacketGroupDetail() { const [popoverError, setPopoverError] = useState(null); const popoverRef = useRef(null); - useEffect(() => { - const controller = new AbortController(); - setGroup(null); - setNotFound(false); - setError(null); - Promise.all([ - apiGet(`/api/v1/packet-groups/${hash}`, {}, { - signal: controller.signal, - }), + const groupQuery = useQuery({ + queryKey: qk.packets.group(hash ?? ""), + queryFn: ({ signal }) => + apiGet(`/api/v1/packet-groups/${hash}`, {}, { signal }), + enabled: !!hash, + }); + const channelsQuery = useQuery({ + queryKey: qk.channels.list({ limit: 200 }), + queryFn: ({ signal }) => apiGet("/api/v1/channels", { limit: 200 }, { - signal: controller.signal, + signal, }).catch(() => ({ items: [] as ChannelItem[] })), - ]) - .then(([g, channelsData]) => { - setGroup(g); - setChannelNames(buildChannelNames(channelsData.items || [])); - }) - .catch((e) => { - if (isAbortError(e)) return; - if (isNotFoundError(e)) { - setNotFound(true); - } else { - setError(e instanceof Error ? e.message : String(e)); - } - }); - return () => controller.abort(); - }, [hash]); + }); + + const group = groupQuery.data ?? null; + const channelNames = buildChannelNames(channelsQuery.data?.items || []); + const notFound = groupQuery.error + ? isNotFoundError(groupQuery.error) + : false; + const error = + groupQuery.error && !isNotFoundError(groupQuery.error) + ? groupQuery.error instanceof Error + ? groupQuery.error.message + : String(groupQuery.error) + : null; useEffect(() => { const onDocClick = (ev: MouseEvent) => { @@ -323,7 +296,7 @@ export function PacketGroupDetail() { const items = (data.items || []) .slice() .sort((a, b) => - nodeDisplayName(a).localeCompare(nodeDisplayName(b)), + resolveNodeName(a).localeCompare(resolveNodeName(b)), ); setPopoverNodes(items); setPopoverTotal(data.total || 0); @@ -371,64 +344,41 @@ export function PacketGroupDetail() { }); }; - const tz = config.timezone || ""; const leaf = group?.packet_hash || group?.event_type || ""; const receptions = group?.receptions ?? []; const sourcePrefix = group?.source_pubkey_prefix ?? null; const observerGroups = groupByObserver(receptions); const moreCount = popoverTotal - (popoverNodes?.length ?? 0); - let channelDisplay: ReactNode = ; - if (group && group.channel_idx != null) { - const name = channelNames.get(group.channel_idx); - channelDisplay = name - ? `${name} (${group.channel_idx})` - : `${group.channel_idx}`; - } - - const receptionTime = (r: Reception) => ( - - {formatRelativeTime(r.received_at)} - + const channelDisplay = channelNameDisplay( + channelNames, + group?.channel_idx ?? null, ); + const receptionTime = (r: Reception) => ; + return (
-
-
    -
  • - {t("entities.home")} -
  • -
  • - {t("entities.packets")} -
  • -
  • {leaf || t("packets.detail_title")}
  • -
-
- -
-

{t("packets.detail_title")}

- {tz && tz !== "UTC" && {tz}} -
+ {notFound && ( -
- {t("packets.not_found_retention")} -
+ )} {error && } {!group && !notFound && !error && } {group && ( <> - {group.redacted && ( -
- {"\u{1F512}"} {t("packets.redacted_notice")} -
- )} + {group.redacted && }
-
+ {formatDateTime(group.first_seen)} @@ -471,7 +421,7 @@ export function PacketGroupDetail() { · {formatNumber(group.observer_count)}{" "} {t("common.observers").toLowerCase()} -
+ {receptions.length > 0 && (
@@ -600,33 +550,11 @@ export function PacketGroupDetail() { )} {!group.redacted && group.raw_hex && ( -
-
- - {t("packets.col_raw")} - - -
-
-                    {group.raw_hex}
-                  
-
+ )} {!group.redacted && group.decoded != null && ( -
- - {t("packets.decoded")} - -
- -
-
+ )}
@@ -677,7 +605,7 @@ export function PacketGroupDetail() { onClick={() => setPopover(null)} className="flex flex-col items-start gap-0" > - {nodeDisplayName(n)} + {resolveNodeName(n)} {truncateKey(n.public_key, 16)} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx index 38f7ea2..8b9e4fd 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx @@ -1,24 +1,24 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useAppConfig } from "@/context/AppConfigContext"; import { usePageTitle } from "@/hooks/usePageTitle"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; -import { apiGet, isAbortError } from "@/utils/api"; +import { apiGet } from "@/utils/api"; +import { qk } from "@/utils/queryKeys"; import { formatNumber, useFormatDateTime } from "@/utils/format"; import { Pagination } from "@/components/Pagination"; -import { FilterForm, FilterToggle } from "@/components/FilterForm"; +import { FilterForm, FilterField } from "@/components/FilterForm"; import { MobileSortSelect, SortableTableHeader, } from "@/components/SortableTable"; -import { Loading, WarningBadge } from "@/components/Alerts"; -import { - IconPath, - IconRefresh, - IconRuler, - IconSatelliteDish, -} from "@/components/icons"; +import { Loading } from "@/components/Alerts"; +import { ListToolbar } from "@/components/ListToolbar"; +import { PageHeader } from "@/components/PageHeader"; +import { EmptyState, EmptyRow } from "@/components/EmptyState"; +import { IconPath, IconRuler, IconSatelliteDish } from "@/components/icons"; const EVENT_TYPES = [ "advertisement", @@ -174,10 +174,6 @@ export function Packets() { const order = searchParams.get("order") ?? "desc"; const offset = (page - 1) * limit; - const [packets, setPackets] = useState(null); - const [total, setTotal] = useState(0); - const [channels, setChannels] = useState([]); - const [error, setError] = useState(null); const hasActiveFilters = search !== "" || eventType !== "" || @@ -185,56 +181,59 @@ export function Packets() { pathHashBytes !== ""; const [filterOpen, setFilterOpen] = useState(hasActiveFilters); + const autoRefresh = useAutoRefresh(); + + const { data, error: queryError } = useQuery({ + queryKey: qk.packets.groups({ + search, + eventType, + channelIdx, + pathHashBytes, + limit, + offset, + sort, + order, + }), + refetchInterval: autoRefresh.refetchInterval, + queryFn: async ({ signal }) => { + const apiParams: Record = { + limit, + offset, + search, + sort, + order, + }; + if (eventType) apiParams.event_type = eventType; + if (channelIdx !== "") apiParams.channel_idx = channelIdx; + if (pathHashBytes !== "") apiParams.path_hash_bytes = pathHashBytes; + + const [groupsData, channelsData] = await Promise.all([ + apiGet("/api/v1/packet-groups", apiParams, { + signal, + }), + apiGet("/api/v1/channels", { limit: 200 }, { + signal, + }).catch(() => ({ items: [] as ChannelItem[] })), + ]); + + return { + packets: groupsData.items || [], + total: groupsData.total || 0, + channels: buildChannelList(channelsData.items || []), + }; + }, + }); + const error = queryError ? queryError.message : null; + + const packets = data?.packets ?? null; + const total = data?.total ?? 0; + const channels = data?.channels ?? []; + const channelNames = useMemo( () => new Map(channels.map((c) => [c.idx, c.name])), [channels], ); - const fetchData = useCallback( - async (signal?: AbortSignal) => { - try { - const apiParams: Record = { - limit, - offset, - search, - sort, - order, - }; - if (eventType) apiParams.event_type = eventType; - if (channelIdx !== "") apiParams.channel_idx = channelIdx; - if (pathHashBytes !== "") apiParams.path_hash_bytes = pathHashBytes; - - const [data, channelsData] = await Promise.all([ - apiGet("/api/v1/packet-groups", apiParams, { - signal, - }), - apiGet("/api/v1/channels", { limit: 200 }, { - signal, - }).catch(() => ({ items: [] as ChannelItem[] })), - ]); - - setPackets(data.items || []); - setTotal(data.total || 0); - setChannels(buildChannelList(channelsData.items || [])); - setError(null); - } catch (e) { - if (isAbortError(e)) return; - setError(e instanceof Error ? e.message : String(e)); - } - }, - [search, eventType, channelIdx, pathHashBytes, limit, offset, sort, order], - ); - - useEffect(() => { - const controller = new AbortController(); - fetchData(controller.signal); - return () => controller.abort(); - }, [fetchData]); - - const autoRefresh = useAutoRefresh({ - onRefresh: () => fetchData(), - }); - const applyFilters = (overrides: Record) => { const next = { search, @@ -251,7 +250,6 @@ export function Packets() { navigate(qs ? `/packets?${qs}` : "/packets"); }; - const tz = config.timezone || ""; const totalPages = Math.ceil(total / limit); const filterParams: Record = { search, @@ -266,56 +264,23 @@ export function Packets() { return (
-
-

{t("entities.packets")}

- {tz && tz !== "UTC" && {tz}} -
+ -
- {packets !== null && ( - - {t("common.total", { count: formatNumber(total) })} - - )} - {error && } -
- {autoRefresh.intervalSeconds > 0 && ( - - )} -
-
- setFilterOpen((o) => !o)} - /> -
-
+ setFilterOpen((o) => !o) }} + /> {filterOpen && (
-
- + -
-
- + + -
-
- + + -
-
- + + -
+
)} @@ -412,7 +368,7 @@ export function Packets() {
{packets.length === 0 ? ( -
{noneFound}
+ {noneFound} ) : ( packets.map((p, i) => ( {packets.length === 0 ? ( - - - {noneFound} - - + {noneFound} ) : ( packets.map((p, i) => ( {roles.map((role) => ( - - {role} - + ))}
); @@ -67,10 +71,7 @@ function MemberSince({ createdAt }: { createdAt?: string | null }) { } function AdoptedNodeLink({ node }: { node: ProfileNode }) { - const { formatDateTime } = useFormatDateTime(); - const displayName = node.name || node.public_key.slice(0, 12) + "..."; - const relTime = node.last_seen ? formatRelativeTime(node.last_seen) : "-"; - const fullTime = node.last_seen ? formatDateTime(node.last_seen) : "-"; + const displayName = resolveNodeName(node); return (
- + {node.last_seen ? ( + + ) : ( + + - + + )} ); } @@ -125,26 +129,12 @@ function AdoptedNodesCard({ function PublicProfileView({ id }: { id: string }) { const { t } = useTranslation(); const config = useAppConfig(); - const [profile, setProfile] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - setProfile(null); - setError(null); - apiGet( - `/api/v1/user/profile/${id}`, - {}, - { signal: controller.signal }, - ) - .then(setProfile) - .catch((e) => { - if (!isAbortError(e)) { - setError((e as Error).message || t("common.failed_to_load_page")); - } - }); - return () => controller.abort(); - }, [id, t]); + const { data: profile, error: queryError } = useQuery({ + queryKey: qk.profiles.detail(id), + queryFn: ({ signal }) => + apiGet(`/api/v1/user/profile/${id}`, {}, { signal }), + }); + const error = queryError ? queryError.message : null; if (error) return ; if (!profile) return ; @@ -154,24 +144,26 @@ function PublicProfileView({ id }: { id: string }) { return ( <> -
-

{t("user_profile.title")}

+ + {isOwner && ( {t("user_profile.edit_profile")} )} -
+

{profile.name || t("common.unnamed")} - {profile.callsign && ( - - {profile.callsign} - - )} + {profile.callsign && }

{profile.description && ( @@ -202,26 +194,24 @@ function OwnProfileView() { const config = useAppConfig(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [profile, setProfile] = useState(null); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const { data: profile, error: queryError } = useQuery({ + queryKey: qk.profiles.me(), + queryFn: ({ signal }) => + apiGet("/api/v1/user/profile/me", {}, { signal }), + }); + const error = queryError ? queryError.message : null; - useEffect(() => { - const controller = new AbortController(); - setProfile(null); - setError(null); - apiGet( - "/api/v1/user/profile/me", - {}, - { signal: controller.signal }, - ) - .then(setProfile) - .catch((e) => { - if (!isAbortError(e)) { - setError((e as Error).message || t("common.failed_to_load_page")); - } - }); - return () => controller.abort(); - }, [searchParams, t]); + const updateMutation = useMutation({ + mutationFn: ({ + id, + body, + }: { + id: string; + body: Record; + }) => apiPut(`/api/v1/user/profile/${id}`, body), + onSuccess: () => invalidate.profiles(queryClient), + }); if (!config.oidc_enabled || !config.user) { return ( @@ -251,7 +241,7 @@ function OwnProfileView() { url: String(data.get("url") ?? "").trim() || null, }; try { - await apiPut(`/api/v1/user/profile/${profile.id}`, body); + await updateMutation.mutateAsync({ id: profile.id, body }); navigate( "/profile?message=" + encodeURIComponent(t("user_profile.profile_updated")), { replace: true }, @@ -266,9 +256,13 @@ function OwnProfileView() { return ( <> -
-

{t("user_profile.title")}

-
+ + {flashMessage ? ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx index e30756b..cd70c3e 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx @@ -1,18 +1,24 @@ import { Fragment, - useCallback, useEffect, useRef, useState, type SVGProps, } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; import { useAppConfig, hasRole } from "@/context/AppConfigContext"; import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api"; +import { qk, invalidate } from "@/utils/queryKeys"; import { usePageTitle } from "@/hooks/usePageTitle"; import { Loading, ErrorAlert } from "@/components/Alerts"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { EmptyState } from "@/components/EmptyState"; +import { Modal } from "@/components/Modal"; +import { PageHeader } from "@/components/PageHeader"; +import { SectionGroup } from "@/components/SectionGroup"; import { RouteDetailStrip } from "@/components/charts/Charts"; import { IconClock, @@ -514,8 +520,6 @@ function DetailContent({ function RouteCard({ route, - detail, - history, isAdmin, packetsEnabled, onEdit, @@ -523,8 +527,6 @@ function RouteCard({ onNavigate, }: { route: RouteItem; - detail: RouteDetail | undefined; - history: RouteHistory | undefined; isAdmin: boolean; packetsEnabled: boolean; onEdit: () => void; @@ -532,6 +534,20 @@ function RouteCard({ onNavigate: (url: string) => void; }) { const { t } = useTranslation(); + const { data: detail } = useQuery({ + queryKey: qk.routes.detail(route.id), + queryFn: ({ signal }) => + apiGet(`/api/v1/routes/${route.id}`, {}, { signal }), + }); + const { data: history } = useQuery({ + queryKey: qk.routes.history(route.id, 6), + queryFn: ({ signal }) => + apiGet( + `/api/v1/routes/${route.id}/history`, + { days: 6 }, + { signal }, + ), + }); const q = qualityOf(route); const badgeCls = qualityBadgeClass(q, route.enabled); const label = qualityLabel(q, route.enabled, t); @@ -765,11 +781,11 @@ function RouteModal({ }; return ( - -
-

- {isEdit ? t("routes.edit_route") : t("routes.add_route")} -

+
@@ -1093,11 +1109,7 @@ function RouteModal({
-
-
- -
-
+ ); } @@ -1117,30 +1129,15 @@ function DeleteRouteModal({ const label = `${route.from_label} ${arrow} ${route.to_label}`; return ( - -
-

{t("routes.delete_route")}

-

{t("routes.delete_confirm", { label })}

-
- - -
-
-
- -
-
+ {t("routes.delete_confirm", { label })}

} + confirmLabel={t("common.delete")} + cancelLabel={t("common.cancel")} + saving={saving} + onConfirm={onConfirm} + onCancel={onCancel} + /> ); } @@ -1152,90 +1149,53 @@ export function RoutesPage() { const isAdmin = hasRole("admin"); usePageTitle("routes.title"); - const [routes, setRoutes] = useState([]); - const [detailCache, setDetailCache] = useState>( - {}, - ); - const [historyCache, setHistoryCache] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + const { + data: routesData, + isLoading: loading, + error: queryError, + } = useQuery({ + queryKey: qk.routes.list(), + queryFn: async ({ signal }) => { + const data = await apiGet( + "/api/v1/routes", + {}, + { signal }, + ); + return data.items || []; + }, + }); + const routes = routesData ?? []; + const error = queryError ? queryError.message : null; const [modal, setModal] = useState(null); - const detailCacheRef = useRef>({}); - const historyCacheRef = useRef>({}); const pathTimerRef = useRef | null>(null); const obsTimerRef = useRef | null>(null); const pathSearchIdRef = useRef(0); const obsSearchIdRef = useRef(0); - const loadAllDetails = useCallback(async (routesList: RouteItem[]) => { - const newDetails: Record = {}; - const newHistories: Record = {}; - const promises: Promise[] = []; - for (const r of routesList) { - if (!detailCacheRef.current[r.id]) { - promises.push( - apiGet(`/api/v1/routes/${r.id}`) - .then((d) => { - newDetails[r.id] = d; - }) - .catch(() => undefined), - ); + const saveMutation = useMutation({ + mutationFn: async ({ + id, + body, + }: { + id?: string; + body: Record; + }) => { + if (id) { + await apiPut(`/api/v1/routes/${id}`, body); + } else { + await apiPost("/api/v1/routes", body); } - if (!historyCacheRef.current[r.id]) { - promises.push( - apiGet(`/api/v1/routes/${r.id}/history`, { days: 6 }) - .then((h) => { - newHistories[r.id] = h; - }) - .catch(() => undefined), - ); - } - } - if (promises.length === 0) return; - await Promise.allSettled(promises); - if (Object.keys(newDetails).length > 0) { - detailCacheRef.current = { ...detailCacheRef.current, ...newDetails }; - setDetailCache(detailCacheRef.current); - } - if (Object.keys(newHistories).length > 0) { - historyCacheRef.current = { ...historyCacheRef.current, ...newHistories }; - setHistoryCache(historyCacheRef.current); - } - }, []); + }, + onSuccess: () => invalidate.routes(queryClient), + }); - const fetchRoutes = useCallback(async (): Promise => { - try { - const data = await apiGet("/api/v1/routes"); - const items = data.items || []; - setRoutes(items); - setError(null); - return items; - } catch (e) { - setError((e as Error).message || t("common.failed_to_load_page")); - return []; - } finally { - setLoading(false); - } - }, [t]); - - const refresh = useCallback(async () => { - const items = await fetchRoutes(); - await loadAllDetails(items); - }, [fetchRoutes, loadAllDetails]); - - useEffect(() => { - let active = true; - (async () => { - const items = await fetchRoutes(); - if (active) await loadAllDetails(items); - })(); - return () => { - active = false; - }; - }, [fetchRoutes, loadAllDetails]); + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/v1/routes/${id}`), + onSuccess: () => invalidate.routes(queryClient), + }); useEffect(() => { return () => { @@ -1461,22 +1421,11 @@ export function RoutesPage() { } setModal((m) => (m ? { ...m, saving: true } : m)); try { - if (isEdit && modal.route) { - const id = modal.route.id; - await apiPut(`/api/v1/routes/${id}`, body); - const nextDetails = { ...detailCacheRef.current }; - delete nextDetails[id]; - detailCacheRef.current = nextDetails; - setDetailCache(nextDetails); - const nextHistories = { ...historyCacheRef.current }; - delete nextHistories[id]; - historyCacheRef.current = nextHistories; - setHistoryCache(nextHistories); - } else { - await apiPost("/api/v1/routes", body); - } + await saveMutation.mutateAsync({ + id: isEdit && modal.route ? modal.route.id : undefined, + body, + }); setModal(null); - await refresh(); } catch (e) { setModal((m) => (m ? { ...m, saving: false } : m)); alert((e as Error).message || "Failed to save route"); @@ -1487,9 +1436,8 @@ export function RoutesPage() { if (!modal || modal.type !== "delete" || !modal.route) return; setModal((m) => (m ? { ...m, saving: true } : m)); try { - await apiDelete(`/api/v1/routes/${modal.route.id}`); + await deleteMutation.mutateAsync(modal.route.id); setModal(null); - await refresh(); } catch (e) { setModal((m) => (m ? { ...m, saving: false } : m)); alert((e as Error).message || "Failed to delete route"); @@ -1508,12 +1456,14 @@ export function RoutesPage() { return (
-
-

- - {t("routes.title")} -

-
+ + + {t("routes.title")} + + } + /> @@ -1531,11 +1481,11 @@ export function RoutesPage() { )} {routes.length === 0 && ( -
+ {t("common.no_entity_found", { entity: t("entities.routes").toLowerCase(), })} -
+ )} {VISIBILITY_ORDER.map((vis) => { @@ -1548,16 +1498,11 @@ export function RoutesPage() { if (group.length === 0) return null; return (
-

- {t(`routes.visibility_${vis}`)} -

-
+ {group.map((r) => ( openEditModal(r)} @@ -1565,7 +1510,7 @@ export function RoutesPage() { onNavigate={navigate} /> ))} -
+
); })} diff --git a/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx b/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx new file mode 100644 index 0000000..a832437 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/test/renderWithProviders.tsx @@ -0,0 +1,48 @@ +import type { ReactElement, ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router"; +import { render, type RenderOptions } from "@testing-library/react"; + +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { makeConfig } from "@/test/makeConfig"; +import type { AppConfig } from "@/types/config"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); +} + +interface ProviderOptions { + config?: AppConfig; + client?: QueryClient; + route?: string; + renderOptions?: Omit; +} + +export function renderWithProviders( + ui: ReactElement, + options: ProviderOptions = {}, +) { + const { + config = makeConfig(), + client = createTestQueryClient(), + route = "/", + renderOptions, + } = options; + + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + } + + return { client, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts index 9b70179..663784d 100644 --- a/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.test.ts @@ -6,6 +6,7 @@ import { formatRelativeTime, getNodeEmoji, parseAppDate, + resolveNodeName, truncateKey, typeEmoji, } from "@/utils/format"; @@ -72,6 +73,38 @@ describe("truncateKey", () => { }); }); +describe("resolveNodeName", () => { + const key = "0123456789abcdef0123456789abcdef"; + + it("returns '-' for null/undefined nodes", () => { + expect(resolveNodeName(null)).toBe("-"); + expect(resolveNodeName(undefined)).toBe("-"); + }); + + it("prefers a 'name' tag value", () => { + expect( + resolveNodeName({ + name: "Real Name", + public_key: key, + tags: [{ key: "name", value: "Tag Name" }], + }), + ).toBe("Tag Name"); + }); + + it("falls back to the node name when there is no name tag", () => { + expect(resolveNodeName({ name: "Real Name", public_key: key })).toBe( + "Real Name", + ); + }); + + it("falls back to a truncated public key when there is no name", () => { + expect(resolveNodeName({ name: null, public_key: key })).toBe( + "0123456789ab...", + ); + expect(resolveNodeName({ public_key: key })).toBe("0123456789ab..."); + }); +}); + describe("typeEmoji", () => { it("maps node types to emoji (incl. inference from substrings)", () => { expect(typeEmoji("chat")).toBe("\u{1F4AC}"); diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts index 79845fe..05eab35 100644 --- a/src/meshcore_hub/web/static/js/spa-react/utils/format.ts +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts @@ -105,6 +105,21 @@ export function truncateKey(key: string | null, length = 12): string { return key.slice(0, length) + "..."; } +export function resolveNodeName( + node: { + name?: string | null; + public_key?: string | null; + tags?: { key: string; value: string | null }[]; + } | null | undefined, + fallbackLength = 12, +): string { + if (!node) return "-"; + const tagName = node.tags?.find((tag) => tag.key === "name")?.value; + return ( + tagName || node.name || truncateKey(node.public_key ?? null, fallbackLength) + ); +} + function inferNodeType(value: string | null): string | null { const normalized = (value ?? "").toLowerCase(); if (!normalized) return null; diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts new file mode 100644 index 0000000..656b5bd --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/packets.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { buildChannelNames, isNotFoundError } from "@/utils/packets"; + +describe("buildChannelNames", () => { + it("maps hex channel_hash to name by parsed index", () => { + const names = buildChannelNames([ + { name: "General", channel_hash: "0" }, + { name: "Lobby", channel_hash: "a" }, + { name: "Ops", channel_hash: "ff" }, + ]); + expect(names.get(0)).toBe("General"); + expect(names.get(10)).toBe("Lobby"); + expect(names.get(255)).toBe("Ops"); + }); + + it("skips entries whose hash is not a number", () => { + const names = buildChannelNames([ + { name: "Bad", channel_hash: "zz" }, + { name: "Good", channel_hash: "1" }, + ]); + expect(names.size).toBe(1); + expect(names.get(1)).toBe("Good"); + }); + + it("returns an empty map for no items", () => { + expect(buildChannelNames([]).size).toBe(0); + }); +}); + +describe("isNotFoundError", () => { + it("detects 404 in the error message", () => { + expect(isNotFoundError(new Error("API error: 404 Not Found"))).toBe(true); + }); + + it("returns false for other errors and non-Error values", () => { + expect(isNotFoundError(new Error("API error: 500"))).toBe(false); + expect(isNotFoundError("404")).toBe(false); + expect(isNotFoundError(null)).toBe(false); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts b/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts new file mode 100644 index 0000000..37b972c --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/packets.ts @@ -0,0 +1,17 @@ +export interface ChannelItem { + name: string; + channel_hash: string; +} + +export function buildChannelNames(items: ChannelItem[]): Map { + const names = new Map(); + for (const c of items) { + const idx = parseInt(c.channel_hash, 16); + if (!Number.isNaN(idx)) names.set(idx, c.name); + } + return names; +} + +export function isNotFoundError(e: unknown): boolean { + return e instanceof Error && e.message.includes("404"); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts b/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts new file mode 100644 index 0000000..255796d --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/queryClient.ts @@ -0,0 +1,15 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const DEFAULT_STALE_TIME_MS = 30_000; + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: DEFAULT_STALE_TIME_MS, + refetchOnWindowFocus: true, + retry: 1, + }, + }, + }); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts new file mode 100644 index 0000000..7428c8a --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/queryKeys.ts @@ -0,0 +1,78 @@ +import type { QueryClient } from "@tanstack/react-query"; + +export const qk = { + nodes: { + all: ["nodes"] as const, + list: (params: unknown) => ["nodes", "list", params] as const, + detail: (publicKey: string) => ["nodes", "detail", publicKey] as const, + prefix: (prefix: string) => ["nodes", "prefix", prefix] as const, + }, + messages: { + all: ["messages"] as const, + list: (params: unknown) => ["messages", "list", params] as const, + }, + channels: { + all: ["channels"] as const, + list: (params: unknown) => ["channels", "list", params] as const, + }, + routes: { + all: ["routes"] as const, + list: () => ["routes", "list"] as const, + detail: (id: string) => ["routes", "detail", id] as const, + history: (id: string, days: number) => + ["routes", "history", id, days] as const, + }, + advertisements: { + all: ["advertisements"] as const, + list: (params: unknown) => ["advertisements", "list", params] as const, + }, + profiles: { + all: ["profiles"] as const, + list: (params: unknown) => ["profiles", "list", params] as const, + detail: (id: string) => ["profiles", "detail", id] as const, + me: () => ["profiles", "me"] as const, + }, + dashboard: { + all: ["dashboard"] as const, + stats: () => ["dashboard", "stats"] as const, + series: (kind: string, params: unknown) => + ["dashboard", "series", kind, params] as const, + recent: (params: unknown) => ["dashboard", "recent", params] as const, + routesOverview: () => ["dashboard", "routes-overview"] as const, + }, + packets: { + all: ["packets"] as const, + groups: (params: unknown) => ["packets", "groups", params] as const, + group: (hash: string) => ["packets", "group", hash] as const, + detail: (id: string) => ["packets", "detail", id] as const, + }, + map: { + all: ["map"] as const, + data: (params: unknown) => ["map", "data", params] as const, + }, +}; + +export const invalidate = { + channels: (qc: QueryClient) => + qc.invalidateQueries({ queryKey: qk.channels.all }), + routes: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.routes.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, + profiles: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.profiles.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, + nodeTags: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.nodes.all }); + qc.invalidateQueries({ queryKey: qk.messages.all }); + qc.invalidateQueries({ queryKey: qk.advertisements.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, + adoptions: (qc: QueryClient) => { + qc.invalidateQueries({ queryKey: qk.nodes.all }); + qc.invalidateQueries({ queryKey: qk.profiles.all }); + qc.invalidateQueries({ queryKey: qk.advertisements.all }); + qc.invalidateQueries({ queryKey: qk.dashboard.all }); + }, +}; diff --git a/tests/test_api/test_cache.py b/tests/test_api/test_cache.py index f563d80..09c1ab0 100644 --- a/tests/test_api/test_cache.py +++ b/tests/test_api/test_cache.py @@ -1313,12 +1313,13 @@ class TestKeyBuilders: ): scope = { "type": "http", + "path": "/api/v1/channels", "query_string": b"", "headers": [], } request = Request(scope) key = _channels_key_builder(request) - assert key == "channels:role=operator:" + assert key == "/api/v1/channels:role=operator:" def test_channels_key_builder_anonymous(self): from meshcore_hub.api.routes.channels import _channels_key_builder @@ -1329,6 +1330,7 @@ class TestKeyBuilders: ): scope = { "type": "http", + "path": "/api/v1/channels", "query_string": b"", "headers": [], } @@ -1345,14 +1347,54 @@ class TestKeyBuilders: ): scope = { "type": "http", + "path": "/api/v1/messages", "query_string": b"limit=10&offset=0", "headers": [], } request = Request(scope) key = _messages_key_builder(request) - assert "role=admin" in key + assert key.startswith("/api/v1/messages:role=admin:") assert "limit=10" in key + def test_role_aware_keys_match_invalidation_prefixes(self): + """The GET cache key must live under the prefix the matching + invalidation helper drops, otherwise a mutation never clears the + stale cached list (the store path and delete path must agree). + """ + from meshcore_hub.api.routes.channels import _channels_key_builder + from meshcore_hub.api.routes.messages import _messages_key_builder + + with ( + patch( + "meshcore_hub.api.routes.channels.resolve_user_role", + return_value="admin", + ), + patch( + "meshcore_hub.api.routes.messages.resolve_user_role", + return_value="admin", + ), + ): + channels_req = Request( + { + "type": "http", + "path": "/api/v1/channels", + "query_string": b"", + "headers": [], + } + ) + messages_req = Request( + { + "type": "http", + "path": "/api/v1/messages", + "query_string": b"", + "headers": [], + } + ) + # invalidate_channels drops "/api/v1/channels", + # invalidate_messages drops "/api/v1/messages". + assert _channels_key_builder(channels_req).startswith("/api/v1/channels") + assert _messages_key_builder(messages_req).startswith("/api/v1/messages") + def _make_request_with_cache(cache): """Build a Request whose ``app.state.redis_cache`` is *cache* (or absent)."""