feat(web): convert all 15 SPA pages to native React — Phase 2

Convert every remaining lit-html page to React 19 + TypeScript and wire
them directly into the router, removing all LitBridge usage from App.tsx:

- Home, CustomPage, Profile, Members, Channels
- Advertisements, Messages, Routes, Nodes, NodeDetail
- Packets, PacketDetail, PacketGroupDetail, Dashboard, MapPage

Pages use the shared React infrastructure (apiGet<T>, useAutoRefresh,
usePageTitle, useFormatDateTime, Pagination, FilterForm, SortableTable,
NodeDisplay, ObserverBadges, RouteTypeBadge, JsonTree, StatCard, icons).

Charts/maps still call window.Chart / window.L / window.QRCode / charts.js
globals — these move to react-chartjs-2 / react-leaflet in Phase 3.

The old lit-html code in spa/ is intentionally kept as the spa.html
fallback (rendered only when the Vite bundle is absent) and is still
referenced by 5 web tests; it will be removed in Phase 4.

Added IconSatelliteDish, IconRuler, IconHopSpan, IconPathLength icons.

Verified: tsc --noEmit clean, npm run build (94 modules),
pytest tests/test_web/ (256 passed), pre-commit (passed).
This commit is contained in:
Louis King
2026-07-21 16:50:33 +01:00
parent 6cd14a58aa
commit 8322b5cf9f
18 changed files with 8893 additions and 83 deletions
+25 -18
View File
@@ -7,11 +7,18 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite.
| Phase | Description | Status |
|-------|-------------|--------|
| 1 | Infrastructure (Vite, React shell, router, LitBridge, build pipeline, shared components) | **Complete** |
| 2 | Convert pages one-by-one from LitBridge to native React | Not started |
| 2 | Convert pages one-by-one from LitBridge to native React | **Complete** |
| 3 | Chart & map components (react-chartjs-2, react-leaflet) | Not started |
| 4 | Cleanup (remove lit-html, old spa/, build.js esbuild remnants) | Not started |
| 5 | Optional enhancements (tests, react-query, Storybook) | Not started |
> **Phase 2 status:** All 15 pages are converted to native React and wired into `App.tsx`.
> The old lit-html code in `spa/` is intentionally **kept** as the `spa.html` fallback
> (rendered only when the Vite bundle/manifest is absent) and is still referenced by
> 5 web tests. It will be removed in Phase 4, after those tests are updated.
> Charts/maps still use `window.Chart`, `window.L`, `window.QRCode`, and the `charts.js`
> globals — these move to `react-chartjs-2` / `react-leaflet` in Phase 3.
## Architecture Decisions
- **TypeScript** strict mode, `@/` alias → `spa-react/`, `@legacy/` alias → `spa/`
@@ -116,23 +123,23 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as
| # | Page | File | Complexity | Notes |
|---|------|------|-----------|-------|
| 1 | NotFound | `not-found.js` | Done | Already native React |
| 2 | Maintenance | `maintenance.js` | Done | Already native React |
| 3 | Home | `home.js` | Low | Stats + nav cards + activity chart (uses `window.createActivityChart`) |
| 4 | CustomPage | `custom-page.js` | Low | Fetches markdown HTML → `dangerouslySetInnerHTML` |
| 5 | Profile | `profile.js` | Medium | Form + PUT + QR code (uses `window.QRCode`) |
| 6 | Members | `members.js` | Medium | Table + filters + pagination |
| 7 | Channels | `channels.js` | Medium | Table + admin CRUD modals |
| 8 | Advertisements | `advertisements.js` | Medium | Table + filters + auto-refresh + observer badges |
| 9 | Messages | `messages.js` | Medium | Table + filters + auto-refresh + observer badges |
| 10 | Routes | `routes.js` | Med-High | Table + filters + history + chart strips |
| 11 | Nodes | `nodes.js` | Med-High | Table + filters + pagination + auto-refresh + observer filter |
| 12 | NodeDetail | `node-detail.js` | High | Tabs, charts, tables, QR, adopt/tags CRUD |
| 13 | Packets | `packets.js` | Medium | Table + filters + auto-refresh |
| 14 | PacketDetail | `packet-detail.js` | Med-High | JSON tree + raw data |
| 15 | PacketGroupDetail | `packet-group-detail.js` | Medium | Grouped packet list |
| 16 | Dashboard | `dashboard.js` | High | Multiple charts, stat cards, auto-refresh |
| 17 | Map | `map.js` | High | Leaflet map, markers, popups, layers |
| 1 | NotFound | `not-found.js` | Done | Native React |
| 2 | Maintenance | `maintenance.js` | Done | Native React |
| 3 | Home | `home.js` | Done | Stats + nav cards + activity chart (still uses `window.createActivityChart`) |
| 4 | CustomPage | `custom-page.js` | Done | Fetches markdown HTML → `dangerouslySetInnerHTML` |
| 5 | Profile | `profile.js` | Done | Form + PUT + adopted nodes |
| 6 | Members | `members.js` | Done | Profile tiles grouped by role |
| 7 | Channels | `channels.js` | Done | Cards + admin CRUD modals + QR (`window.QRCode`) |
| 8 | Advertisements | `advertisements.js` | Done | Table + filters + auto-refresh + observer badges |
| 9 | Messages | `messages.js` | Done | Table + filters + auto-refresh + observer badges + dedupe |
| 10 | Routes | `routes.js` | Done | Cards + quality + history strips (`window.createRouteDetailStrip`) + admin CRUD |
| 11 | Nodes | `nodes.js` | Done | Table + filters + pagination + auto-refresh |
| 12 | NodeDetail | `node-detail.js` | Done | Map (`window.L`), QR, adopt/tags CRUD |
| 13 | Packets | `packets.js` | Done | Table + filters + auto-refresh |
| 14 | PacketDetail | `packet-detail.js` | Done | JSON tree + raw data |
| 15 | PacketGroupDetail | `packet-group-detail.js` | Done | Grouped receptions + path popover |
| 16 | Dashboard | `dashboard.js` | Done | Charts (`window.initDashboardCharts`), stat cards, route health |
| 17 | Map | `map.js` | Done | Leaflet map (`window.L`), markers, popups, filters |
### Key patterns in old pages → React equivalents
@@ -1,4 +1,4 @@
import { useEffect, useCallback } from "react";
import { useEffect } from "react";
import {
BrowserRouter,
Routes,
@@ -7,10 +7,23 @@ import {
useLocation,
useParams,
} from "react-router";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { LitBridge } from "@/components/LitBridge";
import { HomePage } from "@/pages/Home";
import { DashboardPage } from "@/pages/Dashboard";
import { Nodes } from "@/pages/Nodes";
import { NodeDetailPage } from "@/pages/NodeDetail";
import { Channels } from "@/pages/Channels";
import { RoutesPage } from "@/pages/Routes";
import { Messages } from "@/pages/Messages";
import { Advertisements } from "@/pages/Advertisements";
import { Packets } from "@/pages/Packets";
import { PacketDetail } from "@/pages/PacketDetail";
import { PacketGroupDetail } from "@/pages/PacketGroupDetail";
import { MapPage } from "@/pages/MapPage";
import { Members } from "@/pages/Members";
import { CustomPagePage } from "@/pages/CustomPage";
import { Profile } from "@/pages/Profile";
import { NotFound } from "@/pages/NotFound";
import { Maintenance } from "@/pages/Maintenance";
@@ -74,24 +87,6 @@ function ShortLinkRedirect() {
return <Navigate to={`/nodes/${prefix}`} replace />;
}
function LitPage({
loader,
}: {
loader: () => Promise<{
render: (
container: HTMLElement,
params: Record<string, unknown>,
router: { navigate: (url: string, replace?: boolean) => void },
) => Promise<(() => void) | void>;
}>;
}) {
return (
<ErrorBoundary>
<LitBridge loader={loader} />
</ErrorBoundary>
);
}
function AppRoutes() {
const config = useAppConfig();
const features = config.features ?? {};
@@ -112,16 +107,18 @@ function AppRoutes() {
<Route
path="/"
element={
<LitPage loader={() => import("@legacy/pages/home.js")} />
<ErrorBoundary>
<HomePage />
</ErrorBoundary>
}
/>
{features.dashboard !== false && (
<Route
path="/dashboard"
element={
<LitPage
loader={() => import("@legacy/pages/dashboard.js")}
/>
<ErrorBoundary>
<DashboardPage />
</ErrorBoundary>
}
/>
)}
@@ -130,15 +127,17 @@ function AppRoutes() {
<Route
path="/nodes"
element={
<LitPage loader={() => import("@legacy/pages/nodes.js")} />
<ErrorBoundary>
<Nodes />
</ErrorBoundary>
}
/>
<Route
path="/nodes/:publicKey"
element={
<LitPage
loader={() => import("@legacy/pages/node-detail.js")}
/>
<ErrorBoundary>
<NodeDetailPage />
</ErrorBoundary>
}
/>
<Route path="/n/:prefix" element={<ShortLinkRedirect />} />
@@ -148,9 +147,9 @@ function AppRoutes() {
<Route
path="/channels"
element={
<LitPage
loader={() => import("@legacy/pages/channels.js")}
/>
<ErrorBoundary>
<Channels />
</ErrorBoundary>
}
/>
)}
@@ -158,7 +157,9 @@ function AppRoutes() {
<Route
path="/routes"
element={
<LitPage loader={() => import("@legacy/pages/routes.js")} />
<ErrorBoundary>
<RoutesPage />
</ErrorBoundary>
}
/>
)}
@@ -166,9 +167,9 @@ function AppRoutes() {
<Route
path="/messages"
element={
<LitPage
loader={() => import("@legacy/pages/messages.js")}
/>
<ErrorBoundary>
<Messages />
</ErrorBoundary>
}
/>
)}
@@ -176,9 +177,9 @@ function AppRoutes() {
<Route
path="/advertisements"
element={
<LitPage
loader={() => import("@legacy/pages/advertisements.js")}
/>
<ErrorBoundary>
<Advertisements />
</ErrorBoundary>
}
/>
)}
@@ -187,29 +188,25 @@ function AppRoutes() {
<Route
path="/packets"
element={
<LitPage
loader={() => import("@legacy/pages/packets.js")}
/>
<ErrorBoundary>
<Packets />
</ErrorBoundary>
}
/>
<Route
path="/packets/hash/:hash"
element={
<LitPage
loader={() =>
import("@legacy/pages/packet-group-detail.js")
}
/>
<ErrorBoundary>
<PacketGroupDetail />
</ErrorBoundary>
}
/>
<Route
path="/packets/:id"
element={
<LitPage
loader={() =>
import("@legacy/pages/packet-detail.js")
}
/>
<ErrorBoundary>
<PacketDetail />
</ErrorBoundary>
}
/>
</>
@@ -218,7 +215,9 @@ function AppRoutes() {
<Route
path="/map"
element={
<LitPage loader={() => import("@legacy/pages/map.js")} />
<ErrorBoundary>
<MapPage />
</ErrorBoundary>
}
/>
)}
@@ -226,9 +225,9 @@ function AppRoutes() {
<Route
path="/members"
element={
<LitPage
loader={() => import("@legacy/pages/members.js")}
/>
<ErrorBoundary>
<Members />
</ErrorBoundary>
}
/>
)}
@@ -236,9 +235,9 @@ function AppRoutes() {
<Route
path="/pages/:slug"
element={
<LitPage
loader={() => import("@legacy/pages/custom-page.js")}
/>
<ErrorBoundary>
<CustomPagePage />
</ErrorBoundary>
}
/>
)}
@@ -247,17 +246,17 @@ function AppRoutes() {
<Route
path="/profile"
element={
<LitPage
loader={() => import("@legacy/pages/profile.js")}
/>
<ErrorBoundary>
<Profile />
</ErrorBoundary>
}
/>
<Route
path="/profile/:id"
element={
<LitPage
loader={() => import("@legacy/pages/profile.js")}
/>
<ErrorBoundary>
<Profile />
</ErrorBoundary>
}
/>
</>
@@ -435,6 +435,74 @@ export function IconTxPower(props: IconProps) {
);
}
export function IconSatelliteDish(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 10a7.31 7.31 0 0 0 10 10Z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="m9 15 3-3"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13a6 6 0 0 0-6-6"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 13A10 10 0 0 0 11 3"
/>
</svg>
);
}
export function IconRuler(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4Z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="m7.5 10.5 2 2"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="m10.5 7.5 2 2"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="m13.5 4.5 2 2"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="m4.5 13.5 2 2"
/>
</svg>
);
}
export function IconClock(props: IconProps) {
return (
<svg {...base(props)}>
@@ -0,0 +1,565 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { Pagination } from "@/components/Pagination";
import { FilterForm, FilterToggle } from "@/components/FilterForm";
import { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable";
import { NodeDisplay } from "@/components/NodeDisplay";
import {
ObserverFilterBadges,
ObserverIcons,
getDisabledObserverAreas,
toggleObserverArea,
} from "@/components/ObserverBadges";
import { RouteTypeBadge } from "@/components/RouteTypeBadge";
import { Loading, WarningBadge } from "@/components/Alerts";
import { IconRefresh } from "@/components/icons";
interface ObserverInfo {
node_id?: string;
public_key: string;
name?: string;
tag_name?: string;
snr?: number | null;
observed_at?: string;
}
interface Advertisement {
public_key: string;
name?: string | null;
node_name?: string | null;
node_tag_name?: string | null;
node_tag_description?: string | null;
adv_type?: string | null;
route_type?: string | null;
received_at: string;
packet_hash?: string | null;
observed_by?: string | null;
observers?: ObserverInfo[];
}
interface NodeItem {
public_key: string;
tags?: { key: string; value: string | null }[];
}
interface OperatorProfile {
id: string;
user_id: string;
name?: string | null;
callsign?: string | null;
roles: string[];
}
interface ListResponse<T> {
items?: 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();
const [searchParams] = useSearchParams();
const config = useAppConfig();
const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
usePageTitle("entities.advertisements");
const search = searchParams.get("search") ?? "";
const adoptedBy = searchParams.get("adopted_by") ?? "";
const routeType = searchParams.get("route_type") ?? "flood,transport_flood";
const page = parseInt(searchParams.get("page") ?? "", 10) || 1;
const limit = parseInt(searchParams.get("limit") ?? "", 10) || 20;
const sort = searchParams.get("sort") ?? "time";
const order = searchParams.get("order") ?? "desc";
const offset = (page - 1) * limit;
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(),
);
const [filterOpen, setFilterOpen] = useState(
search !== "" ||
(config.oidc_enabled && adoptedBy !== "") ||
routeType !== "flood,transport_flood",
);
const disabledAreasRef = useRef(disabledAreas);
disabledAreasRef.current = disabledAreas;
const abortRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const { signal } = controller;
try {
const nodesPromise = apiGet<ListResponse<NodeItem>>(
"/api/v1/nodes",
{ limit: 500, observer: true },
{ signal },
);
const profilesPromise = config.oidc_enabled
? apiGet<ListResponse<OperatorProfile>>(
"/api/v1/user/profiles",
{ limit: 500 },
{ signal },
)
: Promise.resolve(null);
const [nodesData, profilesData] = await Promise.all([
nodesPromise,
profilesPromise,
]);
const operatorRole = config.role_names?.operator || "operator";
const profiles = (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 ?? []) {
const area = n.tags?.find((tg) => tg.key === "area")?.value;
if (!area || !area.trim()) continue;
const key = area.trim();
if (!areaMap.has(key)) areaMap.set(key, []);
areaMap.get(key)!.push(n.public_key);
}
const areas = [...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 apiParams: Record<string, unknown> = {
limit,
offset,
search,
sort,
order,
route_type: routeType,
};
if (observerFilterActive) {
apiParams.observed_by = areas
.filter((a) => !disabled.has(a))
.flatMap((a) => areaMap.get(a) ?? []);
}
if (adoptedBy) apiParams.adopted_by = adoptedBy;
const data = 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,
});
const handleObserverToggle = (area: string) => {
const updated = toggleObserverArea(area, sortedAreas.length);
setDisabledAreas(new Set(updated));
if (page > 1) {
const sp = new URLSearchParams(searchParams);
sp.delete("page");
const qs = sp.toString();
navigate(qs ? `/advertisements?${qs}` : "/advertisements");
}
};
const totalPages = total !== null ? Math.ceil(total / limit) : 0;
const headerParams = {
search,
adopted_by: adoptedBy,
route_type: routeType,
limit: String(limit),
};
const paginationParams = { ...headerParams, sort, order };
const emptyMessage = t("common.no_entity_found", {
entity: t("entities.advertisements").toLowerCase(),
});
const renderReceivers = (ad: Advertisement, variant: "mobile" | "desktop") => {
if (ad.observers && ad.observers.length >= 1) {
return <ObserverIcons observers={ad.observers} />;
}
if (ad.observed_by) {
return (
<span className={`opacity-50 ${variant === "mobile" ? "text-xs" : ""}`}>
{"\u{1F4E1}"}
</span>
);
}
return variant === "desktop" ? <span className="opacity-50">-</span> : null;
};
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>
<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>
{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>
<input
type="text"
name="search"
key={`search-${search}`}
defaultValue={search}
placeholder={t("common.search_placeholder")}
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
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>
{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
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>
)}
</FilterForm>
</div>
)}
{items === null ? (
<Loading />
) : (
<>
<ObserverFilterBadges
areas={sortedAreas}
disabled={disabledAreas}
onToggle={handleObserverToggle}
extraClass="hidden lg:flex mb-4"
/>
<MobileSortSelect
currentSort={sort}
currentOrder={order}
basePath="/advertisements"
params={headerParams}
options={[
{ value: "time:desc", label: t("advertisements.sort.newest") },
{ value: "time:asc", label: t("advertisements.sort.oldest") },
{
value: "node_name:asc",
label: t("advertisements.sort.node_az"),
},
{
value: "node_name:desc",
label: t("advertisements.sort.node_za"),
},
{
value: "public_key:asc",
label: t("advertisements.sort.key_asc"),
},
{
value: "public_key:desc",
label: t("advertisements.sort.key_desc"),
},
]}
/>
<ObserverFilterBadges
areas={sortedAreas}
disabled={disabledAreas}
onToggle={handleObserverToggle}
extraClass="flex lg:hidden mb-4"
/>
<div className="lg:hidden space-y-3">
{items.length === 0 ? (
<div className="text-center py-8 opacity-70">
{emptyMessage}
</div>
) : (
items.map((ad, idx) => {
const adName =
ad.node_tag_name || ad.node_name || ad.name || null;
const detailUrl =
packetsEnabled && ad.packet_hash
? `/packets/hash/${ad.packet_hash}`
: null;
return (
<div
key={`${ad.public_key}-${ad.received_at}-${idx}`}
className={`card bg-base-100 shadow-sm block ${detailUrl ? "cursor-pointer" : ""}`}
onClick={
detailUrl ? () => navigate(detailUrl) : undefined
}
>
<div className="card-body p-3">
<div className="flex items-center justify-between gap-2">
<Link
to={`/nodes/${ad.public_key}`}
className="min-w-0"
onClick={(e) => e.stopPropagation()}
>
<NodeDisplay
name={adName}
description={ad.node_tag_description ?? null}
publicKey={ad.public_key}
advType={ad.adv_type ?? null}
size="sm"
/>
</Link>
<div className="text-right flex-shrink-0">
<div className="text-xs opacity-60">
{formatDateTimeShort(ad.received_at)}
</div>
<div className="flex items-center justify-end gap-1">
<RouteTypeBadge
routeType={ad.route_type ?? null}
/>
{renderReceivers(ad, "mobile")}
</div>
</div>
</div>
</div>
</div>
);
})
)}
</div>
<div className="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow-sm">
<table className="table table-zebra">
<thead>
<tr>
<SortableTableHeader
label={t("entities.node")}
sortKey="node_name"
currentSort={sort}
currentOrder={order}
basePath="/advertisements"
params={headerParams}
/>
<SortableTableHeader
label={t("common.public_key")}
sortKey="public_key"
currentSort={sort}
currentOrder={order}
basePath="/advertisements"
params={headerParams}
/>
<th>{t("advertisements.col_route_type")}</th>
<SortableTableHeader
label={t("common.time")}
sortKey="time"
currentSort={sort}
currentOrder={order}
basePath="/advertisements"
params={headerParams}
/>
<th>{t("common.observers")}</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={5}
className="text-center py-8 opacity-70"
>
{emptyMessage}
</td>
</tr>
) : (
items.map((ad, idx) => {
const adName =
ad.node_tag_name || ad.node_name || ad.name || null;
const detailUrl =
packetsEnabled && ad.packet_hash
? `/packets/hash/${ad.packet_hash}`
: null;
return (
<tr
key={`${ad.public_key}-${ad.received_at}-${idx}`}
className={detailUrl ? "hover cursor-pointer" : ""}
onClick={
detailUrl ? () => navigate(detailUrl) : undefined
}
>
<td>
<Link
to={`/nodes/${ad.public_key}`}
className="link link-hover"
onClick={(e) => e.stopPropagation()}
>
<NodeDisplay
name={adName}
description={ad.node_tag_description ?? null}
publicKey={ad.public_key}
advType={ad.adv_type ?? null}
size="base"
/>
</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>
</td>
<td>
<RouteTypeBadge routeType={ad.route_type ?? null} />
</td>
<td className="text-sm whitespace-nowrap">
{formatDateTime(ad.received_at)}
</td>
<td>{renderReceivers(ad, "desktop")}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<Pagination
page={page}
totalPages={totalPages}
basePath="/advertisements"
params={paginationParams}
/>
</>
)}
</>
);
}
@@ -0,0 +1,478 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { usePageTitle } from "@/hooks/usePageTitle";
import { Loading, ErrorAlert } from "@/components/Alerts";
import { IconChannel, IconPlus, IconEdit, IconTrash } from "@/components/icons";
interface QRCodeOptions {
text: string;
width: number;
height: number;
correctLevel: number;
}
interface QRCodeConstructor {
new (el: HTMLElement, options: QRCodeOptions): unknown;
CorrectLevel: { L: number; M: number; Q: number; H: number };
}
declare global {
interface Window {
QRCode: QRCodeConstructor;
}
}
interface Channel {
id: string;
name: string;
channel_hash: string;
visibility: string;
enabled: boolean;
masked_key: string;
key_hex: string | null;
created_at: string;
updated_at: string;
}
interface ChannelListResponse {
items: Channel[];
total: number;
}
const VISIBILITY_ORDER = ["community", "member", "operator", "admin"];
type ModalState =
| { type: "add" }
| { type: "edit"; channel: Channel }
| { type: "delete"; channel: Channel };
function ChannelQrCode({ channel }: { channel: Channel }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || !channel.key_hex || el.hasChildNodes()) return;
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`;
new window.QRCode(el, {
text: qrUrl,
width: 128,
height: 128,
correctLevel: window.QRCode.CorrectLevel.M,
});
}, [channel]);
return <div ref={ref} className="qr-container" />;
}
interface ChannelCardProps {
channel: Channel;
oidcEnabled: boolean;
isAdmin: boolean;
onEdit: () => void;
onDelete: () => void;
onNavigate: (channelIdx: number) => void;
}
function ChannelCard({
channel,
oidcEnabled,
isAdmin,
onEdit,
onDelete,
onNavigate,
}: ChannelCardProps) {
const { t } = useTranslation();
const channelIdx = parseInt(channel.channel_hash, 16);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNavigate(channelIdx);
}
};
return (
<div
className="card bg-base-100 shadow-xl cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
role="button"
tabIndex={0}
onClick={() => onNavigate(channelIdx)}
onKeyDown={handleKeyDown}
>
<div className="card-body flex-row gap-4">
<div className="flex-1 min-w-0">
<h2 className="card-title flex items-center gap-2">
{channel.name}
{oidcEnabled && (
<span className="badge badge-primary badge-sm">
{channel.visibility}
</span>
)}
{!channel.enabled && (
<span className="badge badge-ghost badge-sm">
{t("channels.disabled")}
</span>
)}
</h2>
{channel.key_hex && (
<div className="font-mono text-xs opacity-70 mt-1 break-all select-all">
{channel.key_hex.toLowerCase()}
</div>
)}
{isAdmin && (
<div className="flex gap-2 mt-2">
<button
className="btn btn-xs btn-outline"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<IconEdit className="h-3 w-3" /> {t("common.edit")}
</button>
<button
className="btn btn-xs btn-outline btn-error"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<IconTrash className="h-3 w-3" /> {t("common.delete")}
</button>
</div>
)}
</div>
<div className="flex-shrink-0 self-center">
{channel.key_hex && <ChannelQrCode channel={channel} />}
</div>
</div>
</div>
);
}
interface ChannelModalProps {
isEdit: boolean;
channel: Channel | null;
saving: boolean;
onSave: (body: Record<string, unknown>) => void;
onCancel: () => void;
}
function ChannelModal({
isEdit,
channel,
saving,
onSave,
onCancel,
}: ChannelModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(channel?.name ?? "");
const [keyHex, setKeyHex] = useState("");
const [visibility, setVisibility] = useState(
channel?.visibility ?? "community",
);
const [enabled, setEnabled] = useState(channel?.enabled !== false);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const body: Record<string, unknown> = { visibility, enabled };
if (!isEdit) {
body.name = name.trim();
body.key_hex = keyHex.trim().toUpperCase();
}
onSave(body);
};
const title = isEdit
? t("channels.edit_channel")
: 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>
<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">
{t("channels.name_label")}
</label>
<input
type="text"
className="input input-sm"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isEdit}
placeholder={t("channels.name_label")}
required
maxLength={100}
/>
{!isEdit && (
<>
<label className="text-sm opacity-70 text-right">
{t("channels.key_label")}
</label>
<input
type="text"
className="input input-sm font-mono"
value={keyHex}
onChange={(e) => setKeyHex(e.target.value)}
placeholder="e.g. ABCDEF0123456789..."
required
minLength={32}
maxLength={64}
pattern="[0-9A-Fa-f]{32,64}"
/>
</>
)}
<label className="text-sm opacity-70 text-right">
{t("channels.visibility_label")}
</label>
<select
className="select select-sm"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
<option value="community">community</option>
<option value="member">member</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<div></div>
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<span className="text-sm">{t("channels.enabled_label")}</span>
</label>
</div>
<div className="modal-action">
<button
type="button"
className="btn btn-ghost"
onClick={onCancel}
disabled={saving}
>
{t("common.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving && (
<span className="loading loading-spinner loading-sm"></span>
)}
{t("common.save")}
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onCancel}></button>
</form>
</dialog>
);
}
interface DeleteChannelModalProps {
channel: Channel;
saving: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DeleteChannelModal({
channel,
saving,
onConfirm,
onCancel,
}: DeleteChannelModalProps) {
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>
);
}
export function Channels() {
const { t } = useTranslation();
const navigate = useNavigate();
const config = useAppConfig();
const oidcEnabled = config.oidc_enabled;
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 [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);
} else {
await apiPost("/api/v1/channels", 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}`);
setModal(null);
await fetchChannels();
} catch (e) {
alert((e as Error).message || "Failed to delete channel");
} finally {
setSaving(false);
}
};
const handleNavigate = (channelIdx: number) => {
navigate(`/messages?channel_idx=${channelIdx}`);
};
const groups = new Map<string, Channel[]>();
for (const vis of VISIBILITY_ORDER) {
groups.set(vis, []);
}
for (const ch of channels) {
const vis = ch.visibility || "community";
if (!groups.has(vis)) groups.set(vis, []);
groups.get(vis)!.push(ch);
}
if (loading) return <Loading />;
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>
{error && <ErrorAlert message={error} />}
{isAdmin && (
<div className="flex justify-end mb-4">
<button
className="btn btn-primary btn-sm"
onClick={() => setModal({ type: "add" })}
>
<IconPlus className="h-4 w-4" /> {t("channels.add_channel")}
</button>
</div>
)}
{channels.length === 0 && (
<div className="text-center py-8 opacity-70">
{t("common.no_entity_found", {
entity: t("entities.channels").toLowerCase(),
})}
</div>
)}
{VISIBILITY_ORDER.map((vis) => {
const group = groups.get(vis);
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">
{group.map((ch) => (
<ChannelCard
key={ch.id}
channel={ch}
oidcEnabled={oidcEnabled}
isAdmin={isAdmin}
onEdit={() => setModal({ type: "edit", channel: ch })}
onDelete={() => setModal({ type: "delete", channel: ch })}
onNavigate={handleNavigate}
/>
))}
</div>
</div>
);
})}
{modal && (modal.type === "add" || modal.type === "edit") && (
<ChannelModal
key={modal.type === "edit" ? `edit-${modal.channel.id}` : "add"}
isEdit={modal.type === "edit"}
channel={modal.type === "edit" ? modal.channel : null}
saving={saving}
onSave={handleSave}
onCancel={() => setModal(null)}
/>
)}
{modal?.type === "delete" && (
<DeleteChannelModal
channel={modal.channel}
saving={saving}
onConfirm={handleDeleteConfirm}
onCancel={() => setModal(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { ErrorAlert, Loading } from "@/components/Alerts";
import { useAppConfig } from "@/context/AppConfigContext";
import { apiGet, isAbortError } from "@/utils/api";
interface CustomPageData {
slug: string;
title: string;
content_html: string;
}
export function CustomPagePage() {
const { slug = "" } = useParams();
const { t } = useTranslation();
const config = useAppConfig();
const [page, setPage] = useState<CustomPageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
apiGet<CustomPageData>(
`/spa/pages/${encodeURIComponent(slug)}`,
{},
{ signal: controller.signal },
)
.then((data) => {
setPage(data);
setLoading(false);
})
.catch((e) => {
if (isAbortError(e)) return;
const message = e instanceof Error ? e.message : "";
setError(
message.includes("404")
? t("common.page_not_found")
: message || t("custom_page.failed_to_load"),
);
setLoading(false);
});
return () => controller.abort();
}, [slug, t]);
useEffect(() => {
if (!page) return;
const networkName = config.network_name || "MeshCore Network";
document.title = `${page.title} - ${networkName}`;
}, [page, config.network_name]);
if (loading) return <Loading />;
if (error) return <ErrorAlert message={error} />;
if (!page) return null;
return (
<div className="max-w-4xl mx-auto">
<div className="card bg-base-100 shadow-xl">
<div
className="card-body prose prose-lg max-w-none overflow-x-auto"
dangerouslySetInnerHTML={{ __html: page.content_html }}
/>
</div>
</div>
);
}
@@ -0,0 +1,649 @@
import {
useEffect,
useMemo,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { ErrorAlert, Loading } from "@/components/Alerts";
import { ObserverIcons } from "@/components/ObserverBadges";
import { RouteTypeBadge } from "@/components/RouteTypeBadge";
import {
IconAdvertisements,
IconChannel,
IconMessages,
IconNodes,
IconPackets,
} from "@/components/icons";
import {
getChannelLabelsMap,
resolveChannelLabel,
useAppConfig,
} from "@/context/AppConfigContext";
import { usePageTitle } from "@/hooks/usePageTitle";
import { apiGet, isAbortError } from "@/utils/api";
import { formatNumber, useFormatDateTime } from "@/utils/format";
interface DashboardStats {
total_nodes: number;
advertisements_7d: number;
messages_7d: number;
packets_7d: number;
}
interface PacketBreakdown {
by_event_type: { count: number }[];
by_path_width: { count: number }[];
}
interface RouteHealthEntry {
date: string;
quality: string | null;
matched_count: number;
}
interface RouteOverviewItem {
from_label: string;
to_label: string;
enabled: boolean;
quality?: string | null;
matched_count?: number;
history?: RouteHealthEntry[];
}
interface RoutesOverview {
days: number;
routes: RouteOverviewItem[];
}
interface ObserverInfo {
public_key: string;
name?: string;
tag_name?: string;
}
interface RecentAdvertisement {
public_key: string;
name?: string | null;
tag_name?: string | null;
route_type?: string | null;
received_at: string;
observed_by?: string | null;
observers?: ObserverInfo[];
}
interface ChannelMessage {
received_at: string;
text?: string | null;
}
interface RecentActivity {
recent_advertisements: RecentAdvertisement[];
channel_messages: Record<string, ChannelMessage[]>;
}
interface ChannelsResponse {
items?: { channel_hash: string; name: string }[];
}
interface DashboardData {
stats: DashboardStats;
recentActivity: RecentActivity;
advertActivity: unknown;
messageActivity: unknown;
nodeCount: unknown;
packetActivity: unknown;
packetBreakdown: PacketBreakdown;
routesOverview: RoutesOverview | null;
channelsData: ChannelsResponse;
}
const CHART_IDS = [
"nodeChart",
"advertChart",
"messageChart",
"packetChart",
"packetEventTypeChart",
"packetPathWidthChart",
"routesTrendChart",
];
const QUALITY_COLORS: Record<string, string> = {
clear: "oklch(0.72 0.17 145)",
marginal: "oklch(0.75 0.18 85)",
failing: "oklch(0.62 0.24 25)",
no_coverage: "oklch(0.65 0.15 250)",
disabled: "oklch(0.55 0 0)",
};
function qualityColor(quality: string | null): string {
return QUALITY_COLORS[quality ?? ""] ?? QUALITY_COLORS.no_coverage;
}
function gridCols(count: number): string {
if (count === 2) return "sm:grid-cols-2";
if (count === 3) return "sm:grid-cols-2 lg:grid-cols-3";
if (count === 4) return "sm:grid-cols-2 lg:grid-cols-4";
return "";
}
function panelStyle(colorVar: string): CSSProperties {
return { "--panel-color": `var(${colorVar})` } as CSSProperties;
}
function ChartCard({
colorVar,
icon,
title,
subtitle,
value,
canvasId,
children,
}: {
colorVar: string;
icon?: ReactNode;
title: string;
subtitle: string;
value?: number;
canvasId?: string;
children?: ReactNode;
}) {
return (
<div
className="card bg-base-100 shadow-xl panel-accent"
style={panelStyle(colorVar)}
>
<div className="card-body">
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="card-title text-base">
{icon}
{title}
</h2>
<p className="text-xs opacity-80">{subtitle}</p>
</div>
{value !== undefined && (
<div
className="text-3xl font-bold leading-none"
style={{ color: `var(${colorVar})` }}
>
{formatNumber(value)}
</div>
)}
</div>
{canvasId && (
<div className="h-32">
<canvas id={canvasId}></canvas>
</div>
)}
{children}
</div>
</div>
);
}
function RoutesHealth({ routes }: { routes: RouteOverviewItem[] }) {
const { t } = useTranslation();
if (!routes || routes.length === 0) {
return <p className="text-sm opacity-70">{t("dashboard.routes_empty")}</p>;
}
const labelFor = (quality: string | null) =>
t("routes.quality_" + (quality || "unknown"));
const sorted = routes
.slice()
.sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0));
const visible = sorted.slice(0, 6);
const hidden = sorted.length - visible.length;
return (
<div className="space-y-2">
{visible.map((route, i) => {
const history = route.history || [];
const averageTier =
history.length > 0
? ((window as any).averageRouteTier?.(history) as string | null) ??
null
: null;
const current =
averageTier ||
(route.enabled ? route.quality || "no_coverage" : "disabled");
return (
<div
key={`${route.from_label}->${route.to_label}-${i}`}
className="flex items-center gap-2"
>
<span
className="flex-1 min-w-0 truncate text-sm"
title={`${route.from_label} \u2192 ${route.to_label}`}
>
{route.from_label} <span className="opacity-50"></span>{" "}
{route.to_label}
</span>
<div className="flex gap-0.5 flex-shrink-0">
{history.map((entry) => (
<div
key={entry.date}
className="route-health-cell"
style={{ background: qualityColor(entry.quality) }}
title={`${entry.date} \u2014 ${labelFor(entry.quality)} (${entry.matched_count})`}
></div>
))}
</div>
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: qualityColor(current) }}
title={labelFor(current)}
></span>
</div>
);
})}
{hidden > 0 && (
<p className="text-xs opacity-60 pt-1">
{t("dashboard.routes_more", { count: hidden })}
</p>
)}
</div>
);
}
export function DashboardPage() {
const { t } = useTranslation();
const config = useAppConfig();
const { formatDateTime } = useFormatDateTime();
usePageTitle("entities.dashboard");
const features = config.features ?? {};
const showNodes = features.nodes !== false;
const showAdverts = features.advertisements !== false;
const showMessages = features.messages !== false;
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([
apiGet<DashboardStats>("/api/v1/dashboard/stats", {}, { signal }),
apiGet<RecentActivity>(
"/api/v1/dashboard/recent-activity",
{},
{ signal },
),
apiGet<unknown>("/api/v1/dashboard/activity", { days: 7 }, { signal }),
apiGet<unknown>(
"/api/v1/dashboard/message-activity",
{ days: 7 },
{ signal },
),
apiGet<unknown>("/api/v1/dashboard/node-count", { days: 7 }, { signal }),
apiGet<unknown>(
"/api/v1/dashboard/packet-activity",
{ days: 7 },
{ signal },
),
apiGet<PacketBreakdown>(
"/api/v1/dashboard/packet-breakdown",
{ days: 7 },
{ signal },
),
showRoutes
? apiGet<RoutesOverview>(
"/api/v1/dashboard/routes-overview",
{ days: 7 },
{ signal },
)
: Promise.resolve(null),
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]);
useEffect(() => {
if (!data) return;
window.initDashboardCharts(
showNodes ? data.nodeCount : null,
showAdverts ? data.advertActivity : null,
showMessages ? data.messageActivity : null,
showPackets ? data.packetActivity : null,
showPackets ? data.packetBreakdown.by_event_type : null,
showPackets ? data.packetBreakdown.by_path_width : null,
showRoutes && data.routesOverview?.routes
? data.routesOverview.routes
: null,
);
return () => {
for (const id of CHART_IDS) {
const canvas = document.getElementById(id);
if (canvas) (window as any).Chart?.getChart(canvas)?.destroy();
}
};
}, [data, showNodes, showAdverts, showMessages, showPackets, showRoutes]);
const channelLabels = useMemo(() => {
if (!data) return new Map<number, string>();
return new Map<number, string>([
...getChannelLabelsMap(config),
...(data.channelsData.items || [])
.map((ch) => [parseInt(ch.channel_hash, 16), ch.name] as [number, string])
.filter(([idx]) => Number.isInteger(idx)),
]);
}, [config, data]);
if (loading) return <Loading />;
if (error) return <ErrorAlert message={error} />;
if (!data) return null;
const { stats, recentActivity, packetBreakdown, routesOverview } = data;
const formatTimeOnly = (iso: string | null) =>
formatDateTime(iso, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const formatTimeShort = (iso: string | null) =>
formatDateTime(iso, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const labelForChannel = (channel: string): string => {
const idx = parseInt(String(channel), 10);
if (Number.isInteger(idx)) {
return resolveChannelLabel(idx, channelLabels) || `Ch ${idx}`;
}
return String(channel);
};
const eventTypeTotal =
packetBreakdown?.by_event_type?.reduce((sum, b) => sum + b.count, 0) ?? 0;
const pathWidthTotal =
packetBreakdown?.by_path_width?.reduce((sum, b) => sum + b.count, 0) ?? 0;
const hasRoutes = !!(
routesOverview &&
routesOverview.routes &&
routesOverview.routes.length
);
const visibleChartCount =
(showNodes ? 1 : 0) +
(showAdverts ? 1 : 0) +
(showMessages ? 1 : 0) +
(showPackets ? 1 : 0);
const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const ads = recentActivity.recent_advertisements ?? [];
const channelEntries = Object.entries(recentActivity.channel_messages ?? {});
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.dashboard")}</h1>
</div>
{visibleChartCount > 0 && (
<>
<div
className={`grid grid-cols-1 ${gridCols(visibleChartCount)} gap-6 mb-8`}
>
{showNodes && (
<ChartCard
colorVar="--color-nodes"
icon={<IconNodes className="h-5 w-5" />}
title={t("entities.nodes")}
subtitle={t("time.over_time_last_7_days")}
value={stats.total_nodes}
canvasId="nodeChart"
/>
)}
{showAdverts && (
<ChartCard
colorVar="--color-adverts"
icon={<IconAdvertisements className="h-5 w-5" />}
title={t("entities.advertisements")}
subtitle={t("time.per_day_last_7_days")}
value={stats.advertisements_7d}
canvasId="advertChart"
/>
)}
{showMessages && (
<ChartCard
colorVar="--color-messages"
icon={<IconMessages className="h-5 w-5" />}
title={t("entities.messages")}
subtitle={t("time.per_day_last_7_days")}
value={stats.messages_7d}
canvasId="messageChart"
/>
)}
{showPackets && (
<ChartCard
colorVar="--color-packets"
icon={<IconPackets className="h-5 w-5" />}
title={t("entities.packets")}
subtitle={t("time.per_day_last_7_days")}
value={stats.packets_7d}
canvasId="packetChart"
/>
)}
</div>
{(showPackets || (showRoutes && hasRoutes)) && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{showPackets && (
<ChartCard
colorVar="--color-packets"
icon={<IconPackets className="h-5 w-5" />}
title={t("entities.packet_event_types")}
subtitle={t("time.last_7_days")}
value={eventTypeTotal}
canvasId="packetEventTypeChart"
/>
)}
{showPackets && (
<ChartCard
colorVar="--color-packets"
icon={<IconPackets className="h-5 w-5" />}
title={t("entities.path_hash_width")}
subtitle={t("time.last_7_days")}
value={pathWidthTotal}
canvasId="packetPathWidthChart"
/>
)}
{showRoutes && hasRoutes && (
<ChartCard
colorVar="--color-routes"
title={t("dashboard.route_health")}
subtitle={t("time.last_7_days")}
>
<RoutesHealth routes={routesOverview!.routes} />
</ChartCard>
)}
{showRoutes && hasRoutes && (
<ChartCard
colorVar="--color-routes"
title={t("dashboard.routes_trend")}
subtitle={t("time.routes_over_last_n_days", {
n: routesOverview!.days,
})}
canvasId="routesTrendChart"
/>
)}
</div>
)}
</>
)}
{bottomCount > 0 && (
<div className={`grid grid-cols-1 ${gridCols(bottomCount)} gap-6`}>
{showAdverts && (
<div
className="card bg-base-100 shadow-xl panel-accent"
style={panelStyle("--color-adverts")}
>
<div className="card-body">
<h2 className="card-title">
<IconAdvertisements className="h-6 w-6" />
{t("common.recent_entity", {
entity: t("entities.advertisements"),
})}
</h2>
{ads.length === 0 ? (
<p className="text-sm opacity-70">
{t("common.no_entity_yet", {
entity: t("entities.advertisements").toLowerCase(),
})}
</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>{t("entities.node")}</th>
<th className="hidden md:table-cell">
{t("common.type")}
</th>
<th className="text-right">{t("common.received")}</th>
<th>{t("common.observers")}</th>
</tr>
</thead>
<tbody>
{ads.map((ad, i) => {
const friendlyName = ad.tag_name || ad.name;
const displayName =
friendlyName || ad.public_key.slice(0, 12) + "...";
return (
<tr key={`${ad.public_key}-${ad.received_at}-${i}`}>
<td>
<Link
to={`/nodes/${ad.public_key}`}
className="link link-hover"
>
<div className="font-medium">
{displayName}
</div>
</Link>
{friendlyName && (
<div className="text-xs opacity-50 font-mono">
{ad.public_key.slice(0, 12)}...
</div>
)}
</td>
<td className="hidden md:table-cell">
<RouteTypeBadge routeType={ad.route_type ?? null} />
</td>
<td className="text-right text-sm opacity-70">
{formatTimeOnly(ad.received_at)}
</td>
<td>
{ad.observers && ad.observers.length >= 1 ? (
<ObserverIcons observers={ad.observers} />
) : ad.observed_by ? (
<span className="opacity-50">{"\u{1F4E1}"}</span>
) : (
<span className="opacity-50">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{showMessages && channelEntries.length > 0 && (
<div
className="card bg-base-100 shadow-xl panel-accent"
style={panelStyle("--color-messages")}
>
<div className="card-body">
<h2 className="card-title">
<IconChannel className="h-6 w-6" />
{t("dashboard.recent_channel_messages")}
</h2>
<div className="space-y-4">
{channelEntries.map(([channel, messages]) => (
<div key={channel}>
<h3 className="font-semibold text-sm mb-2 flex items-center gap-2">
<span className="badge badge-info badge-sm">
{labelForChannel(channel)}
</span>
</h3>
<div className="space-y-1 pl-2 border-l-2 border-base-300">
{messages.map((msg, i) => (
<div
key={`${channel}-${msg.received_at}-${i}`}
className="text-sm"
>
<span className="text-xs opacity-50">
{formatTimeShort(msg.received_at)}
</span>{" "}
<span
className="break-words"
style={{ whiteSpace: "pre-wrap" }}
>
{msg.text || ""}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</>
);
}
@@ -0,0 +1,482 @@
import {
useCallback,
useEffect,
useRef,
useState,
type ComponentType,
type SVGProps,
} from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { ErrorAlert, Loading } from "@/components/Alerts";
import { StatCard } from "@/components/StatCard";
import {
IconAdvertisements,
IconAntenna,
IconBandwidth,
IconChannel,
IconChart,
IconCodingRate,
IconDashboard,
IconFrequency,
IconInfo,
IconMap,
IconMembers,
IconMessages,
IconNodes,
IconPage,
IconPackets,
IconPath,
IconSettings,
IconSpreadingFactor,
IconTxPower,
IconUsers,
} from "@/components/icons";
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 { getPageColor } from "@/utils/format";
interface DashboardStats {
total_nodes: number;
advertisements_7d: number;
messages_7d: number;
packets_7d: number;
total_operators: number;
total_members: number;
}
interface ActivitySeries {
data: { date: string; count: number }[];
}
interface ChartInstance {
destroy: () => void;
}
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
function NavCard({
href,
icon: Icon,
label,
colorVar,
}: {
href: string;
icon: IconComponent;
label: string;
colorVar: string;
}) {
return (
<Link
to={href}
className="w-20 h-20 sm:w-[6.75rem] sm:h-[6.75rem]
border border-base-content/20 rounded-box
hover:scale-105 hover:border-base-content/40
transition-all duration-200 ease-out
flex flex-col items-center justify-center gap-2
bg-base-200/50 hover:bg-base-200
group"
>
<span
className="w-7 h-7 sm:w-9 sm:h-9 flex items-center justify-center"
style={{ color: `var(${colorVar})` }}
>
<Icon className="w-full h-full" />
</span>
<span className="text-xs sm:text-sm font-medium text-base-content">
{label}
</span>
</Link>
);
}
function RadioTiles({ rc }: { rc?: RadioConfigDisplay }) {
const { t } = useTranslation();
if (!rc) return null;
const tiles = [
{ icon: IconSettings, label: t("links.profile"), value: rc.profile },
{ icon: IconFrequency, label: t("home.frequency"), value: rc.frequency },
{ icon: IconBandwidth, label: t("home.bandwidth"), value: rc.bandwidth },
{
icon: IconSpreadingFactor,
label: t("home.spreading_factor"),
value: rc.spreading_factor,
},
{
icon: IconCodingRate,
label: t("home.coding_rate"),
value: rc.coding_rate,
},
{ icon: IconTxPower, label: t("home.tx_power"), value: rc.tx_power },
].filter((tile) => tile.value);
if (tiles.length === 0) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{tiles.map(({ icon: Icon, label, value }) => (
<div
key={label}
className="flex flex-col items-center justify-center gap-1.5 p-3
border border-base-content/10 rounded-box text-center"
>
<span className="radio-tile-icon w-6 h-6">
<Icon className="w-full h-full" />
</span>
<span className="text-xs opacity-70 leading-tight">{label}</span>
<span className="text-sm font-semibold leading-tight">
{String(value)}
</span>
</div>
))}
</div>
);
}
export function HomePage() {
const { t } = useTranslation();
const config = useAppConfig();
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 chartCanvasRef = useRef<HTMLCanvasElement | 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;
const customPages = config.custom_pages || [];
const showStats =
features.nodes !== false ||
features.advertisements !== false ||
features.messages !== false ||
features.packets !== false;
const showAdvertSeries = features.advertisements !== false;
const showMessageSeries = features.messages !== false;
const showActivityChart = showAdvertSeries || showMessageSeries;
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],
);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
useAutoRefresh({ onRefresh: load });
useEffect(() => {
if (!showActivityChart || !chartCanvasRef.current) return;
const chart = window.createActivityChart(
"activityChart",
showAdvertSeries ? advertActivity : null,
showMessageSeries ? messageActivity : null,
) as ChartInstance | null;
return () => {
chart?.destroy();
};
}, [
showActivityChart,
showAdvertSeries,
showMessageSeries,
advertActivity,
messageActivity,
]);
if (loading) return <Loading />;
if (error) return <ErrorAlert message={error} />;
if (!stats) return null;
const navItems: {
feature: string;
href: string;
icon: IconComponent;
label: string;
colorVar: string;
}[] = [
{
feature: "dashboard",
href: "/dashboard",
icon: IconDashboard,
label: t("entities.dashboard"),
colorVar: "--color-dashboard",
},
{
feature: "nodes",
href: "/nodes",
icon: IconNodes,
label: t("entities.nodes"),
colorVar: "--color-nodes",
},
{
feature: "advertisements",
href: "/advertisements",
icon: IconAdvertisements,
label: t("entities.advertisements"),
colorVar: "--color-adverts",
},
{
feature: "routes",
href: "/routes",
icon: IconPath,
label: t("entities.routes"),
colorVar: "--color-routes",
},
{
feature: "channels",
href: "/channels",
icon: IconChannel,
label: t("entities.channels"),
colorVar: "--color-channels",
},
{
feature: "messages",
href: "/messages",
icon: IconMessages,
label: t("entities.messages"),
colorVar: "--color-messages",
},
{
feature: "packets",
href: "/packets",
icon: IconPackets,
label: t("entities.packets"),
colorVar: "--color-packets",
},
{
feature: "map",
href: "/map",
icon: IconMap,
label: t("entities.map"),
colorVar: "--color-map",
},
{
feature: "members",
href: "/members",
icon: IconMembers,
label: t("entities.members"),
colorVar: "--color-members",
},
];
return (
<>
<div
className={`${showStats ? "grid grid-cols-1 lg:grid-cols-3 gap-6" : ""} bg-base-100 rounded-box shadow-xl p-6`}
>
<div
className={`flex flex-col ${showStats ? "lg:col-span-2" : ""}`}
>
<div className="flex flex-col items-center text-center flex-1">
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8">
<img
src={logoUrl}
alt={networkName}
className={`theme-logo ${logoInvertLight ? "theme-logo--invert-light" : ""} h-24 w-24 sm:h-36 sm:w-36`}
/>
<div className="flex flex-col justify-center">
<h1 className="hero-title text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">
{networkName}
</h1>
{config.network_city && config.network_country && (
<p className="text-lg sm:text-2xl opacity-70 mt-2">
{config.network_city}, {config.network_country}
</p>
)}
</div>
</div>
<div className="flex-1 flex items-center justify-center w-full">
<p className="py-6 max-w-[90%] sm:max-w-[70%]">
{config.network_welcome_text ||
t("home.welcome_default", { network_name: networkName })}
</p>
</div>
<div
className="flex flex-wrap justify-center justify-items-center gap-2
sm:grid sm:grid-cols-4 min-[1536px]:grid-cols-8
sm:gap-3 min-[1536px]:gap-2"
>
{navItems
.filter((item) => features[item.feature] !== false)
.map((item) => (
<NavCard
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
colorVar={item.colorVar}
/>
))}
</div>
{features.pages !== false && customPages.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 mt-4">
{customPages.slice(0, 3).map((page) => (
<Link
key={page.slug}
to={page.url}
className="btn btn-outline border-base-content/20"
>
<IconPage className="h-5 w-5 mr-2" />
{page.title}
</Link>
))}
</div>
)}
</div>
</div>
{showStats && (
<div className="flex flex-col gap-4">
{features.nodes !== false && (
<StatCard
icon={<IconNodes className="h-8 w-8" />}
color={getPageColor("nodes")}
title={t("entities.nodes")}
value={stats.total_nodes}
description={t("home.all_discovered_nodes")}
/>
)}
{features.advertisements !== false && (
<StatCard
icon={<IconAdvertisements className="h-8 w-8" />}
color={getPageColor("adverts")}
title={t("entities.advertisements")}
value={stats.advertisements_7d}
description={t("time.last_7_days")}
/>
)}
{features.messages !== false && (
<StatCard
icon={<IconMessages className="h-8 w-8" />}
color={getPageColor("messages")}
title={t("entities.messages")}
value={stats.messages_7d}
description={t("time.last_7_days")}
/>
)}
{features.packets !== false && (
<StatCard
icon={<IconPackets className="h-8 w-8" />}
color={getPageColor("packets")}
title={t("entities.packets")}
value={stats.packets_7d}
description={t("time.last_7_days")}
/>
)}
</div>
)}
</div>
<div
className={`grid grid-cols-1 md:grid-cols-2 ${showRadioPanel && showMembersPanel && showActivityChart ? "lg:grid-cols-3" : ""} gap-6 mt-6`}
>
{showRadioPanel && (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
<IconInfo className="h-6 w-6" />
{t("home.network_info")}
</h2>
<div className="mt-2">
<RadioTiles rc={config.network_radio_config} />
</div>
</div>
</div>
)}
{showMembersPanel && (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
<IconMembers className="h-6 w-6" />
{t("entities.members")}
</h2>
<div className="grid grid-cols-1 gap-4 mt-2">
<StatCard
icon={<IconAntenna className="h-6 w-6" />}
color={getPageColor("members")}
title={t("members_page.operators")}
value={stats.total_operators ?? 0}
/>
<StatCard
icon={<IconUsers className="h-6 w-6" />}
color={getPageColor("members")}
title={t("members_page.members")}
value={stats.total_members ?? 0}
/>
</div>
</div>
</div>
)}
{showActivityChart && (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
<IconChart className="h-6 w-6" />
{t("home.network_activity")}
</h2>
<p className="text-sm opacity-70 mb-2">
{t("time.activity_per_day_last_7_days")}
</p>
<div className="h-48">
<canvas ref={chartCanvasRef} id="activityChart"></canvas>
</div>
</div>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,604 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import { usePageTitle } from "@/hooks/usePageTitle";
import { apiGet, isAbortError } from "@/utils/api";
import { formatNumber, formatRelativeTime, typeEmoji } from "@/utils/format";
import { FilterToggle } from "@/components/FilterForm";
import { ErrorAlert, Loading } from "@/components/Alerts";
const MAX_BOUNDS_RADIUS_KM = 20;
interface LatLng {
lat: number;
lon: number;
}
interface MapNodeOwner {
name: string;
callsign: string | null;
}
interface MapNode {
public_key: string;
name: string | null;
adv_type: string | null;
lat: number;
lon: number;
last_seen: string | null;
is_adopted?: boolean;
role: string | null;
owner: MapNodeOwner | null;
}
interface Profile {
id: string;
name: string | null;
callsign: string | null;
roles: string[] | null;
}
interface MapDebug {
total_nodes: number;
nodes_with_coords: number;
error: string | null;
}
interface MapData {
nodes: MapNode[];
center: LatLng | null;
adopted_center: LatLng | null;
debug: MapDebug | null;
profiles: Profile[];
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function getDistanceKm(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function getNodesWithinRadius(
nodes: MapNode[],
anchorLat: number,
anchorLon: number,
radiusKm: number,
): MapNode[] {
return nodes.filter(
(n) => getDistanceKm(anchorLat, anchorLon, n.lat, n.lon) <= radiusKm,
);
}
function getAnchorPoint(nodes: MapNode[], adoptedCenter: LatLng | null): LatLng {
if (adoptedCenter) return adoptedCenter;
if (nodes.length === 0) return { lat: 0, lon: 0 };
return {
lat: nodes.reduce((sum, n) => sum + n.lat, 0) / nodes.length,
lon: nodes.reduce((sum, n) => sum + n.lon, 0) / nodes.length,
};
}
function getBoundsPadding(): [number, number] {
if (window.innerWidth < 480) return [50, 50];
if (window.innerWidth < 768) return [75, 75];
return [100, 100];
}
function normalizeType(type: string | null): string | null {
return type ? type.toLowerCase() : null;
}
function getTypeDisplay(node: MapNode, t: TFunction): string {
const type = normalizeType(node.adv_type);
if (type === "chat") return t("node_types.chat");
if (type === "repeater") return t("node_types.repeater");
if (type === "room") return t("node_types.room");
return type
? type.charAt(0).toUpperCase() + type.slice(1)
: t("node_types.unknown");
}
function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any {
const displayName = node.name || "";
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? " (" + relativeTime + ")" : "";
const iconHtml =
oidcEnabled && node.is_adopted
? '<div style="width: 12px; height: 12px; background: var(--color-marker-infra); border: 2px solid var(--color-marker-infra-border); border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>'
: '<div style="width: 12px; height: 12px; background: var(--color-marker-public); border: 2px solid var(--color-marker-public-border); border-radius: 50%; box-shadow: 0 0 4px rgba(34,197,94,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
return L.divIcon({
className: "custom-div-icon",
html:
'<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
iconHtml +
'<span class="map-label" style="font-size: 10px; font-weight: bold; color: #fff; background: rgba(0,0,0,0.5); padding: 1px 4px; border-radius: 3px; white-space: nowrap; text-align: center;">' +
escapeHtml(displayName) +
escapeHtml(timeDisplay) +
"</span>" +
"</div>",
iconSize: [120, 50],
iconAnchor: [60, 12],
});
}
function createPopupContent(
node: MapNode,
oidcEnabled: boolean,
t: TFunction,
): string {
const typeDisplay = getTypeDisplay(node, t);
const nodeTypeEmoji = typeEmoji(node.adv_type);
let infraIndicatorHtml = "";
if (oidcEnabled && typeof node.is_adopted !== "undefined") {
const dotColor = node.is_adopted
? "var(--color-marker-infra)"
: "var(--color-marker-public)";
const borderColor = node.is_adopted
? "var(--color-marker-infra-border)"
: "var(--color-marker-public-border)";
const title = node.is_adopted ? t("map.infrastructure") : t("map.public");
infraIndicatorHtml =
' <span style="display: inline-block; width: 10px; height: 10px; background: ' +
dotColor +
"; border: 2px solid " +
borderColor +
'; border-radius: 50%; vertical-align: middle;" title="' +
escapeHtml(title) +
'"></span>';
}
const typeLabel = t("common.type");
const keyLabel = t("common.key");
const locationLabel = t("common.location");
const lastSeenLabel = t("common.last_seen_label");
const unknownLabel = t("node_types.unknown");
const viewDetailsLabel = t("common.view_details");
let rows = "";
rows +=
'<div class="opacity-70">' +
typeLabel +
"</div><div>" +
escapeHtml(typeDisplay) +
"</div>";
if (node.role) {
const roleLabel = t("map.role");
rows +=
'<div class="opacity-70">' +
roleLabel +
'</div><div><span class="badge badge-xs badge-ghost">' +
escapeHtml(node.role) +
"</span></div>";
}
if (node.owner) {
const ownerLabel = t("map.owner");
const ownerDisplay = node.owner.callsign
? escapeHtml(node.owner.name) +
" (" +
escapeHtml(node.owner.callsign) +
")"
: escapeHtml(node.owner.name);
rows +=
'<div class="opacity-70">' + ownerLabel + "</div><div>" + ownerDisplay + "</div>";
}
rows +=
'<div class="opacity-70">' +
keyLabel +
'</div><div><code class="text-xs">' +
escapeHtml(node.public_key.substring(0, 16)) +
"...</code></div>";
rows +=
'<div class="opacity-70">' +
locationLabel +
"</div><div>" +
node.lat.toFixed(4) +
", " +
node.lon.toFixed(4) +
"</div>";
if (node.last_seen) {
rows +=
'<div class="opacity-70">' +
lastSeenLabel +
"</div><div>" +
node.last_seen.substring(0, 19).replace("T", " ") +
"</div>";
}
return (
'<div class="p-2">' +
'<h3 class="font-bold text-lg mb-2">' +
nodeTypeEmoji +
" " +
escapeHtml(node.name || unknownLabel) +
infraIndicatorHtml +
"</h3>" +
'<div class="text-sm grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">' +
rows +
"</div>" +
'<a href="/nodes/' +
encodeURIComponent(node.public_key) +
'" class="btn btn-outline btn-xs mt-3">' +
viewDetailsLabel +
"</a>" +
"</div>"
);
}
function fitInitialBounds(
map: any,
L: any,
data: MapData,
oidcEnabled: boolean,
): void {
const allNodes = data.nodes || [];
const padding = getBoundsPadding();
if (oidcEnabled) {
const adoptedNodes = allNodes.filter((n) => n.is_adopted);
if (adoptedNodes.length > 0) {
map.fitBounds(
L.latLngBounds(adoptedNodes.map((n) => [n.lat, n.lon])),
{ padding },
);
return;
}
}
if (allNodes.length === 0) return;
const anchor = getAnchorPoint(
allNodes,
oidcEnabled ? data.adopted_center : null,
);
const nearbyNodes = getNodesWithinRadius(
allNodes,
anchor.lat,
anchor.lon,
MAX_BOUNDS_RADIUS_KM,
);
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
map.fitBounds(
L.latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
{ padding },
);
}
export function MapPage() {
const { t } = useTranslation();
const config = useAppConfig();
usePageTitle("entities.map");
const oidcEnabled = config.oidc_enabled;
const tz = config.timezone || "";
const operatorRole = config.role_names?.operator || "operator";
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<any>(null);
const markersRef = useRef<any[]>([]);
const initialFitRef = useRef(false);
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 [nodeCount, setNodeCount] = useState(0);
const [filteredCount, setFilteredCount] = useState<number | null>(null);
const operatorProfiles = useMemo(
() =>
(mapData?.profiles || [])
.filter((p) => p.roles && p.roles.includes(operatorRole))
.sort((a, b) => {
const na = a.name || a.callsign || "";
const nb = b.name || b.callsign || "";
return na.localeCompare(nb);
}),
[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]);
useEffect(() => {
if (loading || mapRef.current) return;
const L = (window as any).L;
const el = mapContainerRef.current;
if (!L || !el) return;
const map = L.map(el).setView([0, 0], 2);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
mapRef.current = map;
return () => {
mapRef.current = null;
markersRef.current = [];
map.remove();
};
}, [loading]);
const updateMarkers = useCallback(
(map: any, L: any, nodes: MapNode[]) => {
markersRef.current.forEach((m) => map.removeLayer(m));
markersRef.current = [];
nodes.forEach((node) => {
const marker = L.marker([node.lat, node.lon], {
icon: createNodeIcon(L, node, oidcEnabled),
}).addTo(map);
marker.bindPopup(createPopupContent(node, oidcEnabled, t));
markersRef.current.push(marker);
});
},
[oidcEnabled, t],
);
const applyFilters = useCallback(() => {
const map = mapRef.current;
const L = (window as any).L;
if (!map || !L || !mapData) return;
const allNodes = mapData.nodes || [];
const filteredNodes = allNodes.filter((node) => {
if (category === "infra" && !node.is_adopted) return false;
if (typeFilter && normalizeType(node.adv_type) !== typeFilter)
return false;
return true;
});
updateMarkers(map, L, filteredNodes);
setNodeCount(allNodes.length);
setFilteredCount(filteredNodes.length);
if (filteredNodes.length > 0) {
let nodesToFit = filteredNodes;
if (category !== "infra") {
const anchor = getAnchorPoint(filteredNodes, mapData.adopted_center);
const nearbyNodes = getNodesWithinRadius(
filteredNodes,
anchor.lat,
anchor.lon,
MAX_BOUNDS_RADIUS_KM,
);
if (nearbyNodes.length > 0) nodesToFit = nearbyNodes;
}
map.fitBounds(
L.latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
{ padding: getBoundsPadding() },
);
} else {
const center = mapData.center;
if (center && (center.lat !== 0 || center.lon !== 0)) {
map.setView([center.lat, center.lon], 10);
}
}
}, [mapData, category, typeFilter, updateMarkers]);
useEffect(() => {
const map = mapRef.current;
const L = (window as any).L;
if (!map || !L || !mapData || mapData.debug?.error) return;
if (!initialFitRef.current) {
initialFitRef.current = true;
fitInitialBounds(map, L, mapData, oidcEnabled);
const allNodes = mapData.nodes || [];
updateMarkers(map, L, allNodes);
setNodeCount(allNodes.length);
setFilteredCount(allNodes.length);
return;
}
applyFilters();
}, [mapData, oidcEnabled, applyFilters, updateMarkers]);
useEffect(() => {
const el = mapContainerRef.current;
if (el) el.classList.toggle("show-labels", showLabels);
}, [showLabels]);
const clearFilters = () => {
setCategory("");
setTypeFilter("");
setOperatorFilter("");
setShowLabels(false);
};
const debug = mapData?.debug ?? null;
let countBadgeText: string;
if (debug?.error) {
countBadgeText = "Error: " + debug.error;
} else if (debug && debug.total_nodes === 0) {
countBadgeText = t("common.no_entity_in_database", {
entity: t("entities.nodes").toLowerCase(),
});
} else if (debug && debug.nodes_with_coords === 0) {
countBadgeText = t("map.nodes_none_have_coordinates", {
count: formatNumber(debug.total_nodes),
});
} else if (filteredCount === null || filteredCount === nodeCount) {
countBadgeText = t("map.nodes_on_map", {
count: formatNumber(nodeCount),
});
} else {
countBadgeText = t("common.total", { count: formatNumber(nodeCount) });
}
const showFilteredBadge = filteredCount !== null && filteredCount !== nodeCount;
if (loading) return <Loading />;
if (error) return <ErrorAlert message={error} />;
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>
{filterOpen && (
<div className="flex gap-4 flex-wrap items-end mb-6">
<div className="fieldset">
<label className="fieldset-label">{t("common.show")}</label>
<select
className="select select-sm"
value={category}
onChange={(e) => setCategory(e.currentTarget.value)}
>
<option value="">
{t("common.all_entity", { entity: t("entities.nodes") })}
</option>
{oidcEnabled && (
<option value="infra">{t("map.infrastructure_only")}</option>
)}
</select>
</div>
<div className="fieldset">
<label className="fieldset-label">{t("common.node_type")}</label>
<select
className="select select-sm"
value={typeFilter}
onChange={(e) => setTypeFilter(e.currentTarget.value)}
>
<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="room">{t("node_types.room")}</option>
</select>
</div>
{oidcEnabled && operatorProfiles.length > 0 && (
<div className="fieldset">
<label className="fieldset-label">
{t("common.filter_operator_label")}
</label>
<select
className="select select-sm"
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>
</div>
)}
<div className="fieldset">
<label className="fieldset-label cursor-pointer gap-2">
<span>{t("map.show_labels")}</span>
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={showLabels}
onChange={(e) => setShowLabels(e.currentTarget.checked)}
/>
</label>
</div>
<button className="btn btn-ghost btn-sm" onClick={clearFilters}>
{t("common.clear_filters")}
</button>
</div>
)}
<div className="card bg-base-100 shadow-xl">
<div className="card-body p-2">
<div
ref={mapContainerRef}
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
/>
</div>
</div>
{oidcEnabled && (
<div className="mt-4 flex flex-wrap gap-4 items-center text-sm">
<span className="opacity-70">{t("map.legend")}</span>
<div className="flex items-center gap-1">
<div
style={{
width: "10px",
height: "10px",
background: "var(--color-marker-infra)",
border: "2px solid var(--color-marker-infra-border)",
borderRadius: "50%",
}}
/>
<span>{t("map.infrastructure")}</span>
</div>
<div className="flex items-center gap-1">
<div
style={{
width: "10px",
height: "10px",
background: "var(--color-marker-public)",
border: "2px solid var(--color-marker-public-border)",
borderRadius: "50%",
}}
/>
<span>{t("map.public")}</span>
</div>
</div>
)}
<div className="mt-2 text-sm opacity-70">
<p>{t("map.gps_description")}</p>
</div>
</div>
);
}
@@ -0,0 +1,245 @@
import {
useEffect,
useState,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
} from "react";
import { Link, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import { apiGet, isAbortError } from "@/utils/api";
import { formatNumber } from "@/utils/format";
import { Loading, ErrorAlert } from "@/components/Alerts";
import { IconAntenna, IconUsers } from "@/components/icons";
import { usePageTitle } from "@/hooks/usePageTitle";
interface MemberNode {
public_key: string;
name?: string | null;
}
interface MemberProfile {
id: string;
name?: string | null;
callsign?: string | null;
description?: string | null;
url?: string | null;
roles?: string[] | null;
node_count?: number | null;
adopted_nodes?: MemberNode[] | null;
}
interface ProfilesResponse {
items?: MemberProfile[] | null;
}
function ProfileTile({ profile }: { profile: MemberProfile }) {
const { t } = useTranslation();
const navigate = useNavigate();
const openNode = (e: MouseEvent | KeyboardEvent, publicKey: string) => {
e.preventDefault();
e.stopPropagation();
navigate(`/nodes/${publicKey}`);
};
const openUrl = (e: MouseEvent | KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
window.open(profile.url ?? undefined, "_blank", "noopener,noreferrer");
};
return (
<Link
to={`/profile/${profile.id}`}
className="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"
>
<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>
)}
</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>
))}
</div>
)}
{profile.description && (
<p className="text-sm opacity-70 mt-1 truncate">
{profile.description}
</p>
)}
{profile.url && (
<span
className="link link-accent text-xs mt-1 inline-block truncate cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
role="link"
tabIndex={0}
onClick={openUrl}
onKeyDown={(e) => {
if (e.key === "Enter") openUrl(e);
}}
>
{profile.url}
</span>
)}
{(profile.node_count ?? 0) > 0 && (
<span className="text-sm opacity-60">
{t("members_page.node_count", {
count: formatNumber(profile.node_count),
})}
</span>
)}
{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) + "...";
return (
<span
key={node.public_key}
className="badge badge-secondary badge-sm cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
role="button"
tabIndex={0}
onClick={(e) => openNode(e, node.public_key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
openNode(e, node.public_key);
}
}}
>
{label}
</span>
);
})}
</div>
)}
</div>
</Link>
);
}
function ProfileGroup({
title,
icon,
profiles,
}: {
title: string;
icon: ReactNode;
profiles: MemberProfile[];
}) {
if (profiles.length === 0) return null;
return (
<>
<h2 className="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
{icon}
{title}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{profiles.map((profile) => (
<ProfileTile key={profile.id} profile={profile} />
))}
</div>
</>
);
}
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]);
if (error) return <ErrorAlert message={error} />;
if (profiles === null) return <Loading />;
const roleNames = config.role_names || {};
const operatorRole = roleNames.operator || "operator";
const memberRole = roleNames.member || "member";
const testRole = roleNames.test || "test";
const visible = profiles.filter((p) => !p.roles || !p.roles.includes(testRole));
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">
<p className="text-lg">{t("members_page.empty_state")}</p>
<p className="text-sm mt-2">{t("members_page.empty_description")}</p>
</div>
</>
);
}
const byName = (a: MemberProfile, b: MemberProfile) =>
(a.name || "").localeCompare(b.name || "");
const operators = visible
.filter((p) => !!p.roles && p.roles.includes(operatorRole))
.sort(byName);
const members = visible
.filter(
(p) =>
!!p.roles && p.roles.includes(memberRole) && !p.roles.includes(operatorRole),
)
.sort(byName);
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("entities.members")}</h1>
<span className="badge badge-lg">
{t("common.count_entity", {
count: formatNumber(operators.length + members.length),
entity: t("entities.members").toLowerCase(),
})}
</span>
</div>
<ProfileGroup
title={t("members_page.operators")}
icon={
<span className="text-primary">
<IconAntenna className="h-6 w-6" />
</span>
}
profiles={operators}
/>
<ProfileGroup
title={t("members_page.members")}
icon={
<span className="text-secondary">
<IconUsers className="h-6 w-6" />
</span>
}
profiles={members}
/>
</>
);
}
@@ -0,0 +1,781 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import {
getChannelLabelsMap,
resolveChannelLabel,
useAppConfig,
} from "@/context/AppConfigContext";
import { apiGet, isAbortError } from "@/utils/api";
import { formatNumber, 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 { MobileSortSelect, SortableTableHeader } from "@/components/SortableTable";
import {
ObserverFilterBadges,
ObserverIcons,
getDisabledObserverAreas,
toggleObserverArea,
} from "@/components/ObserverBadges";
import { Loading, WarningBadge } from "@/components/Alerts";
import { IconRefresh } from "@/components/icons";
interface ObserverInfo {
node_id?: string;
public_key: string;
name?: string;
tag_name?: string;
snr?: number | null;
observed_at?: string;
}
interface Message {
message_type: string;
text: string;
channel_idx?: number | null;
channel_name?: string | null;
signature?: string | null;
pubkey_prefix?: string | null;
sender_name?: string | null;
sender_tag_name?: string | null;
observed_by?: string | null;
observer_name?: string | null;
observer_tag_name?: string | null;
received_at: string;
packet_hash?: string | null;
spam_score?: number | null;
observers?: ObserverInfo[];
}
interface NodeItem {
public_key: string;
tags?: { key: string; value: string | null }[];
}
interface ChannelItem {
channel_hash: string;
name: string;
}
interface ListResponse<T> {
items?: T[];
total?: number;
}
function autoSubmit(e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) {
e.currentTarget.form?.requestSubmit();
}
function parseSenderFromText(text: string | null): {
sender: string | null;
text: string;
} {
if (!text || typeof text !== "string") {
return { sender: null, text: text || "-" };
}
const patterns = [
/^\s*ack\s+@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
/^\s*@\[(.+?)\]\s*:\s*([\s\S]+)$/i,
/^\s*ack\s+([^:|\n]{1,80})\s*:\s*([\s\S]+)$/i,
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (!match) continue;
const sender = (match[1] || "").trim();
const remaining = (match[2] || "").trim();
if (!sender) continue;
return { sender, text: remaining || text };
}
return { sender: null, text };
}
function collapseNewlines(text: string | null): string | null {
if (!text || typeof text !== "string") return text;
return text.replace(/\s*\n\s*/g, " ");
}
function channelInfo(
msg: Message,
channelLabels: Map<number, string>,
fallbackLabel: string,
): { label: string | null; text: string } {
if (msg.message_type !== "channel") {
return { label: null, text: msg.text || "-" };
}
const rawText = msg.text || "";
const match = rawText.match(/^\[([^\]]+)\]\s+([\s\S]*)$/);
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
if (knownLabel) {
return {
label: knownLabel,
text: match ? match[2] || "-" : rawText || "-",
};
}
}
if (msg.channel_name) {
return { label: msg.channel_name, text: msg.text || "-" };
}
if (match) {
return { label: match[1], text: match[2] || "-" };
}
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
const knownLabel = resolveChannelLabel(msg.channel_idx, channelLabels);
return { label: knownLabel || `Ch ${msg.channel_idx}`, text: rawText || "-" };
}
return { label: fallbackLabel, text: rawText || "-" };
}
function messageTextWithSender(msg: Message, text: string): string {
const parsed = parseSenderFromText(text || "-");
const explicitSender =
msg.sender_tag_name ||
msg.sender_name ||
(msg.pubkey_prefix || "").slice(0, 12) ||
null;
const sender = explicitSender || parsed.sender;
const body = collapseNewlines((parsed.text || text || "-").trim()) || "-";
if (!sender) return body;
if (body.toLowerCase().startsWith(`${sender.toLowerCase()}:`)) return body;
return `${sender}: ${body}`;
}
function dedupeBySignature(items: Message[]): Message[] {
const deduped: Message[] = [];
const bySignature = new Map<string, Message>();
for (const msg of items) {
const signature =
typeof msg.signature === "string"
? msg.signature.trim().toUpperCase()
: "";
const canDedupe = msg.message_type === "channel" && signature.length >= 8;
if (!canDedupe) {
deduped.push(msg);
continue;
}
const existing = bySignature.get(signature);
if (!existing) {
const clone: Message = {
...msg,
observers: [...(msg.observers ?? [])],
};
bySignature.set(signature, clone);
deduped.push(clone);
continue;
}
const combined = [...(existing.observers ?? []), ...(msg.observers ?? [])];
const seenReceivers = new Set<string>();
existing.observers = combined.filter((recv) => {
const key =
recv?.public_key ||
recv?.node_id ||
`${recv?.observed_at ?? ""}:${recv?.snr ?? ""}`;
if (seenReceivers.has(key)) return false;
seenReceivers.add(key);
return true;
});
if (!existing.observed_by && msg.observed_by)
existing.observed_by = msg.observed_by;
if (!existing.observer_name && msg.observer_name)
existing.observer_name = msg.observer_name;
if (!existing.observer_tag_name && msg.observer_tag_name)
existing.observer_tag_name = msg.observer_tag_name;
if (!existing.pubkey_prefix && msg.pubkey_prefix)
existing.pubkey_prefix = msg.pubkey_prefix;
if (!existing.sender_name && msg.sender_name)
existing.sender_name = msg.sender_name;
if (!existing.sender_tag_name && msg.sender_tag_name)
existing.sender_tag_name = msg.sender_tag_name;
if (!existing.channel_name && msg.channel_name)
existing.channel_name = msg.channel_name;
if (
existing.channel_name === "Public" &&
msg.channel_name &&
msg.channel_name !== "Public"
) {
existing.channel_name = msg.channel_name;
}
if (existing.channel_idx === null || existing.channel_idx === undefined) {
if (msg.channel_idx !== null && msg.channel_idx !== undefined) {
existing.channel_idx = msg.channel_idx;
}
} else if (
existing.channel_idx === 17 &&
msg.channel_idx !== null &&
msg.channel_idx !== undefined &&
msg.channel_idx !== 17
) {
existing.channel_idx = msg.channel_idx;
}
}
return deduped;
}
export function Messages() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const config = useAppConfig();
const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
usePageTitle("entities.messages");
const messageType = searchParams.get("message_type") ?? "";
const channelIdx = searchParams.get("channel_idx") ?? "";
const includeSpamParam = searchParams.get("include_spam") === "true";
const page = parseInt(searchParams.get("page") ?? "", 10) || 1;
const limit = parseInt(searchParams.get("limit") ?? "", 10) || 50;
const sort = searchParams.get("sort") ?? "time";
const order = searchParams.get("order") ?? "desc";
const offset = (page - 1) * limit;
const features = config.features ?? {};
const packetsEnabled = features.packets !== false;
const spamEnabled = features.spam === true;
const includeSpam = spamEnabled && includeSpamParam;
const spamThreshold =
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(),
);
const [filterOpen, setFilterOpen] = useState(
messageType !== "" || channelIdx !== "" || includeSpam,
);
const disabledAreasRef = useRef(disabledAreas);
disabledAreasRef.current = disabledAreas;
const abortRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const { signal } = controller;
try {
const [nodesData, channelsData] = await Promise.all([
apiGet<ListResponse<NodeItem>>(
"/api/v1/nodes",
{ limit: 500, observer: true },
{ signal },
),
apiGet<ListResponse<ChannelItem>>("/api/v1/channels", {}, { signal }),
]);
const builtin = getChannelLabelsMap(config);
const custom = new Map(
(channelsData.items ?? [])
.map((ch): [number, string] => [
parseInt(ch.channel_hash, 16),
ch.name,
])
.filter(([idx]) => Number.isInteger(idx)),
);
setBuiltinLabels(builtin);
setCustomLabels(custom);
setChannelLabels(new Map([...builtin, ...custom]));
const areaMap = new Map<string, string[]>();
for (const n of nodesData.items ?? []) {
const area = n.tags?.find((tg) => tg.key === "area")?.value;
if (!area || !area.trim()) continue;
const key = area.trim();
if (!areaMap.has(key)) areaMap.set(key, []);
areaMap.get(key)!.push(n.public_key);
}
const areas = [...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 apiParams: Record<string, unknown> = {
limit,
offset,
message_type: messageType,
channel_idx: channelIdx,
sort,
order,
};
if (observerFilterActive) {
apiParams.observed_by = areas
.filter((a) => !disabled.has(a))
.flatMap((a) => areaMap.get(a) ?? []);
}
if (includeSpam) apiParams.include_spam = true;
const data = 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,
});
const handleObserverToggle = (area: string) => {
const updated = toggleObserverArea(area, sortedAreas.length);
setDisabledAreas(new Set(updated));
if (page > 1) {
const sp = new URLSearchParams(searchParams);
sp.delete("page");
const qs = sp.toString();
navigate(qs ? `/messages?${qs}` : "/messages");
}
};
const senderBlock = (msg: Message, emphasize = false): ReactNode => {
const senderName = msg.sender_tag_name || msg.sender_name;
if (senderName) {
return emphasize ? (
<span className="font-medium">{senderName}</span>
) : (
<>{senderName}</>
);
}
const prefix = (msg.pubkey_prefix || "").slice(0, 12);
if (prefix) return <span className="font-mono text-xs">{prefix}</span>;
return <span className="opacity-50">-</span>;
};
const spamBadge = (msg: Message): ReactNode => {
if (
!spamEnabled ||
msg.spam_score == null ||
msg.spam_score < spamThreshold
) {
return null;
}
return (
<span
className="badge badge-warning badge-sm"
title={`${t("messages.spam.badge")} ${msg.spam_score.toFixed(2)}`}
>
{t("messages.spam.badge")}
</span>
);
};
const renderReceivers = (msg: Message, variant: "mobile" | "desktop") => {
if (msg.observers && msg.observers.length >= 1) {
return <ObserverIcons observers={msg.observers} />;
}
if (msg.observed_by) {
return (
<span className={`opacity-50 ${variant === "mobile" ? "text-xs" : ""}`}>
{"\u{1F4E1}"}
</span>
);
}
return variant === "desktop" ? <span className="opacity-50">-</span> : null;
};
const totalPages = total !== null ? Math.ceil(total / limit) : 0;
const headerParams: Record<string, string> = {
message_type: messageType,
channel_idx: channelIdx,
limit: String(limit),
};
if (includeSpam) headerParams.include_spam = "true";
const paginationParams: Record<string, string> = {
...headerParams,
sort,
order,
};
const emptyMessage = t("common.no_entity_found", {
entity: t("entities.messages").toLowerCase(),
});
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>
<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>
{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
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>
<select
name="channel_idx"
key={`channel_idx-${channelIdx}`}
defaultValue={channelIdx}
className="select select-sm"
onChange={autoSubmit}
>
<option value="">{t("common.all_channels")}</option>
{builtinLabels.size > 0 && (
<optgroup label={t("channels.optgroup_standard")}>
{[...builtinLabels.entries()].map(([idx, label]) => (
<option key={idx} value={idx}>
{label}
</option>
))}
</optgroup>
)}
{customLabels.size > 0 && (
<optgroup label={t("channels.optgroup_custom")}>
{[...customLabels.entries()].map(([idx, label]) => (
<option key={idx} value={idx}>
{label}
</option>
))}
</optgroup>
)}
</select>
</div>
{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>
<label className="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
name="include_spam"
value="true"
className="checkbox checkbox-sm"
defaultChecked={includeSpam}
onChange={autoSubmit}
/>
<span className="text-sm">{t("messages.spam.show")}</span>
</label>
</div>
)}
</FilterForm>
</div>
)}
{items === null ? (
<Loading />
) : (
<>
<ObserverFilterBadges
areas={sortedAreas}
disabled={disabledAreas}
onToggle={handleObserverToggle}
extraClass="hidden lg:flex mb-4"
/>
<MobileSortSelect
currentSort={sort}
currentOrder={order}
basePath="/messages"
params={headerParams}
options={[
{ value: "time:desc", label: t("messages.sort.newest") },
{ value: "time:asc", label: t("messages.sort.oldest") },
{ value: "type:asc", label: t("messages.sort.type_az") },
{ value: "type:desc", label: t("messages.sort.type_za") },
{ value: "from:asc", label: t("messages.sort.from_az") },
{ value: "from:desc", label: t("messages.sort.from_za") },
{ value: "message:asc", label: t("messages.sort.message_az") },
{
value: "message:desc",
label: t("messages.sort.message_za"),
},
]}
/>
<ObserverFilterBadges
areas={sortedAreas}
disabled={disabledAreas}
onToggle={handleObserverToggle}
extraClass="flex lg:hidden mb-4"
/>
<div className="lg:hidden space-y-3">
{items.length === 0 ? (
<div className="text-center py-8 opacity-70">
{emptyMessage}
</div>
) : (
items.map((msg, idx) => {
const isChannel = msg.message_type === "channel";
const typeIcon = isChannel ? "\u{1F4FB}" : "\u{1F464}";
const typeTitle = isChannel
? t("messages.type_channel")
: t("messages.type_contact");
const chInfo = channelInfo(
msg,
channelLabels,
t("messages.type_channel"),
);
const displayMessage = messageTextWithSender(
msg,
chInfo.text,
);
const fromPrimary = isChannel ? (
<span className="font-medium">
{chInfo.label || t("messages.type_channel")}
</span>
) : (
senderBlock(msg)
);
const detailUrl =
packetsEnabled && msg.packet_hash
? `/packets/hash/${msg.packet_hash}`
: null;
return (
<div
key={`${msg.signature ?? ""}-${msg.received_at}-${idx}`}
className={`card bg-base-100 shadow-sm ${detailUrl ? "cursor-pointer" : ""}`}
onClick={
detailUrl ? () => navigate(detailUrl) : undefined
}
>
<div className="card-body p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className="text-lg flex-shrink-0"
title={typeTitle}
>
{typeIcon}
</span>
<div className="min-w-0">
<div className="font-medium text-sm truncate">
{fromPrimary}
</div>
<div className="text-xs opacity-60 flex items-center gap-1">
{formatDateTimeShort(msg.received_at)}
{spamBadge(msg)}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{renderReceivers(msg, "mobile")}
</div>
</div>
<p className="text-sm mt-2 break-words whitespace-pre-wrap">
{displayMessage}
</p>
</div>
</div>
);
})
)}
</div>
<div className="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow-sm">
<table className="table table-zebra">
<thead>
<tr>
<SortableTableHeader
label={t("common.type")}
sortKey="type"
currentSort={sort}
currentOrder={order}
basePath="/messages"
params={headerParams}
/>
<SortableTableHeader
label={t("common.time")}
sortKey="time"
currentSort={sort}
currentOrder={order}
basePath="/messages"
params={headerParams}
/>
<SortableTableHeader
label={t("common.from")}
sortKey="from"
currentSort={sort}
currentOrder={order}
basePath="/messages"
params={headerParams}
/>
<SortableTableHeader
label={t("entities.message")}
sortKey="message"
currentSort={sort}
currentOrder={order}
basePath="/messages"
params={headerParams}
/>
<th>{t("common.observers")}</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={5}
className="text-center py-8 opacity-70"
>
{emptyMessage}
</td>
</tr>
) : (
items.map((msg, idx) => {
const isChannel = msg.message_type === "channel";
const typeIcon = isChannel ? "\u{1F4FB}" : "\u{1F464}";
const typeTitle = isChannel
? t("messages.type_channel")
: t("messages.type_contact");
const chInfo = channelInfo(
msg,
channelLabels,
t("messages.type_channel"),
);
const displayMessage = messageTextWithSender(
msg,
chInfo.text,
);
const fromPrimary = isChannel ? (
<span className="font-medium">
{chInfo.label || t("messages.type_channel")}
</span>
) : (
senderBlock(msg, true)
);
const detailUrl =
packetsEnabled && msg.packet_hash
? `/packets/hash/${msg.packet_hash}`
: null;
return (
<tr
key={`${msg.signature ?? ""}-${msg.received_at}-${idx}`}
className={detailUrl ? "hover cursor-pointer" : ""}
onClick={
detailUrl ? () => navigate(detailUrl) : undefined
}
>
<td className="text-lg" title={typeTitle}>
{typeIcon}
</td>
<td className="text-sm whitespace-nowrap">
{formatDateTime(msg.received_at)}
</td>
<td className="text-sm whitespace-nowrap">
<div>{fromPrimary}</div>
</td>
<td className="break-words max-w-md">
<div className="flex items-start gap-2">
<span style={{ whiteSpace: "pre-wrap" }}>
{displayMessage}
</span>
{spamBadge(msg)}
</div>
</td>
<td>{renderReceivers(msg, "desktop")}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<Pagination
page={page}
totalPages={totalPages}
basePath="/messages"
params={paginationParams}
/>
</>
)}
</>
);
}
@@ -0,0 +1,957 @@
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts";
import { IconEdit, IconError, IconPlus, IconTrash } from "@/components/icons";
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 { typeEmoji, truncateKey, useFormatDateTime } from "@/utils/format";
interface NodeTag {
key: string;
value: string | null;
value_type: string | null;
}
interface AdoptionInfo {
user_id: string;
name: string | null;
profile_id: string;
}
interface NodeDetailData {
public_key: string;
name: string | null;
adv_type: string | null;
lat: number | null;
lon: number | null;
first_seen: string | null;
last_seen: string | null;
tags: NodeTag[] | null;
adopted_by: AdoptionInfo | null;
}
interface AdvertisementItem {
received_at: string | null;
adv_type: string | null;
observed_by: string | null;
observer_name: string | null;
observer_tag_name: string | null;
}
interface AdvertisementListResponse {
items: AdvertisementItem[];
}
interface PrefixResolution {
public_key: string;
}
interface FlashState {
type: "success" | "error";
message: string;
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export function NodeDetailPage() {
const { t } = useTranslation();
const config = useAppConfig();
const navigate = useNavigate();
const { publicKey: publicKeyParam } = useParams();
const [searchParams] = useSearchParams();
const { formatDateTime } = useFormatDateTime();
usePageTitle("entities.node_detail");
const publicKey = publicKeyParam ?? "";
const searchKey = searchParams.toString();
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 [flash, setFlash] = useState<FlashState | null>(null);
const [addKey, setAddKey] = useState("");
const [addValue, setAddValue] = useState("");
const [addType, setAddType] = useState("string");
const [addError, setAddError] = useState("");
const [editTag, setEditTag] = useState<NodeTag | null>(null);
const [editValue, setEditValue] = useState("");
const [editType, setEditType] = useState("string");
const [editError, setEditError] = useState("");
const [editSaving, setEditSaving] = useState(false);
const [deleteKey, setDeleteKey] = useState<string | null>(null);
const [deleteSaving, setDeleteSaving] = useState(false);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const qrRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<unknown>(null);
const qrInitRef = useRef(false);
useEffect(() => {
if (!publicKey || publicKey.length === 64) return;
const ac = new AbortController();
(async () => {
try {
const resolved = await apiGet<PrefixResolution>(
`/api/v1/nodes/prefix/${encodeURIComponent(publicKey)}`,
{},
{ signal: ac.signal },
);
navigate(`/nodes/${resolved.public_key}`, { replace: true });
} catch (e) {
if (isAbortError(e)) return;
if (errorMessage(e).includes("404")) {
setNotFound(true);
} else {
setError(errorMessage(e));
}
setLoading(false);
}
})();
return () => ac.abort();
}, [publicKey, 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],
);
useEffect(() => {
if (!publicKey || publicKey.length !== 64) return;
const ac = new AbortController();
loadData(ac.signal);
return () => ac.abort();
}, [loadData, searchKey]);
let lat: number | null = node?.lat ?? null;
let lon: number | null = node?.lon ?? null;
if (node) {
for (const tag of node.tags || []) {
if (tag.key === "lat" && !lat) lat = parseFloat(tag.value ?? "");
if (tag.key === "lon" && !lon) lon = parseFloat(tag.value ?? "");
}
}
const hasCoords =
lat != null &&
lon != null &&
!Number.isNaN(lat) &&
!Number.isNaN(lon) &&
!(lat === 0 && lon === 0);
const tagName = node?.tags?.find((tag) => tag.key === "name")?.value ?? null;
const tagDescription =
node?.tags?.find((tag) => tag.key === "description")?.value ?? null;
const displayName = tagName || node?.name || t("common.unnamed_node");
const emoji = typeEmoji(node?.adv_type ?? null);
useEffect(() => {
if (!node || !hasCoords || lat == null || lon == null) return;
const L = (window as any).L;
const mapEl = mapContainerRef.current;
if (!L || !mapEl) return;
const map = L.map(mapEl, {
zoomControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
attributionControl: false,
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(
map,
);
map.setView([lat, lon], 14);
const point = map.latLngToContainerPoint([lat, lon]);
const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y);
const newLatLng = map.containerPointToLatLng(newPoint);
map.setView(newLatLng, 14, { animate: false });
const mapIcon = L.divIcon({
html:
'<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' +
emoji +
"</span>",
className: "",
iconSize: [32, 32],
iconAnchor: [16, 16],
});
L.marker([lat, lon], { icon: mapIcon }).addTo(map);
mapRef.current = map;
return () => {
mapRef.current = null;
map.remove();
};
}, [node, hasCoords, lat, lon, emoji]);
useEffect(() => {
if (!node) return;
qrInitRef.current = false;
const typeMap: Record<string, number> = {
chat: 1,
repeater: 2,
room: 3,
companion: 1,
sensor: 4,
};
const typeNum = typeMap[(node.adv_type || "").toLowerCase()] || 1;
const url =
"meshcore://contact/add?name=" +
encodeURIComponent(displayName) +
"&public_key=" +
node.public_key +
"&type=" +
typeNum;
const initQr = (): boolean => {
const QRCode = (window as any).QRCode;
const el = qrRef.current;
if (!QRCode || !el || qrInitRef.current) return false;
el.innerHTML = "";
new QRCode(el, {
text: url,
width: 140,
height: 140,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.L,
});
qrInitRef.current = true;
return true;
};
if (initQr()) return;
let attempts = 0;
const interval = setInterval(() => {
if (initQr() || ++attempts >= 20) clearInterval(interval);
}, 100);
return () => clearInterval(interval);
}, [node, displayName, hasCoords]);
useEffect(() => {
if (!flash) return;
const timer = setTimeout(() => setFlash(null), 3000);
return () => clearTimeout(timer);
}, [flash]);
const showFlash = (type: "success" | "error", message: string) => {
setFlash({ type, message });
};
const reloadNode = () => {
navigate(`/nodes/${publicKey}?refresh=${Date.now()}`, { replace: true });
};
const validateTagValue = (value: string, type: string): string | null => {
if (!value || !type) return null;
if (type === "number" && isNaN(Number(value))) {
return t("common.validation_invalid_number");
}
if (type === "boolean") {
const normalized = value.toLowerCase().trim();
if (!["true", "false", "yes", "no", "1", "0"].includes(normalized)) {
return t("common.validation_invalid_boolean");
}
}
return null;
};
const handleAdopt = async () => {
if (!node) return;
try {
await apiPost("/api/v1/adoptions", { public_key: node.public_key });
navigate(
`/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.adopt_success"))}`,
{ replace: true },
);
} catch (e) {
navigate(
`/nodes/${node.public_key}?error=${encodeURIComponent(errorMessage(e))}`,
{ replace: true },
);
}
};
const handleRelease = async () => {
if (!node) return;
if (!confirm(t("nodes.release_confirm"))) return;
try {
await apiDelete(`/api/v1/adoptions/${node.public_key}`);
navigate(
`/nodes/${node.public_key}?message=${encodeURIComponent(t("nodes.release_success"))}`,
{ replace: true },
);
} catch (e) {
navigate(
`/nodes/${node.public_key}?error=${encodeURIComponent(errorMessage(e))}`,
{ replace: true },
);
}
};
const handleAddTag = async (e: FormEvent) => {
e.preventDefault();
if (!node) return;
const validationError = validateTagValue(addValue, addType);
if (validationError) {
setAddError(validationError);
return;
}
setAddError("");
try {
await apiPost(`/api/v1/nodes/${node.public_key}/tags`, {
key: addKey,
value: addValue,
value_type: addType,
});
setAddKey("");
setAddValue("");
setAddType("string");
showFlash(
"success",
t("common.entity_added_success", { entity: t("entities.tag") }),
);
reloadNode();
} catch (e) {
showFlash("error", errorMessage(e));
}
};
const openEditTag = (tag: NodeTag) => {
setEditTag(tag);
setEditValue(tag.value ?? "");
setEditType(tag.value_type || "string");
setEditError("");
};
const handleEditTag = async (e: FormEvent) => {
e.preventDefault();
if (!node || !editTag) return;
const validationError = validateTagValue(editValue, editType);
if (validationError) {
setEditError(validationError);
return;
}
setEditError("");
setEditSaving(true);
try {
await apiPut(
`/api/v1/nodes/${node.public_key}/tags/${encodeURIComponent(editTag.key)}`,
{ value: editValue, value_type: editType },
);
setEditTag(null);
showFlash(
"success",
t("common.entity_updated_success", { entity: t("entities.tag") }),
);
reloadNode();
} catch (e) {
setEditError(errorMessage(e));
} finally {
setEditSaving(false);
}
};
const handleDeleteTag = async () => {
if (!node || deleteKey === null) return;
setDeleteSaving(true);
try {
await apiDelete(
`/api/v1/nodes/${node.public_key}/tags/${encodeURIComponent(deleteKey)}`,
);
setDeleteKey(null);
showFlash(
"success",
t("common.entity_deleted_success", { entity: t("entities.tag") }),
);
reloadNode();
} catch (e) {
setDeleteKey(null);
showFlash("error", errorMessage(e));
} finally {
setDeleteSaving(false);
}
};
if (!node) {
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>
<Link to="/nodes" className="btn btn-primary mt-4">
{t("common.view_entity", { entity: t("entities.nodes") })}
</Link>
</>
);
}
if (error) {
return <ErrorAlert message={error} />;
}
return <Loading />;
}
const canEditTags =
config.oidc_enabled &&
!!config.user &&
(hasRole("admin") ||
(hasRole("operator") && node.adopted_by?.user_id === config.user.sub));
const isOperator = hasRole("operator");
const isAdmin = hasRole("admin");
let adoptionCard: ReactNode = null;
if (config.oidc_enabled && config.user) {
if (node.adopted_by) {
const adoptedBy = node.adopted_by;
const ownerName = adoptedBy.name || adoptedBy.user_id;
const canRelease =
(isOperator || isAdmin) &&
(adoptedBy.user_id === config.user.sub || isAdmin);
adoptionCard = (
<div className="card bg-base-100 shadow-xl h-full">
<div className="card-body">
<h2 className="card-title">{t("nodes.ownership")}</h2>
<div className="flex items-center justify-between">
<p className="text-sm opacity-70">
{t("nodes.adopted_by_prefix")}{" "}
<Link
to={`/profile/${adoptedBy.profile_id}`}
className="link link-hover text-primary"
>
{ownerName}
</Link>
</p>
{canRelease && (
<button
className="btn btn-sm btn-outline btn-error"
onClick={handleRelease}
>
{t("nodes.release")}
</button>
)}
</div>
</div>
</div>
);
} else if (isOperator || isAdmin) {
adoptionCard = (
<div className="card bg-base-100 shadow-xl h-full">
<div className="card-body">
<h2 className="card-title">{t("nodes.ownership")}</h2>
<p className="text-sm opacity-70">{t("nodes.not_adopted")}</p>
<div className="mt-2">
<button className="btn btn-sm btn-primary" onClick={handleAdopt}>
{t("nodes.adopt")}
</button>
</div>
</div>
</div>
);
}
}
const publicKeyCard = (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<div>
<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>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div>
<span className="opacity-70">{t("common.first_seen_label")}</span>{" "}
{formatDateTime(node.first_seen)}
</div>
<div>
<span className="opacity-70">{t("common.last_seen_label")}</span>{" "}
{formatDateTime(node.last_seen)}
</div>
{hasCoords && (
<div>
<span className="opacity-70">{t("common.location")}:</span>{" "}
{lat}, {lon}
</div>
)}
</div>
</div>
</div>
);
const tags = node.tags || [];
const tagsTable = canEditTags ? (
tags.length > 0 ? (
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>{t("common.key")}</th>
<th>{t("common.value")}</th>
<th className="hidden sm:table-cell">{t("common.type")}</th>
<th className="w-16">{t("common.actions")}</th>
</tr>
</thead>
<tbody>
{tags.map((tag) => (
<tr key={tag.key}>
<td className="font-mono min-w-0 truncate max-w-[8rem]">
{tag.key}
</td>
<td className="min-w-0 truncate max-w-[12rem]">
{tag.value || ""}
</td>
<td className="hidden sm:table-cell opacity-70">
{tag.value_type || "string"}
</td>
<td>
<div className="flex gap-1">
<button
className="btn btn-xs btn-ghost"
onClick={() => openEditTag(tag)}
>
<IconEdit className="h-4 w-4" />
</button>
<button
className="btn btn-xs btn-ghost"
onClick={() => setDeleteKey(tag.key)}
>
<IconTrash className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="opacity-70">
{t("common.no_entity_defined", {
entity: t("entities.tags").toLowerCase(),
})}
</p>
)
) : tags.length > 0 ? (
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>{t("common.key")}</th>
<th>{t("common.value")}</th>
<th>{t("common.type")}</th>
</tr>
</thead>
<tbody>
{tags.map((tag) => (
<tr key={tag.key}>
<td className="font-mono">{tag.key}</td>
<td>{tag.value || ""}</td>
<td className="opacity-70">{tag.value_type || "string"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="opacity-70">
{t("common.no_entity_defined", {
entity: t("entities.tags").toLowerCase(),
})}
</p>
);
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>
<div className="flex items-start gap-4 mb-6">
<span
className="text-6xl flex-shrink-0"
title={node.adv_type || t("node_types.unknown")}
>
{emoji}
</span>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold">{displayName}</h1>
{tagDescription && (
<p className="opacity-70 mt-2">{tagDescription}</p>
)}
</div>
</div>
{flashMessage ? (
<SuccessAlert message={flashMessage} />
) : flashError ? (
<ErrorAlert message={flashError} />
) : null}
{flash &&
(flash.type === "success" ? (
<SuccessAlert message={flash.message} />
) : (
<ErrorAlert message={flash.message} />
))}
{hasCoords ? (
<div
className="relative rounded-box overflow-hidden mb-6 shadow-xl"
style={{ height: 180 }}
>
<div ref={mapContainerRef} className="absolute inset-0 z-0" />
<div className="relative z-20 h-full p-3 flex items-center justify-end">
<div ref={qrRef} className="bg-white p-2 rounded-box shadow-lg" />
</div>
</div>
) : (
<div className="card bg-base-100 shadow-xl mb-6">
<div className="card-body flex-row items-center gap-4">
<div ref={qrRef} className="bg-white p-2 rounded-box" />
<p className="text-sm opacity-70">{t("nodes.scan_to_add")}</p>
</div>
</div>
)}
{adoptionCard ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{publicKeyCard}
{adoptionCard}
</div>
) : (
<div className="mb-6">{publicKeyCard}</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">
{t("common.recent_entity", {
entity: t("entities.advertisements"),
})}
</h2>
{advertisements.length > 0 ? (
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>{t("common.time")}</th>
<th>{t("common.type")}</th>
<th>{t("common.received_by")}</th>
</tr>
</thead>
<tbody>
{advertisements.map((adv, idx) => {
const recvName = adv.observed_by
? (adv.observer_tag_name || adv.observer_name)
: null;
return (
<tr key={adv.observed_by ? `${adv.observed_by}-${adv.received_at}` : idx}>
<td className="text-xs whitespace-nowrap">
{formatDateTime(adv.received_at)}
</td>
<td>
{adv.adv_type ? (
<span
title={
adv.adv_type.charAt(0).toUpperCase() +
adv.adv_type.slice(1)
}
>
{typeEmoji(adv.adv_type)}
</span>
) : (
<span className="opacity-50">-</span>
)}
</td>
<td>
{!adv.observed_by ? (
<span className="opacity-50">-</span>
) : recvName ? (
<Link
to={`/nodes/${adv.observed_by}`}
className="link link-hover"
>
<div className="font-medium text-sm truncate max-w-[8rem]">
{recvName}
</div>
<div className="text-xs font-mono opacity-70 hidden sm:block">
{adv.observed_by.slice(0, 16)}...
</div>
</Link>
) : (
<Link
to={`/nodes/${adv.observed_by}`}
className="link link-hover"
>
<span className="font-mono text-xs">
{adv.observed_by.slice(0, 12)}...
</span>
</Link>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="opacity-70">
{t("common.no_entity_recorded", {
entity: t("entities.advertisements").toLowerCase(),
})}
</p>
)}
</div>
</div>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">{t("entities.tags")}</h2>
{tagsTable}
{canEditTags && (
<form className="mt-4" onSubmit={handleAddTag}>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_1fr_auto_auto] gap-2 items-end">
<div className="fieldset">
<input
type="text"
className="input input-sm w-full"
placeholder={t("common.key")}
required
value={addKey}
onChange={(e) => setAddKey(e.target.value)}
/>
</div>
<div className="fieldset">
<input
type="text"
className="input input-sm w-full"
placeholder={t("common.value")}
value={addValue}
onChange={(e) => setAddValue(e.target.value)}
/>
{addError && (
<div className="text-xs text-error">{addError}</div>
)}
</div>
<select
className="select select-sm w-28"
value={addType}
onChange={(e) => setAddType(e.target.value)}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
<button type="submit" className="btn btn-sm btn-primary">
<IconPlus className="h-4 w-4" /> {t("common.add")}
</button>
</div>
</form>
)}
</div>
</div>
</div>
{canEditTags && editTag && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">
{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}>
<div className="fieldset mb-4">
<label className="fieldset-label">{t("common.value")}</label>
<input
type="text"
className="input w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
/>
{editError && (
<div className="text-xs text-error">{editError}</div>
)}
</div>
<div className="fieldset mb-4">
<label className="fieldset-label">{t("common.type")}</label>
<select
className="select w-full"
value={editType}
onChange={(e) => setEditType(e.target.value)}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<div className="modal-action">
<button
type="button"
className="btn"
onClick={() => setEditTag(null)}
disabled={editSaving}
>
{t("common.cancel")}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={editSaving}
>
{editSaving && (
<span className="loading loading-spinner loading-sm" />
)}
{t("common.save_changes")}
</button>
</div>
</form>
</div>
<div
className="modal-backdrop"
onClick={() => !editSaving && setEditTag(null)}
/>
</div>
)}
{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>
)}
</>
);
}
@@ -0,0 +1,433 @@
import { useCallback, useEffect, useMemo, useState } from "react";
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 { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { Pagination } from "@/components/Pagination";
import { FilterForm, FilterToggle } 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";
interface NodeTag {
key: string;
value: string | null;
}
interface NodeItem {
public_key: string;
name: string | null;
adv_type: string | null;
last_seen: string | null;
tags: NodeTag[];
}
interface NodeListResponse {
items: NodeItem[];
total: number;
limit: number;
offset: number;
}
interface Profile {
id: string;
name: string | null;
callsign: string | null;
roles: string[];
user_id?: string;
}
interface ProfileListResponse {
items: Profile[];
total: number;
}
function tagValue(tags: NodeTag[] | undefined, key: string): string | null {
return tags?.find((tag) => tag.key === key)?.value ?? null;
}
export function Nodes() {
const { t } = useTranslation();
const config = useAppConfig();
const [searchParams] = useSearchParams();
const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
usePageTitle("entities.nodes");
const search = searchParams.get("search") || "";
const advType = searchParams.get("adv_type") || "";
const adoptedBy = searchParams.get("adopted_by") || "";
const pubkeyPrefix = searchParams.get("pubkey_prefix") || "";
const page = parseInt(searchParams.get("page") || "", 10) || 1;
const limit = parseInt(searchParams.get("limit") || "", 10) || 20;
const offset = (page - 1) * limit;
const sort = searchParams.get("sort") || "last_seen";
const order = searchParams.get("order") || "desc";
const tz = config.timezone || "";
const hasActiveFilters =
search !== "" ||
advType !== "" ||
pubkeyPrefix !== "" ||
(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 apiParams: Record<string, unknown> = {
limit,
offset,
search,
adv_type: advType,
sort,
order,
};
if (adoptedBy) apiParams.adopted_by = adoptedBy;
if (pubkeyPrefix) apiParams.pubkey_prefix = pubkeyPrefix;
const fetches: Promise<unknown>[] = [
apiGet<NodeListResponse>("/api/v1/nodes", apiParams),
];
if (oidcEnabled) {
fetches.push(
apiGet<ProfileListResponse>("/api/v1/user/profiles", { limit: 500 }),
);
}
const results = await Promise.all(fetches);
const data = 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,
});
const sortedProfiles = useMemo(
() =>
[...profiles].sort((a, b) => {
const na = a.name || a.callsign || "";
const nb = b.name || b.callsign || "";
return na.localeCompare(nb);
}),
[profiles],
);
const totalPages = total !== null ? Math.ceil(total / limit) : 0;
const headerParams: Record<string, string> = {
search,
adv_type: advType,
adopted_by: adoptedBy,
pubkey_prefix: pubkeyPrefix,
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>
) : (
nodes.map((node) => {
const displayName = tagValue(node.tags, "name") || node.name;
const tagDescription = tagValue(node.tags, "description");
const lastSeen = node.last_seen
? formatDateTimeShort(node.last_seen)
: "-";
return (
<Link
key={node.public_key}
to={`/nodes/${node.public_key}`}
className="card bg-base-100 shadow-sm block"
>
<div className="card-body p-3">
<div className="flex items-center justify-between gap-2">
<NodeDisplay
name={displayName}
description={tagDescription}
publicKey={node.public_key}
advType={node.adv_type}
size="sm"
/>
<div className="text-right flex-shrink-0">
<div className="text-xs opacity-60">{lastSeen}</div>
</div>
</div>
</div>
</Link>
);
})
);
const tableRows =
nodes.length === 0 ? (
<tr>
<td colSpan={3} className="text-center py-8 opacity-70">
{noEntity}
</td>
</tr>
) : (
nodes.map((node) => {
const displayName = tagValue(node.tags, "name") || node.name;
const tagDescription = tagValue(node.tags, "description");
const lastSeen = node.last_seen ? formatDateTime(node.last_seen) : "-";
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>
</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>
</td>
<td className="text-sm whitespace-nowrap">{lastSeen}</td>
</tr>
);
})
);
if (loading) return <Loading />;
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>
<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>
{filterOpen && (
<div className="mb-4">
<FilterForm
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>
<input
type="text"
name="search"
defaultValue={search}
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
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>
{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
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>
)}
</FilterForm>
</div>
)}
<MobileSortSelect
currentSort={sort}
currentOrder={order}
basePath="/nodes"
params={headerParams}
options={[
{ value: "last_seen:desc", label: t("nodes.sort.last_seen_newest") },
{ value: "last_seen:asc", label: t("nodes.sort.last_seen_oldest") },
{ value: "name:asc", label: t("nodes.sort.name_az") },
{ value: "name:desc", label: t("nodes.sort.name_za") },
{ value: "public_key:asc", label: t("nodes.sort.key_asc") },
{ value: "public_key:desc", label: t("nodes.sort.key_desc") },
]}
/>
<div className="lg:hidden space-y-3">{mobileCards}</div>
<div className="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow-sm">
<table className="table table-zebra">
<thead>
<tr>
<SortableTableHeader
label={t("entities.node")}
sortKey="name"
currentSort={sort}
currentOrder={order}
basePath="/nodes"
params={headerParams}
/>
<SortableTableHeader
label={t("common.public_key")}
sortKey="public_key"
currentSort={sort}
currentOrder={order}
basePath="/nodes"
params={headerParams}
/>
<SortableTableHeader
label={t("common.last_seen")}
sortKey="last_seen"
currentSort={sort}
currentOrder={order}
basePath="/nodes"
params={headerParams}
/>
</tr>
</thead>
<tbody>{tableRows}</tbody>
</table>
</div>
<Pagination
page={page}
totalPages={totalPages}
basePath="/nodes"
params={{
search,
adv_type: advType,
adopted_by: adoptedBy,
pubkey_prefix: pubkeyPrefix,
limit: String(limit),
sort,
order,
}}
/>
</div>
);
}
@@ -0,0 +1,247 @@
import { useEffect, useState, type ReactNode } from "react";
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 { useFormatDateTime } from "@/utils/format";
import { copyToClipboard } from "@/utils/clipboard";
import { Loading, WarningBadge } from "@/components/Alerts";
import { JsonTree } from "@/components/JsonTree";
interface PacketDetailData {
packet_hash: string | null;
event_type: string | null;
channel_idx: number | null;
observed_by: string | null;
observer_name: string | null;
observer_tag_name: string | null;
source_pubkey_prefix: string | null;
packet_type: number | null;
payload_type: number | null;
route_type: string | null;
snr: number | null;
path_len: number | null;
received_at: string | null;
redacted: boolean;
raw_hex: string | null;
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,
}),
apiGet<ChannelsResponse>("/api/v1/channels", { limit: 200 }, {
signal: controller.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 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}`;
}
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>
{notFound && (
<div role="alert" className="alert alert-error">
{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>
)}
<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">
<Field label={t("common.time")}>
{formatDateTime(packet.received_at)}
</Field>
<Field label={t("common.observers")}>
{packet.observed_by ? (
<Link
to={`/nodes/${packet.observed_by}`}
className="link link-hover"
>
{packet.observer_tag_name ||
packet.observer_name ||
packet.observed_by}
</Link>
) : (
<span className="opacity-50"></span>
)}
</Field>
<Field label={t("packets.col_event_type")}>
{packet.event_type || "—"}
</Field>
<Field label={t("entities.channel")}>{channelDisplay}</Field>
<Field label={t("packets.col_source")}>
{packet.source_pubkey_prefix ? (
<code className="font-mono text-xs">
{packet.source_pubkey_prefix}
</code>
) : (
<span className="opacity-50"></span>
)}
</Field>
<Field label={t("packets.packet_hash")}>
{packet.packet_hash ? (
<code className="font-mono text-xs">
{packet.packet_hash}
</code>
) : (
<span className="opacity-50"></span>
)}
</Field>
<Field label={t("packets.packet_type")}>
{packet.packet_type != null ? packet.packet_type : "—"}
</Field>
<Field label={t("packets.payload_type")}>
{packet.payload_type != null ? packet.payload_type : "—"}
</Field>
<Field label={t("packets.col_route_type")}>
{packet.route_type || "—"}
</Field>
<Field label={t("common.snr_db")}>
{packet.snr != null ? Number(packet.snr).toFixed(1) : "—"}
</Field>
<Field label={t("common.hops")}>
{packet.path_len != null ? packet.path_len : "—"}
</Field>
</div>
{!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 && 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>
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,702 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
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 {
formatNumber,
formatRelativeTime,
truncateKey,
useFormatDateTime,
} from "@/utils/format";
import { copyToClipboard } from "@/utils/clipboard";
import { Loading, WarningBadge } from "@/components/Alerts";
import { JsonTree } from "@/components/JsonTree";
import { IconSatelliteDish } from "@/components/icons";
const PATH_MAX_BADGES = 16;
const PATH_HEAD = 7;
const PATH_TAIL = 7;
const PATH_POPOVER_NODE_CAP = 8;
interface Reception {
packet_id: string;
observed_by: string | null;
observer_name: string | null;
observer_tag_name: string | null;
path_hashes: string[] | null;
path_len: number | null;
snr: number | null;
received_at: string | null;
}
interface PacketGroupData {
packet_hash: string | null;
event_type: string | null;
channel_idx: number | null;
source_pubkey_prefix: string | null;
packet_type: number | null;
payload_type: number | null;
route_type: string | null;
reception_count: number;
observer_count: number;
first_seen: string | null;
redacted: boolean;
raw_hex: string | null;
decoded: unknown;
receptions: Reception[];
}
interface ChannelItem {
name: string;
channel_hash: string;
}
interface ChannelsResponse {
items: ChannelItem[];
}
interface NodeItem {
public_key: string;
name: string | null;
tags?: { key: string; value: string }[];
}
interface NodesResponse {
items: NodeItem[];
total: number;
}
interface PopoverAnchor {
hash: string;
left: number;
bottom: number;
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) {
const key = r.observed_by || "__unknown__";
const list = groups.get(key);
if (list) {
list.push(r);
} else {
groups.set(key, [r]);
}
}
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">
<span className="text-[10px] uppercase opacity-60">{label}</span>
<span className="text-sm">{value}</span>
</div>
);
}
function PathBadge({
hash,
onOpen,
}: {
hash: string;
onOpen: (e: React.MouseEvent, hash: string) => void;
}) {
return (
<span
className="badge badge-sm badge-primary font-mono text-xs cursor-pointer"
onClick={(e) => onOpen(e, hash)}
>
{hash}
</span>
);
}
function PathFlow({
reception,
sourcePrefix,
onBadgeOpen,
}: {
reception: Reception;
sourcePrefix: string | null;
onBadgeOpen: (e: React.MouseEvent, hash: string) => void;
}) {
const { t } = useTranslation();
const hashes = reception.path_hashes ?? [];
let middle: ReactNode[];
if (hashes.length > 0) {
if (hashes.length <= PATH_MAX_BADGES) {
middle = hashes.map((h, i) => (
<PathBadge key={`${i}-${h}`} hash={h} onOpen={onBadgeOpen} />
));
} else {
const hidden = hashes.length - PATH_HEAD - PATH_TAIL;
middle = [
...hashes
.slice(0, PATH_HEAD)
.map((h, i) => (
<PathBadge key={`head-${i}-${h}`} hash={h} onOpen={onBadgeOpen} />
)),
<span
key="ellipsis"
className="badge badge-sm badge-ghost cursor-help"
title={t("packets.hops_hidden", { count: hidden })}
>
</span>,
...hashes
.slice(-PATH_TAIL)
.map((h, i) => (
<PathBadge key={`tail-${i}-${h}`} hash={h} onOpen={onBadgeOpen} />
)),
];
}
} else if (reception.path_len != null) {
middle = [
<span key="len" className="text-xs opacity-60">
{reception.path_len} {t("common.hops").toLowerCase()}
</span>,
];
} else {
middle = [
<span key="none" className="opacity-50">
</span>,
];
}
const parts: ReactNode[] = [
<span
key="sender"
className="inline-block h-3 w-3 rounded-full bg-success flex-shrink-0"
title={
sourcePrefix
? `${t("packets.col_source")}: ${sourcePrefix}`
: t("packets.col_source")
}
/>,
...middle,
<span key="observer" className="flex-shrink-0">
<IconSatelliteDish className="h-4 w-4 opacity-70" />
</span>,
];
const joined: ReactNode[] = [];
parts.forEach((part, i) => {
if (i > 0) {
joined.push(
<span key={`arrow-${i}`} className="opacity-40 text-xs">
</span>,
);
}
joined.push(part);
});
return <span className="flex flex-wrap items-center gap-1">{joined}</span>;
}
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;
top: number;
} | null>(null);
const [popoverNodes, setPopoverNodes] = useState<NodeItem[] | null>(null);
const [popoverTotal, setPopoverTotal] = useState(0);
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,
}),
apiGet<ChannelsResponse>("/api/v1/channels", { limit: 200 }, {
signal: controller.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]);
useEffect(() => {
const onDocClick = (ev: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(ev.target as Node)
) {
setPopover(null);
}
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") setPopover(null);
};
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, []);
const popoverHash = popover?.hash ?? null;
useEffect(() => {
if (!popoverHash) return;
let cancelled = false;
setPopoverNodes(null);
setPopoverError(null);
apiGet<NodesResponse>("/api/v1/nodes", {
pubkey_prefix: popoverHash,
sort: "name",
order: "asc",
limit: PATH_POPOVER_NODE_CAP,
})
.then((data) => {
if (cancelled) return;
const items = (data.items || [])
.slice()
.sort((a, b) =>
nodeDisplayName(a).localeCompare(nodeDisplayName(b)),
);
setPopoverNodes(items);
setPopoverTotal(data.total || 0);
})
.catch((e) => {
if (cancelled || isAbortError(e)) return;
setPopoverError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, [popoverHash]);
useLayoutEffect(() => {
if (!popover || !popoverRef.current) return;
const el = popoverRef.current;
const margin = 8;
const pw = el.offsetWidth || 256;
const ph = el.offsetHeight || 0;
let left = Math.min(popover.left, window.innerWidth - pw - margin);
if (left < margin) left = margin;
let top = popover.bottom + 4;
if (
top + ph + margin > window.innerHeight &&
popover.top - ph - 4 > margin
) {
top = popover.top - ph - 4;
}
setPopoverPos({ left, top });
}, [popover, popoverNodes, popoverError]);
const openPathPopover = (e: React.MouseEvent, pathHash: string) => {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setPopoverPos(null);
setPopover({
hash: pathHash,
left: rect.left,
bottom: rect.bottom,
top: rect.top,
});
};
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>
);
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>
{notFound && (
<div role="alert" className="alert alert-warning">
{t("packets.not_found_retention")}
</div>
)}
{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>
)}
<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">
<Field label={t("common.time")}>
{formatDateTime(group.first_seen)}
</Field>
<Field label={t("packets.col_event_type")}>
{group.event_type || "—"}
</Field>
<Field label={t("entities.channel")}>{channelDisplay}</Field>
<Field label={t("packets.col_source")}>
{group.source_pubkey_prefix ? (
<code className="font-mono text-xs">
{group.source_pubkey_prefix}
</code>
) : (
<span className="opacity-50"></span>
)}
</Field>
<Field label={t("packets.packet_hash")}>
{group.packet_hash ? (
<code className="font-mono text-xs">
{group.packet_hash}
</code>
) : (
<span className="opacity-50"></span>
)}
</Field>
<Field label={t("packets.packet_type")}>
{group.packet_type != null ? group.packet_type : "—"}
</Field>
<Field label={t("packets.payload_type")}>
{group.payload_type != null ? group.payload_type : "—"}
</Field>
<Field label={t("packets.col_route_type")}>
{group.route_type || "—"}
</Field>
<Field label={t("packets.receptions_count")}>
{formatNumber(group.reception_count)}{" "}
{group.reception_count === 1
? t("packets.reception_singular")
: t("packets.reception_plural")}{" "}
· {formatNumber(group.observer_count)}{" "}
{t("common.observers").toLowerCase()}
</Field>
</div>
{receptions.length > 0 && (
<div className="mt-6">
<h2 className="text-sm font-semibold uppercase opacity-60 mb-3">
{t("packets.receptions_title")}
<span className="ml-1 normal-case opacity-80">
({formatNumber(group.reception_count)}{" "}
{group.reception_count === 1
? t("packets.reception_singular")
: t("packets.reception_plural")}
, {formatNumber(group.observer_count)}{" "}
{t("common.observers").toLowerCase()})
</span>
</h2>
{[...observerGroups.entries()].map(([key, recs]) => {
const first = recs[0];
const displayName =
first.observer_tag_name ||
first.observer_name ||
(first.observed_by
? first.observed_by.slice(0, 12) + "…"
: "—");
return (
<div key={key} className="mb-5">
<div className="text-sm font-medium mb-1">
{"\u{1F4E1}"}{" "}
{first.observed_by ? (
<Link
to={`/nodes/${first.observed_by}`}
className="link link-hover"
>
{displayName}
</Link>
) : (
displayName
)}
{recs.length > 1 && (
<span className="text-xs opacity-50 ml-1">
({formatNumber(recs.length)}{" "}
{t("packets.reception_plural")})
</span>
)}
</div>
<div className="lg:hidden space-y-2">
{recs.map((r) => (
<div
key={r.packet_id}
className="rounded-box bg-base-200/60 p-3"
>
<div className="mb-2">
<PathFlow
reception={r}
sourcePrefix={sourcePrefix}
onBadgeOpen={openPathPopover}
/>
</div>
<div className="grid grid-cols-3 gap-2">
<Stat
label={t("common.time")}
value={receptionTime(r)}
/>
<Stat
label={t("common.hops")}
value={r.path_len != null ? r.path_len : "—"}
/>
<Stat
label={t("common.snr_db")}
value={
r.snr != null
? Number(r.snr).toFixed(1)
: "—"
}
/>
</div>
</div>
))}
</div>
<div className="hidden lg:block overflow-x-auto">
<table className="table table-xs table-fixed w-full">
<thead>
<tr>
<th>{t("packets.col_path")}</th>
<th className="w-16 text-right">
{t("common.hops")}
</th>
<th className="w-20 text-right">
{t("common.snr_db")}
</th>
<th className="w-32 text-right">
{t("common.time")}
</th>
</tr>
</thead>
<tbody>
{recs.map((r) => (
<tr key={r.packet_id}>
<td className="whitespace-normal align-top">
<PathFlow
reception={r}
sourcePrefix={sourcePrefix}
onBadgeOpen={openPathPopover}
/>
</td>
<td className="w-16 text-right text-sm align-top">
{r.path_len != null ? r.path_len : "—"}
</td>
<td className="w-20 text-right text-sm align-top">
{r.snr != null
? Number(r.snr).toFixed(1)
: "—"}
</td>
<td className="w-32 text-right text-xs opacity-60 align-top whitespace-nowrap">
{receptionTime(r)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
)}
{!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>
)}
{!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>
)}
</div>
</div>
</>
)}
{popover && (
<div
ref={popoverRef}
className="fixed z-[1000] w-64 max-w-[90vw] max-h-[60vh] overflow-y-auto bg-base-100 rounded-box shadow-lg border border-base-300"
style={{
left: popoverPos?.left ?? -9999,
top: popoverPos?.top ?? -9999,
}}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-base-200 sticky top-0 bg-base-100 rounded-t-box">
<span className="text-xs font-semibold uppercase opacity-70">
{t("packets.path_nodes_title", { hash: popover.hash })}
</span>
<button
className="btn btn-xs btn-ghost btn-circle"
aria-label={t("common.close")}
onClick={() => setPopover(null)}
>
</button>
</div>
<div>
{popoverError ? (
<div className="p-3">
<WarningBadge message={popoverError} />
</div>
) : popoverNodes === null ? (
<div className="p-3">
<Loading />
</div>
) : popoverNodes.length === 0 ? (
<div className="px-3 py-4 text-sm opacity-60 text-center">
{t("packets.path_no_nodes")}
</div>
) : (
<ul className="menu menu-sm w-full">
{popoverNodes.map((n) => (
<li key={n.public_key}>
<Link
to={`/nodes/${n.public_key}`}
onClick={() => setPopover(null)}
className="flex flex-col items-start gap-0"
>
<span className="text-sm">{nodeDisplayName(n)}</span>
<span className="font-mono text-xs opacity-50">
{truncateKey(n.public_key, 16)}
</span>
</Link>
</li>
))}
{moreCount > 0 && (
<li>
<Link
to={`/nodes?pubkey_prefix=${popover.hash}`}
onClick={() => setPopover(null)}
className="text-xs opacity-70"
>
{t("packets.path_nodes_more", {
count: formatNumber(moreCount),
})}
</Link>
</li>
)}
</ul>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,527 @@
import { useCallback, useEffect, useMemo, useState } from "react";
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 { formatNumber, useFormatDateTime } from "@/utils/format";
import { Pagination } from "@/components/Pagination";
import { FilterForm, FilterToggle } from "@/components/FilterForm";
import {
MobileSortSelect,
SortableTableHeader,
} from "@/components/SortableTable";
import { Loading, WarningBadge } from "@/components/Alerts";
import {
IconPath,
IconRefresh,
IconRuler,
IconSatelliteDish,
} from "@/components/icons";
const EVENT_TYPES = [
"advertisement",
"channel_msg_recv",
"contact_msg_recv",
"trace_data",
"telemetry_response",
"path_updated",
"status_response",
"req",
"response",
"ack",
"encrypted_direct",
"encrypted_channel",
"grp_data",
"anon_req",
"multipart",
"control",
"raw_custom",
"advert",
"path",
"trace",
"letsmesh_packet",
];
interface PacketGroupItem {
packet_hash: string | null;
event_type: string | null;
channel_idx: number | null;
path_hash_bytes: number | null;
reception_count: number | null;
observer_count: number | null;
first_seen: string | null;
redacted?: boolean;
receptions?: { packet_id: string }[];
}
interface PacketGroupsResponse {
items: PacketGroupItem[];
total: number;
}
interface ChannelItem {
name: string;
channel_hash: string;
}
interface ChannelsResponse {
items: ChannelItem[];
}
interface ChannelEntry {
idx: number;
name: string;
}
function buildChannelList(items: ChannelItem[]): ChannelEntry[] {
return items
.map((c) => ({ name: c.name, idx: parseInt(c.channel_hash, 16) }))
.filter((c) => !Number.isNaN(c.idx));
}
function packetUrl(p: PacketGroupItem): string {
if (p.packet_hash) return `/packets/hash/${p.packet_hash}`;
if (p.receptions && p.receptions.length > 0)
return `/packets/${p.receptions[0].packet_id}`;
return "/packets";
}
function ChannelLabel({
packet,
channelNames,
}: {
packet: PacketGroupItem;
channelNames: Map<number, string>;
}) {
const { t } = useTranslation();
if (packet.channel_idx == null) return <span className="opacity-50"></span>;
const name = channelNames.get(packet.channel_idx);
const text = name ? `${name} (${packet.channel_idx})` : `${packet.channel_idx}`;
return (
<>
{text}
{packet.redacted && (
<>
{" "}
<span className="opacity-60" title={t("packets.redacted_title")}>
{"\u{1F512}"}
</span>
</>
)}
</>
);
}
function ReceptionBadge({ packet }: { packet: PacketGroupItem }) {
const { t } = useTranslation();
const rc = packet.reception_count ?? 1;
const oc = packet.observer_count ?? 1;
const pb = packet.path_hash_bytes;
const knownWidth = pb != null && pb > 0;
const widthLabel = knownWidth
? t("packets.path_width_bytes", { count: pb })
: t("packets.path_width_unknown");
return (
<span className="inline-flex items-center gap-1">
<IconSatelliteDish className="h-4 w-4 opacity-70" />
<span
className="badge badge-sm badge-primary"
title={t("common.observers")}
>
{formatNumber(oc)}
</span>
<span className="opacity-40" aria-hidden="true">
×
</span>
<IconPath className="h-4 w-4 opacity-70" />
<span
className="badge badge-sm badge-primary"
title={t("packets.reception_plural")}
>
{formatNumber(rc)}
</span>
<span className="opacity-40" aria-hidden="true">
@
</span>
<IconRuler className="h-4 w-4 opacity-70" />
<span
className={`badge badge-sm badge-primary ${knownWidth ? "" : "opacity-60"}`}
title={t("packets.path_width_title")}
>
{widthLabel}
</span>
</span>
);
}
export function Packets() {
const { t } = useTranslation();
usePageTitle("entities.packets");
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const config = useAppConfig();
const { formatDateTime, formatDateTimeShort } = useFormatDateTime();
const search = searchParams.get("search") ?? "";
const eventType = searchParams.get("event_type") ?? "";
const channelIdx = searchParams.get("channel_idx") ?? "";
const pathHashBytes = searchParams.get("path_hash_bytes") ?? "";
const page = parseInt(searchParams.get("page") ?? "", 10) || 1;
const limit = parseInt(searchParams.get("limit") ?? "", 10) || 20;
const sort = searchParams.get("sort") ?? "time";
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 !== "" ||
channelIdx !== "" ||
pathHashBytes !== "";
const [filterOpen, setFilterOpen] = useState(hasActiveFilters);
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,
event_type: eventType,
channel_idx: channelIdx,
path_hash_bytes: pathHashBytes,
...overrides,
};
const params = new URLSearchParams();
for (const [k, v] of Object.entries(next)) {
if (v) params.set(k, v);
}
const qs = params.toString();
navigate(qs ? `/packets?${qs}` : "/packets");
};
const tz = config.timezone || "";
const totalPages = Math.ceil(total / limit);
const filterParams: Record<string, string> = {
search,
event_type: eventType,
channel_idx: channelIdx,
path_hash_bytes: pathHashBytes,
limit: String(limit),
};
const noneFound = t("common.no_entity_found", {
entity: t("entities.packets").toLowerCase(),
});
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>
<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>
{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>
<input
type="text"
name="search"
defaultValue={search}
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>
<select
name="event_type"
className="select select-sm"
value={eventType}
onChange={(e) => applyFilters({ event_type: e.target.value })}
>
<option value="">{t("common.all_types")}</option>
{EVENT_TYPES.map((et) => (
<option key={et} value={et}>
{et}
</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>
<select
name="channel_idx"
className="select select-sm"
value={channelIdx}
onChange={(e) => applyFilters({ channel_idx: e.target.value })}
>
<option value="">{t("common.all_channels")}</option>
{channels.map((c) => (
<option key={c.idx} value={c.idx}>
{c.name} ({c.idx})
</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>
<select
name="path_hash_bytes"
className="select select-sm"
value={pathHashBytes}
onChange={(e) =>
applyFilters({ path_hash_bytes: e.target.value })
}
>
<option value="">{t("common.all")}</option>
{[1, 2, 3].map((w) => (
<option key={w} value={w}>
{t("packets.path_width_bytes", { count: w })}
</option>
))}
</select>
</div>
</FilterForm>
</div>
)}
{packets === null ? (
<Loading />
) : (
<>
<MobileSortSelect
currentSort={sort}
currentOrder={order}
basePath="/packets"
params={filterParams}
options={[
{ value: "time:desc", label: t("packets.sort.newest") },
{ value: "time:asc", label: t("packets.sort.oldest") },
{ value: "event_type:asc", label: t("packets.sort.event_az") },
{
value: "reception_count:desc",
label: t("packets.sort.receptions_high"),
},
]}
/>
<div className="lg:hidden space-y-3">
{packets.length === 0 ? (
<div className="text-center py-8 opacity-70">{noneFound}</div>
) : (
packets.map((p, i) => (
<Link
key={p.packet_hash ?? p.receptions?.[0]?.packet_id ?? i}
to={packetUrl(p)}
className="card bg-base-100 shadow-sm block"
>
<div className="card-body p-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-sm truncate">
{p.event_type || "—"}
</div>
<div className="text-xs opacity-60">
<ChannelLabel packet={p} channelNames={channelNames} />
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-xs opacity-60">
{formatDateTimeShort(p.first_seen)}
</div>
<div className="text-xs opacity-60">
<ReceptionBadge packet={p} />
</div>
</div>
</div>
</div>
</Link>
))
)}
</div>
<div className="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow-sm">
<table className="table table-zebra">
<thead>
<tr>
<SortableTableHeader
label={t("common.time")}
sortKey="time"
currentSort={sort}
currentOrder={order}
basePath="/packets"
params={filterParams}
/>
<th>{t("packets.packet_hash")}</th>
<th title={t("packets.receptions_title")}>
{t("packets.col_receptions")}
</th>
<SortableTableHeader
label={t("packets.col_event_type")}
sortKey="event_type"
currentSort={sort}
currentOrder={order}
basePath="/packets"
params={filterParams}
/>
<th>{t("entities.channel")}</th>
</tr>
</thead>
<tbody>
{packets.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 opacity-70">
{noneFound}
</td>
</tr>
) : (
packets.map((p, i) => (
<tr
key={p.packet_hash ?? p.receptions?.[0]?.packet_id ?? i}
className="hover cursor-pointer"
onClick={() => navigate(packetUrl(p))}
>
<td className="text-sm whitespace-nowrap">
{formatDateTime(p.first_seen)}
</td>
<td>
{p.packet_hash ? (
<code className="font-mono text-xs">
{p.packet_hash}
</code>
) : (
<span className="opacity-50"></span>
)}
</td>
<td className="text-sm">
<ReceptionBadge packet={p} />
</td>
<td className="font-mono text-xs">
{p.event_type || "—"}
</td>
<td className="text-sm">
<ChannelLabel packet={p} channelNames={channelNames} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
page={page}
totalPages={totalPages}
basePath="/packets"
params={{ ...filterParams, sort, order }}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,360 @@
import { useEffect, useState, type FormEvent } from "react";
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 { Loading, ErrorAlert, SuccessAlert } from "@/components/Alerts";
import { usePageTitle } from "@/hooks/usePageTitle";
interface ProfileNode {
public_key: string;
name?: string | null;
last_seen?: string | null;
}
interface UserProfileData {
id: string;
user_id?: string | null;
name?: string | null;
callsign?: string | null;
description?: string | null;
url?: string | null;
roles?: string[] | null;
created_at?: string | null;
nodes?: ProfileNode[] | null;
}
function hasOperatorOrAdmin(
roles: string[] | null | undefined,
config: AppConfig,
): boolean {
const roleNames = config.role_names || {};
const operatorRole = roleNames.operator || "operator";
const adminRole = roleNames.admin || "admin";
return !!roles && (roles.includes(operatorRole) || roles.includes(adminRole));
}
function RoleBadges({ roles }: { roles?: string[] | null }) {
if (!roles || roles.length === 0) return null;
return (
<div className="flex gap-2 mt-2">
{roles.map((role) => (
<span key={role} className="badge badge-primary badge-sm">
{role}
</span>
))}
</div>
);
}
function MemberSince({ createdAt }: { createdAt?: string | null }) {
const { t } = useTranslation();
const { formatDateTime } = useFormatDateTime();
if (!createdAt) return null;
return (
<p className="text-sm opacity-60 mt-2">
{t("user_profile.member_since", {
date: formatDateTime(createdAt, {
year: "numeric",
month: "long",
day: "numeric",
}),
})}
</p>
);
}
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) : "-";
return (
<Link
to={`/nodes/${node.public_key}`}
className="flex items-center justify-between gap-3 p-3 bg-base-200 rounded-box hover:bg-base-300 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{displayName}</div>
<div className="font-mono text-xs opacity-60 truncate">
{node.public_key}
</div>
</div>
<time
className="text-xs opacity-60 whitespace-nowrap shrink-0"
dateTime={node.last_seen ?? undefined}
title={fullTime}
>
{relTime}
</time>
</Link>
);
}
function AdoptedNodesCard({
profile,
className,
}: {
profile: UserProfileData;
className?: string;
}) {
const { t } = useTranslation();
return (
<div className={`card bg-base-100 shadow-xl ${className ?? ""}`}>
<div className="card-body">
<h2 className="card-title">{t("user_profile.adopted_nodes")}</h2>
{profile.nodes && profile.nodes.length > 0 ? (
<div className="space-y-2">
{profile.nodes.map((node) => (
<AdoptedNodeLink key={node.public_key} node={node} />
))}
</div>
) : (
<p className="opacity-60 text-sm py-4">
{t("user_profile.no_adopted_nodes")}
</p>
)}
</div>
</div>
);
}
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]);
if (error) return <ErrorAlert message={error} />;
if (!profile) return <Loading />;
const isOwner =
!!config.user && !!profile.user_id && config.user.sub === profile.user_id;
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("user_profile.title")}</h1>
{isOwner && (
<Link to="/profile" className="btn btn-primary btn-sm">
{t("user_profile.edit_profile")}
</Link>
)}
</div>
<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>
)}
</h2>
<RoleBadges roles={profile.roles} />
{profile.description && (
<p className="text-sm opacity-80 mt-2">{profile.description}</p>
)}
{profile.url && (
<a
href={profile.url}
target="_blank"
rel="noopener noreferrer"
className="link link-primary text-sm mt-1 inline-block"
>
{profile.url}
</a>
)}
<MemberSince createdAt={profile.created_at} />
{hasOperatorOrAdmin(profile.roles, config) && (
<AdoptedNodesCard profile={profile} className="mt-6" />
)}
</div>
</div>
</>
);
}
function OwnProfileView() {
const { t } = useTranslation();
const config = useAppConfig();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
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/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]);
if (!config.oidc_enabled || !config.user) {
return (
<div className="flex flex-col items-center justify-center py-20">
<h1 className="text-3xl font-bold mb-2">{t("user_profile.title")}</h1>
<p className="opacity-70 mb-6">{t("user_profile.login_to_view")}</p>
<a href="/auth/login" className="btn btn-primary">
{t("auth.login")}
</a>
</div>
);
}
if (error) return <ErrorAlert message={error} />;
if (!profile) return <Loading />;
const flashMessage = searchParams.get("message") || "";
const flashError = searchParams.get("error") || "";
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const body = {
name: String(data.get("name") ?? "").trim() || null,
callsign: String(data.get("callsign") ?? "").trim() || null,
description: String(data.get("description") ?? "").trim() || null,
url: String(data.get("url") ?? "").trim() || null,
};
try {
await apiPut(`/api/v1/user/profile/${profile.id}`, body);
navigate(
"/profile?message=" + encodeURIComponent(t("user_profile.profile_updated")),
{ replace: true },
);
} catch (err) {
navigate(
"/profile?error=" + encodeURIComponent((err as Error).message),
{ replace: true },
);
}
};
return (
<>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{t("user_profile.title")}</h1>
</div>
{flashMessage ? (
<SuccessAlert message={flashMessage} />
) : flashError ? (
<ErrorAlert message={flashError} />
) : null}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">{t("user_profile.your_profile")}</h2>
<RoleBadges roles={profile.roles} />
<form onSubmit={handleSubmit} className="py-4 space-y-4">
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.name_label")}
</span>
<input
type="text"
name="name"
className="input flex-1"
defaultValue={profile.name || ""}
placeholder={t("user_profile.name_placeholder")}
maxLength={255}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.callsign_label")}
</span>
<input
type="text"
name="callsign"
className="input flex-1"
defaultValue={profile.callsign || ""}
placeholder={t("user_profile.callsign_placeholder")}
maxLength={20}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.description_label")}
</span>
<input
type="text"
name="description"
className="input flex-1"
defaultValue={profile.description || ""}
placeholder={t("user_profile.description_placeholder")}
maxLength={500}
/>
</label>
<label className="flex items-center gap-3 py-1">
<span className="text-sm font-medium shrink-0 w-24">
{t("user_profile.url_label")}
</span>
<input
type="url"
name="url"
className="input flex-1"
defaultValue={profile.url || ""}
placeholder={t("user_profile.url_placeholder")}
maxLength={2048}
/>
</label>
<button type="submit" className="btn btn-primary btn-sm">
{t("user_profile.save_profile")}
</button>
</form>
<MemberSince createdAt={profile.created_at} />
</div>
</div>
</div>
{hasOperatorOrAdmin(profile.roles, config) && (
<AdoptedNodesCard profile={profile} />
)}
</div>
</>
);
}
export function Profile() {
const { id } = useParams();
usePageTitle("links.profile");
return id ? <PublicProfileView id={id} /> : <OwnProfileView />;
}
File diff suppressed because it is too large Load Diff