From 8322b5cf9feb3d451375adfd1509945698bd87df Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 16:50:33 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20convert=20all=2015=20SPA=20pages?= =?UTF-8?q?=20to=20native=20React=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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). --- REACT_MIGRATION.md | 43 +- .../web/static/js/spa-react/App.tsx | 129 +- .../js/spa-react/components/icons/index.tsx | 68 + .../js/spa-react/pages/Advertisements.tsx | 565 ++++++ .../static/js/spa-react/pages/Channels.tsx | 478 +++++ .../static/js/spa-react/pages/CustomPage.tsx | 70 + .../static/js/spa-react/pages/Dashboard.tsx | 649 +++++++ .../web/static/js/spa-react/pages/Home.tsx | 482 +++++ .../web/static/js/spa-react/pages/MapPage.tsx | 604 ++++++ .../web/static/js/spa-react/pages/Members.tsx | 245 +++ .../static/js/spa-react/pages/Messages.tsx | 781 ++++++++ .../static/js/spa-react/pages/NodeDetail.tsx | 957 ++++++++++ .../web/static/js/spa-react/pages/Nodes.tsx | 433 +++++ .../js/spa-react/pages/PacketDetail.tsx | 247 +++ .../js/spa-react/pages/PacketGroupDetail.tsx | 702 +++++++ .../web/static/js/spa-react/pages/Packets.tsx | 527 ++++++ .../web/static/js/spa-react/pages/Profile.tsx | 360 ++++ .../web/static/js/spa-react/pages/Routes.tsx | 1636 +++++++++++++++++ 18 files changed, 8893 insertions(+), 83 deletions(-) create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx diff --git a/REACT_MIGRATION.md b/REACT_MIGRATION.md index c18ac19..d38678b 100644 --- a/REACT_MIGRATION.md +++ b/REACT_MIGRATION.md @@ -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 diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx index 994f30f..52f70ad 100644 --- a/src/meshcore_hub/web/static/js/spa-react/App.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx @@ -1,4 +1,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 ; } -function LitPage({ - loader, -}: { - loader: () => Promise<{ - render: ( - container: HTMLElement, - params: Record, - router: { navigate: (url: string, replace?: boolean) => void }, - ) => Promise<(() => void) | void>; - }>; -}) { - return ( - - - - ); -} - function AppRoutes() { const config = useAppConfig(); const features = config.features ?? {}; @@ -112,16 +107,18 @@ function AppRoutes() { import("@legacy/pages/home.js")} /> + + + } /> {features.dashboard !== false && ( import("@legacy/pages/dashboard.js")} - /> + + + } /> )} @@ -130,15 +127,17 @@ function AppRoutes() { import("@legacy/pages/nodes.js")} /> + + + } /> import("@legacy/pages/node-detail.js")} - /> + + + } /> } /> @@ -148,9 +147,9 @@ function AppRoutes() { import("@legacy/pages/channels.js")} - /> + + + } /> )} @@ -158,7 +157,9 @@ function AppRoutes() { import("@legacy/pages/routes.js")} /> + + + } /> )} @@ -166,9 +167,9 @@ function AppRoutes() { import("@legacy/pages/messages.js")} - /> + + + } /> )} @@ -176,9 +177,9 @@ function AppRoutes() { import("@legacy/pages/advertisements.js")} - /> + + + } /> )} @@ -187,29 +188,25 @@ function AppRoutes() { import("@legacy/pages/packets.js")} - /> + + + } /> - import("@legacy/pages/packet-group-detail.js") - } - /> + + + } /> - import("@legacy/pages/packet-detail.js") - } - /> + + + } /> @@ -218,7 +215,9 @@ function AppRoutes() { import("@legacy/pages/map.js")} /> + + + } /> )} @@ -226,9 +225,9 @@ function AppRoutes() { import("@legacy/pages/members.js")} - /> + + + } /> )} @@ -236,9 +235,9 @@ function AppRoutes() { import("@legacy/pages/custom-page.js")} - /> + + + } /> )} @@ -247,17 +246,17 @@ function AppRoutes() { import("@legacy/pages/profile.js")} - /> + + + } /> import("@legacy/pages/profile.js")} - /> + + + } /> diff --git a/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx b/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx index 4735de7..d723bb2 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx @@ -435,6 +435,74 @@ export function IconTxPower(props: IconProps) { ); } +export function IconSatelliteDish(props: IconProps) { + return ( + + + + + + + ); +} + +export function IconRuler(props: IconProps) { + return ( + + + + + + + + ); +} + export function IconClock(props: IconProps) { return ( diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx new file mode 100644 index 0000000..ff2c333 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx @@ -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 { + items?: T[]; + total?: number; +} + +function submitOnEnter(e: React.KeyboardEvent) { + if (e.key === "Enter") e.currentTarget.form?.requestSubmit(); +} + +function autoSubmit(e: React.ChangeEvent) { + e.currentTarget.form?.requestSubmit(); +} + +export function Advertisements() { + const { t } = useTranslation(); + const navigate = useNavigate(); + 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(null); + const [total, setTotal] = useState(null); + const [error, setError] = useState(null); + const [sortedAreas, setSortedAreas] = useState([]); + const [operators, setOperators] = useState([]); + const [disabledAreas, setDisabledAreas] = useState>(() => + getDisabledObserverAreas(), + ); + const [filterOpen, setFilterOpen] = useState( + search !== "" || + (config.oidc_enabled && adoptedBy !== "") || + routeType !== "flood,transport_flood", + ); + + const disabledAreasRef = useRef(disabledAreas); + disabledAreasRef.current = disabledAreas; + const abortRef = useRef(null); + + const fetchData = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + try { + const nodesPromise = apiGet>( + "/api/v1/nodes", + { limit: 500, observer: true }, + { signal }, + ); + const profilesPromise = config.oidc_enabled + ? apiGet>( + "/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(); + 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 = { + 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>( + "/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 ; + } + if (ad.observed_by) { + return ( + + {"\u{1F4E1}"} + + ); + } + return variant === "desktop" ? - : null; + }; + + return ( + <> +
+

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

+ {tz && tz !== "UTC" && ( + {tz} + )} +
+ +
+ {total !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((o) => !o)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+ {config.oidc_enabled && operators.length > 0 && ( +
+ + +
+ )} +
+
+ )} + + {items === null ? ( + + ) : ( + <> + + + + + + +
+ {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + 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 ( +
navigate(detailUrl) : undefined + } + > +
+
+ e.stopPropagation()} + > + + +
+
+ {formatDateTimeShort(ad.received_at)} +
+
+ + {renderReceivers(ad, "mobile")} +
+
+
+
+
+ ); + }) + )} +
+ +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + 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 ( + navigate(detailUrl) : undefined + } + > + + + + + + + ); + }) + )} + +
{t("advertisements.col_route_type")}{t("common.observers")}
+ {emptyMessage} +
+ e.stopPropagation()} + > + + + + + copyToClipboard(e, ad.public_key) + } + title="Click to copy" + > + {ad.public_key} + + + + + {formatDateTime(ad.received_at)} + {renderReceivers(ad, "desktop")}
+
+ + + + )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx new file mode 100644 index 0000000..18ab93e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx @@ -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(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
; +} + +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 ( +
onNavigate(channelIdx)} + onKeyDown={handleKeyDown} + > +
+
+

+ {channel.name} + {oidcEnabled && ( + + {channel.visibility} + + )} + {!channel.enabled && ( + + {t("channels.disabled")} + + )} +

+ {channel.key_hex && ( +
+ {channel.key_hex.toLowerCase()} +
+ )} + {isAdmin && ( +
+ + +
+ )} +
+
+ {channel.key_hex && } +
+
+
+ ); +} + +interface ChannelModalProps { + isEdit: boolean; + channel: Channel | null; + saving: boolean; + onSave: (body: Record) => 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) => { + e.preventDefault(); + const body: Record = { 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 ( + +
+

{title}

+
+
+ + setName(e.target.value)} + disabled={isEdit} + placeholder={t("channels.name_label")} + required + maxLength={100} + /> + {!isEdit && ( + <> + + setKeyHex(e.target.value)} + placeholder="e.g. ABCDEF0123456789..." + required + minLength={32} + maxLength={64} + pattern="[0-9A-Fa-f]{32,64}" + /> + + )} + + +
+ +
+
+ + +
+
+
+
+ +
+
+ ); +} + +interface DeleteChannelModalProps { + channel: Channel; + saving: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +function DeleteChannelModal({ + channel, + saving, + onConfirm, + onCancel, +}: DeleteChannelModalProps) { + const { t } = useTranslation(); + + return ( + +
+

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

+

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

+
+ + +
+
+
+ +
+
+ ); +} + +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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); + const [saving, setSaving] = useState(false); + + const fetchChannels = useCallback(async () => { + try { + const data = await apiGet("/api/v1/channels"); + setChannels(data.items || []); + setError(null); + } catch (e) { + setError((e as Error).message || t("common.failed_to_load_page")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + const handleSave = async (body: Record) => { + setSaving(true); + try { + if (modal?.type === "edit") { + await apiPut(`/api/v1/channels/${modal.channel.id}`, body); + } 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(); + 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 ; + + return ( +
+
+

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

+
+ + {error && } + + {isAdmin && ( +
+ +
+ )} + + {channels.length === 0 && ( +
+ {t("common.no_entity_found", { + entity: t("entities.channels").toLowerCase(), + })} +
+ )} + + {VISIBILITY_ORDER.map((vis) => { + const group = groups.get(vis); + if (!group || group.length === 0) return null; + return ( +
+

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

+
+ {group.map((ch) => ( + setModal({ type: "edit", channel: ch })} + onDelete={() => setModal({ type: "delete", channel: ch })} + onNavigate={handleNavigate} + /> + ))} +
+
+ ); + })} + + {modal && (modal.type === "add" || modal.type === "edit") && ( + setModal(null)} + /> + )} + + {modal?.type === "delete" && ( + setModal(null)} + /> + )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx new file mode 100644 index 0000000..ea359b2 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + apiGet( + `/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 ; + if (error) return ; + if (!page) return null; + + return ( +
+
+
+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx new file mode 100644 index 0000000..f06543b --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx @@ -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; +} + +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 = { + 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 ( +
+
+
+
+

+ {icon} + {title} +

+

{subtitle}

+
+ {value !== undefined && ( +
+ {formatNumber(value)} +
+ )} +
+ {canvasId && ( +
+ +
+ )} + {children} +
+
+ ); +} + +function RoutesHealth({ routes }: { routes: RouteOverviewItem[] }) { + const { t } = useTranslation(); + if (!routes || routes.length === 0) { + return

{t("dashboard.routes_empty")}

; + } + + 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 ( +
+ {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 ( +
${route.to_label}-${i}`} + className="flex items-center gap-2" + > + + {route.from_label} {" "} + {route.to_label} + +
+ {history.map((entry) => ( +
+ ))} +
+ +
+ ); + })} + {hidden > 0 && ( +

+ {t("dashboard.routes_more", { count: hidden })} +

+ )} +
+ ); +} + +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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; + (async () => { + try { + const [ + stats, + recentActivity, + advertActivity, + messageActivity, + nodeCount, + packetActivity, + packetBreakdown, + routesOverview, + channelsData, + ] = await Promise.all([ + apiGet("/api/v1/dashboard/stats", {}, { signal }), + apiGet( + "/api/v1/dashboard/recent-activity", + {}, + { signal }, + ), + apiGet("/api/v1/dashboard/activity", { days: 7 }, { signal }), + apiGet( + "/api/v1/dashboard/message-activity", + { days: 7 }, + { signal }, + ), + apiGet("/api/v1/dashboard/node-count", { days: 7 }, { signal }), + apiGet( + "/api/v1/dashboard/packet-activity", + { days: 7 }, + { signal }, + ), + apiGet( + "/api/v1/dashboard/packet-breakdown", + { days: 7 }, + { signal }, + ), + showRoutes + ? apiGet( + "/api/v1/dashboard/routes-overview", + { days: 7 }, + { signal }, + ) + : Promise.resolve(null), + apiGet("/api/v1/channels", {}, { signal }), + ]); + setData({ + stats, + recentActivity, + advertActivity, + messageActivity, + nodeCount, + packetActivity, + packetBreakdown, + routesOverview, + channelsData, + }); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + setError( + e instanceof Error && e.message + ? e.message + : t("common.failed_to_load_page"), + ); + } finally { + setLoading(false); + } + })(); + return () => controller.abort(); + }, [showRoutes, t]); + + 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(); + return new Map([ + ...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 ; + if (error) return ; + 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 ( + <> +
+

{t("entities.dashboard")}

+
+ + {visibleChartCount > 0 && ( + <> +
+ {showNodes && ( + } + title={t("entities.nodes")} + subtitle={t("time.over_time_last_7_days")} + value={stats.total_nodes} + canvasId="nodeChart" + /> + )} + {showAdverts && ( + } + title={t("entities.advertisements")} + subtitle={t("time.per_day_last_7_days")} + value={stats.advertisements_7d} + canvasId="advertChart" + /> + )} + {showMessages && ( + } + title={t("entities.messages")} + subtitle={t("time.per_day_last_7_days")} + value={stats.messages_7d} + canvasId="messageChart" + /> + )} + {showPackets && ( + } + title={t("entities.packets")} + subtitle={t("time.per_day_last_7_days")} + value={stats.packets_7d} + canvasId="packetChart" + /> + )} +
+ + {(showPackets || (showRoutes && hasRoutes)) && ( +
+ {showPackets && ( + } + title={t("entities.packet_event_types")} + subtitle={t("time.last_7_days")} + value={eventTypeTotal} + canvasId="packetEventTypeChart" + /> + )} + {showPackets && ( + } + title={t("entities.path_hash_width")} + subtitle={t("time.last_7_days")} + value={pathWidthTotal} + canvasId="packetPathWidthChart" + /> + )} + {showRoutes && hasRoutes && ( + + + + )} + {showRoutes && hasRoutes && ( + + )} +
+ )} + + )} + + {bottomCount > 0 && ( +
+ {showAdverts && ( +
+
+

+ + {t("common.recent_entity", { + entity: t("entities.advertisements"), + })} +

+ {ads.length === 0 ? ( +

+ {t("common.no_entity_yet", { + entity: t("entities.advertisements").toLowerCase(), + })} +

+ ) : ( +
+ + + + + + + + + + + {ads.map((ad, i) => { + const friendlyName = ad.tag_name || ad.name; + const displayName = + friendlyName || ad.public_key.slice(0, 12) + "..."; + return ( + + + + + + + ); + })} + +
{t("entities.node")} + {t("common.type")} + {t("common.received")}{t("common.observers")}
+ +
+ {displayName} +
+ + {friendlyName && ( +
+ {ad.public_key.slice(0, 12)}... +
+ )} +
+ + + {formatTimeOnly(ad.received_at)} + + {ad.observers && ad.observers.length >= 1 ? ( + + ) : ad.observed_by ? ( + {"\u{1F4E1}"} + ) : ( + - + )} +
+
+ )} +
+
+ )} + + {showMessages && channelEntries.length > 0 && ( +
+
+

+ + {t("dashboard.recent_channel_messages")} +

+
+ {channelEntries.map(([channel, messages]) => ( +
+

+ + {labelForChannel(channel)} + +

+
+ {messages.map((msg, i) => ( +
+ + {formatTimeShort(msg.received_at)} + {" "} + + {msg.text || ""} + +
+ ))} +
+
+ ))} +
+
+
+ )} +
+ )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx new file mode 100644 index 0000000..af1a09d --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx @@ -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>; + +function NavCard({ + href, + icon: Icon, + label, + colorVar, +}: { + href: string; + icon: IconComponent; + label: string; + colorVar: string; +}) { + return ( + + + + + + {label} + + + ); +} + +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 ( +
+ {tiles.map(({ icon: Icon, label, value }) => ( +
+ + + + {label} + + {String(value)} + +
+ ))} +
+ ); +} + +export function HomePage() { + const { t } = useTranslation(); + const config = useAppConfig(); + const features = useFeatures(); + usePageTitle(); + + const [stats, setStats] = useState(null); + const [advertActivity, setAdvertActivity] = useState( + null, + ); + const [messageActivity, setMessageActivity] = useState( + null, + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const chartCanvasRef = useRef(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("/api/v1/dashboard/stats", {}, { signal }), + apiGet( + "/api/v1/dashboard/activity", + { days: 7 }, + { signal }, + ), + apiGet( + "/api/v1/dashboard/message-activity", + { days: 7 }, + { signal }, + ), + ]); + setStats(statsData); + setAdvertActivity(advertData); + setMessageActivity(messageData); + hasDataRef.current = true; + setError(null); + } catch (e) { + if (isAbortError(e)) return; + if (!hasDataRef.current) { + setError( + e instanceof Error && e.message + ? e.message + : t("common.failed_to_load_page"), + ); + } + } finally { + setLoading(false); + } + }, + [t], + ); + + 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 ; + if (error) return ; + 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 ( + <> +
+
+
+
+ {networkName} +
+

+ {networkName} +

+ {config.network_city && config.network_country && ( +

+ {config.network_city}, {config.network_country} +

+ )} +
+
+
+

+ {config.network_welcome_text || + t("home.welcome_default", { network_name: networkName })} +

+
+
+ {navItems + .filter((item) => features[item.feature] !== false) + .map((item) => ( + + ))} +
+ {features.pages !== false && customPages.length > 0 && ( +
+ {customPages.slice(0, 3).map((page) => ( + + + {page.title} + + ))} +
+ )} +
+
+ {showStats && ( +
+ {features.nodes !== false && ( + } + color={getPageColor("nodes")} + title={t("entities.nodes")} + value={stats.total_nodes} + description={t("home.all_discovered_nodes")} + /> + )} + {features.advertisements !== false && ( + } + color={getPageColor("adverts")} + title={t("entities.advertisements")} + value={stats.advertisements_7d} + description={t("time.last_7_days")} + /> + )} + {features.messages !== false && ( + } + color={getPageColor("messages")} + title={t("entities.messages")} + value={stats.messages_7d} + description={t("time.last_7_days")} + /> + )} + {features.packets !== false && ( + } + color={getPageColor("packets")} + title={t("entities.packets")} + value={stats.packets_7d} + description={t("time.last_7_days")} + /> + )} +
+ )} +
+ +
+ {showRadioPanel && ( +
+
+

+ + {t("home.network_info")} +

+
+ +
+
+
+ )} + + {showMembersPanel && ( +
+
+

+ + {t("entities.members")} +

+
+ } + color={getPageColor("members")} + title={t("members_page.operators")} + value={stats.total_operators ?? 0} + /> + } + color={getPageColor("members")} + title={t("members_page.members")} + value={stats.total_members ?? 0} + /> +
+
+
+ )} + + {showActivityChart && ( +
+
+

+ + {t("home.network_activity")} +

+

+ {t("time.activity_per_day_last_7_days")} +

+
+ +
+
+
+ )} +
+ + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx new file mode 100644 index 0000000..7a978cf --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx @@ -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 + ? '
' + : '
'; + + return L.divIcon({ + className: "custom-div-icon", + html: + '
' + + iconHtml + + '' + + escapeHtml(displayName) + + escapeHtml(timeDisplay) + + "" + + "
", + 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 = + ' '; + } + + 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 += + '
' + + typeLabel + + "
" + + escapeHtml(typeDisplay) + + "
"; + + if (node.role) { + const roleLabel = t("map.role"); + rows += + '
' + + roleLabel + + '
' + + escapeHtml(node.role) + + "
"; + } + + 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 += + '
' + ownerLabel + "
" + ownerDisplay + "
"; + } + + rows += + '
' + + keyLabel + + '
' + + escapeHtml(node.public_key.substring(0, 16)) + + "...
"; + rows += + '
' + + locationLabel + + "
" + + node.lat.toFixed(4) + + ", " + + node.lon.toFixed(4) + + "
"; + + if (node.last_seen) { + rows += + '
' + + lastSeenLabel + + "
" + + node.last_seen.substring(0, 19).replace("T", " ") + + "
"; + } + + return ( + '
' + + '

' + + nodeTypeEmoji + + " " + + escapeHtml(node.name || unknownLabel) + + infraIndicatorHtml + + "

" + + '
' + + rows + + "
" + + '' + + viewDetailsLabel + + "" + + "
" + ); +} + +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(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + const initialFitRef = useRef(false); + + const [mapData, setMapData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterOpen, setFilterOpen] = useState(false); + const [category, setCategory] = useState(""); + const [typeFilter, setTypeFilter] = useState(""); + const [operatorFilter, setOperatorFilter] = useState(""); + const [showLabels, setShowLabels] = useState(false); + const [nodeCount, setNodeCount] = useState(0); + const [filteredCount, setFilteredCount] = useState(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 = {}; + if (operatorFilter) params.adopted_by = operatorFilter; + apiGet("/map/data", params, { signal: ac.signal }) + .then((data) => { + setMapData(data); + setError(null); + }) + .catch((e) => { + if (isAbortError(e)) return; + setError((e as Error).message || t("common.failed_to_load_page")); + }) + .finally(() => setLoading(false)); + return () => ac.abort(); + }, [operatorFilter, t]); + + 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: + '© OpenStreetMap 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 ; + if (error) return ; + + return ( +
+
+

{t("entities.map")}

+
+ {tz && tz !== "UTC" && ( + {tz} + )} + {countBadgeText} + {showFilteredBadge && ( + + {t("common.shown", { count: formatNumber(filteredCount) })} + + )} + setFilterOpen((open) => !open)} + /> +
+
+ + {filterOpen && ( +
+
+ + +
+
+ + +
+ {oidcEnabled && operatorProfiles.length > 0 && ( +
+ + +
+ )} +
+ +
+ +
+ )} + +
+
+
+
+
+ + {oidcEnabled && ( +
+ {t("map.legend")} +
+
+ {t("map.infrastructure")} +
+
+
+ {t("map.public")} +
+
+ )} + +
+

{t("map.gps_description")}

+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx new file mode 100644 index 0000000..16f3ac1 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx @@ -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 ( + +
+

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

+ {profile.roles && profile.roles.length > 0 && ( +
+ {profile.roles.map((role) => ( + + {role} + + ))} +
+ )} + {profile.description && ( +

+ {profile.description} +

+ )} + {profile.url && ( + { + if (e.key === "Enter") openUrl(e); + }} + > + {profile.url} + + )} + {(profile.node_count ?? 0) > 0 && ( + + {t("members_page.node_count", { + count: formatNumber(profile.node_count), + })} + + )} + {profile.adopted_nodes && profile.adopted_nodes.length > 0 && ( +
+ {profile.adopted_nodes.map((node) => { + const label = node.name || node.public_key.slice(0, 12) + "..."; + return ( + openNode(e, node.public_key)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + openNode(e, node.public_key); + } + }} + > + {label} + + ); + })} +
+ )} +
+ + ); +} + +function ProfileGroup({ + title, + icon, + profiles, +}: { + title: string; + icon: ReactNode; + profiles: MemberProfile[]; +}) { + if (profiles.length === 0) return null; + return ( + <> +

+ {icon} + {title} +

+
+ {profiles.map((profile) => ( + + ))} +
+ + ); +} + +export function Members() { + const { t } = useTranslation(); + const config = useAppConfig(); + usePageTitle("entities.members"); + const [profiles, setProfiles] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + apiGet( + "/api/v1/user/profiles", + { limit: 500 }, + { signal: controller.signal }, + ) + .then((resp) => setProfiles(resp.items || [])) + .catch((e) => { + if (!isAbortError(e)) { + setError((e as Error).message || t("common.failed_to_load_page")); + } + }); + return () => controller.abort(); + }, [t]); + + if (error) return ; + if (profiles === null) return ; + + 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 ( + <> +
+

{t("entities.members")}

+
+
+

{t("members_page.empty_state")}

+

{t("members_page.empty_description")}

+
+ + ); + } + + 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 ( + <> +
+

{t("entities.members")}

+ + {t("common.count_entity", { + count: formatNumber(operators.length + members.length), + entity: t("entities.members").toLowerCase(), + })} + +
+ + + + + } + profiles={operators} + /> + + + + } + profiles={members} + /> + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx new file mode 100644 index 0000000..973db76 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx @@ -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 { + items?: T[]; + total?: number; +} + +function autoSubmit(e: React.ChangeEvent) { + 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, + 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(); + + 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(); + 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(null); + const [total, setTotal] = useState(null); + const [error, setError] = useState(null); + const [sortedAreas, setSortedAreas] = useState([]); + const [builtinLabels, setBuiltinLabels] = useState>( + () => new Map(), + ); + const [customLabels, setCustomLabels] = useState>( + () => new Map(), + ); + const [channelLabels, setChannelLabels] = useState>( + () => new Map(), + ); + const [disabledAreas, setDisabledAreas] = useState>(() => + getDisabledObserverAreas(), + ); + const [filterOpen, setFilterOpen] = useState( + messageType !== "" || channelIdx !== "" || includeSpam, + ); + + const disabledAreasRef = useRef(disabledAreas); + disabledAreasRef.current = disabledAreas; + const abortRef = useRef(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>( + "/api/v1/nodes", + { limit: 500, observer: true }, + { signal }, + ), + apiGet>("/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(); + 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 = { + 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>( + "/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 ? ( + {senderName} + ) : ( + <>{senderName} + ); + } + const prefix = (msg.pubkey_prefix || "").slice(0, 12); + if (prefix) return {prefix}; + return -; + }; + + const spamBadge = (msg: Message): ReactNode => { + if ( + !spamEnabled || + msg.spam_score == null || + msg.spam_score < spamThreshold + ) { + return null; + } + return ( + + {t("messages.spam.badge")} + + ); + }; + + const renderReceivers = (msg: Message, variant: "mobile" | "desktop") => { + if (msg.observers && msg.observers.length >= 1) { + return ; + } + if (msg.observed_by) { + return ( + + {"\u{1F4E1}"} + + ); + } + return variant === "desktop" ? - : null; + }; + + const totalPages = total !== null ? Math.ceil(total / limit) : 0; + const headerParams: Record = { + message_type: messageType, + channel_idx: channelIdx, + limit: String(limit), + }; + if (includeSpam) headerParams.include_spam = "true"; + const paginationParams: Record = { + ...headerParams, + sort, + order, + }; + const emptyMessage = t("common.no_entity_found", { + entity: t("entities.messages").toLowerCase(), + }); + + return ( + <> +
+

{t("entities.messages")}

+ {tz && tz !== "UTC" && ( + {tz} + )} +
+ +
+ {total !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((o) => !o)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+ {spamEnabled && ( +
+ + +
+ )} +
+
+ )} + + {items === null ? ( + + ) : ( + <> + + + + + + +
+ {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + 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 ? ( + + {chInfo.label || t("messages.type_channel")} + + ) : ( + senderBlock(msg) + ); + const detailUrl = + packetsEnabled && msg.packet_hash + ? `/packets/hash/${msg.packet_hash}` + : null; + return ( +
navigate(detailUrl) : undefined + } + > +
+
+
+ + {typeIcon} + +
+
+ {fromPrimary} +
+
+ {formatDateTimeShort(msg.received_at)} + {spamBadge(msg)} +
+
+
+
+ {renderReceivers(msg, "mobile")} +
+
+

+ {displayMessage} +

+
+
+ ); + }) + )} +
+ +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + 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 ? ( + + {chInfo.label || t("messages.type_channel")} + + ) : ( + senderBlock(msg, true) + ); + const detailUrl = + packetsEnabled && msg.packet_hash + ? `/packets/hash/${msg.packet_hash}` + : null; + return ( + navigate(detailUrl) : undefined + } + > + + + + + + + ); + }) + )} + +
{t("common.observers")}
+ {emptyMessage} +
+ {typeIcon} + + {formatDateTime(msg.received_at)} + +
{fromPrimary}
+
+
+ + {displayMessage} + + {spamBadge(msg)} +
+
{renderReceivers(msg, "desktop")}
+
+ + + + )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx new file mode 100644 index 0000000..0c7362e --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx @@ -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(null); + const [advertisements, setAdvertisements] = useState( + [], + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notFound, setNotFound] = useState(false); + const [flash, setFlash] = useState(null); + + const [addKey, setAddKey] = useState(""); + const [addValue, setAddValue] = useState(""); + const [addType, setAddType] = useState("string"); + const [addError, setAddError] = useState(""); + + const [editTag, setEditTag] = useState(null); + const [editValue, setEditValue] = useState(""); + const [editType, setEditType] = useState("string"); + const [editError, setEditError] = useState(""); + const [editSaving, setEditSaving] = useState(false); + + const [deleteKey, setDeleteKey] = useState(null); + const [deleteSaving, setDeleteSaving] = useState(false); + + const mapContainerRef = useRef(null); + const qrRef = useRef(null); + const mapRef = useRef(null); + const qrInitRef = useRef(false); + + useEffect(() => { + if (!publicKey || publicKey.length === 64) return; + const ac = new AbortController(); + (async () => { + try { + const resolved = await apiGet( + `/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( + `/api/v1/nodes/${publicKey}`, + {}, + { signal }, + ), + apiGet( + "/api/v1/advertisements", + { public_key: publicKey, limit: 10 }, + { signal }, + ), + apiGet( + "/api/v1/telemetry", + { node_public_key: publicKey, limit: 10 }, + { signal }, + ), + ]); + if (!nodeData) { + setNotFound(true); + return; + } + setNode(nodeData); + setAdvertisements(adsData.items || []); + setNotFound(false); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + if (errorMessage(e).includes("404")) { + setNotFound(true); + } else { + setError(errorMessage(e)); + } + } finally { + if (!signal.aborted) setLoading(false); + } + }, + [publicKey], + ); + + 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: + '' + + emoji + + "", + 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 = { + 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 ( + <> +
+
    +
  • + {t("entities.home")} +
  • +
  • + {t("entities.nodes")} +
  • +
  • {t("common.page_not_found")}
  • +
+
+
+ + + {t("common.entity_not_found_details", { + entity: t("entities.node"), + details: publicKey, + })} + +
+ + {t("common.view_entity", { entity: t("entities.nodes") })} + + + ); + } + if (error) { + return ; + } + return ; + } + + 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 = ( +
+
+

{t("nodes.ownership")}

+
+

+ {t("nodes.adopted_by_prefix")}{" "} + + {ownerName} + +

+ {canRelease && ( + + )} +
+
+
+ ); + } else if (isOperator || isAdmin) { + adoptionCard = ( +
+
+

{t("nodes.ownership")}

+

{t("nodes.not_adopted")}

+
+ +
+
+
+ ); + } + } + + const publicKeyCard = ( +
+
+
+

+ {t("common.public_key")} +

+ copyToClipboard(e, node.public_key)} + title="Click to copy" + > + {node.public_key} + +
+
+
+ {t("common.first_seen_label")}{" "} + {formatDateTime(node.first_seen)} +
+
+ {t("common.last_seen_label")}{" "} + {formatDateTime(node.last_seen)} +
+ {hasCoords && ( +
+ {t("common.location")}:{" "} + {lat}, {lon} +
+ )} +
+
+
+ ); + + const tags = node.tags || []; + + const tagsTable = canEditTags ? ( + tags.length > 0 ? ( +
+ + + + + + + + + + + {tags.map((tag) => ( + + + + + + + ))} + +
{t("common.key")}{t("common.value")}{t("common.type")}{t("common.actions")}
+ {tag.key} + + {tag.value || ""} + + {tag.value_type || "string"} + +
+ + +
+
+
+ ) : ( +

+ {t("common.no_entity_defined", { + entity: t("entities.tags").toLowerCase(), + })} +

+ ) + ) : tags.length > 0 ? ( +
+ + + + + + + + + + {tags.map((tag) => ( + + + + + + ))} + +
{t("common.key")}{t("common.value")}{t("common.type")}
{tag.key}{tag.value || ""}{tag.value_type || "string"}
+
+ ) : ( +

+ {t("common.no_entity_defined", { + entity: t("entities.tags").toLowerCase(), + })} +

+ ); + + return ( + <> +
+
    +
  • + {t("entities.home")} +
  • +
  • + {t("entities.nodes")} +
  • +
  • {tagName || node.name || truncateKey(node.public_key)}
  • +
+
+ +
+ + {emoji} + +
+

{displayName}

+ {tagDescription && ( +

{tagDescription}

+ )} +
+
+ + {flashMessage ? ( + + ) : flashError ? ( + + ) : null} + + {flash && + (flash.type === "success" ? ( + + ) : ( + + ))} + + {hasCoords ? ( +
+
+
+
+
+
+ ) : ( +
+
+
+

{t("nodes.scan_to_add")}

+
+
+ )} + + {adoptionCard ? ( +
+ {publicKeyCard} + {adoptionCard} +
+ ) : ( +
{publicKeyCard}
+ )} + +
+
+
+

+ {t("common.recent_entity", { + entity: t("entities.advertisements"), + })} +

+ {advertisements.length > 0 ? ( +
+ + + + + + + + + + {advertisements.map((adv, idx) => { + const recvName = adv.observed_by + ? (adv.observer_tag_name || adv.observer_name) + : null; + return ( + + + + + + ); + })} + +
{t("common.time")}{t("common.type")}{t("common.received_by")}
+ {formatDateTime(adv.received_at)} + + {adv.adv_type ? ( + + {typeEmoji(adv.adv_type)} + + ) : ( + - + )} + + {!adv.observed_by ? ( + - + ) : recvName ? ( + +
+ {recvName} +
+
+ {adv.observed_by.slice(0, 16)}... +
+ + ) : ( + + + {adv.observed_by.slice(0, 12)}... + + + )} +
+
+ ) : ( +

+ {t("common.no_entity_recorded", { + entity: t("entities.advertisements").toLowerCase(), + })} +

+ )} +
+
+ +
+
+

{t("entities.tags")}

+ {tagsTable} + {canEditTags && ( +
+
+
+ setAddKey(e.target.value)} + /> +
+
+ setAddValue(e.target.value)} + /> + {addError && ( +
{addError}
+ )} +
+ + +
+
+ )} +
+
+
+ + {canEditTags && editTag && ( +
+
+

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

+
+
+ + setEditValue(e.target.value)} + /> + {editError && ( +
{editError}
+ )} +
+
+ + +
+
+ + +
+
+
+
!editSaving && setEditTag(null)} + /> +
+ )} + + {canEditTags && deleteKey !== null && ( +
+
+

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

+

+

+ {t("common.cannot_be_undone")} +
+
+ + +
+
+
!deleteSaving && setDeleteKey(null)} + /> +
+ )} + + ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx new file mode 100644 index 0000000..87bdecf --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx @@ -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([]); + const [total, setTotal] = useState(null); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const oidcEnabled = config.oidc_enabled; + const operatorRole = config.role_names?.operator || "operator"; + + const fetchData = useCallback(async () => { + try { + const apiParams: Record = { + limit, + offset, + search, + adv_type: advType, + sort, + order, + }; + if (adoptedBy) apiParams.adopted_by = adoptedBy; + if (pubkeyPrefix) apiParams.pubkey_prefix = pubkeyPrefix; + + const fetches: Promise[] = [ + apiGet("/api/v1/nodes", apiParams), + ]; + if (oidcEnabled) { + fetches.push( + apiGet("/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 = { + search, + adv_type: advType, + adopted_by: adoptedBy, + pubkey_prefix: pubkeyPrefix, + limit: String(limit), + }; + + const autoSubmit = (e: React.ChangeEvent) => { + e.currentTarget.form?.requestSubmit(); + }; + + const noEntity = t("common.no_entity_found", { + entity: t("entities.nodes").toLowerCase(), + }); + + const mobileCards = + nodes.length === 0 ? ( +
{noEntity}
+ ) : ( + 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 ( + +
+
+ +
+
{lastSeen}
+
+
+
+ + ); + }) + ); + + const tableRows = + nodes.length === 0 ? ( + + + {noEntity} + + + ) : ( + 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 ( + + + + + + + + copyToClipboard(e, node.public_key)} + title="Click to copy" + > + {node.public_key} + + + {lastSeen} + + ); + }) + ); + + if (loading) return ; + + return ( +
+
+

{t("entities.nodes")}

+ {tz && tz !== "UTC" && ( + {tz} + )} +
+ +
+ {total !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((open) => !open)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+ {oidcEnabled && sortedProfiles.length > 0 && ( +
+ + +
+ )} +
+
+ )} + + + +
{mobileCards}
+ +
+ + + + + + + + + {tableRows} +
+
+ + +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx new file mode 100644 index 0000000..b18bd7f --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx @@ -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 { + const names = new Map(); + for (const c of items) { + const idx = parseInt(c.channel_hash, 16); + if (!Number.isNaN(idx)) names.set(idx, c.name); + } + return names; +} + +function isNotFoundError(e: unknown): boolean { + return e instanceof Error && e.message.includes("404"); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export function PacketDetail() { + const { t } = useTranslation(); + usePageTitle("packets.detail_title"); + const { id } = useParams(); + const config = useAppConfig(); + const { formatDateTime } = useFormatDateTime(); + + const [packet, setPacket] = useState(null); + const [channelNames, setChannelNames] = useState>( + new Map(), + ); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setPacket(null); + setNotFound(false); + setError(null); + Promise.all([ + apiGet(`/api/v1/packets/${id}`, {}, { + signal: controller.signal, + }), + apiGet("/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 = ; + if (packet && packet.channel_idx != null) { + const name = channelNames.get(packet.channel_idx); + channelDisplay = name + ? `${name} (${packet.channel_idx})` + : `${packet.channel_idx}`; + } + + return ( +
+
+
    +
  • + {t("entities.home")} +
  • +
  • + {t("entities.packets")} +
  • +
  • {leaf || t("packets.detail_title")}
  • +
+
+ +
+

{t("packets.detail_title")}

+ {tz && tz !== "UTC" && {tz}} +
+ + {notFound && ( +
+ {t("common.entity_not_found_details", { + entity: t("entities.packet").toLowerCase(), + })} +
+ )} + {error && } + {!packet && !notFound && !error && } + + {packet && ( + <> + {packet.redacted && ( +
+ {"\u{1F512}"} {t("packets.redacted_notice")} +
+ )} +
+
+
+ + {formatDateTime(packet.received_at)} + + + {packet.observed_by ? ( + + {packet.observer_tag_name || + packet.observer_name || + packet.observed_by} + + ) : ( + + )} + + + {packet.event_type || "—"} + + {channelDisplay} + + {packet.source_pubkey_prefix ? ( + + {packet.source_pubkey_prefix} + + ) : ( + + )} + + + {packet.packet_hash ? ( + + {packet.packet_hash} + + ) : ( + + )} + + + {packet.packet_type != null ? packet.packet_type : "—"} + + + {packet.payload_type != null ? packet.payload_type : "—"} + + + {packet.route_type || "—"} + + + {packet.snr != null ? Number(packet.snr).toFixed(1) : "—"} + + + {packet.path_len != null ? packet.path_len : "—"} + +
+ + {!packet.redacted && ( +
+
+ + {t("packets.col_raw")} + + {packet.raw_hex && ( + + )} +
+
+                    {packet.raw_hex || "—"}
+                  
+
+ )} + + {!packet.redacted && packet.decoded != null && ( +
+ + {t("packets.decoded")} + +
+ +
+
+ )} +
+
+ + )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx new file mode 100644 index 0000000..e2cbbb0 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx @@ -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 { + const names = new Map(); + for (const c of items) { + const idx = parseInt(c.channel_hash, 16); + if (!Number.isNaN(idx)) names.set(idx, c.name); + } + return names; +} + +function isNotFoundError(e: unknown): boolean { + return e instanceof Error && e.message.includes("404"); +} + +function groupByObserver(receptions: Reception[]): Map { + const groups = new Map(); + for (const r of receptions) { + 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 ( +
+ {label} + {children} +
+ ); +} + +function Stat({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function PathBadge({ + hash, + onOpen, +}: { + hash: string; + onOpen: (e: React.MouseEvent, hash: string) => void; +}) { + return ( + onOpen(e, hash)} + > + {hash} + + ); +} + +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) => ( + + )); + } else { + const hidden = hashes.length - PATH_HEAD - PATH_TAIL; + middle = [ + ...hashes + .slice(0, PATH_HEAD) + .map((h, i) => ( + + )), + , + ...hashes + .slice(-PATH_TAIL) + .map((h, i) => ( + + )), + ]; + } + } else if (reception.path_len != null) { + middle = [ + + {reception.path_len} {t("common.hops").toLowerCase()} + , + ]; + } else { + middle = [ + + — + , + ]; + } + + const parts: ReactNode[] = [ + , + ...middle, + + + , + ]; + + const joined: ReactNode[] = []; + parts.forEach((part, i) => { + if (i > 0) { + joined.push( + + → + , + ); + } + joined.push(part); + }); + + return {joined}; +} + +export function PacketGroupDetail() { + const { t } = useTranslation(); + usePageTitle("packets.detail_title"); + const { hash } = useParams(); + const config = useAppConfig(); + const { formatDateTime } = useFormatDateTime(); + + const [group, setGroup] = useState(null); + const [channelNames, setChannelNames] = useState>( + new Map(), + ); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + + const [popover, setPopover] = useState(null); + const [popoverPos, setPopoverPos] = useState<{ + left: number; + top: number; + } | null>(null); + const [popoverNodes, setPopoverNodes] = useState(null); + const [popoverTotal, setPopoverTotal] = useState(0); + const [popoverError, setPopoverError] = useState(null); + const popoverRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + setGroup(null); + setNotFound(false); + setError(null); + Promise.all([ + apiGet(`/api/v1/packet-groups/${hash}`, {}, { + signal: controller.signal, + }), + apiGet("/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("/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 = ; + if (group && group.channel_idx != null) { + const name = channelNames.get(group.channel_idx); + channelDisplay = name + ? `${name} (${group.channel_idx})` + : `${group.channel_idx}`; + } + + const receptionTime = (r: Reception) => ( + + {formatRelativeTime(r.received_at)} + + ); + + return ( +
+
+
    +
  • + {t("entities.home")} +
  • +
  • + {t("entities.packets")} +
  • +
  • {leaf || t("packets.detail_title")}
  • +
+
+ +
+

{t("packets.detail_title")}

+ {tz && tz !== "UTC" && {tz}} +
+ + {notFound && ( +
+ {t("packets.not_found_retention")} +
+ )} + {error && } + {!group && !notFound && !error && } + + {group && ( + <> + {group.redacted && ( +
+ {"\u{1F512}"} {t("packets.redacted_notice")} +
+ )} +
+
+
+ + {formatDateTime(group.first_seen)} + + + {group.event_type || "—"} + + {channelDisplay} + + {group.source_pubkey_prefix ? ( + + {group.source_pubkey_prefix} + + ) : ( + + )} + + + {group.packet_hash ? ( + + {group.packet_hash} + + ) : ( + + )} + + + {group.packet_type != null ? group.packet_type : "—"} + + + {group.payload_type != null ? group.payload_type : "—"} + + + {group.route_type || "—"} + + + {formatNumber(group.reception_count)}{" "} + {group.reception_count === 1 + ? t("packets.reception_singular") + : t("packets.reception_plural")}{" "} + · {formatNumber(group.observer_count)}{" "} + {t("common.observers").toLowerCase()} + +
+ + {receptions.length > 0 && ( +
+

+ {t("packets.receptions_title")} + + ({formatNumber(group.reception_count)}{" "} + {group.reception_count === 1 + ? t("packets.reception_singular") + : t("packets.reception_plural")} + , {formatNumber(group.observer_count)}{" "} + {t("common.observers").toLowerCase()}) + +

+ {[...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 ( +
+
+ {"\u{1F4E1}"}{" "} + {first.observed_by ? ( + + {displayName} + + ) : ( + displayName + )} + {recs.length > 1 && ( + + ({formatNumber(recs.length)}{" "} + {t("packets.reception_plural")}) + + )} +
+ +
+ {recs.map((r) => ( +
+
+ +
+
+ + + +
+
+ ))} +
+ +
+ + + + + + + + + + + {recs.map((r) => ( + + + + + + + ))} + +
{t("packets.col_path")} + {t("common.hops")} + + {t("common.snr_db")} + + {t("common.time")} +
+ + + {r.path_len != null ? r.path_len : "—"} + + {r.snr != null + ? Number(r.snr).toFixed(1) + : "—"} + + {receptionTime(r)} +
+
+
+ ); + })} +
+ )} + + {!group.redacted && group.raw_hex && ( +
+
+ + {t("packets.col_raw")} + + +
+
+                    {group.raw_hex}
+                  
+
+ )} + + {!group.redacted && group.decoded != null && ( +
+ + {t("packets.decoded")} + +
+ +
+
+ )} +
+
+ + )} + + {popover && ( +
+
+ + {t("packets.path_nodes_title", { hash: popover.hash })} + + +
+
+ {popoverError ? ( +
+ +
+ ) : popoverNodes === null ? ( +
+ +
+ ) : popoverNodes.length === 0 ? ( +
+ {t("packets.path_no_nodes")} +
+ ) : ( +
    + {popoverNodes.map((n) => ( +
  • + setPopover(null)} + className="flex flex-col items-start gap-0" + > + {nodeDisplayName(n)} + + {truncateKey(n.public_key, 16)} + + +
  • + ))} + {moreCount > 0 && ( +
  • + setPopover(null)} + className="text-xs opacity-70" + > + {t("packets.path_nodes_more", { + count: formatNumber(moreCount), + })} + +
  • + )} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx new file mode 100644 index 0000000..38f7ea2 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx @@ -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; +}) { + const { t } = useTranslation(); + if (packet.channel_idx == null) return ; + const name = channelNames.get(packet.channel_idx); + const text = name ? `${name} (${packet.channel_idx})` : `${packet.channel_idx}`; + return ( + <> + {text} + {packet.redacted && ( + <> + {" "} + + {"\u{1F512}"} + + + )} + + ); +} + +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 ( + + + + {formatNumber(oc)} + + + + + {formatNumber(rc)} + + + + + {widthLabel} + + + ); +} + +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(null); + const [total, setTotal] = useState(0); + const [channels, setChannels] = useState([]); + const [error, setError] = useState(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 = { + limit, + offset, + search, + sort, + order, + }; + if (eventType) apiParams.event_type = eventType; + if (channelIdx !== "") apiParams.channel_idx = channelIdx; + if (pathHashBytes !== "") apiParams.path_hash_bytes = pathHashBytes; + + const [data, channelsData] = await Promise.all([ + apiGet("/api/v1/packet-groups", apiParams, { + signal, + }), + apiGet("/api/v1/channels", { limit: 200 }, { + signal, + }).catch(() => ({ items: [] as ChannelItem[] })), + ]); + + setPackets(data.items || []); + setTotal(data.total || 0); + setChannels(buildChannelList(channelsData.items || [])); + setError(null); + } catch (e) { + if (isAbortError(e)) return; + setError(e instanceof Error ? e.message : String(e)); + } + }, + [search, eventType, channelIdx, pathHashBytes, limit, offset, sort, order], + ); + + useEffect(() => { + const controller = new AbortController(); + fetchData(controller.signal); + return () => controller.abort(); + }, [fetchData]); + + const autoRefresh = useAutoRefresh({ + onRefresh: () => fetchData(), + }); + + const applyFilters = (overrides: Record) => { + const next = { + search, + 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 = { + 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 ( +
+
+

{t("entities.packets")}

+ {tz && tz !== "UTC" && {tz}} +
+ +
+ {packets !== null && ( + + {t("common.total", { count: formatNumber(total) })} + + )} + {error && } +
+ {autoRefresh.intervalSeconds > 0 && ( + + )} +
+
+ setFilterOpen((o) => !o)} + /> +
+
+ + {filterOpen && ( +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} + + {packets === null ? ( + + ) : ( + <> + + +
+ {packets.length === 0 ? ( +
{noneFound}
+ ) : ( + packets.map((p, i) => ( + +
+
+
+
+ {p.event_type || "—"} +
+
+ +
+
+
+
+ {formatDateTimeShort(p.first_seen)} +
+
+ +
+
+
+
+ + )) + )} +
+ +
+ + + + + + + + + + + + {packets.length === 0 ? ( + + + + ) : ( + packets.map((p, i) => ( + navigate(packetUrl(p))} + > + + + + + + + )) + )} + +
{t("packets.packet_hash")} + {t("packets.col_receptions")} + {t("entities.channel")}
+ {noneFound} +
+ {formatDateTime(p.first_seen)} + + {p.packet_hash ? ( + + {p.packet_hash} + + ) : ( + + )} + + + + {p.event_type || "—"} + + +
+
+ + + + )} +
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx new file mode 100644 index 0000000..359b710 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx @@ -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 ( +
+ {roles.map((role) => ( + + {role} + + ))} +
+ ); +} + +function MemberSince({ createdAt }: { createdAt?: string | null }) { + const { t } = useTranslation(); + const { formatDateTime } = useFormatDateTime(); + if (!createdAt) return null; + return ( +

+ {t("user_profile.member_since", { + date: formatDateTime(createdAt, { + year: "numeric", + month: "long", + day: "numeric", + }), + })} +

+ ); +} + +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 ( + +
+
{displayName}
+
+ {node.public_key} +
+
+ + + ); +} + +function AdoptedNodesCard({ + profile, + className, +}: { + profile: UserProfileData; + className?: string; +}) { + const { t } = useTranslation(); + return ( +
+
+

{t("user_profile.adopted_nodes")}

+ {profile.nodes && profile.nodes.length > 0 ? ( +
+ {profile.nodes.map((node) => ( + + ))} +
+ ) : ( +

+ {t("user_profile.no_adopted_nodes")} +

+ )} +
+
+ ); +} + +function PublicProfileView({ id }: { id: string }) { + const { t } = useTranslation(); + const config = useAppConfig(); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setProfile(null); + setError(null); + apiGet( + `/api/v1/user/profile/${id}`, + {}, + { signal: controller.signal }, + ) + .then(setProfile) + .catch((e) => { + if (!isAbortError(e)) { + setError((e as Error).message || t("common.failed_to_load_page")); + } + }); + return () => controller.abort(); + }, [id, t]); + + if (error) return ; + if (!profile) return ; + + const isOwner = + !!config.user && !!profile.user_id && config.user.sub === profile.user_id; + + return ( + <> +
+

{t("user_profile.title")}

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

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

+ + {profile.description && ( +

{profile.description}

+ )} + {profile.url && ( + + {profile.url} + + )} + + {hasOperatorOrAdmin(profile.roles, config) && ( + + )} +
+
+ + ); +} + +function OwnProfileView() { + const { t } = useTranslation(); + const config = useAppConfig(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setProfile(null); + setError(null); + apiGet( + "/api/v1/user/profile/me", + {}, + { signal: controller.signal }, + ) + .then(setProfile) + .catch((e) => { + if (!isAbortError(e)) { + setError((e as Error).message || t("common.failed_to_load_page")); + } + }); + return () => controller.abort(); + }, [searchParams, t]); + + if (!config.oidc_enabled || !config.user) { + return ( +
+

{t("user_profile.title")}

+

{t("user_profile.login_to_view")}

+ + {t("auth.login")} + +
+ ); + } + + if (error) return ; + if (!profile) return ; + + const flashMessage = searchParams.get("message") || ""; + const flashError = searchParams.get("error") || ""; + + const handleSubmit = async (e: FormEvent) => { + 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 ( + <> +
+

{t("user_profile.title")}

+
+ + {flashMessage ? ( + + ) : flashError ? ( + + ) : null} + +
+
+
+
+

{t("user_profile.your_profile")}

+ +
+ + + + + +
+ +
+
+
+ + {hasOperatorOrAdmin(profile.roles, config) && ( + + )} +
+ + ); +} + +export function Profile() { + const { id } = useParams(); + usePageTitle("links.profile"); + + return id ? : ; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx new file mode 100644 index 0000000..d83e1ce --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx @@ -0,0 +1,1636 @@ +import { + Fragment, + useCallback, + useEffect, + useRef, + useState, + type SVGProps, +} 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 { + IconClock, + IconEdit, + IconNodes, + IconPackets, + IconPath, + IconPlus, + IconRuler, + IconSatelliteDish, + IconTrash, +} from "@/components/icons"; + +interface RouteResultInfo { + quality?: string | null; + state?: string | null; + matched_count?: number | null; + threshold?: number | null; + effective_clear?: number | null; +} + +interface RouteNodeInfo { + node_id?: string | null; + public_key?: string | null; + name?: string | null; + expected_hash?: string | null; +} + +interface RouteObserverInfo { + public_key?: string | null; + name?: string | null; +} + +interface RouteItem { + id: string; + from_label?: string | null; + to_label?: string | null; + description?: string | null; + visibility?: string | null; + enabled: boolean; + reversible?: boolean | null; + match_width?: number | null; + window_hours?: number | null; + packet_count_threshold?: number | null; + clear_threshold?: number | null; + max_hop_span?: number | null; + max_path_length?: number | null; + quality_avg?: string | null; + route_result?: RouteResultInfo | null; + route_nodes?: RouteNodeInfo[]; + route_observers?: RouteObserverInfo[]; +} + +interface RouteListResponse { + items: RouteItem[]; +} + +interface MatchHop { + node_hash?: string | null; +} + +interface RouteMatch { + packet_hash?: string | null; + received_at?: string | null; + hops?: MatchHop[]; +} + +interface RouteDetail { + recent_matches?: RouteMatch[]; +} + +interface HistoryDay { + date: string; + quality?: string | null; + matched_count?: number | null; +} + +interface RouteHistory { + data?: HistoryDay[]; +} + +interface NodeSearchResult { + public_key: string; + name?: string | null; + adv_type?: string | null; +} + +interface NodeListResponse { + items: NodeSearchResult[]; +} + +interface SelectedNode { + public_key: string; + name?: string | null; +} + +interface ChartInstance { + destroy: () => void; +} + +interface ModalState { + type: "add" | "edit" | "delete"; + route: RouteItem | null; + pathNodes: SelectedNode[]; + observerNodes: SelectedNode[]; + pathResults: NodeSearchResult[]; + obsResults: NodeSearchResult[]; + saving: boolean; +} + +interface RouteFormValues { + from_label: string; + to_label: string; + description: string; + visibility: string; + match_width: number; + window_hours: string; + packet_count_threshold: string; + clear_threshold: string; + max_hop_span: string; + max_path_length: string; + enabled: boolean; + reversible: boolean; +} + +type MatchEntry = + | { kind: "hop"; hop: MatchHop } + | { kind: "ellipsis"; hidden: number }; + +type TranslateFn = ReturnType["t"]; + +const VISIBILITY_ORDER = ["community", "member", "operator", "admin"]; +const PATH_MAX = 5; +const PATH_HEAD = 2; +const PATH_TAIL = 2; + +function qualityOf(route: RouteItem): string { + return route.quality_avg || route.route_result?.quality || "unknown"; +} + +function qualityBadgeClass(quality: string, enabled: boolean): string { + if (!enabled) return "badge-neutral"; + const map: Record = { + clear: "badge-success", + marginal: "badge-warning", + failing: "badge-error", + no_coverage: "badge-info", + unknown: "badge-ghost", + }; + return map[quality] || "badge-ghost"; +} + +function qualityLabel( + quality: string, + enabled: boolean, + t: TranslateFn, +): string { + if (!enabled) return t("routes.disabled"); + const map: Record = { + clear: t("routes.quality_clear"), + marginal: t("routes.quality_marginal"), + failing: t("routes.quality_failing"), + no_coverage: t("routes.quality_no_coverage"), + unknown: t("routes.quality_unknown"), + }; + return map[quality] || quality || t("routes.quality_unknown"); +} + +function qualityDot(quality: string, enabled: boolean): string { + if (!enabled) return "\u25CC"; + const dots: Record = { + clear: "\u25CF", + marginal: "\u25CF", + failing: "\u25CF", + no_coverage: "\u25D0", + unknown: "\u25D0", + }; + return dots[quality] || "\u25D0"; +} + +function diagnosisText(route: RouteItem, t: TranslateFn): string { + const result = route.route_result; + if (!result || !route.enabled) return ""; + if (result.state === "healthy") return t("routes.diagnosis_healthy"); + if (result.state === "unhealthy") return t("routes.diagnosis_unhealthy"); + if (result.state === "no_coverage") return t("routes.diagnosis_no_coverage"); + return ""; +} + +function IconRouteFrom(props: SVGProps) { + return ( + + + + + ); +} + +function IconRouteTo(props: SVGProps) { + return ( + + + + + ); +} + +function SummaryStrip({ routes }: { routes: RouteItem[] }) { + const { t } = useTranslation(); + const counts = { clear: 0, marginal: 0, failing: 0, no_coverage: 0, disabled: 0 }; + for (const r of routes) { + if (!r.enabled) { + counts.disabled++; + continue; + } + const q = qualityOf(r); + if (q === "clear") counts.clear++; + else if (q === "marginal") counts.marginal++; + else if (q === "failing") counts.failing++; + else counts.no_coverage++; + } + return ( +
+ + {"\u25CF"} {counts.clear}{" "} + {t("routes.quality_clear")} + + + {"\u25CF"} {counts.marginal}{" "} + {t("routes.quality_marginal")} + + + {"\u25CF"} {counts.failing}{" "} + {t("routes.quality_failing")} + + + {"\u25D0"} {counts.no_coverage}{" "} + {t("routes.quality_no_coverage")} + + + {"\u25CC"} {counts.disabled} {t("routes.disabled")} + +
+ ); +} + +function PathChips({ route }: { route: RouteItem }) { + const nodes = route.route_nodes || []; + const arrow = route.reversible !== false ? "\u2194" : "\u2192"; + const prefixLen = 2 * (route.match_width || 1); + return ( +
+ {nodes.map((rn, i) => ( + + {i > 0 && {arrow}} + + {rn.name + ? `${rn.name} (${rn.public_key?.slice(0, prefixLen)})` + : rn.public_key?.slice(0, prefixLen) || rn.node_id?.slice(0, 8)} + + + ))} +
+ ); +} + +function StatsRow({ route }: { route: RouteItem }) { + const { t } = useTranslation(); + const result = route.route_result; + const matched = result?.matched_count ?? "?"; + const threshold = result?.threshold ?? "?"; + const degraded = result?.effective_clear ?? "?"; + const nodeCount = (route.route_nodes || []).length; + const obsCount = (route.route_observers || []).length; + + return ( +
+ + + + {matched}/{threshold} + {"\u2192"} + {degraded} + + + + + {route.window_hours}h + + + + {route.match_width}B + + + + {nodeCount} + + + + {route.max_hop_span || "\u221E"} + + + + {route.max_path_length || "\u221E"} + + + + {obsCount || "\u221E"} + +
+ ); +} + +function MatchRow({ + match, + route, + packetsEnabled, + onNavigate, +}: { + match: RouteMatch; + route: RouteItem; + packetsEnabled: boolean; + onNavigate: (url: string) => void; +}) { + const { t } = useTranslation(); + const prefixLen = 2 * (route.match_width || 1); + const pathLookup = new Map( + (route.route_nodes || []).map((rn) => [ + (rn.expected_hash || "").toLowerCase(), + rn, + ]), + ); + const detailUrl = + packetsEnabled && match.packet_hash + ? `/packets/hash/${match.packet_hash}` + : null; + const hops = match.hops || []; + const entries: MatchEntry[] = []; + if (hops.length > PATH_MAX) { + const hidden = hops.length - PATH_HEAD - PATH_TAIL; + for (const h of hops.slice(0, PATH_HEAD)) entries.push({ kind: "hop", hop: h }); + entries.push({ kind: "ellipsis", hidden }); + for (const h of hops.slice(-PATH_TAIL)) entries.push({ kind: "hop", hop: h }); + } else { + for (const h of hops) entries.push({ kind: "hop", hop: h }); + } + + return ( +
{ + e.stopPropagation(); + onNavigate(detailUrl); + } + : undefined + } + > + {entries.map((entry, i) => { + if (entry.kind === "ellipsis") { + return ( + + {i > 0 && {"\u2192"}} + + {"\u2026"} + + + ); + } + const hash = (entry.hop.node_hash || "").toLowerCase(); + const inPath = pathLookup.has(hash.slice(0, prefixLen)); + return ( + + {i > 0 && {"\u2192"}} + {inPath ? ( + {hash} + ) : ( + {hash} + )} + + ); + })} + {match.received_at && ( + + {new Date(match.received_at).toLocaleString()} + + )} +
+ ); +} + +function DetailContent({ + route, + detail, + history, + packetsEnabled, + onNavigate, +}: { + route: RouteItem; + detail: RouteDetail; + history: RouteHistory | undefined; + packetsEnabled: boolean; + onNavigate: (url: string) => void; +}) { + const { t } = useTranslation(); + const matches = detail.recent_matches || []; + const historyData = history?.data ?? []; + + return ( +
+ {history && ( +
+
+ +
+ {historyData.length > 0 && ( +
+ {historyData.map((d, i) => ( + + {i === historyData.length - 1 + ? t("routes.last_n_hours", { n: route.window_hours }) + : new Date(`${d.date}T00:00:00`).toLocaleDateString( + undefined, + { day: "2-digit", month: "2-digit" }, + )} + + ))} +
+ )} +
+ )} + {matches.length > 0 && ( +
+ {t("routes.recent_packets")} +
+ {matches.map((m, i) => ( + + ))} +
+
+ )} +
+ ); +} + +function RouteCard({ + route, + detail, + history, + isAdmin, + packetsEnabled, + onEdit, + onDelete, + onNavigate, +}: { + route: RouteItem; + detail: RouteDetail | undefined; + history: RouteHistory | undefined; + isAdmin: boolean; + packetsEnabled: boolean; + onEdit: () => void; + onDelete: () => void; + onNavigate: (url: string) => void; +}) { + const { t } = useTranslation(); + const q = qualityOf(route); + const badgeCls = qualityBadgeClass(q, route.enabled); + const label = qualityLabel(q, route.enabled, t); + const dot = qualityDot(q, route.enabled); + const tip = diagnosisText(route, t); + + return ( +
+
+
+
+

+
+ + + + + {route.from_label} + + + + + + {route.to_label} + +
+

+ {route.description && ( +

{route.description}

+ )} +
+
+ {tip ? ( + + {dot} {label} + + ) : ( + + {dot} {label} + + )} +
+
+
+ +
+ + {detail ? ( + + ) : ( +
+ +
+ )} + {isAdmin && ( +
+ + +
+ )} +
+
+ ); +} + +function NodeSearchResultRow({ + node, + onSelect, +}: { + node: NodeSearchResult; + onSelect: () => void; +}) { + const name = node.name || `${node.public_key.slice(0, 12)}\u2026`; + return ( +
  • + +
  • + ); +} + +interface RouteModalProps { + route: RouteItem | null; + isEdit: boolean; + pathNodes: SelectedNode[]; + observerNodes: SelectedNode[]; + pathResults: NodeSearchResult[]; + obsResults: NodeSearchResult[]; + saving: boolean; + onPathSearch: (query: string) => void; + onPathSelect: (node: NodeSearchResult) => void; + onPathRemove: (index: number) => void; + onPathMove: (index: number, dir: number) => void; + onPathEnter: (query: string) => void; + onObsSearch: (query: string) => void; + onObsSelect: (node: NodeSearchResult) => void; + onObsRemove: (index: number) => void; + onObsEnter: (query: string) => void; + onSubmit: (values: RouteFormValues) => void; + onCancel: () => void; +} + +function RouteModal({ + route, + isEdit, + pathNodes, + observerNodes, + pathResults, + obsResults, + saving, + onPathSearch, + onPathSelect, + onPathRemove, + onPathMove, + onPathEnter, + onObsSearch, + onObsSelect, + onObsRemove, + onObsEnter, + onSubmit, + onCancel, +}: RouteModalProps) { + const { t } = useTranslation(); + const [fromLabel, setFromLabel] = useState(route?.from_label ?? ""); + const [toLabel, setToLabel] = useState(route?.to_label ?? ""); + const [description, setDescription] = useState(route?.description ?? ""); + const [visibility, setVisibility] = useState( + route?.visibility || "community", + ); + const [matchWidth, setMatchWidth] = useState(route?.match_width || 1); + const [pathQuery, setPathQuery] = useState(""); + const [obsQuery, setObsQuery] = useState(""); + const [windowHours, setWindowHours] = useState( + String(route?.window_hours || 48), + ); + const [threshold, setThreshold] = useState( + String(route?.packet_count_threshold || 5), + ); + const [clearThreshold, setClearThreshold] = useState( + route?.clear_threshold ? String(route.clear_threshold) : "", + ); + const [hopSpan, setHopSpan] = useState( + route ? (route.max_hop_span ? String(route.max_hop_span) : "") : "8", + ); + const [pathLength, setPathLength] = useState( + route?.max_path_length ? String(route.max_path_length) : "", + ); + const [enabled, setEnabled] = useState(route?.enabled !== false); + const [reversible, setReversible] = useState(route?.reversible !== false); + + const selectedPathKeys = new Set(pathNodes.map((n) => n.public_key)); + const selectedObsKeys = new Set(observerNodes.map((n) => n.public_key)); + const availPathResults = pathResults.filter( + (n) => !selectedPathKeys.has(n.public_key), + ); + const availObsResults = obsResults.filter( + (n) => !selectedObsKeys.has(n.public_key), + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + from_label: fromLabel, + to_label: toLabel, + description, + visibility, + match_width: matchWidth, + window_hours: windowHours, + packet_count_threshold: threshold, + clear_threshold: clearThreshold, + max_hop_span: hopSpan, + max_path_length: pathLength, + enabled, + reversible, + }); + }; + + const handlePathKeydown = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + const first = availPathResults[0]; + if (first) { + onPathSelect(first); + setPathQuery(""); + return; + } + onPathEnter(pathQuery); + setPathQuery(""); + }; + + const handleObsKeydown = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + const first = availObsResults[0]; + if (first) { + onObsSelect(first); + setObsQuery(""); + return; + } + onObsEnter(obsQuery); + setObsQuery(""); + }; + + return ( + +
    +

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

    +
    +
    +
    +
    + + setFromLabel(e.target.value)} + placeholder={t("routes.from_label")} + required + maxLength={255} + /> +
    +
    + + setToLabel(e.target.value)} + placeholder={t("routes.to_label")} + required + maxLength={255} + /> +
    +
    +
    + + setDescription(e.target.value)} + placeholder={t("routes.description_label")} + /> +
    +
    +
    + + +
    +
    + +
    + {[1, 2, 3].map((w) => ( + + ))} +
    +
    +
    +
    + +
    + { + setPathQuery(e.target.value); + onPathSearch(e.target.value); + }} + onKeyDown={handlePathKeydown} + placeholder={t("routes.search_nodes_placeholder")} + autoComplete="off" + /> + {availPathResults.length > 0 && ( +
      + {availPathResults.map((n) => ( + { + onPathSelect(n); + setPathQuery(""); + }} + /> + ))} +
    + )} +
    +

    {t("routes.path_help")}

    +
    + {pathNodes.length === 0 ? ( + + {t("routes.path_empty")} + + ) : ( + pathNodes.map((n, i) => ( + + {i > 0 && ( + + {"\u2192"} + + )} + + {i > 0 && ( + + )} + {n.name || n.public_key.slice(0, 8)} + + {i < pathNodes.length - 1 && ( + + )} + + + )) + )} +
    +
    +
    + +
    + { + setObsQuery(e.target.value); + onObsSearch(e.target.value); + }} + onKeyDown={handleObsKeydown} + placeholder={t("routes.search_nodes_placeholder")} + autoComplete="off" + /> + {availObsResults.length > 0 && ( +
      + {availObsResults.map((n) => ( + { + onObsSelect(n); + setObsQuery(""); + }} + /> + ))} +
    + )} +
    +

    + {t("routes.observers_help")} +

    +
    + {observerNodes.length === 0 ? ( + + {t("routes.observers_empty")} + + ) : ( + observerNodes.map((n, i) => ( + + {n.name || n.public_key.slice(0, 8)} + + + )) + )} +
    +
    +
    +
    + + setWindowHours(e.target.value)} + min={1} + max={720} + /> +
    +
    + + setThreshold(e.target.value)} + min={1} + max={10000} + /> +
    +
    + + setClearThreshold(e.target.value)} + placeholder={String(3 * (parseInt(threshold, 10) || 5))} + min={1} + /> +
    +
    + + setHopSpan(e.target.value)} + placeholder={"\u221E"} + min={1} + /> +
    +
    + + setPathLength(e.target.value)} + placeholder={"\u221E"} + min={1} + /> +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    + ); +} + +function DeleteRouteModal({ + route, + saving, + onConfirm, + onCancel, +}: { + route: RouteItem; + saving: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const arrow = route.reversible !== false ? "\u2194" : "\u2192"; + const label = `${route.from_label} ${arrow} ${route.to_label}`; + + return ( + +
    +

    {t("routes.delete_route")}

    +

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

    +
    + + +
    +
    +
    + +
    +
    + ); +} + +export function RoutesPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const config = useAppConfig(); + const packetsEnabled = config.features?.packets !== false; + const isAdmin = hasRole("admin"); + usePageTitle("routes.title"); + + const [routes, setRoutes] = useState([]); + const [detailCache, setDetailCache] = useState>( + {}, + ); + const [historyCache, setHistoryCache] = useState< + Record + >({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modal, setModal] = useState(null); + + const detailCacheRef = useRef>({}); + const historyCacheRef = useRef>({}); + const chartsRef = useRef([]); + const pathTimerRef = useRef | null>(null); + const obsTimerRef = useRef | null>(null); + const pathSearchIdRef = useRef(0); + const obsSearchIdRef = useRef(0); + + const destroyCharts = useCallback(() => { + chartsRef.current.forEach((c) => { + try { + c.destroy(); + } catch (_) {} + }); + chartsRef.current = []; + }, []); + + const loadAllDetails = useCallback(async (routesList: RouteItem[]) => { + const newDetails: Record = {}; + const newHistories: Record = {}; + const promises: Promise[] = []; + for (const r of routesList) { + if (!detailCacheRef.current[r.id]) { + promises.push( + apiGet(`/api/v1/routes/${r.id}`) + .then((d) => { + newDetails[r.id] = d; + }) + .catch(() => undefined), + ); + } + if (!historyCacheRef.current[r.id]) { + promises.push( + apiGet(`/api/v1/routes/${r.id}/history`, { days: 6 }) + .then((h) => { + newHistories[r.id] = h; + }) + .catch(() => undefined), + ); + } + } + if (promises.length === 0) return; + await Promise.allSettled(promises); + if (Object.keys(newDetails).length > 0) { + detailCacheRef.current = { ...detailCacheRef.current, ...newDetails }; + setDetailCache(detailCacheRef.current); + } + if (Object.keys(newHistories).length > 0) { + historyCacheRef.current = { ...historyCacheRef.current, ...newHistories }; + setHistoryCache(historyCacheRef.current); + } + }, []); + + const fetchRoutes = useCallback(async (): Promise => { + try { + const data = await apiGet("/api/v1/routes"); + const items = data.items || []; + setRoutes(items); + setError(null); + return items; + } catch (e) { + setError((e as Error).message || t("common.failed_to_load_page")); + return []; + } finally { + setLoading(false); + } + }, [t]); + + const refresh = useCallback(async () => { + const items = await fetchRoutes(); + await loadAllDetails(items); + }, [fetchRoutes, loadAllDetails]); + + useEffect(() => { + let active = true; + (async () => { + const items = await fetchRoutes(); + if (active) await loadAllDetails(items); + })(); + return () => { + active = false; + }; + }, [fetchRoutes, loadAllDetails]); + + useEffect(() => { + destroyCharts(); + for (const r of routes) { + const h = historyCache[r.id]; + if (detailCache[r.id] && h) { + const chart = window.createRouteDetailStrip( + `routeStripChart-${r.id}`, + h, + ) as ChartInstance | null; + if (chart) chartsRef.current.push(chart); + } + } + }, [routes, detailCache, historyCache, destroyCharts]); + + useEffect(() => { + return () => { + destroyCharts(); + if (pathTimerRef.current) clearTimeout(pathTimerRef.current); + if (obsTimerRef.current) clearTimeout(obsTimerRef.current); + }; + }, [destroyCharts]); + + const openAddModal = () => { + setModal({ + type: "add", + route: null, + pathNodes: [], + observerNodes: [], + pathResults: [], + obsResults: [], + saving: false, + }); + }; + + const openEditModal = (route: RouteItem) => { + setModal({ + type: "edit", + route, + pathNodes: (route.route_nodes || []).map((rn) => ({ + public_key: rn.public_key ?? "", + name: rn.name, + })), + observerNodes: (route.route_observers || []).map((ro) => ({ + public_key: ro.public_key ?? "", + name: ro.name, + })), + pathResults: [], + obsResults: [], + saving: false, + }); + }; + + const openDeleteModal = (route: RouteItem) => { + setModal({ + type: "delete", + route, + pathNodes: [], + observerNodes: [], + pathResults: [], + obsResults: [], + saving: false, + }); + }; + + const handlePathSearch = (query: string) => { + if (pathTimerRef.current) clearTimeout(pathTimerRef.current); + const q = query.trim(); + if (q.length < 2) { + setModal((m) => (m ? { ...m, pathResults: [] } : m)); + return; + } + pathTimerRef.current = setTimeout(async () => { + const myId = ++pathSearchIdRef.current; + try { + const data = await apiGet("/api/v1/nodes", { + search: q, + limit: 10, + }); + if (myId !== pathSearchIdRef.current) return; + setModal((m) => (m ? { ...m, pathResults: data.items || [] } : m)); + } catch (_) {} + }, 300); + }; + + const handlePathSelect = (node: NodeSearchResult) => { + setModal((m) => { + if (!m) return m; + if (m.pathNodes.some((n) => n.public_key === node.public_key)) return m; + return { + ...m, + pathNodes: [ + ...m.pathNodes, + { public_key: node.public_key, name: node.name }, + ], + pathResults: [], + }; + }); + }; + + const handlePathRemove = (index: number) => { + setModal((m) => { + if (!m) return m; + const next = [...m.pathNodes]; + next.splice(index, 1); + return { ...m, pathNodes: next }; + }); + }; + + const handlePathMove = (index: number, dir: number) => { + setModal((m) => { + if (!m) return m; + const newIndex = index + dir; + if (newIndex < 0 || newIndex >= m.pathNodes.length) return m; + const next = [...m.pathNodes]; + [next[index], next[newIndex]] = [next[newIndex], next[index]]; + return { ...m, pathNodes: next }; + }); + }; + + const handlePathEnter = async (query: string) => { + const q = query.trim(); + if (q.length < 2) return; + if (pathTimerRef.current) clearTimeout(pathTimerRef.current); + const myId = ++pathSearchIdRef.current; + try { + const data = await apiGet("/api/v1/nodes", { + search: q, + limit: 10, + }); + if (myId !== pathSearchIdRef.current) return; + const items = data.items || []; + const selectedKeys = new Set( + (modal?.pathNodes ?? []).map((n) => n.public_key), + ); + setModal((m) => (m ? { ...m, pathResults: items } : m)); + const first = items.find((n) => !selectedKeys.has(n.public_key)); + if (first) handlePathSelect(first); + } catch (_) {} + }; + + const handleObsSearch = (query: string) => { + if (obsTimerRef.current) clearTimeout(obsTimerRef.current); + const q = query.trim(); + if (q.length < 2) { + setModal((m) => (m ? { ...m, obsResults: [] } : m)); + return; + } + obsTimerRef.current = setTimeout(async () => { + const myId = ++obsSearchIdRef.current; + try { + const data = await apiGet("/api/v1/nodes", { + search: q, + limit: 10, + observer: true, + }); + if (myId !== obsSearchIdRef.current) return; + setModal((m) => (m ? { ...m, obsResults: data.items || [] } : m)); + } catch (_) {} + }, 300); + }; + + const handleObsSelect = (node: NodeSearchResult) => { + setModal((m) => { + if (!m) return m; + if (m.observerNodes.some((n) => n.public_key === node.public_key)) + return m; + return { + ...m, + observerNodes: [ + ...m.observerNodes, + { public_key: node.public_key, name: node.name }, + ], + obsResults: [], + }; + }); + }; + + const handleObsRemove = (index: number) => { + setModal((m) => { + if (!m) return m; + const next = [...m.observerNodes]; + next.splice(index, 1); + return { ...m, observerNodes: next }; + }); + }; + + const handleObsEnter = async (query: string) => { + const q = query.trim(); + if (q.length < 2) return; + if (obsTimerRef.current) clearTimeout(obsTimerRef.current); + const myId = ++obsSearchIdRef.current; + try { + const data = await apiGet("/api/v1/nodes", { + search: q, + limit: 10, + observer: true, + }); + if (myId !== obsSearchIdRef.current) return; + const items = data.items || []; + const selectedKeys = new Set( + (modal?.observerNodes ?? []).map((n) => n.public_key), + ); + setModal((m) => (m ? { ...m, obsResults: items } : m)); + const first = items.find((n) => !selectedKeys.has(n.public_key)); + if (first) handleObsSelect(first); + } catch (_) {} + }; + + const handleSave = async (values: RouteFormValues) => { + if (!modal || modal.type === "delete") return; + if (modal.pathNodes.length < 2) { + alert(t("routes.min_nodes_error")); + return; + } + const isEdit = modal.type === "edit" && modal.route !== null; + const body: Record = { + from_label: values.from_label.trim(), + to_label: values.to_label.trim(), + description: values.description.trim() || null, + visibility: values.visibility, + match_width: values.match_width || 1, + window_hours: parseInt(values.window_hours, 10) || 48, + packet_count_threshold: parseInt(values.packet_count_threshold, 10) || 5, + max_hop_span: values.max_hop_span + ? parseInt(values.max_hop_span, 10) + : null, + max_path_length: values.max_path_length + ? parseInt(values.max_path_length, 10) + : null, + enabled: values.enabled, + reversible: values.reversible, + node_public_keys: modal.pathNodes.map((n) => n.public_key), + observer_public_keys: modal.observerNodes.map((n) => n.public_key), + }; + if (values.clear_threshold.trim()) { + body.clear_threshold = parseInt(values.clear_threshold, 10); + } + setModal((m) => (m ? { ...m, saving: true } : m)); + try { + if (isEdit && modal.route) { + const id = modal.route.id; + await apiPut(`/api/v1/routes/${id}`, body); + const nextDetails = { ...detailCacheRef.current }; + delete nextDetails[id]; + detailCacheRef.current = nextDetails; + setDetailCache(nextDetails); + const nextHistories = { ...historyCacheRef.current }; + delete nextHistories[id]; + historyCacheRef.current = nextHistories; + setHistoryCache(nextHistories); + } else { + await apiPost("/api/v1/routes", body); + } + setModal(null); + await refresh(); + } catch (e) { + setModal((m) => (m ? { ...m, saving: false } : m)); + alert((e as Error).message || "Failed to save route"); + } + }; + + const handleDeleteConfirm = async () => { + if (!modal || modal.type !== "delete" || !modal.route) return; + setModal((m) => (m ? { ...m, saving: true } : m)); + try { + await apiDelete(`/api/v1/routes/${modal.route.id}`); + setModal(null); + await refresh(); + } catch (e) { + setModal((m) => (m ? { ...m, saving: false } : m)); + alert((e as Error).message || "Failed to delete route"); + } + }; + + if (loading) return ; + + const groups = new Map(); + for (const vis of VISIBILITY_ORDER) groups.set(vis, []); + for (const r of routes) { + const vis = r.visibility || "community"; + if (!groups.has(vis)) groups.set(vis, []); + groups.get(vis)!.push(r); + } + + return ( +
    +
    +

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

    +
    + + + + {error && } + + {isAdmin && ( +
    + +
    + )} + + {routes.length === 0 && ( +
    + {t("common.no_entity_found", { + entity: t("entities.routes").toLowerCase(), + })} +
    + )} + + {VISIBILITY_ORDER.map((vis) => { + const group = (groups.get(vis) || []).slice().sort((a, b) => { + const cmp = (a.from_label || "").localeCompare(b.from_label || ""); + return cmp !== 0 + ? cmp + : (a.to_label || "").localeCompare(b.to_label || ""); + }); + if (group.length === 0) return null; + return ( +
    +

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

    +
    + {group.map((r) => ( + openEditModal(r)} + onDelete={() => openDeleteModal(r)} + onNavigate={navigate} + /> + ))} +
    +
    + ); + })} + + {modal && (modal.type === "add" || modal.type === "edit") && ( + setModal(null)} + /> + )} + + {modal?.type === "delete" && modal.route && ( + setModal(null)} + /> + )} +
    + ); +}