mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-08 01:42:53 +02:00
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:
Generated
+30
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user