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.
This commit is contained in:
Louis King
2026-07-21 23:24:58 +01:00
parent bf8e0620a5
commit de9784211d
63 changed files with 2703 additions and 1616 deletions
+30
View File
@@ -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",
+1
View File
@@ -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",
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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]:
@@ -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 (
<BrowserRouter>
<Shell />
</BrowserRouter>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Shell />
</BrowserRouter>
</QueryClientProvider>
);
}
@@ -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(
<AutoRefreshToggle
paused={false}
onToggle={() => {}}
intervalSeconds={0}
/>,
);
expect(container.firstChild).toBeNull();
});
it("shows the interval and a checked toggle while running", () => {
const onToggle = vi.fn();
render(
<AutoRefreshToggle
paused={false}
onToggle={onToggle}
intervalSeconds={30}
/>,
);
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(
<AutoRefreshToggle paused onToggle={() => {}} intervalSeconds={30} />,
);
expect(screen.getByRole("checkbox")).not.toBeChecked();
});
});
@@ -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 (
<label
className="label cursor-pointer gap-2"
title={paused ? t("auto_refresh.resume") : t("auto_refresh.pause")}
>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconRefresh className="w-4 h-4" />
<span className="text-xs">{intervalSeconds}s</span>
</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={!paused}
onChange={onToggle}
/>
</label>
);
}
@@ -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(<CountBadge>42 things</CountBadge>);
const el = screen.getByText("42 things");
expect(el).toHaveClass("badge");
expect(el).toHaveClass("badge-lg");
});
it("RoleBadge renders a primary small badge", () => {
render(<RoleBadge role="operator" />);
const el = screen.getByText("operator");
expect(el).toHaveClass("badge-primary");
expect(el).toHaveClass("badge-sm");
});
it("CallsignBadge renders a neutral small badge", () => {
render(<CallsignBadge callsign="AB1CDE" />);
const el = screen.getByText("AB1CDE");
expect(el).toHaveClass("badge-neutral");
expect(el).toHaveClass("badge-sm");
});
});
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
export function CountBadge({ children }: { children: ReactNode }) {
return <span className="badge badge-lg">{children}</span>;
}
export function RoleBadge({ role }: { role: string }) {
return <span className="badge badge-primary badge-sm">{role}</span>;
}
export function CallsignBadge({ callsign }: { callsign: string }) {
return <span className="badge badge-neutral badge-sm">{callsign}</span>;
}
@@ -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(
<MemoryRouter>
<Breadcrumbs items={items} />
</MemoryRouter>,
);
}
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();
});
});
@@ -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 (
<nav aria-label="Breadcrumb">
<div className="breadcrumbs text-sm mb-4">
<ul>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} aria-current={isLast ? "page" : undefined}>
{item.to && !isLast ? (
<Link to={item.to}>{item.label}</Link>
) : (
item.label
)}
</li>
);
})}
</ul>
</div>
</nav>
);
}
@@ -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<Parameters<typeof ConfirmDialog>[0]> = {}) {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog
title="Delete thing"
message="Are you sure?"
confirmLabel="Delete"
cancelLabel="Cancel"
onConfirm={onConfirm}
onCancel={onCancel}
{...props}
/>,
);
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(
<ConfirmDialog
title="t"
message="m"
confirmLabel="Go"
cancelLabel="No"
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Go" })).toHaveClass(
"btn-error",
);
rerender(
<ConfirmDialog
title="t"
message="m"
confirmLabel="Go"
cancelLabel="No"
tone="primary"
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Go" })).toHaveClass(
"btn-primary",
);
});
it("disables both buttons and shows a spinner while saving", () => {
const { container } = render(
<ConfirmDialog
title="t"
message="m"
confirmLabel="Delete"
cancelLabel="Cancel"
saving
onConfirm={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(container.querySelector(".loading-spinner")).not.toBeNull();
});
});
@@ -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 (
<Modal
title={title}
onClose={onCancel}
footer={
<>
<button
type="button"
className="btn btn-ghost"
onClick={onCancel}
disabled={saving}
>
{cancelLabel}
</button>
<button
type="button"
className={tone === "error" ? "btn btn-error" : "btn btn-primary"}
onClick={onConfirm}
disabled={saving}
>
{saving && (
<span className="loading loading-spinner loading-sm" />
)}
{confirmLabel}
</button>
</>
}
>
{message}
</Modal>
);
}
@@ -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(<CopyableValue value="abc123" />);
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(<CopyableValue value="deadbeef" variant="block" />);
const el = screen.getByText("deadbeef");
expect(el).toHaveClass("block");
expect(el).toHaveClass("break-all");
expect(el).not.toHaveClass("font-mono");
});
});
@@ -0,0 +1,23 @@
import { copyToClipboard } from "@/utils/clipboard";
export function CopyableValue({
value,
variant = "inline",
}: {
value: string;
variant?: "inline" | "block";
}) {
return (
<code
className={
variant === "block"
? "text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
: "font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
}
onClick={(e) => copyToClipboard(e, value)}
title="Click to copy"
>
{value}
</code>
);
}
@@ -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(<DefinitionField label="Channel">5 (chan)</DefinitionField>);
expect(screen.getByText("Channel")).toBeInTheDocument();
expect(screen.getByText("5 (chan)")).toBeInTheDocument();
});
});
describe("DefinitionGrid", () => {
it("uses the default two-column grid classes", () => {
const { container } = render(
<DefinitionGrid>
<span>x</span>
</DefinitionGrid>,
);
expect(container.firstChild).toHaveClass("grid");
expect(container.firstChild).toHaveClass("md:grid-cols-2");
});
it("allows a custom className override", () => {
const { container } = render(
<DefinitionGrid className="grid grid-cols-3">
<span>x</span>
</DefinitionGrid>,
);
expect(container.firstChild).toHaveClass("grid-cols-3");
expect(container.firstChild).not.toHaveClass("md:grid-cols-2");
});
});
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
export function DefinitionField({
label,
children,
}: {
label: ReactNode;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-0.5 py-2 border-b border-base-200">
<span className="text-xs uppercase opacity-60">{label}</span>
<span className="text-sm">{children}</span>
</div>
);
}
export function DefinitionGrid({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div className={className ?? "grid grid-cols-1 md:grid-cols-2 gap-x-8"}>
{children}
</div>
);
}
@@ -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(<EmptyState>No nodes found</EmptyState>);
expect(screen.getByText("No nodes found")).toBeInTheDocument();
});
});
describe("EmptyRow", () => {
it("renders a table cell spanning the given columns", () => {
const { container } = render(
<table>
<tbody>
<EmptyRow colSpan={5}>Nothing here</EmptyRow>
</tbody>
</table>,
);
const td = container.querySelector("td");
expect(td).not.toBeNull();
expect(td!.getAttribute("colspan")).toBe("5");
expect(screen.getByText("Nothing here")).toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export function EmptyState({ children }: { children: ReactNode }) {
return <div className="text-center py-8 opacity-70">{children}</div>;
}
export function EmptyRow({
colSpan,
children,
}: {
colSpan: number;
children: ReactNode;
}) {
return (
<tr>
<td colSpan={colSpan} className="text-center py-8 opacity-70">
{children}
</td>
</tr>
);
}
@@ -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(<OperatorSelect profiles={profiles} defaultValue="" />);
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(
<OperatorSelect profiles={profiles} value="1" onChange={onChange} />,
);
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(
<FilterField label="Search">
<input data-testid="control" />
</FilterField>,
);
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(
<form>
<input data-testid="inp" onKeyDown={submitOnEnter} />
</form>,
);
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(
<form>
<input data-testid="inp" onKeyDown={submitOnEnter} />
</form>,
);
fireEvent.keyDown(screen.getByTestId("inp"), { key: "a" });
expect(requestSubmit).not.toHaveBeenCalled();
requestSubmit.mockRestore();
});
});
describe("FilterForm clear navigation", () => {
function LocationProbe() {
const location = useLocation();
return (
<div data-testid="loc">{location.pathname + location.search}</div>
);
}
it("clears filters via client-side navigation (no full reload)", () => {
render(
<MemoryRouter initialEntries={["/nodes?search=foo"]}>
<FilterForm basePath="/nodes">
<input name="search" defaultValue="foo" />
</FilterForm>
<LocationProbe />
</MemoryRouter>,
);
expect(screen.getByTestId("loc").textContent).toBe("/nodes?search=foo");
fireEvent.click(screen.getByText("common.clear"));
expect(screen.getByTestId("loc").textContent).toBe("/nodes");
});
});
@@ -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({
<button type="submit" className="btn btn-primary btn-sm">
{submitLabel || t("common.filter")}
</button>
<a href={basePath} className="btn btn-ghost btn-sm">
<Link to={basePath} className="btn btn-ghost btn-sm">
{clearLabel || t("common.clear")}
</a>
</Link>
</div>
</form>
);
@@ -74,3 +74,113 @@ export function FilterToggle({ open, onChange }: FilterToggleProps) {
</label>
);
}
export function autoSubmit(
e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>,
) {
e.currentTarget.form?.requestSubmit();
}
export function submitOnEnter(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") e.currentTarget.form?.requestSubmit();
}
export function FilterField({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={`flex flex-col gap-1 ${className ?? ""}`.trim()}>
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">{label}</span>
</label>
{children}
</div>
);
}
interface FilterSelectOption {
value: string;
label: string;
}
interface FilterSelectProps {
name: string;
options: FilterSelectOption[];
defaultValue?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
className?: string;
}
export function FilterSelect({
name,
options,
defaultValue,
onChange,
className,
}: FilterSelectProps) {
return (
<select
name={name}
className={`select select-sm ${className ?? ""}`.trim()}
defaultValue={defaultValue}
onChange={onChange}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
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<HTMLSelectElement>) => void;
className?: string;
}
export function OperatorSelect({
name,
profiles,
value,
defaultValue,
onChange,
className,
}: OperatorSelectProps) {
const { t } = useTranslation();
const controlled = value !== undefined;
return (
<select
name={name}
className={`select select-sm ${className ?? ""}`.trim()}
{...(controlled ? { value } : { defaultValue })}
onChange={onChange}
>
<option value="">{t("common.all_operators")}</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.callsign
? `${p.name} (${p.callsign})`
: p.name || p.callsign || p.user_id || p.id}
</option>
))}
</select>
);
}
@@ -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(<ListToolbar total={42} autoRefresh={autoRefresh} />);
expect(screen.getByText("common.total")).toBeInTheDocument();
});
it("hides the total badge when total is null", () => {
render(<ListToolbar total={null} autoRefresh={autoRefresh} />);
expect(screen.queryByText("common.total")).not.toBeInTheDocument();
});
it("renders a warning badge only when there is an error", () => {
const { container, rerender } = render(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
);
expect(container.querySelector(".badge-warning")).toBeNull();
rerender(
<ListToolbar total={null} error="boom" autoRefresh={autoRefresh} />,
);
expect(container.querySelector(".badge-warning")).not.toBeNull();
});
it("renders the auto-refresh toggle when interval is positive", () => {
const { container } = render(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
);
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
});
it("omits the auto-refresh toggle when interval is not positive", () => {
const { container } = render(
<ListToolbar
total={null}
autoRefresh={{ ...autoRefresh, intervalSeconds: 0 }}
/>,
);
expect(container.querySelector('input[type="checkbox"]')).toBeNull();
});
it("renders the filter toggle only when provided", () => {
const { container, rerender } = render(
<ListToolbar total={null} autoRefresh={autoRefresh} />,
);
expect(container.querySelector("#filter-toggle")).toBeNull();
rerender(
<ListToolbar
total={null}
autoRefresh={autoRefresh}
filterToggle={{ open: false, onChange: () => {} }}
/>,
);
expect(container.querySelector("#filter-toggle")).not.toBeNull();
});
});
@@ -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 (
<div className="flex items-center gap-2 mb-4">
{total !== null && (
<CountBadge>{t("common.total", { count: formatNumber(total) })}</CountBadge>
)}
{error && <WarningBadge message={error} />}
<div className="ml-auto flex items-center gap-3">
<AutoRefreshToggle
paused={autoRefresh.paused}
onToggle={autoRefresh.onToggle}
intervalSeconds={autoRefresh.intervalSeconds}
/>
{filterToggle && <FilterToggle {...filterToggle} />}
</div>
</div>
);
}
@@ -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(<MeshQrCode value="meshcore://test" />);
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(
<MeshQrCode value="x" className="bg-white p-2 rounded-box shadow-lg" />,
);
expect(container.firstChild).toHaveClass("shadow-lg");
});
});
@@ -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 (
<div className={className}>
<QRCode
value={value}
size={size}
level={level}
fgColor="#000000"
bgColor="#ffffff"
/>
</div>
);
}
@@ -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(
<Modal title="My Title" onClose={() => {}} footer={<span>foot</span>}>
<p>body content</p>
</Modal>,
);
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(
<Modal title="t" onClose={() => {}}>
<p>body</p>
</Modal>,
);
expect(container.querySelector(".modal-action")).toBeNull();
});
it("applies the large size class", () => {
const { container } = render(
<Modal title="t" size="lg" onClose={() => {}}>
<p>body</p>
</Modal>,
);
expect(container.querySelector(".modal-box-lg")).not.toBeNull();
});
it("calls onClose when the backdrop button is clicked", () => {
const onClose = vi.fn();
render(
<Modal title="t" onClose={onClose}>
<p>body</p>
</Modal>,
);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -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 (
<dialog open className="modal modal-open">
<div
className={size === "lg" ? "modal-box modal-box-lg" : "modal-box"}
>
<h3 className="font-bold text-lg mb-4">{title}</h3>
{children}
{footer && <div className="modal-action">{footer}</div>}
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose} aria-label="Close" />
</form>
</dialog>
);
}
@@ -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({
</div>
);
}
interface NodeLinkProps extends NodeDisplayProps {
className?: string;
}
export function NodeLink({ className, ...display }: NodeLinkProps) {
return (
<Link
to={`/nodes/${display.publicKey}`}
className={className ?? "link link-hover"}
>
<NodeDisplay {...display} />
</Link>
);
}
@@ -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(<NotFoundState message="No such node" />);
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(
<NotFoundState tone="warning" message="Gone after retention" />,
);
const alert = screen.getByRole("alert");
expect(alert).toHaveClass("alert-warning");
expect(alert).toHaveTextContent("Gone after retention");
expect(container.querySelector("svg")).toBeNull();
});
});
@@ -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 (
<div role="alert" className={`alert alert-${tone} mb-4`}>
{tone === "error" && (
<IconError className="stroke-current shrink-0 h-6 w-6" />
)}
<span>{message}</span>
</div>
);
}
@@ -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(<Field label="Time">12:00</Field>);
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(<RedactedNotice />);
expect(container.querySelector(".alert-warning")).not.toBeNull();
expect(container.textContent).toContain("packets.redacted_notice");
});
});
@@ -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 (
<div className="alert alert-warning mb-4">
{"\u{1F512}"} {t("packets.redacted_notice")}
</div>
);
}
export function channelNameDisplay(
names: Map<number, string>,
channelIdx: number | null,
): ReactNode {
if (channelIdx == null) return <span className="opacity-50"></span>;
const name = names.get(channelIdx);
return name ? `${name} (${channelIdx})` : `${channelIdx}`;
}
export function RawHexBlock({ hex }: { hex: string | null }) {
const { t } = useTranslation();
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-1">
<span className="text-xs uppercase opacity-60">{t("packets.col_raw")}</span>
{hex && (
<button
className="btn btn-xs btn-ghost"
onClick={(e) => copyToClipboard(e, hex)}
>
{t("packets.copy_raw")}
</button>
)}
</div>
<pre className="bg-base-200 rounded p-3 text-xs overflow-x-auto whitespace-pre-wrap break-all">
{hex || "—"}
</pre>
</div>
);
}
export function DecodedJsonBlock({ value }: { value: unknown }) {
const { t } = useTranslation();
return (
<div className="mt-4">
<span className="text-xs uppercase opacity-60">{t("packets.decoded")}</span>
<div className="bg-base-200 rounded p-3">
<JsonTree value={value} openDepth={1} />
</div>
</div>
);
}
@@ -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(
<AppConfigProvider config={config}>
<PageHeader title="Nodes">{children}</PageHeader>
</AppConfigProvider>,
);
}
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" }),
<span>extra badge</span>,
);
expect(screen.getByText("EST")).toBeInTheDocument();
expect(screen.getByText("extra badge")).toBeInTheDocument();
});
});
@@ -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 (
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{title}</h1>
<div className="flex items-center gap-2">
{tz && tz !== "UTC" && (
<span className="text-sm opacity-60">{tz}</span>
)}
{children}
</div>
</div>
);
}
@@ -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(
<SectionGroup title="Community">
<span>card</span>
</SectionGroup>,
);
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(
<SectionGroup title="t" className="grid grid-cols-2">
<span>c</span>
</SectionGroup>,
);
expect(container.querySelector(".grid-cols-2")).not.toBeNull();
expect(container.querySelector(".lg\\:grid-cols-3")).toBeNull();
});
});
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
export function SectionGroup({
title,
className,
children,
}: {
title: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<>
<h2 className="text-lg font-semibold mt-6 mb-3 opacity-70">{title}</h2>
<div
className={
className ?? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
}
>
{children}
</div>
</>
);
}
@@ -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<Record<string, unknown>>("@/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(<TimeAgo iso="2026-01-01T12:00:00Z" />);
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(
<TimeAgo iso="2026-01-01T12:00:00Z" className="text-xs" />,
);
expect(container.querySelector("time")).toHaveClass("text-xs");
});
it("renders nothing when iso is null", () => {
const { container } = render(<TimeAgo iso={null} />);
expect(container.querySelector("time")).toBeNull();
});
});
@@ -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 (
<time className={className} dateTime={iso} title={formatDateTime(iso)}>
{formatRelativeTime(iso)}
</time>
);
}
@@ -1,7 +0,0 @@
import { useAppConfig } from "@/context/AppConfigContext";
export function TimezoneIndicator() {
const config = useAppConfig();
const tz = config.timezone || "UTC";
return <span className="text-xs opacity-50 ml-2">({tz})</span>;
}
@@ -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<void>;
}
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<ReturnType<typeof setInterval> | 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 };
}
@@ -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<T> {
total?: number;
}
function submitOnEnter(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") e.currentTarget.form?.requestSubmit();
}
function autoSubmit(e: React.ChangeEvent<HTMLSelectElement>) {
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<Advertisement[] | null>(null);
const [total, setTotal] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [sortedAreas, setSortedAreas] = useState<string[]>([]);
const [operators, setOperators] = useState<OperatorProfile[]>([]);
const [disabledAreas, setDisabledAreas] = useState<Set<string>>(() =>
getDisabledObserverAreas(),
);
@@ -105,16 +102,24 @@ export function Advertisements() {
routeType !== "flood,transport_flood",
);
const disabledAreasRef = useRef(disabledAreas);
disabledAreasRef.current = disabledAreas;
const abortRef = useRef<AbortController | null>(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<ListResponse<NodeItem>>(
"/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<string, string[]>();
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<string, unknown> = {
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<ListResponse<Advertisement>>(
const adData = await apiGet<ListResponse<Advertisement>>(
"/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 (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">
{t("entities.advertisements")}
</h1>
{tz && tz !== "UTC" && (
<span className="text-sm opacity-60">{tz}</span>
)}
</div>
<PageHeader title={t("entities.advertisements")} />
<div className="flex items-center gap-2 mb-4">
{total !== null && (
<span className="badge badge-lg">
{t("common.total", { count: formatNumber(total) })}
</span>
)}
{error && <WarningBadge message={error} />}
<div className="ml-auto flex items-center gap-3">
{intervalSeconds > 0 && (
<label
className="label cursor-pointer gap-2"
title={
paused
? t("auto_refresh.resume")
: t("auto_refresh.pause")
}
>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconRefresh className="w-4 h-4" />
<span className="text-xs">{intervalSeconds}s</span>
</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={!paused}
onChange={toggle}
/>
</label>
)}
</div>
<div className="ml-4">
<FilterToggle
open={filterOpen}
onChange={() => setFilterOpen((o) => !o)}
/>
</div>
</div>
<ListToolbar
total={total}
error={error}
autoRefresh={{ paused, onToggle: toggle, intervalSeconds }}
filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }}
/>
{filterOpen && (
<div className="mb-4">
<FilterForm basePath="/advertisements">
<div className="flex flex-col gap-1">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("common.search")}
</span>
</label>
<FilterField label={t("common.search")}>
<input
type="text"
name="search"
@@ -299,55 +257,42 @@ export function Advertisements() {
className="input input-sm w-80"
onKeyDown={submitOnEnter}
/>
</div>
<div className="flex flex-col gap-1 max-w-48">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("advertisements.filter_route_type_label")}
</span>
</label>
<select
</FilterField>
<FilterField
label={t("advertisements.filter_route_type_label")}
className="max-w-48"
>
<FilterSelect
name="route_type"
key={`route_type-${routeType}`}
defaultValue={routeType}
className="select select-sm"
onChange={autoSubmit}
>
<option value="flood,transport_flood">
{t("advertisements.route_type_flood")}
</option>
<option value="all">
{t("advertisements.route_type_all")}
</option>
<option value="direct">
{t("advertisements.route_type_direct")}
</option>
</select>
</div>
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"),
},
]}
/>
</FilterField>
{config.oidc_enabled && operators.length > 0 && (
<div className="flex flex-col gap-1 max-w-56">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("common.filter_operator_label")}
</span>
</label>
<select
<FilterField
label={t("common.filter_operator_label")}
className="max-w-56"
>
<OperatorSelect
name="adopted_by"
key={`adopted_by-${adoptedBy}`}
defaultValue={adoptedBy}
className="select select-sm"
onChange={autoSubmit}
>
<option value="">{t("common.all_operators")}</option>
{operators.map((p) => (
<option key={p.id} value={p.id}>
{p.callsign
? `${p.name} (${p.callsign})`
: p.name || p.callsign || p.user_id || p.id}
</option>
))}
</select>
</div>
profiles={operators}
/>
</FilterField>
)}
</FilterForm>
</div>
@@ -400,9 +345,7 @@ export function Advertisements() {
<div className="lg:hidden space-y-3">
{items.length === 0 ? (
<div className="text-center py-8 opacity-70">
{emptyMessage}
</div>
<EmptyState>{emptyMessage}</EmptyState>
) : (
items.map((ad, idx) => {
const adName =
@@ -487,14 +430,7 @@ export function Advertisements() {
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={5}
className="text-center py-8 opacity-70"
>
{emptyMessage}
</td>
</tr>
<EmptyRow colSpan={5}>{emptyMessage}</EmptyRow>
) : (
items.map((ad, idx) => {
const adName =
@@ -527,15 +463,7 @@ export function Advertisements() {
</Link>
</td>
<td>
<code
className="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
onClick={(e) =>
copyToClipboard(e, ad.public_key)
}
title="Click to copy"
>
{ad.public_key}
</code>
<CopyableValue value={ad.public_key} />
</td>
<td>
<RouteTypeBadge routeType={ad.route_type ?? null} />
@@ -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 (
<div className="qr-container">
<QRCode value={qrUrl} size={128} level="M" />
</div>
);
return <MeshQrCode value={qrUrl} size={128} level="M" />;
}
interface ChannelCardProps {
@@ -167,9 +170,7 @@ function ChannelModal({
: t("channels.add_channel");
return (
<dialog open className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">{title}</h3>
<Modal title={title} onClose={onCancel}>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center mb-4">
<label className="text-sm opacity-70 text-right">
@@ -244,11 +245,7 @@ function ChannelModal({
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onCancel}></button>
</form>
</dialog>
</Modal>
);
}
@@ -268,32 +265,15 @@ function DeleteChannelModal({
const { t } = useTranslation();
return (
<dialog open className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">
{t("channels.delete_channel")}
</h3>
<p>{t("channels.delete_confirm", { name: channel.name })}</p>
<div className="modal-action">
<button
className="btn btn-ghost"
onClick={onCancel}
disabled={saving}
>
{t("common.cancel")}
</button>
<button className="btn btn-error" onClick={onConfirm} disabled={saving}>
{saving && (
<span className="loading loading-spinner loading-sm"></span>
)}
{t("common.delete")}
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onCancel}></button>
</form>
</dialog>
<ConfirmDialog
title={t("channels.delete_channel")}
message={<p>{t("channels.delete_confirm", { name: channel.name })}</p>}
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<Channel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
data,
isLoading: loading,
error: queryError,
} = useQuery({
queryKey: qk.channels.list({}),
queryFn: async ({ signal }) => {
const resp = await apiGet<ChannelListResponse>(
"/api/v1/channels",
{},
{ signal },
);
return resp.items || [];
},
});
const channels = data ?? [];
const error = queryError ? queryError.message : null;
const [modal, setModal] = useState<ModalState | null>(null);
const [saving, setSaving] = useState(false);
const fetchChannels = useCallback(async () => {
try {
const data = await apiGet<ChannelListResponse>("/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<string, unknown>) => {
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<string, unknown>;
}) => {
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<string, unknown>) => {
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 (
<div>
<div className="mb-6">
<h1 className="text-3xl font-bold flex items-center gap-2">
<IconChannel className="h-8 w-8" />
{t("channels.title")}
</h1>
</div>
<PageHeader
title={
<span className="flex items-center gap-2">
<IconChannel className="h-8 w-8" />
{t("channels.title")}
</span>
}
/>
{error && <ErrorAlert message={error} />}
@@ -397,11 +393,11 @@ export function Channels() {
)}
{channels.length === 0 && (
<div className="text-center py-8 opacity-70">
<EmptyState>
{t("common.no_entity_found", {
entity: t("entities.channels").toLowerCase(),
})}
</div>
</EmptyState>
)}
{VISIBILITY_ORDER.map((vis) => {
@@ -409,10 +405,7 @@ export function Channels() {
if (!group || group.length === 0) return null;
return (
<div key={vis}>
<h2 className="text-lg font-semibold mt-6 mb-3 opacity-70">
{t(`channels.visibility_${vis}`)}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<SectionGroup title={t(`channels.visibility_${vis}`)}>
{group.map((ch) => (
<ChannelCard
key={ch.id}
@@ -424,7 +417,7 @@ export function Channels() {
onNavigate={handleNavigate}
/>
))}
</div>
</SectionGroup>
</div>
);
})}
@@ -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 (
<div className="max-w-4xl mx-auto">
<Breadcrumbs
items={[{ label: t("entities.home"), to: "/" }, { label: page.title }]}
/>
<div className="card bg-base-100 shadow-xl">
<div
className="card-body prose prose-lg max-w-none overflow-x-auto"
@@ -1,10 +1,5 @@
import {
useEffect,
useMemo,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { useMemo, type CSSProperties, type ReactNode } from "react";
import { useQueries } from "@tanstack/react-query";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
@@ -15,6 +10,7 @@ import {
TrendLineChart,
} from "@/components/charts/Charts";
import { ObserverIcons } from "@/components/ObserverBadges";
import { PageHeader } from "@/components/PageHeader";
import { RouteTypeBadge } from "@/components/RouteTypeBadge";
import {
IconAdvertisements,
@@ -29,7 +25,8 @@ 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 {
averageRouteTier,
ChartColors,
@@ -254,91 +251,122 @@ export function DashboardPage() {
const showPackets = features.packets !== false;
const showRoutes = features.routes !== false;
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<DashboardStats>("/api/v1/dashboard/stats", {}, { signal }),
},
{
queryKey: qk.dashboard.recent({}),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<RecentActivity>(
"/api/v1/dashboard/recent-activity",
{},
{ signal },
),
},
{
queryKey: qk.dashboard.series("activity", { days: 7 }),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<ActivitySeries>(
"/api/v1/dashboard/activity",
{ days: 7 },
{ signal },
),
},
{
queryKey: qk.dashboard.series("message-activity", { days: 7 }),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<ActivitySeries>(
"/api/v1/dashboard/message-activity",
{ days: 7 },
{ signal },
),
},
{
queryKey: qk.dashboard.series("node-count", { days: 7 }),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<ActivitySeries>(
"/api/v1/dashboard/node-count",
{ days: 7 },
{ signal },
),
},
{
queryKey: qk.dashboard.series("packet-activity", { days: 7 }),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<ActivitySeries>(
"/api/v1/dashboard/packet-activity",
{ days: 7 },
{ signal },
),
},
{
queryKey: qk.dashboard.series("packet-breakdown", { days: 7 }),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<PacketBreakdown>(
"/api/v1/dashboard/packet-breakdown",
{ days: 7 },
{ signal },
),
showRoutes
? apiGet<RoutesOverview>(
"/api/v1/dashboard/routes-overview",
{ days: 7 },
{ signal },
)
: Promise.resolve(null),
},
{
queryKey: qk.dashboard.routesOverview(),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<RoutesOverview>(
"/api/v1/dashboard/routes-overview",
{ days: 7 },
{ signal },
),
enabled: showRoutes,
},
{
queryKey: qk.channels.list({}),
queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet<ChannelsResponse>("/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<number, string>();
@@ -400,9 +428,7 @@ export function DashboardPage() {
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.dashboard")}</h1>
</div>
<PageHeader title={t("entities.dashboard")} />
{visibleChartCount > 0 && (
<>
@@ -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<DashboardStats | null>(null);
const [advertActivity, setAdvertActivity] = useState<ActivitySeries | null>(
null,
);
const [messageActivity, setMessageActivity] = useState<ActivitySeries | null>(
null,
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<DashboardStats>("/api/v1/dashboard/stats", {}, { signal }),
apiGet<ActivitySeries>(
"/api/v1/dashboard/activity",
{ days: 7 },
{ signal },
),
apiGet<ActivitySeries>(
"/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<DashboardStats>("/api/v1/dashboard/stats", {}, { signal }),
refetchInterval,
});
const advertQuery = useQuery({
queryKey: qk.dashboard.series("activity", { days: 7 }),
queryFn: ({ signal }) =>
apiGet<ActivitySeries>(
"/api/v1/dashboard/activity",
{ days: 7 },
{ signal },
),
refetchInterval,
});
const messageQuery = useQuery({
queryKey: qk.dashboard.series("message-activity", { days: 7 }),
queryFn: ({ signal }) =>
apiGet<ActivitySeries>(
"/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 <Loading />;
if (error) return <ErrorAlert message={error} />;
@@ -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<MapData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, unknown> = {};
if (operatorFilter) params.adopted_by = operatorFilter;
return apiGet<MapData>("/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<string, unknown> = {};
if (operatorFilter) params.adopted_by = operatorFilter;
apiGet<MapData>("/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 (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.map")}</h1>
<div className="flex items-center gap-2">
{tz && tz !== "UTC" && (
<span className="text-sm opacity-60">{tz}</span>
)}
<span className="badge badge-lg">{countBadgeText}</span>
{showFilteredBadge && (
<span className="badge badge-lg badge-ghost">
{t("common.shown", { count: formatNumber(filteredCount) })}
</span>
)}
<FilterToggle
open={filterOpen}
onChange={() => setFilterOpen((open) => !open)}
/>
</div>
</div>
<PageHeader title={t("entities.map")}>
<span className="badge badge-lg">{countBadgeText}</span>
{showFilteredBadge && (
<span className="badge badge-lg badge-ghost">
{t("common.shown", { count: formatNumber(filteredCount) })}
</span>
)}
<FilterToggle
open={filterOpen}
onChange={() => setFilterOpen((open) => !open)}
/>
</PageHeader>
{filterOpen && (
<div className="flex gap-4 flex-wrap items-end mb-6">
@@ -485,20 +475,11 @@ export function MapPage() {
<label className="fieldset-label">
{t("common.filter_operator_label")}
</label>
<select
className="select select-sm"
<OperatorSelect
value={operatorFilter}
onChange={(e) => setOperatorFilter(e.currentTarget.value)}
>
<option value="">{t("common.all_operators")}</option>
{operatorProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.callsign
? `${p.name} (${p.callsign})`
: p.name || p.callsign || p.id}
</option>
))}
</select>
profiles={operatorProfiles}
/>
</div>
)}
<div className="fieldset">
@@ -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 }) {
<div className="card-body">
<h2 className="card-title">
{profile.name || t("common.unnamed")}
{profile.callsign && (
<span className="badge badge-neutral badge-sm">
{profile.callsign}
</span>
)}
{profile.callsign && <CallsignBadge callsign={profile.callsign} />}
</h2>
{profile.roles && profile.roles.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{profile.roles.map((role) => (
<span key={role} className="badge badge-primary badge-sm">
{role}
</span>
<RoleBadge key={role} role={role} />
))}
</div>
)}
@@ -101,7 +98,7 @@ function ProfileTile({ profile }: { profile: MemberProfile }) {
{profile.adopted_nodes && profile.adopted_nodes.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{profile.adopted_nodes.map((node) => {
const label = node.name || node.public_key.slice(0, 12) + "...";
const label = resolveNodeName(node);
return (
<span
key={node.public_key}
@@ -155,24 +152,20 @@ export function Members() {
const { t } = useTranslation();
const config = useAppConfig();
usePageTitle("entities.members");
const [profiles, setProfiles] = useState<MemberProfile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
apiGet<ProfilesResponse>(
"/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<ProfilesResponse>(
"/api/v1/user/profiles",
{ limit: 500 },
{ signal },
);
return resp.items || [];
},
});
const profiles = data ?? null;
const error = queryError ? queryError.message : null;
if (error) return <ErrorAlert message={error} />;
if (profiles === null) return <Loading />;
@@ -187,13 +180,11 @@ export function Members() {
if (visible.length === 0) {
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.members")}</h1>
</div>
<div className="text-center py-8 opacity-70">
<PageHeader title={t("entities.members")} />
<EmptyState>
<p className="text-lg">{t("members_page.empty_state")}</p>
<p className="text-sm mt-2">{t("members_page.empty_description")}</p>
</div>
</EmptyState>
</>
);
}
@@ -212,15 +203,14 @@ export function Members() {
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.members")}</h1>
<PageHeader title={t("entities.members")}>
<span className="badge badge-lg">
{t("common.count_entity", {
count: formatNumber(operators.length + members.length),
entity: t("entities.members").toLowerCase(),
})}
</span>
</div>
</PageHeader>
<ProfileGroup
title={t("members_page.operators")}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import {
@@ -6,12 +7,18 @@ import {
resolveChannelLabel,
useAppConfig,
} from "@/context/AppConfigContext";
import { apiGet, isAbortError } from "@/utils/api";
import { formatNumber, useFormatDateTime } from "@/utils/format";
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,
autoSubmit,
} from "@/components/FilterForm";
import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable";
import {
ObserverFilterBadges,
@@ -19,8 +26,10 @@ import {
getDisabledObserverAreas,
toggleObserverArea,
} from "@/components/ObserverBadges";
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;
@@ -64,10 +73,6 @@ interface ListResponse<T> {
total?: number;
}
function autoSubmit(e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) {
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<Message[] | null>(null);
const [total, setTotal] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [sortedAreas, setSortedAreas] = useState<string[]>([]);
const [builtinLabels, setBuiltinLabels] = useState<Map<number, string>>(
() => new Map(),
);
const [customLabels, setCustomLabels] = useState<Map<number, string>>(
() => new Map(),
);
const [channelLabels, setChannelLabels] = useState<Map<number, string>>(
() => new Map(),
);
const [disabledAreas, setDisabledAreas] = useState<Set<string>>(() =>
getDisabledObserverAreas(),
);
@@ -265,16 +256,23 @@ export function Messages() {
messageType !== "" || channelIdx !== "" || includeSpam,
);
const disabledAreasRef = useRef(disabledAreas);
disabledAreasRef.current = disabledAreas;
const abortRef = useRef<AbortController | null>(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<ListResponse<NodeItem>>(
"/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<string, string[]>();
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<string, unknown> = {
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<ListResponse<Message>>(
const messagesData = await apiGet<ListResponse<Message>>(
"/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<number, string>();
const customLabels = data?.customLabels ?? new Map<number, string>();
const channelLabels = data?.channelLabels ?? new Map<number, string>();
const handleObserverToggle = (area: string) => {
const updated = toggleObserverArea(area, sortedAreas.length);
@@ -425,76 +422,32 @@ export function Messages() {
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.messages")}</h1>
{tz && tz !== "UTC" && (
<span className="text-sm opacity-60">{tz}</span>
)}
</div>
<PageHeader title={t("entities.messages")} />
<div className="flex items-center gap-2 mb-4">
{total !== null && (
<span className="badge badge-lg">
{t("common.total", { count: formatNumber(total) })}
</span>
)}
{error && <WarningBadge message={error} />}
<div className="ml-auto flex items-center gap-3">
{intervalSeconds > 0 && (
<label
className="label cursor-pointer gap-2"
title={
paused
? t("auto_refresh.resume")
: t("auto_refresh.pause")
}
>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconRefresh className="w-4 h-4" />
<span className="text-xs">{intervalSeconds}s</span>
</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={!paused}
onChange={toggle}
/>
</label>
)}
</div>
<div className="ml-4">
<FilterToggle
open={filterOpen}
onChange={() => setFilterOpen((o) => !o)}
/>
</div>
</div>
<ListToolbar
total={total}
error={error}
autoRefresh={{ paused, onToggle: toggle, intervalSeconds }}
filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }}
/>
{filterOpen && (
<div className="mb-4">
<FilterForm basePath="/messages">
<div className="flex flex-col gap-1">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">{t("common.type")}</span>
</label>
<select
<FilterField label={t("common.type")}>
<FilterSelect
name="message_type"
key={`message_type-${messageType}`}
defaultValue={messageType}
className="select select-sm"
onChange={autoSubmit}
>
<option value="">{t("common.all_types")}</option>
<option value="contact">{t("messages.type_direct")}</option>
<option value="channel">{t("messages.type_channel")}</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("entities.channel")}
</span>
</label>
options={[
{ value: "", label: t("common.all_types") },
{ value: "contact", label: t("messages.type_direct") },
{ value: "channel", label: t("messages.type_channel") },
]}
/>
</FilterField>
<FilterField label={t("entities.channel")}>
<select
name="channel_idx"
key={`channel_idx-${channelIdx}`}
@@ -522,14 +475,9 @@ export function Messages() {
</optgroup>
)}
</select>
</div>
</FilterField>
{spamEnabled && (
<div className="flex flex-col gap-1">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("messages.spam.filter_label")}
</span>
</label>
<FilterField label={t("messages.spam.filter_label")}>
<label className="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
@@ -541,7 +489,7 @@ export function Messages() {
/>
<span className="text-sm">{t("messages.spam.show")}</span>
</label>
</div>
</FilterField>
)}
</FilterForm>
</div>
@@ -587,9 +535,7 @@ export function Messages() {
<div className="lg:hidden space-y-3">
{items.length === 0 ? (
<div className="text-center py-8 opacity-70">
{emptyMessage}
</div>
<EmptyState>{emptyMessage}</EmptyState>
) : (
items.map((msg, idx) => {
const isChannel = msg.message_type === "channel";
@@ -699,14 +645,7 @@ export function Messages() {
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={5}
className="text-center py-8 opacity-70"
>
{emptyMessage}
</td>
</tr>
<EmptyRow colSpan={5}>{emptyMessage}</EmptyRow>
) : (
items.map((msg, idx) => {
const isChannel = msg.message_type === "channel";
@@ -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 (
<div className={className}>
<QRCode
value={url}
size={140}
level="L"
fgColor="#000000"
bgColor="#ffffff"
/>
</div>
);
}
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<NodeDetailData | null>(null);
const [advertisements, setAdvertisements] = useState<AdvertisementItem[]>(
[],
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const queryClient = useQueryClient();
const [flash, setFlash] = useState<FlashState | null>(null);
const [prefixNotFound, setPrefixNotFound] = useState(false);
const [prefixError, setPrefixError] = useState<string | null>(null);
const [addKey, setAddKey] = useState("");
const [addValue, setAddValue] = useState("");
@@ -132,9 +119,10 @@ export function NodeDetailPage() {
const [deleteKey, setDeleteKey] = useState<string | null>(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<NodeDetailData | null>(
`/api/v1/nodes/${publicKey}`,
{},
{ signal },
),
apiGet<AdvertisementListResponse>(
"/api/v1/advertisements",
{ public_key: publicKey, limit: 10 },
{ signal },
),
apiGet<unknown>(
"/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<NodeDetailData | null>(
`/api/v1/nodes/${publicKey}`,
{},
{ signal },
),
enabled: isFullKey,
});
const advertisementsQuery = useQuery({
queryKey: qk.advertisements.list({ public_key: publicKey, limit: 10 }),
queryFn: ({ signal }) =>
apiGet<AdvertisementListResponse>(
"/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 (
<>
<div className="breadcrumbs text-sm mb-4">
<ul>
<li>
<Link to="/">{t("entities.home")}</Link>
</li>
<li>
<Link to="/nodes">{t("entities.nodes")}</Link>
</li>
<li>{t("common.page_not_found")}</li>
</ul>
</div>
<div className="alert alert-error">
<IconError className="stroke-current shrink-0 h-6 w-6" />
<span>
{t("common.entity_not_found_details", {
entity: t("entities.node"),
details: publicKey,
})}
</span>
</div>
<Breadcrumbs
items={[
{ label: t("entities.home"), to: "/" },
{ label: t("entities.nodes"), to: "/nodes" },
{ label: t("common.page_not_found") },
]}
/>
<NotFoundState
message={t("common.entity_not_found_details", {
entity: t("entities.node"),
details: publicKey,
})}
/>
<Link to="/nodes" className="btn btn-primary mt-4">
{t("common.view_entity", { entity: t("entities.nodes") })}
</Link>
@@ -476,7 +450,7 @@ export function NodeDetailPage() {
{canRelease && (
<button
className="btn btn-sm btn-outline btn-error"
onClick={handleRelease}
onClick={() => setConfirmRelease(true)}
>
{t("nodes.release")}
</button>
@@ -509,13 +483,7 @@ export function NodeDetailPage() {
<h3 className="font-semibold opacity-70 mb-2">
{t("common.public_key")}
</h3>
<code
className="text-sm bg-base-200 p-2 rounded block break-all cursor-pointer hover:bg-base-300 select-all"
onClick={(e) => copyToClipboard(e, node.public_key)}
title="Click to copy"
>
{node.public_key}
</code>
<CopyableValue value={node.public_key} variant="block" />
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div>
@@ -622,17 +590,13 @@ export function NodeDetailPage() {
return (
<>
<div className="breadcrumbs text-sm mb-4">
<ul>
<li>
<Link to="/">{t("entities.home")}</Link>
</li>
<li>
<Link to="/nodes">{t("entities.nodes")}</Link>
</li>
<li>{tagName || node.name || truncateKey(node.public_key)}</li>
</ul>
</div>
<Breadcrumbs
items={[
{ label: t("entities.home"), to: "/" },
{ label: t("entities.nodes"), to: "/nodes" },
{ label: tagName || node.name || truncateKey(node.public_key) },
]}
/>
<div className="flex items-start gap-4 mb-6">
<span
@@ -686,8 +650,8 @@ export function NodeDetailPage() {
</MapContainer>
</div>
<div className="relative z-20 h-full p-3 flex items-center justify-end">
<NodeQrCode
url={qrUrl}
<MeshQrCode
value={qrUrl}
className="bg-white p-2 rounded-box shadow-lg"
/>
</div>
@@ -695,7 +659,7 @@ export function NodeDetailPage() {
) : (
<div className="card bg-base-100 shadow-xl mb-6">
<div className="card-body flex-row items-center gap-4">
<NodeQrCode url={qrUrl} className="bg-white p-2 rounded-box" />
<MeshQrCode value={qrUrl} />
<p className="text-sm opacity-70">{t("nodes.scan_to_add")}</p>
</div>
</div>
@@ -843,15 +807,20 @@ export function NodeDetailPage() {
</div>
{canEditTags && editTag && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">
<Modal
title={
<>
{t("common.edit_entity", { entity: t("entities.tag") })}:{" "}
<span className="font-mono text-base font-normal">
{editTag.key}
</span>
</h3>
<form className="py-4" onSubmit={handleEditTag}>
</>
}
onClose={() => {
if (!editSaving) setEditTag(null);
}}
>
<form className="py-4" onSubmit={handleEditTag}>
<div className="fieldset mb-4">
<label className="fieldset-label">{t("common.value")}</label>
<input
@@ -897,59 +866,47 @@ export function NodeDetailPage() {
</button>
</div>
</form>
</div>
<div
className="modal-backdrop"
onClick={() => !editSaving && setEditTag(null)}
/>
</div>
</Modal>
)}
{canEditTags && deleteKey !== null && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">
{t("common.delete_entity", { entity: t("entities.tag") })}
</h3>
<p
className="py-4"
dangerouslySetInnerHTML={{
__html: t("common.delete_entity_confirm", {
entity: t("entities.tag"),
name: deleteKey,
}),
}}
/>
<div className="alert alert-error mb-4">
<span>{t("common.cannot_be_undone")}</span>
</div>
<div className="modal-action">
<button
type="button"
className="btn"
onClick={() => setDeleteKey(null)}
disabled={deleteSaving}
>
{t("common.cancel")}
</button>
<button
type="button"
className="btn btn-error"
onClick={handleDeleteTag}
disabled={deleteSaving}
>
{deleteSaving && (
<span className="loading loading-spinner loading-sm" />
)}
{t("common.delete")}
</button>
</div>
</div>
<div
className="modal-backdrop"
onClick={() => !deleteSaving && setDeleteKey(null)}
/>
</div>
<ConfirmDialog
title={t("common.delete_entity", { entity: t("entities.tag") })}
message={
<>
<p
className="py-4"
dangerouslySetInnerHTML={{
__html: t("common.delete_entity_confirm", {
entity: t("entities.tag"),
name: deleteKey,
}),
}}
/>
<div className="alert alert-error mb-4">
<span>{t("common.cannot_be_undone")}</span>
</div>
</>
}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
saving={deleteSaving}
onConfirm={handleDeleteTag}
onCancel={() => {
if (!deleteSaving) setDeleteKey(null);
}}
/>
)}
{confirmRelease && (
<ConfirmDialog
title={t("nodes.release")}
message={t("nodes.release_confirm")}
confirmLabel={t("nodes.release")}
cancelLabel={t("common.cancel")}
onConfirm={handleRelease}
onCancel={() => setConfirmRelease(false)}
/>
)}
</>
);
@@ -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<NodeItem[]>([]);
const [total, setTotal] = useState<number | null>(null);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, unknown> = {
limit,
offset,
@@ -103,50 +127,37 @@ export function Nodes() {
if (pubkeyPrefix) apiParams.pubkey_prefix = pubkeyPrefix;
const fetches: Promise<unknown>[] = [
apiGet<NodeListResponse>("/api/v1/nodes", apiParams),
apiGet<NodeListResponse>("/api/v1/nodes", apiParams, { signal }),
];
if (oidcEnabled) {
fetches.push(
apiGet<ProfileListResponse>("/api/v1/user/profiles", { limit: 500 }),
apiGet<ProfileListResponse>(
"/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<HTMLSelectElement>) => {
e.currentTarget.form?.requestSubmit();
};
const noEntity = t("common.no_entity_found", {
entity: t("entities.nodes").toLowerCase(),
});
const mobileCards =
nodes.length === 0 ? (
<div className="text-center py-8 opacity-70">{noEntity}</div>
<EmptyState>{noEntity}</EmptyState>
) : (
nodes.map((node) => {
const displayName = tagValue(node.tags, "name") || node.name;
@@ -212,11 +219,7 @@ export function Nodes() {
const tableRows =
nodes.length === 0 ? (
<tr>
<td colSpan={3} className="text-center py-8 opacity-70">
{noEntity}
</td>
</tr>
<EmptyRow colSpan={3}>{noEntity}</EmptyRow>
) : (
nodes.map((node) => {
const displayName = tagValue(node.tags, "name") || node.name;
@@ -225,27 +228,16 @@ export function Nodes() {
return (
<tr key={node.public_key} className="hover">
<td>
<Link
to={`/nodes/${node.public_key}`}
className="link link-hover"
>
<NodeDisplay
name={displayName}
description={tagDescription}
publicKey={node.public_key}
advType={node.adv_type}
size="base"
/>
</Link>
<NodeLink
name={displayName}
description={tagDescription}
publicKey={node.public_key}
advType={node.adv_type}
size="base"
/>
</td>
<td>
<code
className="font-mono text-xs cursor-pointer hover:bg-base-200 px-1 py-0.5 rounded select-all"
onClick={(e) => copyToClipboard(e, node.public_key)}
title="Click to copy"
>
{node.public_key}
</code>
<CopyableValue value={node.public_key} />
</td>
<td className="text-sm whitespace-nowrap">{lastSeen}</td>
</tr>
@@ -257,48 +249,17 @@ export function Nodes() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.nodes")}</h1>
{tz && tz !== "UTC" && (
<span className="text-sm opacity-60">{tz}</span>
)}
</div>
<PageHeader title={t("entities.nodes")} />
<div className="flex items-center gap-2 mb-4">
{total !== null && (
<span className="badge badge-lg">
{t("common.total", { count: formatNumber(total) })}
</span>
)}
{error && <WarningBadge message={error} />}
<div className="ml-auto flex items-center gap-3">
{intervalSeconds > 0 && (
<label
className="label cursor-pointer gap-2"
title={
paused ? t("auto_refresh.resume") : t("auto_refresh.pause")
}
>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconRefresh className="w-4 h-4" />
<span className="text-xs">{intervalSeconds}s</span>
</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={!paused}
onChange={toggle}
/>
</label>
)}
</div>
<div className="ml-4">
<FilterToggle
open={filterOpen}
onChange={() => setFilterOpen((open) => !open)}
/>
</div>
</div>
<ListToolbar
total={total}
error={error}
autoRefresh={{ paused, onToggle: toggle, intervalSeconds }}
filterToggle={{
open: filterOpen,
onChange: () => setFilterOpen((open) => !open),
}}
/>
{filterOpen && (
<div className="mb-4">
@@ -306,10 +267,7 @@ export function Nodes() {
key={`filters-${search}-${advType}-${adoptedBy}-${pubkeyPrefix}`}
basePath="/nodes"
>
<div className="flex flex-col gap-1">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">{t("common.search")}</span>
</label>
<FilterField label={t("common.search")}>
<input
type="text"
name="search"
@@ -317,47 +275,33 @@ export function Nodes() {
placeholder={t("common.search_placeholder")}
className="input input-sm w-80"
/>
</div>
<div className="flex flex-col gap-1">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">{t("common.type")}</span>
</label>
<select
</FilterField>
<FilterField label={t("common.type")}>
<FilterSelect
name="adv_type"
className="select select-sm"
defaultValue={advType}
onChange={autoSubmit}
>
<option value="">{t("common.all_types")}</option>
<option value="chat">{t("node_types.chat")}</option>
<option value="repeater">{t("node_types.repeater")}</option>
<option value="companion">{t("node_types.companion")}</option>
<option value="room">{t("node_types.room")}</option>
</select>
</div>
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") },
]}
/>
</FilterField>
{oidcEnabled && sortedProfiles.length > 0 && (
<div className="flex flex-col gap-1 max-w-56">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("common.filter_operator_label")}
</span>
</label>
<select
<FilterField
label={t("common.filter_operator_label")}
className="max-w-56"
>
<OperatorSelect
name="adopted_by"
className="select select-sm"
defaultValue={adoptedBy}
onChange={autoSubmit}
>
<option value="">{t("common.all_operators")}</option>
{sortedProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.callsign
? `${p.name} (${p.callsign})`
: p.name || p.callsign || p.user_id || p.id}
</option>
))}
</select>
</div>
profiles={sortedProfiles}
/>
</FilterField>
)}
</FilterForm>
</div>
@@ -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<number, string> {
const names = new Map<number, string>();
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 (
<div className="flex flex-col gap-0.5 py-2 border-b border-base-200">
<span className="text-xs uppercase opacity-60">{label}</span>
<span className="text-sm">{children}</span>
</div>
);
}
export function PacketDetail() {
const { t } = useTranslation();
usePageTitle("packets.detail_title");
const { id } = useParams();
const config = useAppConfig();
const { formatDateTime } = useFormatDateTime();
const [packet, setPacket] = useState<PacketDetailData | null>(null);
const [channelNames, setChannelNames] = useState<Map<number, string>>(
new Map(),
);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setPacket(null);
setNotFound(false);
setError(null);
Promise.all([
apiGet<PacketDetailData>(`/api/v1/packets/${id}`, {}, {
signal: controller.signal,
}),
const packetQuery = useQuery({
queryKey: qk.packets.detail(id ?? ""),
queryFn: ({ signal }) =>
apiGet<PacketDetailData>(`/api/v1/packets/${id}`, {}, { signal }),
enabled: !!id,
});
const channelsQuery = useQuery({
queryKey: qk.channels.list({ limit: 200 }),
queryFn: ({ signal }) =>
apiGet<ChannelsResponse>("/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 = <span className="opacity-50"></span>;
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 (
<div>
<div className="breadcrumbs text-sm mb-4">
<ul>
<li>
<Link to="/">{t("entities.home")}</Link>
</li>
<li>
<Link to="/packets">{t("entities.packets")}</Link>
</li>
<li>{leaf || t("packets.detail_title")}</li>
</ul>
</div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("packets.detail_title")}</h1>
{tz && tz !== "UTC" && <span className="text-sm opacity-60">{tz}</span>}
</div>
<Breadcrumbs
items={[
{ label: t("entities.home"), to: "/" },
{ label: t("entities.packets"), to: "/packets" },
{ label: leaf || t("packets.detail_title") },
]}
/>
{notFound && (
<div role="alert" className="alert alert-error">
{t("common.entity_not_found_details", {
<NotFoundState
message={t("common.entity_not_found_details", {
entity: t("entities.packet").toLowerCase(),
})}
</div>
/>
)}
{error && <WarningBadge message={error} />}
{!packet && !notFound && !error && <Loading />}
{packet && (
<>
{packet.redacted && (
<div className="alert alert-warning mb-4">
{"\u{1F512}"} {t("packets.redacted_notice")}
</div>
)}
{packet.redacted && <RedactedNotice />}
<div className="card bg-base-100 shadow-sm">
<div className="card-body">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<DefinitionGrid>
<Field label={t("common.time")}>
{formatDateTime(packet.received_at)}
</Field>
@@ -205,38 +160,12 @@ export function PacketDetail() {
<Field label={t("common.hops")}>
{packet.path_len != null ? packet.path_len : "—"}
</Field>
</div>
</DefinitionGrid>
{!packet.redacted && (
<div className="mt-4">
<div className="flex items-center justify-between mb-1">
<span className="text-xs uppercase opacity-60">
{t("packets.col_raw")}
</span>
{packet.raw_hex && (
<button
className="btn btn-xs btn-ghost"
onClick={(e) => copyToClipboard(e, packet.raw_hex!)}
>
{t("packets.copy_raw")}
</button>
)}
</div>
<pre className="bg-base-200 rounded p-3 text-xs overflow-x-auto whitespace-pre-wrap break-all">
{packet.raw_hex || "—"}
</pre>
</div>
)}
{!packet.redacted && <RawHexBlock hex={packet.raw_hex} />}
{!packet.redacted && packet.decoded != null && (
<div className="mt-4">
<span className="text-xs uppercase opacity-60">
{t("packets.decoded")}
</span>
<div className="bg-base-200 rounded p-3">
<JsonTree value={packet.decoded} openDepth={1} />
</div>
</div>
<DecodedJsonBlock value={packet.decoded} />
)}
</div>
</div>
@@ -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<number, string> {
const names = new Map<number, string>();
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<string, Reception[]> {
const groups = new Map<string, Reception[]>();
for (const r of receptions) {
@@ -109,20 +106,6 @@ function groupByObserver(receptions: Reception[]): Map<string, Reception[]> {
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 (
<div className="flex flex-col gap-0.5 py-2 border-b border-base-200">
<span className="text-xs uppercase opacity-60">{label}</span>
<span className="text-sm">{children}</span>
</div>
);
}
function Stat({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex flex-col">
@@ -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<PacketGroupData | null>(null);
const [channelNames, setChannelNames] = useState<Map<number, string>>(
new Map(),
);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
const [popover, setPopover] = useState<PopoverAnchor | null>(null);
const [popoverPos, setPopoverPos] = useState<{
left: number;
@@ -258,33 +233,31 @@ export function PacketGroupDetail() {
const [popoverError, setPopoverError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const controller = new AbortController();
setGroup(null);
setNotFound(false);
setError(null);
Promise.all([
apiGet<PacketGroupData>(`/api/v1/packet-groups/${hash}`, {}, {
signal: controller.signal,
}),
const groupQuery = useQuery({
queryKey: qk.packets.group(hash ?? ""),
queryFn: ({ signal }) =>
apiGet<PacketGroupData>(`/api/v1/packet-groups/${hash}`, {}, { signal }),
enabled: !!hash,
});
const channelsQuery = useQuery({
queryKey: qk.channels.list({ limit: 200 }),
queryFn: ({ signal }) =>
apiGet<ChannelsResponse>("/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 = <span className="opacity-50"></span>;
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) => (
<span title={formatDateTime(r.received_at)}>
{formatRelativeTime(r.received_at)}
</span>
const channelDisplay = channelNameDisplay(
channelNames,
group?.channel_idx ?? null,
);
const receptionTime = (r: Reception) => <TimeAgo iso={r.received_at} />;
return (
<div>
<div className="breadcrumbs text-sm mb-4">
<ul>
<li>
<Link to="/">{t("entities.home")}</Link>
</li>
<li>
<Link to="/packets">{t("entities.packets")}</Link>
</li>
<li>{leaf || t("packets.detail_title")}</li>
</ul>
</div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("packets.detail_title")}</h1>
{tz && tz !== "UTC" && <span className="text-sm opacity-60">{tz}</span>}
</div>
<Breadcrumbs
items={[
{ label: t("entities.home"), to: "/" },
{ label: t("entities.packets"), to: "/packets" },
{ label: leaf || t("packets.detail_title") },
]}
/>
{notFound && (
<div role="alert" className="alert alert-warning">
{t("packets.not_found_retention")}
</div>
<NotFoundState tone="warning" message={t("packets.not_found_retention")} />
)}
{error && <WarningBadge message={error} />}
{!group && !notFound && !error && <Loading />}
{group && (
<>
{group.redacted && (
<div className="alert alert-warning mb-4">
{"\u{1F512}"} {t("packets.redacted_notice")}
</div>
)}
{group.redacted && <RedactedNotice />}
<div className="card bg-base-100 shadow-sm">
<div className="card-body">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<DefinitionGrid>
<Field label={t("common.time")}>
{formatDateTime(group.first_seen)}
</Field>
@@ -471,7 +421,7 @@ export function PacketGroupDetail() {
· {formatNumber(group.observer_count)}{" "}
{t("common.observers").toLowerCase()}
</Field>
</div>
</DefinitionGrid>
{receptions.length > 0 && (
<div className="mt-6">
@@ -600,33 +550,11 @@ export function PacketGroupDetail() {
)}
{!group.redacted && group.raw_hex && (
<div className="mt-4">
<div className="flex items-center justify-between mb-1">
<span className="text-xs uppercase opacity-60">
{t("packets.col_raw")}
</span>
<button
className="btn btn-xs btn-ghost"
onClick={(e) => copyToClipboard(e, group.raw_hex!)}
>
{t("packets.copy_raw")}
</button>
</div>
<pre className="bg-base-200 rounded p-3 text-xs overflow-x-auto whitespace-pre-wrap break-all">
{group.raw_hex}
</pre>
</div>
<RawHexBlock hex={group.raw_hex} />
)}
{!group.redacted && group.decoded != null && (
<div className="mt-4">
<span className="text-xs uppercase opacity-60">
{t("packets.decoded")}
</span>
<div className="bg-base-200 rounded p-3">
<JsonTree value={group.decoded} openDepth={1} />
</div>
</div>
<DecodedJsonBlock value={group.decoded} />
)}
</div>
</div>
@@ -677,7 +605,7 @@ export function PacketGroupDetail() {
onClick={() => setPopover(null)}
className="flex flex-col items-start gap-0"
>
<span className="text-sm">{nodeDisplayName(n)}</span>
<span className="text-sm">{resolveNodeName(n)}</span>
<span className="font-mono text-xs opacity-50">
{truncateKey(n.public_key, 16)}
</span>
@@ -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<PacketGroupItem[] | null>(null);
const [total, setTotal] = useState(0);
const [channels, setChannels] = useState<ChannelEntry[]>([]);
const [error, setError] = useState<string | null>(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<string, unknown> = {
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<PacketGroupsResponse>("/api/v1/packet-groups", apiParams, {
signal,
}),
apiGet<ChannelsResponse>("/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<string, unknown> = {
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<PacketGroupsResponse>("/api/v1/packet-groups", apiParams, {
signal,
}),
apiGet<ChannelsResponse>("/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<string, string>) => {
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<string, string> = {
search,
@@ -266,56 +264,23 @@ export function Packets() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.packets")}</h1>
{tz && tz !== "UTC" && <span className="text-sm opacity-60">{tz}</span>}
</div>
<PageHeader title={t("entities.packets")} />
<div className="flex items-center gap-2 mb-4">
{packets !== null && (
<span className="badge badge-lg">
{t("common.total", { count: formatNumber(total) })}
</span>
)}
{error && <WarningBadge message={error} />}
<div className="ml-auto flex items-center gap-3">
{autoRefresh.intervalSeconds > 0 && (
<label
className="label cursor-pointer gap-2"
title={
autoRefresh.paused
? t("auto_refresh.resume")
: t("auto_refresh.pause")
}
>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconRefresh className="w-4 h-4" />
<span className="text-xs">{autoRefresh.intervalSeconds}s</span>
</span>
<input
type="checkbox"
className="toggle toggle-sm toggle-primary"
checked={!autoRefresh.paused}
onChange={autoRefresh.toggle}
/>
</label>
)}
</div>
<div className="ml-4">
<FilterToggle
open={filterOpen}
onChange={() => setFilterOpen((o) => !o)}
/>
</div>
</div>
<ListToolbar
total={packets !== null ? total : null}
error={error}
autoRefresh={{
paused: autoRefresh.paused,
onToggle: autoRefresh.toggle,
intervalSeconds: autoRefresh.intervalSeconds,
}}
filterToggle={{ open: filterOpen, onChange: () => setFilterOpen((o) => !o) }}
/>
{filterOpen && (
<div className="mb-4">
<FilterForm basePath="/packets">
<div className="flex flex-col gap-1">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">{t("common.search")}</span>
</label>
<FilterField label={t("common.search")}>
<input
type="text"
name="search"
@@ -323,13 +288,11 @@ export function Packets() {
placeholder={t("common.search_placeholder")}
className="input input-sm w-80"
/>
</div>
<div className="flex flex-col gap-1 max-w-48">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("packets.filter_event_type")}
</span>
</label>
</FilterField>
<FilterField
label={t("packets.filter_event_type")}
className="max-w-48"
>
<select
name="event_type"
className="select select-sm"
@@ -343,13 +306,8 @@ export function Packets() {
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 max-w-48">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("entities.channel")}
</span>
</label>
</FilterField>
<FilterField label={t("entities.channel")} className="max-w-48">
<select
name="channel_idx"
className="select select-sm"
@@ -363,13 +321,11 @@ export function Packets() {
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 max-w-48">
<label className="flex items-center py-1">
<span className="opacity-80 text-sm">
{t("packets.filter_path_width")}
</span>
</label>
</FilterField>
<FilterField
label={t("packets.filter_path_width")}
className="max-w-48"
>
<select
name="path_hash_bytes"
className="select select-sm"
@@ -385,7 +341,7 @@ export function Packets() {
</option>
))}
</select>
</div>
</FilterField>
</FilterForm>
</div>
)}
@@ -412,7 +368,7 @@ export function Packets() {
<div className="lg:hidden space-y-3">
{packets.length === 0 ? (
<div className="text-center py-8 opacity-70">{noneFound}</div>
<EmptyState>{noneFound}</EmptyState>
) : (
packets.map((p, i) => (
<Link
@@ -474,11 +430,7 @@ export function Packets() {
</thead>
<tbody>
{packets.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 opacity-70">
{noneFound}
</td>
</tr>
<EmptyRow colSpan={5}>{noneFound}</EmptyRow>
) : (
packets.map((p, i) => (
<tr
@@ -1,11 +1,17 @@
import { useEffect, useState, type FormEvent } from "react";
import { type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import type { AppConfig } from "@/types/config";
import { apiGet, apiPut, isAbortError } from "@/utils/api";
import { formatRelativeTime, useFormatDateTime } from "@/utils/format";
import { apiGet, apiPut } from "@/utils/api";
import { qk, invalidate } from "@/utils/queryKeys";
import { resolveNodeName, useFormatDateTime } from "@/utils/format";
import { Loading, ErrorAlert, SuccessAlert } from "@/components/Alerts";
import { CallsignBadge, RoleBadge } from "@/components/Badges";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { PageHeader } from "@/components/PageHeader";
import { TimeAgo } from "@/components/TimeAgo";
import { usePageTitle } from "@/hooks/usePageTitle";
interface ProfileNode {
@@ -41,9 +47,7 @@ function RoleBadges({ roles }: { roles?: string[] | null }) {
return (
<div className="flex gap-2 mt-2">
{roles.map((role) => (
<span key={role} className="badge badge-primary badge-sm">
{role}
</span>
<RoleBadge key={role} role={role} />
))}
</div>
);
@@ -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 (
<Link
@@ -83,13 +84,16 @@ function AdoptedNodeLink({ node }: { node: ProfileNode }) {
{node.public_key}
</div>
</div>
<time
className="text-xs opacity-60 whitespace-nowrap shrink-0"
dateTime={node.last_seen ?? undefined}
title={fullTime}
>
{relTime}
</time>
{node.last_seen ? (
<TimeAgo
iso={node.last_seen}
className="text-xs opacity-60 whitespace-nowrap shrink-0"
/>
) : (
<span className="text-xs opacity-60 whitespace-nowrap shrink-0">
-
</span>
)}
</Link>
);
}
@@ -125,26 +129,12 @@ function AdoptedNodesCard({
function PublicProfileView({ id }: { id: string }) {
const { t } = useTranslation();
const config = useAppConfig();
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setProfile(null);
setError(null);
apiGet<UserProfileData>(
`/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<UserProfileData>(`/api/v1/user/profile/${id}`, {}, { signal }),
});
const error = queryError ? queryError.message : null;
if (error) return <ErrorAlert message={error} />;
if (!profile) return <Loading />;
@@ -154,24 +144,26 @@ function PublicProfileView({ id }: { id: string }) {
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("user_profile.title")}</h1>
<Breadcrumbs
items={[
{ label: t("entities.home"), to: "/" },
{ label: t("entities.members"), to: "/members" },
{ label: profile.name || t("common.unnamed") },
]}
/>
<PageHeader title={t("user_profile.title")}>
{isOwner && (
<Link to="/profile" className="btn btn-primary btn-sm">
{t("user_profile.edit_profile")}
</Link>
)}
</div>
</PageHeader>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
{profile.name || t("common.unnamed")}
{profile.callsign && (
<span className="badge badge-neutral badge-sm">
{profile.callsign}
</span>
)}
{profile.callsign && <CallsignBadge callsign={profile.callsign} />}
</h2>
<RoleBadges roles={profile.roles} />
{profile.description && (
@@ -202,26 +194,24 @@ function OwnProfileView() {
const config = useAppConfig();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: profile, error: queryError } = useQuery({
queryKey: qk.profiles.me(),
queryFn: ({ signal }) =>
apiGet<UserProfileData>("/api/v1/user/profile/me", {}, { signal }),
});
const error = queryError ? queryError.message : null;
useEffect(() => {
const controller = new AbortController();
setProfile(null);
setError(null);
apiGet<UserProfileData>(
"/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<string, unknown>;
}) => 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 (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("user_profile.title")}</h1>
</div>
<Breadcrumbs
items={[
{ label: t("entities.home"), to: "/" },
{ label: t("user_profile.title") },
]}
/>
<PageHeader title={t("user_profile.title")} />
{flashMessage ? (
<SuccessAlert message={flashMessage} />
@@ -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<RouteDetail>(`/api/v1/routes/${route.id}`, {}, { signal }),
});
const { data: history } = useQuery({
queryKey: qk.routes.history(route.id, 6),
queryFn: ({ signal }) =>
apiGet<RouteHistory>(
`/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 (
<dialog open className="modal modal-open">
<div className="modal-box modal-box-lg">
<h3 className="font-bold text-lg mb-4">
{isEdit ? t("routes.edit_route") : t("routes.add_route")}
</h3>
<Modal
size="lg"
title={isEdit ? t("routes.edit_route") : t("routes.add_route")}
onClose={onCancel}
>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-3 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -1093,11 +1109,7 @@ function RouteModal({
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onCancel}></button>
</form>
</dialog>
</Modal>
);
}
@@ -1117,30 +1129,15 @@ function DeleteRouteModal({
const label = `${route.from_label} ${arrow} ${route.to_label}`;
return (
<dialog open className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">{t("routes.delete_route")}</h3>
<p>{t("routes.delete_confirm", { label })}</p>
<div className="modal-action">
<button
className="btn btn-ghost"
onClick={onCancel}
disabled={saving}
>
{t("common.cancel")}
</button>
<button className="btn btn-error" onClick={onConfirm} disabled={saving}>
{saving && (
<span className="loading loading-spinner loading-sm"></span>
)}
{t("common.delete")}
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onCancel}></button>
</form>
</dialog>
<ConfirmDialog
title={t("routes.delete_route")}
message={<p>{t("routes.delete_confirm", { label })}</p>}
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<RouteItem[]>([]);
const [detailCache, setDetailCache] = useState<Record<string, RouteDetail>>(
{},
);
const [historyCache, setHistoryCache] = useState<
Record<string, RouteHistory>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const {
data: routesData,
isLoading: loading,
error: queryError,
} = useQuery({
queryKey: qk.routes.list(),
queryFn: async ({ signal }) => {
const data = await apiGet<RouteListResponse>(
"/api/v1/routes",
{},
{ signal },
);
return data.items || [];
},
});
const routes = routesData ?? [];
const error = queryError ? queryError.message : null;
const [modal, setModal] = useState<ModalState | null>(null);
const detailCacheRef = useRef<Record<string, RouteDetail>>({});
const historyCacheRef = useRef<Record<string, RouteHistory>>({});
const pathTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const obsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pathSearchIdRef = useRef(0);
const obsSearchIdRef = useRef(0);
const loadAllDetails = useCallback(async (routesList: RouteItem[]) => {
const newDetails: Record<string, RouteDetail> = {};
const newHistories: Record<string, RouteHistory> = {};
const promises: Promise<void>[] = [];
for (const r of routesList) {
if (!detailCacheRef.current[r.id]) {
promises.push(
apiGet<RouteDetail>(`/api/v1/routes/${r.id}`)
.then((d) => {
newDetails[r.id] = d;
})
.catch(() => undefined),
);
const saveMutation = useMutation({
mutationFn: async ({
id,
body,
}: {
id?: string;
body: Record<string, unknown>;
}) => {
if (id) {
await apiPut(`/api/v1/routes/${id}`, body);
} else {
await apiPost("/api/v1/routes", body);
}
if (!historyCacheRef.current[r.id]) {
promises.push(
apiGet<RouteHistory>(`/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<RouteItem[]> => {
try {
const data = await apiGet<RouteListResponse>("/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 (
<div>
<div className="mb-6">
<h1 className="text-3xl font-bold flex items-center gap-2">
<IconPath className="h-8 w-8" />
{t("routes.title")}
</h1>
</div>
<PageHeader
title={
<span className="flex items-center gap-2">
<IconPath className="h-8 w-8" />
{t("routes.title")}
</span>
}
/>
<SummaryStrip routes={routes} />
@@ -1531,11 +1481,11 @@ export function RoutesPage() {
)}
{routes.length === 0 && (
<div className="text-center py-8 opacity-70">
<EmptyState>
{t("common.no_entity_found", {
entity: t("entities.routes").toLowerCase(),
})}
</div>
</EmptyState>
)}
{VISIBILITY_ORDER.map((vis) => {
@@ -1548,16 +1498,11 @@ export function RoutesPage() {
if (group.length === 0) return null;
return (
<div key={vis}>
<h2 className="text-lg font-semibold mt-6 mb-3 opacity-70">
{t(`routes.visibility_${vis}`)}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<SectionGroup title={t(`routes.visibility_${vis}`)}>
{group.map((r) => (
<RouteCard
key={r.id}
route={r}
detail={detailCache[r.id]}
history={historyCache[r.id]}
isAdmin={isAdmin}
packetsEnabled={packetsEnabled}
onEdit={() => openEditModal(r)}
@@ -1565,7 +1510,7 @@ export function RoutesPage() {
onNavigate={navigate}
/>
))}
</div>
</SectionGroup>
</div>
);
})}
@@ -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<RenderOptions, "wrapper">;
}
export function renderWithProviders(
ui: ReactElement,
options: ProviderOptions = {},
) {
const {
config = makeConfig(),
client = createTestQueryClient(),
route = "/",
renderOptions,
} = options;
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={client}>
<AppConfigProvider config={config}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</AppConfigProvider>
</QueryClientProvider>
);
}
return { client, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}
@@ -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}");
@@ -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;
@@ -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);
});
});
@@ -0,0 +1,17 @@
export interface ChannelItem {
name: string;
channel_hash: string;
}
export function buildChannelNames(items: ChannelItem[]): Map<number, string> {
const names = new Map<number, string>();
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");
}
@@ -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,
},
},
});
}
@@ -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 });
},
};
+44 -2
View File
@@ -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)."""