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